Obsidianを知識管理に、開発にAIアシスタントを使うなら、きっと疑問に思ったことでしょう:Claudeが私のノートをただ読み取ることができないのはなぜか? それが、私がobsidian-mcp-proを作るきっかけとなった質問です。23ツールを備えたMCPサーバーが、AIアシスタントにObsidianのボールトへ深く、構造化されたアクセスを提供します。
MCPとは何ですか?
Anthropicのオープン標準であるModel Context Protocol(MCP)は、AIアシスタントが外部ツールを呼び出し、統一インターフェースを通じて外部リソースを読み取ることを可能にします。AI向けのUSB-Cポートのようなものと考えてください:1つのプロトコル、任意のデータソース。MCPサーバーはツール(AIが呼び出せる関数)とリソース(AIが読み取れるデータ)を公開し、任意のMCP互換クライアント — Claude Desktop、Claude Code、Cursor、Windsurf — がそれらを利用できます。
課題
私が始めた頃、npmにはすでに4つのObsidian MCPサーバーが存在しました。すべて基本機能をカバーしていました:ノートを読む、検索する、もしかしたらファイルを作成する。どれもObsidianをObsidianたらしめる要素、すなわちObsidianならではの機能を扱ってはいませんでした:
- Wikilinks — Obsidianの独自の最短経路解決機能を用いた
- Graph analysis — バックリンク、孤立ノード、壊れたリンク、隣接ノードの巡回
- Canvas files — Obsidianのビジュアル思考ツール
- Frontmatter-aware operations — YAMLフィールドによる検索、プロパティのプログラム的更新
- Cross-platform vault detection — Windows、macOS、Linux上でボールトを自動検出
私はObsidianをファイルのフォルダではなく、第一級の知識グラフとして扱うものを作りたかった。
アーキテクチャ
このスタックは意図的に最小限です:
- TypeScript を厳格モードで
- @modelcontextprotocol/sdk を MCPサーバーの雛形作成用に
- Zod を全ツールの入力検証に使用
- gray-matter を YAMLフロントマター解析に使用
3つの本番依存関係。以上です。
ファイル構成
src/
├── index.ts # Server bootstrap, MCP resources
├── config.ts # Vault detection (cross-platform)
├── types.ts # Shared type definitions
├── lib/
│ ├── vault.ts # File I/O, search, path security
│ └── markdown.ts # Frontmatter, wikilinks, tags, code block tracking
└── tools/
├── read.ts # 5 read tools
├── write.ts # 7 write tools
├── tags.ts # 2 tag tools
├── links.ts # 5 link/graph tools
└── canvas.ts # 4 canvas tools
ボールト検出
サーバーはObsidian自身の設定ファイルを読み取ることによってボールトを自動検出します。検出チェーンは最初に環境変数OBSIDIAN_VAULT_PATHを確認し、次にプラットフォーム固有の設定ディレクトリからobsidian.jsonの解析にフォールバックします:
function getObsidianConfigPath(): string {
const platform = os.platform();
if (platform === "win32") {
return path.join(process.env["APPDATA"]!, "obsidian", "obsidian.json");
}
if (platform === "darwin") {
return path.join(os.homedir(), "Library", "Application Support", "obsidian", "obsidian.json");
}
// Linux: respect XDG_CONFIG_HOME
const configDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
return path.join(configDir, "obsidian", "obsidian.json");
}
複数のボールトが存在し、OBSIDIAN_VAULT_NAMEで名前が指定されていない場合、サーバーは最初の有効なものを選択し、警告をログに記録します。
主な技術的課題
1. ウィキリンクの解決
Obsidianは[[wikilinks]]に対して最短経路マッチング戦略を使用します。もし[[Meeting Notes]]と書くと、Obsidianは完全なパスを要求せず、ベース名でノートを見つけ、重複がある場合にはより短いパスを優先します。これを正しく再現することは、バックリンク/アウトリンク分析にとって重要でした:
export function resolveWikilink(
link: string,
_currentNotePath: string,
allNotePaths: string[],
): string | null {
const cleanLink = link.split(\"#\")[0].split(\"^\")[0].trim();
if (!cleanLink) return null;
const normalizedLink = cleanLink.replace(/\\.md$/i, \"\");
// 1. Exact relative path match
for (const notePath of allNotePaths) {
const withoutExt = notePath.replace(/\\.md$/i, \"\").toLowerCase();
if (withoutExt =\= normalizedLink.toLowerCase()) return notePath;
}
// 2. Path suffix match (for links like \"folder/note\")
if (normalizedLink.includes(\"/\")) {
for (const notePath of allNotePaths) {
const withoutExt = notePath.replace(/\\.md$/i, \"\").toLowerCase();
if (withoutExt.endsWith(normalizedLink.toLowerCase())) {
const prefix = withoutExt.slice(
0, withoutExt.length - normalizedLink.length
);
if (prefix =\= \"\" || prefix.endsWith(\"/\")) return notePath;
}
}
}
// 3. Shortest-path: basename match, prefer shortest vault path
const linkBasename = path.basename(normalizedLink).toLowerCase();
const candidates = allNotePaths.filter((p) =>> path.basename(p, \".md\").toLowerCase() === linkBasename
);
candidates.sort((a, b) => a.length - b.length);
return candidates[0] ?? null;
}
The three-phase strategy (exact match, suffix match, basename match) mirrors how Obsidian itself resolves links. Heading anchors (#heading) and block references (^blockid) are stripped before resolution.
2. Code Block-Aware Extraction
Tags and wikilinks inside code blocks should be ignored. A #typescript inside a fenced code block is syntax highlighting, not an Obsidian tag. The solution is a stateful tracker that handles both backtick and tilde fences of arbitrary length:
function createCodeBlockTracker(): (line: string) => boolean {
let insideCodeBlock = false;
let fenceChar = \"`\";
let fenceLength = 0;
return (line: string): boolean => {
const trimmed = line.trimStart();
if (!insideCodeBlock) {
const backtickMatch = trimmed.match(/^^(`{3,})/);
const tildeMatch = trimmed.match(/^^(~{3,})/);
if (backtickMatch) {
insideCodeBlock = true;
fenceChar = \"`\";
fenceLength = backtickMatch[1].length;
return true;
}
if (tildeMatch) {
insideCodeBlock = true;
fenceChar = \"~\";
fenceLength = tildeMatch[1].length;
return true;
}
return false;
} else {
const closePattern = new RegExp(
`^${fenceChar.replace(/[.*+?^${}()|[\\]\\/g, '\\$&')}{${fenceLength},}\\s*$`
);
if (closePattern.test(trimmed)) {
insideCodeBlock = false;
}
return true;
}
};
}
This tracker is used in both extractTags and extractWikilinks. Inline code spans (`like this`) are stripped separately before regex matching.
3. パストラバーサルのセキュリティ
AIアシスタントがファイルの読み取りと書き込みを行える場合、パスのセキュリティは譲れません。単純な path.join(vaultPath, userInput) は ../../etc/passwd がボールト(vault)から抜け出すことを許してしまいます。対策は次のとおりです:
export function resolveVaultPath(
vaultPath: string, relativePath: string
): string {
if (relativePath.includes(\\0')) {
throw new Error("Invalid path: contains null byte");
}
const resolved = path.resolve(vaultPath, relativePath);
const resolvedVault = path.resolve(vaultPath);
if (!resolved.startsWith(resolvedVault + path.sep)
&& resolved !== r
{
throw new Error("Invalid path: outside vault");
}
return path.relative(resolved);
}
返却形式: {\"translated\": \"翻訳されたHTML\"}esolvedVault) { throw new Error(`Path traversal detected: ${relativePath}`); } return resolved; }
ヌルバイトのチェックは、いくつかのファイルシステムAPIで文字列を終端させる典型的な回避をブロックします。\0 のチェックはこれをブロックします。+ path.sep の接尾辞は、/home/user/notes にあるボールトが誤って /home/user/notes-private へアクセスする微妙なバグを防ぎます("notes-private".startsWith("notes") が true であるため)。
4. BFSによるグラフ探索
The get_graph_neighbors ツールを使えば、任意のノートの周囲にある知識グラフを幅優先探索で探索できます。開始ノートと深さ(1〜5ホップ)を指定すると、距離とリンク方向を含むすべての接続ノートを返します:
// BFS traversal
const visited = new Map<string, GraphNeighbor>();
const queue: { path: string; currentDepth: number }[] = [
{ path: resolvedStart, currentDepth: 0 },
];
while (queue.length > 0) {
const { path: currentPath, currentDepth } = queue.shift()!;
if (currentDepth >= depth) continue;
const neighbors: { path: string; dir: "inbound" | "outbound" }[] = [];
if (direction === "outbound" || direction === "both") {
for (const target of graph.outlinks.get(currentPath) ?? []) {
neighbors.push({ path: target, dir: "outbound" });
}
}
if (direction === "inbound" || direction === "both") {
for (const source of graph.backlinks.get(currentPath) ?? []) {
neighbors.push({ path: source, dir: "inbound" });
}
}
for (const neighbor of neighbors) {
if (!visited.has(neighbor.path)) {
visited.set(neighbor.path, {
path: neighbor.path,
depth: currentDepth+ 1,
direction: neighbor.dir,
});
queue.push({ path: neighbor.path, currentDepth:
currentDepth + 1 });
}
}
}
このAIに文脈について推論させる方法を提供します:「私のアーキテクチャ決定レコードから2ホップ以内のすべてを表示してください。」
23のツール
| Category | Tool | Description |
|---|---|---|
| Read | search_notes |
すべてのボールトノートの全文検索 |
get_note |
フロントマターを解析して単一ノートを読み取る | |
list_notes |
フォルダー、日付、またはパターンでノートを一覧表示 | |
get_daily_note |
本日のデイリーノートを取得 | |
search_by_frontmatter |
YAML フィールドの値でノートを照会 | |
| Write | create_note |
フロントマターと本文を含むノートを作成 |
append_to_note |
ノートに内容を追記 | |
prepend_to_note |
先頭へ追加(フロントマター対応) | |
update_frontmatter |
YAML プロパティをプログラム的に更新 | |
create_daily_note |
テンプレートから今日の日別ノートを作成 | |
move_note |
パス検証付きで移動/名前変更 | |
delete_note |
Obsidian のゴミ箱へ削除(デフォルトは安全) | |
| Tags | get_tags |
使用頻度付きの完全なタグインデックス |
search_by_tag |
1つ以上のタグでノートを検索 | |
| Links | get_backlinks |
ノートへリンクしている参照元 |
get_outlinks |
ノートがリンクしている先 | |
find_orphans |
接続のないノートを検索 | |
find_broken_links |
存在しないノートを指すウィキリンク | |
get_graph_neighbors |
設定可能な深さまでの BFS 探索 | |
| Canvas | list_canvases |
すべての .canvas ファイルを一覧表示 |
read_canvas |
ノード/エッジデータを含むキャンバスを読み込む | |
add_canvas_node |
テキスト、ファイル、リンク、またはグループノードを追加 | |
add_canvas_edge |
キャンバスのノードをエッジで接続 |
さらに3つの MCP リソース: obsidian://note/{path}, obsidian://tags, および obsidian://daily。
セキュリティの深掘り
公開前のセキュリティ監査中に、いくつかの問題を発見し修正しました:
-
プレフィックスを利用したパス横断: 初期の
startsWith(resolvedVault)チェックには+ path.sepがありませんでした。そのため、名前の接頭辞を共有する同階層のディレクトリへアクセスできてしまう可能性がありました。/vaultのボールトは誤って/vault-backupへアクセスを許してしまうことがありました。セパレータを必須にすることで修正しました。 -
ヌルバイト注入: パスに
\0を渡すと、特定のファイルシステム操作で文字列が切り詰められることがあります。パス解決の前に明示的なヌルバイトのチェックを追加しました。 -
Frontmatter 経由の YAML 注入:
update_frontmatterツールは解析と直列化にgray-matterを使用しており、YAML を安全に扱います。ただし、frontmatter の値に信頼できない入力がある場合、結合して生の文字列として扱うと不正な YAML が生成される可能性があります —matter.stringifyのアプローチがこれを回避します。 - Trash パス検証: 削除してゴミ箱へ移動する操作には独自のパストラバーサル検証があり、作成された相対パスを介して任意の場所へ書き込みを行うのを防ぎます。
Results
- Published on npm as
obsidian-mcp-pro - Listed on Glama MCP server directory
- PR submitted to awesome-mcp-servers
- Works with Claude Desktop, Claude Code, Cursor, and any MCP-compatible client
- Zero-config for single-vault setups:
npx -y obsidian-mcp-projust works - 122 automated tests covering vault operations, markdown parsing, and full integration testing
What I Learned
Building for npm is its own discipline. bin フィールド、files 配列、prepublishOnly スクリプト、そしてシェバン行をすべて正しく機能させるには、思っていたよりも多くの反復が必要でした。 公開前に npm pack でテストすることを習慣にしました。
MCP プロトコルの準拠は重要です。 SDK はプロトコルの大半の詳細を処理しますが、ツールの応答には特定の形状 ({ content: [{ type: "text", text }] }) があり、エラー報告には慣例 (isError: true) があり、リソース URI はテンプレート仕様に従う必要があります。これらを正しくするには、仕様を読むことが必要で、例だけでは十分ではありませんでした。
セキュリティ第一の思考は早期に効果を発揮します。 パス traversal の修正は2行の変更でしたが、公開前に捕捉することで、AI アシスタントがホストマシン上の任意のファイルを読み取る脆弱性を回避しました。 あなたのツールがAIにファイルシステムへのアクセスを提供する場合、すべての入力が攻撃面となります。
Try It
npx -y obsidian-mcp-pro
- GitHub: github.com/rps321321/obsidian-mcp-pro
- npm: npmjs.com/package/obsidian-mcp-pro
- Glama: glama.ai/mcp/servers/rps321321/obsidian-mcp-pro
Obsidian と AI アシスタントをお使いなら、ぜひ試してみてください。フィードバックと貢献を歓迎します。