2026年4月18日土曜日

Roo Code + LM Studio 最強ローカルAI開発環境

 AIによるコード生成が当たり前になった今、エンジニアが次に求めるのは「自分のコードベースをすべて把握した上での提案」です。VS Code拡張の Roo Code(旧Cline) は、プロジェクト全体をスキャンして記憶する「セマンティック検索(RAG)」機能を備えています。

今回は、プライバシーとパフォーマンスを両立させるため、M4 Max Mac Studioのパワーをフル活用し、「すべてをローカル完結させる」 環境構築の全記録をまとめました。

今回の構成とポイント

巷の解説記事はDockerを使うものが多いですが、今回はインフラエンジニアらしく、Macネイティブバイナリlaunchdを駆使して「軽量・常駐・爆速」を目指します。

  • 脳 (LLM): LM Studio (qwen2.5-coder:32b)

  • 翻訳機 (Embedding): LM Studio (nomic-embed-text-v2-moe)

  • 本棚 (Vector DB)Qdrant (Macバイナリ版)

  • 常駐化: macOS launchd


1. 意外な落とし穴:VS Codeのプロキシ設定

構築を始める前に、一番ハマりやすいポイントを潰しておきます。VS Codeはシステムのプロキシ設定を拾ってしまい、127.0.0.1 への通信すら外へ投げようとすることがあります。

settings.json に以下を追記し、VS Codeを完全に再起動(Cmd+Q)しておきましょう。

JSON
"http.proxySupport": "off"

2. Qdrantの導入と常駐化(Docker不要)

Roo Codeが「コードの記憶」を保存するには、ベクトルデータベースの Qdrant が必須です。Dockerを使わずに導入する手順は以下の通り。

バイナリの設置

Bash
# 作業ディレクトリの作成
mkdir ~/qdrant && cd ~/qdrant

# Apple Silicon(aarch64)用バイナリのダウンロード
curl -L https://github.com/qdrant/qdrant/releases/latest/download/qdrant-aarch64-apple-darwin.tar.gz -o qdrant.tar.gz

# 解凍と実行権限の付与
tar -xvf qdrant.tar.gz
chmod +x qdrant

launchdによる自動起動設定

毎回手動で起動するのはスマートではありません。Macの起動時にバックグラウンドで動き、プロセスが落ちても自動復旧するように設定します。

Bash
cat << EOF > ~/Library/LaunchAgents/io.qdrant.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>io.qdrant</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Users/$(whoami)/qdrant/qdrant</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>WorkingDirectory</key>
    <string>/Users/$(whoami)/qdrant</string>
    <key>StandardOutPath</key>
    <string>/Users/$(whoami)/qdrant/qdrant.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/$(whoami)/qdrant/qdrant-error.log</string>
</dict>
</plist>
EOF

# サービスの登録と開始
launchctl load ~/Library/LaunchAgents/io.qdrant.plist

ヘルスチェック

Bash
ps aux | grep qdrant
lsof -i :6333  # 6333ポートがLISTENしていればOK

3. LM Studio側の準備

対話用のモデルだけでなく、Embedding用のモデルもロードしておきます。

  • Local Server 画面で Start Server を実行。

  • ポート番号(デフォルト 1234)を確認。

  • 重要: 設定パネルの CORS (Cross-Origin Resource Sharing) を ON にします。これがOFFだとVS Codeからのアクセスが拒絶されます。


4. Roo Codeの設定(統合)

VS CodeのRoo Code設定画面(インデックス作成)で以下を入力します。

  • エンベッダープロバイダーOpenAI互換

  • ベースURLhttp://127.0.0.1:1234/v1 (末尾の /v1 を忘れずに)

  • モデルtext-embedding-nomic-embed-text-v2-moe

  • モデルディメンション768

  • Qdrant URLhttp://127.0.0.1:6333


5. 運用とリソース

M4 Max Mac Studio(36GB RAM)環境において、この構成でのリソース消費は驚くほど低いです。

  • 待機時CPU: ほぼ 0%。

  • メモリ: Qdrant単体では数百MB程度。

  • インデックス作成時: ログ(tail -f ~/qdrant/qdrant.log)が流れる瞬間だけCPUを使いますが、バックグラウンドでの作業を妨げることはありません。

まとめ

1992年からUnixを触り、インフラを見続けてきた人間にとっても、手元のマシンでこれほど高度な検索・推論基盤が完結するのは感慨深いものがあります。

一度デーモン化してしまえば、あとはAIがあなたの過去のコード資産をすべて把握した状態で、最高の相棒として機能してくれます。Dockerを使わずに構築したいMacユーザーの方は、ぜひ試してみてください。




2026年4月16日木曜日

