AI エージェントに自分の Obsidian を読ませたい。しかも1本の MCP サーバを、複数のエージェントから使い回したい。
……と思ったら、それまで動いてた MCP サーバがある日いきなり死にました。しかも中身を追いかけたら、直そうにも直す先(上流リポジトリ)が消滅していたという。
結局まるっと自作したので、その記録です。エラーの切り分けから、npm 公開して IBM Bob に食わせるところまで一通り書きます。
先に成果物だけ置いておきます。
- リポジトリ: https://github.com/phssakaigawa/obsidian-vault-mcp
- npm:
@phssakaigawa/obsidian-vault-mcp
環境(材料)
- Windows 11 Pro 26100
- Node.js v24.14.0(
C:\Program Files\nodejs)/ npm 11.9.0 - MCP クライアント3種
- IBM Bob CLI(
bob、npm グローバル) - Claude Desktop
1.40609.0.0 - Claude Code
- IBM Bob CLI(
- Obsidian vault(ノート 783 枚くらいのやつ)
※以下、vault のパスは C:\Users\<you>\Documents\MyVault に読み替えてください。
発端: ある日いきなり出たエラー
それまで普通に使えてた Obsidian 用の MCP サーバ(npm の mcp-obsidian)が、こんなエラーを吐くようになりました。
MCP mcp-obsidian: Couldn't start for Cowork and Code sessions.
Error: Invalid result for tools/list: [
{
"code": "invalid_value",
"values": [ "object" ],
"path": [ "tools", 0, "inputSchema", "type" ],
"message": "Invalid input: expected \"object\""
},
...
tools/list の inputSchema.type が object じゃない、と怒られています。
ここで大事なのは、「接続できない」系のエラーではないということ。サーバのプロセスは上がっているし、初期化も通っている。通った上で、返した中身を検証で弾かれている。この見分けを最初に間違えると、PATH だの権限だのを延々疑うことになります(なりました)。
切り分け: MCP サーバは手で叩ける
MCP の stdio サーバって、要は標準入出力で JSON-RPC を喋るだけのプロセスなので、クライアントを介さずに直接叩けます。これを知ってるとデバッグが一気に楽になります。
initialize → notifications/initialized → tools/list の3発を流し込むだけ。
const p = spawn(NODE, [NPX, '-y', 'mcp-obsidian', VAULT], { stdio: ['pipe','pipe','pipe'] });
p.stdin.write(JSON.stringify({
jsonrpc:'2.0', id:1, method:'initialize',
params:{ protocolVersion:'2024-11-05', capabilities:{}, clientInfo:{name:'probe',version:'1'} }
}) + '\n');
// id:1 が返ってきたら initialized を投げて、続けて tools/list
で、返ってきたのがこれ。
{
"tools": [
{
"name": "read_notes",
"description": "Read the contents of multiple notes. ...",
"inputSchema": {
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
...
inputSchema が $schema 1行だけ。type も properties も無い。スキーマが空っぽで生成されていました。犯人はサーバ側で確定です。
犯人: zod のメジャー跨ぎ
npx が持ってきた依存を覗いたら、こうなっていました。
$ cat .../node_modules/zod/package.json | grep version
"version": "4.3.6"
$ cat .../node_modules/zod-to-json-schema/package.json | grep version
"version": "3.25.2"
- サーバ本体は
import { z } from "zod"で zod v4 のスキーマを組み立てる - それを
zod-to-json-schema@3.25に渡すが、こいつは内部でzod/v3を import していて v3 の内部構造しか読めない - 読めないので変換結果が空になり、
typeが落ちる
そして極めつけがこれ。このパッケージ、zod を dependencies に宣言していませんでした。
"dependencies": {
"@modelcontextprotocol/sdk": "0.5.0",
"glob": "^10.3.10",
"zod-to-json-schema": "^3.23.5"
}
import してるのに、書いてない。SDK 経由で hoist されてくる zod に乗っかる作りです。公開当時は hoist されるのが v3 だったので、これで動いていました。
npm の公開日を並べるとこうなります。
| 日付 | 出来事 |
|---|---|
| 2024-11-29 |
mcp-obsidian 1.0.0 公開(以後1度も更新されていない) |
| 2025-07-09 | zod 4.0.0 リリース |
| 2025-11-18 |
zod-to-json-schema 3.25.0 リリース ← 内部で zod/v3 を import する版 |
| 2026-08 | 僕が踏む |
本体の依存指定は "zod-to-json-schema": "^3.23.5" なので、2025-11 以降に npx すると勝手に 3.25 系が入ります。壊れる条件が揃ったのは、ざっくり公開の1年後あたり。あとは誰かがそこを引くのを待つだけの状態でした。
※「実際にいつ壊れたか」までは特定していません。npx のキャッシュがいつ再解決されたか次第ですし、クライアント側の検証が厳しくなったタイミングも噛んでいるので、そこは断定を避けておきます。
※これ、モジュールが見つからないエラーになってくれれば一発なんですが、**「動くけど出力が空」**になるのが厄介でした。
応急処置: バージョンを固定する
とりあえず動かしたいので、npx をやめて zod v3 世代を固定したローカル install を作ります。
{
"dependencies": { "mcp-obsidian": "1.0.0" },
"overrides": {
"zod": "3.24.1",
"zod-to-json-schema": "3.23.5"
}
}
※zod だけ v3 に落とすと今度は zod-to-json-schema@3.25 が zod/v3 を解決できずに ERR_PACKAGE_PATH_NOT_EXPORTED で起動不能になります。両方セットで落とす必要があります。ここ1回踏みました。
固定して同じ probe を流すと、ちゃんと出ました。
"inputSchema": {
"type": "object",
"properties": { "paths": { "type": "array", "items": { "type": "string" } } },
"required": ["paths"],
"additionalProperties": false
}
上流に投げようとしたら、上流が消えていた
応急処置はできたので、直し方(zod を dependencies に書く+zod-to-json-schema を ~3.23.5 に絞る)を PR にしようと思って、リポジトリを探しました。
npm の package.json に repository フィールドがありません。仕方ないので gh で探し回った結果がこれです。
- 作者名義の repo は存在しない(404)
- 見つかった repo は
fork: falseなのに forks が 90、★が 0、created_atよりpushed_atの方が古い - 元の org の public repo は現在1個だけで、そこに当該 repo は無い
- Issues が無効(フォークの既定値のまま)
- LICENSE 無し、README にも記載無し
- 最終 push は 2024-11-30。PR は過去も現在も0件
要するに、オリジナルは削除済みで、残っているのは他人のフォークが root に昇格したやつでした。GitHub は親 repo が消えると最古のフォークを新しい root に繰り上げるので、こういう「本物っぽく見える生き残り」ができあがります。
Issue も立てられない、PR の宛先も無い、npm の公開権限はまた別アカウント。詰みです。
じゃあ作る
というわけで自作しました。方針は3つだけ。
1. 実行時 import は全部 dependencies に明示する。 今回の原因そのものへの対策です。依存は3つ(MCP SDK / zod / yaml)で、全部レンジ付きで書いてあります。1行書くだけでこのクラスの事故が丸ごと消えるので、ケチるところじゃないです。
2. Obsidian を起動していなくても読める。 Obsidian 用の MCP サーバは Local REST API プラグイン経由のものが多くて、あれは Obsidian を動かす前提です。アプリを操作したいなら正しい設計ですが、僕は閉じてても読みたい。なので .md を直接読む方式にしました。読み取り専用です。
3. 「何という名前か」じゃなく「何が書いてあるか」で引けるようにする。 壊れたやつはツールが2つ(複数ノート読み取り / ファイル名検索)で、本文が引けませんでした。エージェントに vault を渡す意味の半分がここなので、全文検索と frontmatter・タグ検索を足しました。
ツールは5つです。
| ツール | 何をするか |
|---|---|
read_notes |
複数ノートを読む。パスでも、拡張子なしでも、ノート名だけでも解決する |
search_notes |
タイトル・パスで探す(正規表現可) |
search_content |
本文の全文検索。行番号と前後コンテキスト付き |
search_meta |
frontmatter / タグ検索。tags: とインライン #tag の両方を拾う |
list_notes |
更新日降順で一覧。ページング付き |
SDK は最新の 1.30.0 を使ったので、書き方は McpServer + registerTool です。壊れたやつは 0.5.0 の Server + setRequestHandler 時代で、この2つは別物と言っていいくらい書き味が違います。
server.registerTool(
"search_content",
{
title: "Search note contents",
description: "Full-text search across the body of every note, ...",
inputSchema: {
query: z.string().min(1),
regex: z.boolean().default(false),
context_lines: z.number().int().min(0).max(10).default(1),
limit: z.number().int().min(1).max(500).default(50),
},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
},
async ({ query, regex, context_lines, limit }) => { /* ... */ },
);
zod の生スキーマを渡すだけで inputSchema を生成してくれます。冒頭のエラーがまさにここの生成物だったわけで、なんとも。
vault の外に出られないようにする境界チェックは、MIT ライセンスの MCP 公式 filesystem server を下敷きにしました。ライセンス表記と、どのコミットから取ってどこを変えたかは NOTICE に書いてあります。
変えたところで1つだけ書いておくと、元の実装は許可ディレクトリの判定が正規化した文字列の startsWith だけなんですが、これだと /vault を許可すると /vault-private も通ります。セパレータ境界を見るようにしました。
IBM Bob に登録する
ここからが本題です。
MCP はクライアント非依存のプロトコルなので、同じ stdio サーバをそのまま登録するだけです。Bob 用に何か作り直す必要はありません。
Bob には mcp サブコマンドが生えています。
$ bob mcp --help
Usage: bob mcp [options] [command]
Manage MCP server configurations
Commands:
add [options] <name> <commandOrUrl> [args...] Add an MCP server entry
add-json [options] <name> <json> Add or overwrite an MCP server entry from a raw JSON config string
remove [options] <name> Remove an MCP server entry
list List configured MCP servers
enable [options] <name> Enable an MCP server
disable [options] <name> Disable an MCP server
設定の実体は ~/.bob/settings/mcp.json。スキーマは mcpServers + command / args / env で、Claude Desktop の config とほぼ同じです。これに alwaysAllow(毎回の承認を省くツール名)が乗ります。
まず入れます。
$ npm install -g @phssakaigawa/obsidian-vault-mcp
登録。
$ bob mcp add obsidian -s global \
--include-tools read_notes,search_notes,search_content,search_meta,list_notes \
-- "C:\Program Files\nodejs\node.exe" \
"%APPDATA%\npm\node_modules\@phssakaigawa\obsidian-vault-mcp\dist\index.js" \
"C:\Users\<you>\Documents\MyVault"
Added MCP server "obsidian" to C:\Users\<you>\.bob\settings\mcp.json
※サーバに渡す引数の前に -- が要ります。 無いとこう怒られます。ヘルプの [args...] を素直に読むと引っかかるところです。
$ bob mcp add obsidian "C:\Program Files\nodejs\node.exe" ...
Error: For stdio servers, separate server arguments with --.
※-s の既定は workspace なので、どこからでも使いたいなら -s global を忘れずに。
※--include-tools は alwaysAllow に落ちます。今回は5つとも読み取り専用(readOnlyHint を立ててある)なので全部入れました。書き込み系のあるサーバでこれをやると事故るので、そこは中身を見てから。
確認。
$ bob mcp list
obsidian: C:\Program Files\nodejs\node.exe ...\dist\index.js C:\Users\<you>\Documents\MyVault | enabled | stdio | global
登録できました。あとは bob chat から普通に呼べます。
ついでに Claude Desktop と Claude Code にも同じものを
せっかく1本にしたので、3つとも同じ実体を指すようにします。
Claude Code:
$ claude mcp add obsidian -s user -- "C:\Program Files\nodejs\node.exe" "<entry>" "<vault>"
$ claude mcp list
obsidian: ... - ✔ Connected
Claude Desktop: claude_desktop_config.json に足します。
{
"mcpServers": {
"obsidian": {
"command": "C:\\Program Files\\nodejs\\node.exe",
"args": [
"C:\\Users\\<you>\\AppData\\Roaming\\npm\\node_modules\\@phssakaigawa\\obsidian-vault-mcp\\dist\\index.js",
"C:\\Users\\<you>\\Documents\\MyVault"
]
}
}
}
※Windows では npx 経由より node.exe の絶対パス+エントリポイント直叩きが堅いです。PATH に依存しないし、引用符が壊れません。
※3つとも開発ツリーではなく npm i -g した実体を指しています。開発中のディレクトリを直接指すと、ビルドし直した瞬間に全クライアントが道連れになります。1回やりかけました。
ハマりどころ
Claude Desktop の新しいビルドがログを書かない。 1.40609.0.0 にしたら logs\mcp.log も main.log も更新されなくなっていました(最終更新が前の世代のまま)。ログで切り分ける手が使えないので、プロセスツリーを見る方式に切り替えました。親が Desktop の実行ファイルになっている子プロセスを数えます。正常なら2本上がります(本体接続用と、Cowork/Code 用の別インスタンス)。
claude.exe という名前が衝突する。 Claude Desktop も Claude Code CLI も、プロセス名が claude.exe です。「Desktop が起動中か」を名前で判定すると必ず誤検知します。実行パスで見ましょう。
PS> Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -like '*WindowsApps*Claude*' }
npm publish が 2FA を要求する。 ブラウザ認証の URL が出るので、そこを踏まないと通りません。Authenticator を使ってるなら --otp=123456 で一発です。
publish 成功直後の npm view は 404 を返す。 ログ上は PUT 200 / exit 0 で成功しているのに、npm view も registry.npmjs.org/<pkg> も 404。一瞬「失敗した?」と焦りましたが、パッケージ全体のメタデータ(packument)の配信が遅れているだけでした。バージョン指定の URL は先に 200 になります。
$ curl -s -o /dev/null -w "%{http_code}\n" https://registry.npmjs.org/@phssakaigawa%2Fobsidian-vault-mcp
404
$ curl -s -o /dev/null -w "%{http_code}\n" https://registry.npmjs.org/@phssakaigawa%2Fobsidian-vault-mcp/0.1.0
200
publish 直後の確認はこっちを見た方が精神衛生に良いです。
動作確認用のスクリプトを同梱した
冒頭で使った「MCP を直接叩く」やつ、毎回書くのが面倒なのでリポジトリに入れました。
$ node scripts/probe.mjs "C:\Users\<you>\Documents\MyVault"
[server] obsidian-vault-mcp 0.1.0 serving 1 vault root(s): ...
tools/list -> 5 tool(s)
OK read_notes type=object props=[paths]
OK search_notes type=object props=[query, regex, case_sensitive, limit]
OK search_content type=object props=[query, regex, case_sensitive, context_lines, limit]
OK search_meta type=object props=[tag, key, value, limit]
OK list_notes type=object props=[folder, limit, offset]
--- boundary check: path outside the vault ---
[could not be read: note not found: ../../../Windows/win.ini]
all checks passed
全ツールの inputSchema.type が object か(=冒頭のエラーを踏まないか)と、vault の外に出られないかを確認します。npm から入れ直したものでも通してあります。
MCP サーバを書く人は、こういう検証を1本持っておくと安心だと思います。クライアント側のエラーメッセージって、だいたい親切じゃないので。
さいごに
3つのエージェントが同じサーバを見る形で運用に入れました。どれに話しかけても同じ道具が同じ名前で生えているのは、思っていた以上に快適です。エージェントを乗り換えるたびに vault の入口を作り直さなくてよくなりました。
技量不足で見落としがあるかもしれないので、おかしなところがあれば Issue なり PR なりで教えてもらえると助かります(今度は Issue、ちゃんと開けてあります)。
やり残しはこのあたり。後日また書ければと思います。
- ノートのインデックスを持っていないので、毎回 vault を歩いています。数千枚くらいまでは体感問題ないんですが、それ以上だと厳しいはず
-
[[wiki-link]]をグラフとして解決していない(今はただのテキスト) - 全文検索がリテラル+正規表現のみ。埋め込みベースの検索は入れてない
- Canvas や添付ファイルは対象外
そして今回いちばんの教訓。import したものは dependencies に書きましょう。 書いてなくても今日は動きます。動かなくなるのは、依存の向こう側がメジャーバージョンを跨いだ日です。今回はそれが、公開のだいたい1年後に来ていました。