← ブログに戻る

Tree-sitter ASTで30分Blast Radius可視化 MCP設計3原則

Tree-sitterでASTを取り、SQLiteに突っ込み、MCPサーバとしてClaude Codeから叩ける「Blast Radius可視化」ツールを土日の午前中で書いてみました。タイマーは35分。そのうち5分はライブラリのバージョン衝突を潰していた時間なので、リトライすれば30分で組めるはずです。

書き終わって手を止めたとき、私が最初に思ったのは「動いた」ではなく「削って良かった」でした。書いている途中で入れたくなった機能が5つあり、全部後回しにした結果、30分に収まった。捨てなかったら90分は超えていたと思います。

この記事では、30分MVPで何を捨てたか (5つ)何を残したか (3つ) を、実測タイマーと一緒に晒します。ツールを増やすときの判断軸として使えるはずです。

30分MVPで捨てた5機能と残した3原則

35分の内訳 (正直に)

まずタイマーを開示しておきます。

フェーズ実測やったこと
環境構築5分pip install tree-sitter tree-sitter-python mcp
バージョン衝突5分tree-sitter-language-pack を入れたら余計だった
Step1: AST抽出8分function_definitioncall ノードだけ拾う
Step2: SQLite4分nodes / edges の2テーブル、dst側にindex
Step3: BFS8分max_hops=3 でcaller方向に幅優先
Step4: MCP公開5分list_tools / call_tool の2つだけ実装
合計35分

30分に収めたければ、Step1-2で「余計なノード種を拾おう」と思わないこと、Step4で「Resourcesも公開したい」と思わないこと。この2つで10分は削れます。

捨てた5機能

MVP中に「入れたい」と思って、全部後回しにしたやつです。

1. 継承エッジ (INHERITS) class Foo(Bar) を拾って Bar → Foo のエッジを引くやつ。書くのは10分でできますが、blast radiusに継承を混ぜると BaseRepository.save みたいなベースクラスメソッドで結果が爆発します。ノイズが増えて誰も読まなくなる。ベースクラスの伝播を止める設計判断ができてから入れる方が安全です。

2. import解決 (呼び出し先のfully qualified化) foo()bar.foo() を同じ関数として繋げる処理。これは真面目にやろうとするとPython名前空間解決の縮小版を書く羽目になります。30分では絶対終わらない。まずは同一ファイル内の呼び出しだけ繋いで、精度は後で上げます。

3. Pass 2 のLLMセマンティック抽出 動的呼び出し・ダックタイピング・DIで渡される関数は静的ASTでは追えません。ここをLLMで補完するとPass 2になりますが、MVP段階でこれを入れるとAPIキー管理・料金試算・タイムアウト設計が全部乗ってきて別プロジェクト化します。

4. インクリメンタル更新 ファイルが変わったら差分だけ再インデックスするやつ。実装は面白いのですが、初回はフルスキャンで数秒で終わるので、MVPには不要でした。

5. MCPのResourcesとPromptsの公開 MCP仕様 は Tools / Resources / Prompts の3プリミティブを持ちます。全部公開したくなったのですが、Blast Radius可視化に Resourcesは要らない (呼び出し元は動的に計算するもので、事前に晒すファイル的なものではない)、Promptsも要らない (Claude Code側が自然言語で聞いてくるから、テンプレを渡す必要がない) と判断して、Toolsだけにしました。

残した3原則

ここが本題です。30分に押し込むためではなく、「後で拡張しても崩れない」ように残した設計軸です。

原則1: Toolだけを主役にする、Resources/Promptsは初回封印

MCPサーバを書き始めると、@server.list_resources() @server.list_prompts() も装飾子で用意されているので「せっかくだから全部埋めよう」と思います。私も一瞬思いました。

でも、Blast Radiusという機能の本質は「関数名を渡すと呼び出し元集合が返る」という 1つの動詞 です。この動詞をToolとして磨き切ることに集中して、Resources (静的ファイルの提示) と Prompts (定型プロンプト) は封印。実装コードは以下の2デコレータのみ。

