AI Navigate

Obsidian向けに、機能が最も充実したMCPサーバーを作りました――作り方はこちら

Dev.to / 2026/3/22

📰 ニュースDeveloper Stack & InfrastructureTools & Practical Usage

要点

  • 本記事では obsidian-mcp-pro を紹介します。これは 23 ツールを備えた機能完備の MCP サーバーで、AI アシスタントに Obsidian のボールトへ深く、構造化されたアクセスを提供します。
  • Model Context Protocol (MCP) を、AI が外部ツールを呼び出し、統一されたインターフェースを介してリソースを読み取ることを可能にするオープン標準として説明し、Obsidian データとの対話を可能にします。
  • 既存の Obsidian MCP サーバーは、ウィキリンク、グラフ分析、キャンバスファイル、frontmatter 対応の操作、クロスプラットフォームのボールト検出といった Obsidian 固有の機能を欠いていると指摘します。
  • 最小限かつ焦点を絞ったスタック(TypeScript strict モード付き、@modelcontextprotocol/sdk、Zod、gray-matter)と、src、lib、tools に整理されたコンパクトなコードベースを概説します。
  • Obsidian の設定と OBSIDIAN_VAULT_PATH 環境変数を読み取って自動的にボールトを検出する仕組みを紹介し、Windows、macOS、Linux でのクロスプラットフォーム検出を可能にします。

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-pro just 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

Obsidian と AI アシスタントをお使いなら、ぜひ試してみてください。フィードバックと貢献を歓迎します。