インメモリMCP検索サーバー

 

1. 導入:RAGの「アホアホマン」からの卒業

  • 悩み: Obsidianのメモが増えるたびに、検索が重くなる。

  • 課題: 毎回ディスクをなめる従来の検索(アホアホマン方式)の限界。

  • 解決策: 「DBを入れずに、M4 Maxのメモリに全て載せればいいじゃない」という富豪的発想。

2. アーキテクチャ:なぜDB不要なのか?

  • ハードウェア: Mac Studio (M4 Max / 36GB RAM) の圧倒的スペック紹介。

  • ソフトウェア: MCP (Model Context Protocol) サーバーを自作。

  • ロジック: 起動時に全メモを Map オブジェクトにロード。検索は String.includes() だけの爆速処理。

  • 「10万件載っても大丈夫」の試算: 実際、テキスト10万件なら数百MBで収まるという「気づき」。

3. 実装のこだわりポイント

  • バックアップ機能write_note 時の .bak 生成というプロの配慮。

  • キャッシュ同期: ファイルを書き換えた瞬間にメモリも更新するリアルタイム性。

  • AIとの親和性: ヒット箇所の「前後数十文字(Excerpt)」をAIに渡すことで、回答精度を高める工夫。


    以下、サンプルコード

    ------

    import { Server } from "@modelcontextprotocol/sdk/server/index.js";
    import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
    import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
    import * as fs from "fs/promises";
    import * as path from "path";
    import { glob } from "glob";
    // Vaultのパス(環境変数から取得)
    const VAULT_PATH = path.resolve(process.env.OBSIDIAN_VAULT_PATH || "");
    /**
     * 【富豪的設計】インメモリ全文キャッシュ
     * M4 Maxの広大なメモリを信じ、全データをMapに展開する
     */
    const vaultCache = new Map();
    /**
     * インデックス作成:全ファイルをメモリにロード
     */
    async function refreshIndex() {
        console.error("🚀 Indexing vault..."); 
        const files = await glob("**/*.md", { 
            cwd: VAULT_PATH, 
            ignore: ["**/node_modules/**", "**/.git/**", "**/bin/**"] 
        });
        
        for (const file of files) {
            const fullPath = path.join(VAULT_PATH, file);
            try {
                const stats = await fs.stat(fullPath);
                const content = await fs.readFile(fullPath, "utf-8");
                vaultCache.set(file, { content, mtime: stats.mtimeMs });
            } catch (e) {
                console.error(`⚠️ Skip ${file}: ${e.message}`);
            }
        }
        console.error(`✅ Indexed ${vaultCache.size} files. Ready for ultra-fast search.`);
    }
    // サーバー起動時に一括ロード
    await refreshIndex();
    const server = new Server(
        { name: "obsidian-pro-server", version: "2.0.0" },
        { capabilities: { tools: {} } }
    );
    /**
     * ツール定義:AIに「何ができるか」を教える
     */
    server.setRequestHandler(ListToolsRequestSchema, async () => ({
        tools: [
            {
                name: "search_vault",
                description: "メモリ上のキャッシュから、ファイル名および本文を高速全文検索します。",
                inputSchema: {
                    type: "object",
                    properties: { 
                        query: { type: "string", description: "検索ワード" } 
                    },
                    required: ["query"],
                },
            },
            {
                name: "read_note",
                description: "指定されたファイルをメモリから即座に読み込みます。",
                inputSchema: {
                    type: "object",
                    properties: { relativePath: { type: "string", description: "vaultからの相対パス" } },
                    required: ["relativePath"],
                },
            },
            {
                name: "write_note",
                description: "ファイルを上書き保存し、自動的に.bakバックアップを作成します。メモリも即時更新します。",
                inputSchema: {
                    type: "object",
                    properties: { 
                        relativePath: { type: "string" }, 
                        content: { type: "string" } 
                    },
                    required: ["relativePath", "content"],
                },
            }
        ],
    }));
    /**
     * 各ツールの実行ロジック
     */
    server.setRequestHandler(CallToolRequestSchema, async (request) => {
        const { name, arguments: args } = request.params;
        // パス安全性チェック
        const getSafePath = (rel) => {
            const fullPath = path.join(VAULT_PATH, rel);
            if (!fullPath.startsWith(VAULT_PATH)) throw new Error("Security Error: Access denied");
            return fullPath;
        };
        // --- 1. 高速全文検索 (Search) ---
        if (name === "search_vault") {
            const q = (args?.query || "").toLowerCase();
            const results = [];
            // メモリ上を総当たり(10万件でもM4 Maxなら一瞬)
            for (const [relPath, data] of vaultCache.entries()) {
                if (relPath.toLowerCase().includes(q) || data.content.toLowerCase().includes(q)) {
                    const index = data.content.toLowerCase().indexOf(q);
                    // ヒット箇所の前後30/60文字を抜粋してAIに渡す
                    const excerpt = data.content.substring(
                        Math.max(0, index - 30), 
                        Math.min(data.content.length, index + 60)
                    ).replace(/\n/g, " ");
                    
                    results.push(`- Path: ${relPath}\n  Excerpt: "...${excerpt}..."`);
                }
            }
            return { 
                content: [{ 
                    type: "text", 
                    text: results.length > 0 
                        ? `Found ${results.length} matches:\n\n${results.join("\n")}` 
                        : "No matches found." 
                }] 
            };
        }
        // --- 2. 読み込み (Read) ---
        if (name === "read_note") {
            const cached = vaultCache.get(args.relativePath);
            if (cached) return { content: [{ type: "text", text: cached.content }] };
            
            // 未インデックスの場合のみディスク参照
            try {
                const content = await fs.readFile(getSafePath(args.relativePath), "utf-8");
                return { content: [{ type: "text", text: content }] };
            } catch (e) { return { content: [{ type: "text", text: "File not found." }], isError: true }; }
        }
        // --- 3. 書き込み (Write) ---
        if (name === "write_note") {
            try {
                const targetPath = getSafePath(args.relativePath);
                
                // 安全第一:上書き前にバックアップ作成
                try {
                    const oldContent = await fs.readFile(targetPath, "utf-8");
                    await fs.writeFile(`${targetPath}.bak`, oldContent);
                } catch (e) { /* 新規作成時はパス */ }
                await fs.mkdir(path.dirname(targetPath), { recursive: true });
                await fs.writeFile(targetPath, args.content, "utf-8");
                // メモリも即時同期
                vaultCache.set(args.relativePath, { 
                    content: args.content, 
                    mtime: Date.now() 
                });
                return { content: [{ type: "text", text: `Success: Updated ${args.relativePath} and synced memory.` }] };
            } catch (e) { return { content: [{ type: "text", text: e.message }], isError: true }; }
        }
        throw new Error("Tool not found");
    });
    // サーバー起動
    const transport = new StdioServerTransport();
    await server.connect(transport);