@server.list_tools()
async def list_tools() -> list[types.Tool]:
    return [
        types.Tool(
            name="blast_radius",
            description="Compute callers of a function up to N hops (default 3).",
            inputSchema={
                "type": "object",
                "properties": {
                    "function_name": {"type": "string"},
                    "max_hops": {"type": "integer", "default": 3},
                },
                "required": ["function_name"],
            },
        )
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
    result = blast_radius(conn, arguments["function_name"],
                          arguments.get("max_hops", 3))
    return [types.TextContent(type="text", text=json.dumps(result, default=list))]

MCPのlow-level Server はこの2つが基本形です (FastMCP@server.tool() はhigh-level側で、直接low-levelを触るときは使えません)。動詞1つに絞ると、input schemaのフィールドが2つに収まり、Claude Code側の自然言語→引数マッピングが安定します。 これがMVPで動く最大の理由でした。

「後でResourcesも足したくなったら」と思うかもしれませんが、その時点で「resourceとして提示したい何か」が具体的に見つかっているはずです。見つかっていない状態で用意する空のresourceは、後で必ず整理コストを払わされます。

原則2: confidenceで結果を絞る、精度は集合の大きさで語る

Blast Radiusは呼び出し元集合を返しますが、静的AST抽出だけだと必ず取りこぼしがあります。動的呼び出し、依存性注入、名前解決のあいまいさ。これを黙って返すと、レビューワーは「この結果を100%信じていいのか」と迷って、結局手で全ファイル読み直す羽目になる。

私はSQLite側のedgesテーブルに confidence REAL DEFAULT 1.0 を1カラム追加して、BFSクエリで WHERE confidence >= 0.8 の閾値を効かせるようにしました。Pass 1 (静的AST) のエッジは1.0、後でPass 2 (LLM抽出) を足したときのエッジは0.7とかで入れる想定。閾値0.8なら「Pass 1で拾ったやつだけ」が返ります。

cur.execute(
    f"SELECT src FROM edges WHERE type='CALLS' AND dst IN ({placeholders}) "
    f"AND confidence >= 0.8",
    tuple(frontier)
)

MCPのTool description に「confidence >= 0.8 の高信頼エッジのみ集計」と1行書いておくと、Claude Codeが「取りこぼしがある可能性」を自分でユーザーに伝えるようになります。 精度をToolの契約に埋め込むと、後段のUXが変わる。この設計は GraphRAGで3種のrelation queryを試したとき にも同じ結論に至っていて、「精度は集合の大きさで語る」のはコードKGでもドメインKGでも通ずる原則だと思っています。

原則3: dst側index + max_hops=3 を触らない

BFSの実装で唯一速度を気にした場所です。呼び出し元を逆引きするクエリが中心なので、edgesテーブルの dst 列にindexを貼る:

CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst, type);

これがないと10万エッジのSQLiteでBFS 3-hopが数秒に膨れます。あると数十ミリ秒。速度差はレビュー体験を殺すので、ここは30分MVPでも省けません。

そして max_hops のデフォルトは3。4以上に広げたい誘惑は毎回来るのですが、30万行コードベースの中核関数だと Hop 4 で数百ノードに達して、人間の頭に入らなくなります。「人間が消化できる量」を設計値として固定してしまう のが、後の拡張で崩れない設計軸でした。

30分後に気づいたこと

動かしてClaude Codeから UserService.create を投げたとき、返ってきた集合が私の頭の中の「なんとなくの範囲」より小さかった。理由は明白で、動的呼び出しが取りこぼされていたからです。Pass 2の必要性が、30分MVPを動かした瞬間に体で理解できました。先にドキュメント読んでからPass 2実装を考えるより、静的AST版を動かしてから「足りない」を体験する方が、設計判断がぶれない。 これは30分投資して回収した一番大きな知見です。

もう一つ。MCPサーバとして公開してClaude Codeから叩けるようにした瞬間、ツール単体で回すよりも「使ってみて、足りない機能を1つ足す」というループが桁違いに速く回るようになりました。CLIで叩いていた頃は結果を目視するのに毎回コマンドを組み直していましたが、MCPにすると自然言語で「じゃあ2-hopまでで頼む」「関数名の候補を教えて」と会話で回せます。MCPのToolは「実装」ではなく「操作面」だと考えると、初回に何を1つ切り出すかの判断が楽になります。

次の拡張はどの順番か

30分MVPを動かした後、私が次に足す優先順位はこうです。

  1. Pass 2 (LLMセマンティック抽出) の追加 — confidence 0.7でedgesに追加、閾値を0.7に下げる引数をToolに1つ足す
  2. リポジトリ全体走査 — 現状は単一ファイル、globとgitignore尊重を入れる
  3. 継承エッジ (INHERITS) — ベースクラス伝播を止めるフラグと一緒に入れる
  4. インクリメンタル更新 — 差分再インデックス、ここでSQLiteの限界が見えるならNeo4j等へ移行検討
  5. MCPのResources公開 — 「Hop別ノード一覧」を静的に提示したくなったらここで入れる

各項目に「入れる引き金」を先に決めておくと、機能追加の判断でぶれません。「入れたくなったから入れる」ではなく「この症状が出たから入れる」に翻訳できるかどうかが、MVPを長く保つコツだと思います。

まとめ

  • Tree-sitter + SQLite + MCPで Blast Radius可視化は実測30-35分で組める
  • 30分に押し込むために捨てるべきは 継承 / import解決 / Pass 2 / インクリメンタル / Resources+Prompts の5つ
  • 残すべきは Toolだけを主役 / confidenceで結果を絞る / dst側index + max_hops=3 の3つ
  • 動かしてから「足りない」を体験する順序が、設計判断のぶれを最小化する

コードKGを1本のツールにまとめる話をしましたが、実務では コードKGとドメインKG (顧客・仕様・意思決定の知識グラフ) を行き来する設計 の方が最終的に効いてきます。その全体像は ナレッジグラフ実践ガイド にまとめました。今日書いた30分MVPは、その大きな地図の中の一つのピンです。