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)には合わないかも?」**と自ら判断するヒントを与えられるようになりました。

ローカルLLMにObsidianの「書き込み権限」を与えて真の相棒にしてみた

 

はじめに

ついに導入した M4 Max Mac Studio。36GBのメモリを活かしてローカルLLM(Qwen 3.5 9Bなど)をぶん回しているが、ただチャットするだけではもったいない。 自分の外部脳である Obsidian と MCP (Model Context Protocol) で連携させ、AIに「メモを読ませる」だけでなく、ついに「メモを書き込ませる」ところまで進化したので、その備忘録を残しておく。


今回の課題:AIに「権限がない」と拒否される

当初、LM Studio経由で自作のMCPサーバーを介してObsidianと連携させていたが、AIに「この手順書を更新しておいて」と頼むと、こんな冷たい返事が返ってきた。

「申し訳ありませんが、メモファイルを直接編集・更新する権限を持っておりません…」

インフラ屋として、権限(Permission)で弾かれるほど悔しいことはない。 原因は、MCPサーバー(index.mjs)に search(検索) と read(読み込み) のツールしか実装していなかったからだ。


解決策:index.mjs を改造して「書く力」を与える

Node.jsで書いたMCPサーバーの index.mjs に、fs.writeFile と fs.appendFile を叩くためのツールを追加実装した。

実装した主なツール

  • write_note: 指定したパスに新規作成、または上書き。

  • append_note: 既存のメモの末尾に追記。

これで、AIが「あ、この情報は重要だな」と思ったら、自動的にナレッジベースを更新できるようになった。


実際のコード(抜粋)

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);





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

 最近、LM StudioとObsidianをMCP(Model Context Protocol)で連携させて、自作のナレッジベースをAIに読み書きさせています。 M4 Max(36GBメモリ)というモンスターマシンを使っていると、ふと思うわけです。**「AIが同じメモを何度も...