2026年4月14日火曜日

MCPサーバーに10分間のキャッシュを実装する

 最近、LM StudioとObsidianをMCP(Model Context Protocol)で連携させて、自作のナレッジベースをAIに読み書きさせています。

M4 Max(36GBメモリ)というモンスターマシンを使っていると、ふと思うわけです。**「AIが同じメモを何度も読みに行くなら、その内容を全部メモリに載せてしまえば爆速になるのでは?」**と。

そこで、自作のMCPサーバー(index.mjs)に、インフラ屋の視点で「キャッシュ機能」と「整合性チェック」を組み込んでみました。

1. なぜ「キャッシュ」が必要なのか?

通常、AIがObsidianのメモを参照するたびに、ディスクI/O(ファイルの読み込み)が発生します。 今のMacのSSDは十分に速いですが、プロンプトの組み立てのためにAIが複数のファイルを何度も読み直す際、わずかな遅延がチリも積もれば山となります。

また、API(Omnisearch等)経由での検索は、Obsidianが起動していないと機能しないという弱点もありました。

2. 実装のこだわり:10分間のインメモリキャッシュ

今回実装したのは、Node.jsのプロセス上に Map オブジェクトとしてデータを保持する、シンプルなインメモリキャッシュです。

  • キャッシュ期間は「10分(600,000ms)」: 1分では短すぎるし、1日は長すぎる。一つのトピックについてAIと深く対話する「エンジニアの集中時間」を考慮して10分に設定しました。

  • メモリ消費は「誤差」: テキストデータなので、1,000ファイル載せても数十MB程度。36GBメモリを積んだM4 Maxにとっては、広大な草原に小石を置くようなものです。

3. インフラ屋の意地:整合性チェック(mtimeMs)

単純に10分間保持するだけだと、**「自分でObsidianを開いてメモを書き換えたのに、AIが古いキャッシュを読み続けてしまう」**という事故が起きます。

これを防ぐために、キャッシュを返す前に fs.stat でファイルの**最終更新日時(mtimeMs)**を確認するロジックを入れました。

JavaScript

import { Server } from "@modelcontextprotocol/sdk/server/index.js";

import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";

import * as fs from "fs/promises";

import * as path from "path";

import { glob } from "glob";


const VAULT_PATH = path.resolve(process.env.OBSIDIAN_VAULT_PATH || "");

const OMNISEARCH_HOST = process.env.OMNISEARCH_HOST || "http://localhost:51361";

