はじめに
ついに導入した 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);
0 件のコメント:
コメントを投稿