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