const server = new Server(

    { name: "obsidian-local-server", version: "1.4.1" },

    { capabilities: { tools: {} } }

);


server.setRequestHandler(ListToolsRequestSchema, async () => ({

    tools: [

        {

            name: "search_vault",

            description: "ファイル名と中身を検索します。",

            inputSchema: {

                type: "object",

                properties: { query: { type: "string" } },

                required: ["query"],

            },

        },

        {

            name: "read_note",

            description: "指定された相対パスのファイルを読み込みます。",

            inputSchema: {

                type: "object",

                properties: { relativePath: { type: "string" } },

                required: ["relativePath"],

            },

        },

        {

            name: "write_note",

            description: "指定された相対パスにファイルを作成、または上書き保存します。",

            inputSchema: {

                type: "object",

                properties: { 

                    relativePath: { type: "string", description: "例: tech/k8s.md" },

                    content: { type: "string", description: "書き込む内容" }

                },

                required: ["relativePath", "content"],

            },

        },

        {

            name: "append_note",

            description: "指定された相対パスのファイルの末尾に追記します。",

            inputSchema: {

                type: "object",

                properties: { 

                    relativePath: { type: "string" },

                    content: { type: "string", description: "追記する内容" }

                },

                required: ["relativePath", "content"],

            },

        }

    ],

}));

server.setRequestHandler(CallToolRequestSchema, async (request) => {

    const { name, arguments: args } = request.params;


    // ヘルパー: 安全なパス解決

    const getSafePath = (rel) => {

        const fullPath = path.join(VAULT_PATH, rel);

        if (!fullPath.startsWith(VAULT_PATH)) throw new Error("Access denied: Outside of Vault");

        return fullPath;

    };


    if (name === "search_vault") {

        try {

            const q = (args?.query || "").replace(/[_-]/g, ' ');

            const fileNameMatches = await glob(`**/*${q}*.md`, { cwd: VAULT_PATH, nocase: true });

            const url = `${OMNISEARCH_HOST}/search?q=${encodeURIComponent(q)}&limit=10`;

            const response = await fetch(url).catch(() => null);

            const contentMatches = response?.ok ? await response.json() : [];


            let report = `[System Log] BasePath: ${VAULT_PATH}\n[Found]: ${fileNameMatches.length}件\n`;

            const results = [

                ...fileNameMatches.map(f => `Path: ${f} (Filename Match)`),

                ...contentMatches.map(c => `Path: ${c.path}\nExcerpt: ${c.excerpt}`)

            ].join("\n---\n");

            return { content: [{ type: "text", text: report + (results || "No results found.") }] };

        } catch (e) { return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true }; }

    }


    if (name === "read_note") {

        try {

            const targetPath = getSafePath(args.relativePath);

            const content = await fs.readFile(targetPath, "utf-8");

            return { content: [{ type: "text", text: content }] };

        } catch (e) { return { content: [{ type: "text", text: `Read Error: ${e.message}` }], isError: true }; }

    }


    if (name === "write_note") {

        try {

            const targetPath = getSafePath(args.relativePath);

            await fs.mkdir(path.dirname(targetPath), { recursive: true });

            await fs.writeFile(targetPath, args.content, "utf-8");

            return { content: [{ type: "text", text: `Successfully wrote to ${args.relativePath}` }] };

        } catch (e) { return { content: [{ type: "text", text: `Write Error: ${e.message}` }], isError: true }; }

    }


    if (name === "append_note") {

        try {

            const targetPath = getSafePath(args.relativePath);

            await fs.appendFile(targetPath, "\n" + args.content, "utf-8");

            return { content: [{ type: "text", text: `Successfully appended to ${args.relativePath}` }] };

        } catch (e) { return { content: [{ type: "text", text: `Append Error: ${e.message}` }], isError: true }; }

    }

    throw new Error("Tool not found");

});


const transport = new StdioServerTransport();

await server.connect(transport);

----------------------------




「10分間という有効期限」を守りつつ、「手動更新があったら即座に破棄して読み直す」。この一貫性(Consistency)の担保こそが、インフラエンジニアのこだわりです。

4. 副次的なメリット:AIへの「鮮度」通知

検索結果にも最終更新日時を含めるようにしたことで、AIが**「このメモは3年前のものだから、今のKubernetesのバージョン(v1.30)には合わないかも?」**と自ら判断するヒントを与えられるようになりました。

Roo Code + LM Studio 最強ローカルAI開発環境

 AIによるコード生成が当たり前になった今、エンジニアが次に求めるのは「自分のコードベースをすべて把握した上での提案」です。VS Code拡張の  Roo Code(旧Cline)  は、プロジェクト全体をスキャンして記憶する「セマンティック検索(RAG)」機能を備えています。 ...