powered by TechFeed
表示モード
ハウツー

curl・jq・BashだけでMCPプロトコルの内部を丸裸にする — AIエージェント接続の仕組みを手を動かして理解する

11月8日、code.mendhak.comが「A hands-on, low-level understanding of MCP using command-line tools」と題した記事を公開した。stdioにJSONを1行流し込んだら"text": "8"が返ってきた——そこで初めて「MCPとは結局JSON-RPCだ」と腑に落ちた、という感覚を再現するチュートリアルだ。curl・jq・Bashだけを使い、普段はクライアントライブラリの奥に隠れているMCPプロトコルの全層を可視化する。

11月8日、code.mendhak.comが「A hands-on, low-level understanding of MCP using command-line tools」と題した記事を公開した。stdioにJSONを1行流し込んだら"text": "8"が返ってきた——そこで初めて「MCPとは結局JSON-RPCだ」と腑に落ちた、という感覚を再現するチュートリアルだ。curl・jq・Bashだけを使い、普段はクライアントライブラリの奥に隠れているMCPプロトコルの全層を可視化する。


MCP(Model Context Protocol)は、AIエージェントとツール・データソースを接続するための標準プロトコルだ。VS CodeやCursorなどのAIコーディング環境への統合が進み、2024年後半の仕様公開から現在までに急速に採用が広がった結果、今日では多くのエンジニアが日常的に触れるインフラとなっている。しかし実際には、MCPはプリビルトのクライアントを通じて使われるため、内部で何が起きているかは見えにくい。この記事は、その実装の詳細を直接手を動かして確認するためのチュートリアルだ。

stdioトランスポート:JSON-RPCを生で叩く

まずPythonのMCPサーバーフレームワークFastMCPを使い、2つの整数を足すaddツールだけを持つ最小サーバーを立てる。

from fastmcp import FastMCP
mcp = FastMCP("learn")

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two integers together!"""
    return a + b
fastmcp run server.py:mcp --transport stdio

サーバーはstdio(標準入出力)で待ち受けを開始する。MCPの通信は**JSON-RPC 2.0**ベースで、リクエストを標準入力に流し込むと、レスポンスが標準出力に返ってくる。

最初に送るのはserver/discoverメソッドだ。元記事ではFastMCPを介したサーバー機能一覧の取得に使われており、クライアントとサーバーが互いの対応バージョン・ケイパビリティを確認する起点となる(※MCP仕様の標準的な初期化シーケンスはinitialize→notifications/initializedのハンドシェイクを経るが、元記事ではserver/discoverをそのエントリーポイントとして使用している。これがFastMCP独自の拡張か仕様上の別経路かについては、元記事およびMCP仕様書を参照されたい)。

echo '{
  "jsonrpc": "2.0",
  "id": "discover-1",
  "method": "server/discover",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientInfo": {
        "name": "ExampleClient",
        "version": "1.0.0"
      },
      "io.modelcontextprotocol/clientCapabilities": {}
    }
  }
}' | jq -c | fastmcp run server.py:mcp --transport stdio | jq

ここでjq -cがポイントだ。stdioトランスポートはJSONを1行で渡す必要があるため、jq -cでコンパクト化してからサーバーに渡し、レスポンスはjqで整形して読みやすくする。

レスポンスにはsupportedVersions(サポートするMCPバージョン)やcapabilities(tools・resources・promptsなどの対応機能)が含まれる。VS CodeにnpxやuvxでMCPサーバーを登録したとき、裏ではこれと同じやり取りが走っている。

ツールの一覧取得と呼び出し

tools/listでツール一覧を取得すると、addツールのスキーマが返ってくる。descriptionはPythonのdocstringから、input/outputスキーマは型アノテーションから自動生成される仕組みだ。

ツールの呼び出しはtools/callで行う。5と3を渡す例:

(echo '{
  "jsonrpc": "2.0",
  "id": "call-add-1",
  "method": "tools/call",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {}
    },
    "name": "add",
    "arguments": { "a": 5, "b": 3 }
  }
}' | jq -c ; sleep 1) | fastmcp run server.py:mcp --transport stdio | jq

sleep 1を挟んでいるのは、サーバーがレスポンスを返し切る前にパイプが閉じるのを防ぐためだ。結果は以下のように返ってくる。

{
  "jsonrpc": "2.0",
  "id": "call-add-1",
  "result": {
    "content": [
      { "type": "text", "text": "8" }
    ]
  }
}

"text": "8"——この1行がMCPの実体だ。 LLMから見れば、ツール呼び出しの結果とはこのtextフィールドの文字列にすぎない。

Resources・Promptsも同じ構造

MCPはツール呼び出しだけでなく、Resources(データや設定の取得)とPrompts(再利用可能なメッセージテンプレート)もサポートする。サーバー側のコード例:

@mcp.resource("database://users")
def users_table() -> list[dict]:
    """Return a table of the users"""
    return [
        {"id": 1, "name": "Alice"},
        {"id": 2, "name": "Bob"},
    ]

@mcp.prompt()
def ask_question(question: str) -> str:
    """Ask a question and get an answer"""
    return f"You asked: {question} but there is no answer."

Resourcesの取得はresources/readメソッドを使い、ツール名ではなくURIでリソースを指定する点が異なる(例:"uri": "database://users")。レスポンスはtoolsと同様にcontentsフィールドにテキストやバイナリが格納される形式だ。

Promptsはprompts/getで取得し、引数を渡すとメッセージテンプレートが返ってくる。prompts/listでサーバーが持つPrompt一覧を確認してからprompts/getで内容を引き出す流れはtools/listとtools/callの対応関係とまったく同じであり、MCPの3つの主要プリミティブ(Tools・Resources・Prompts)はすべてJSON-RPCのlist→get/read/callという共通パターンで統一されていることが見えてくる。

HTTPトランスポート:curlで叩く

stdioがローカルプロセス間通信向けなのに対し、HTTPトランスポートはリモートサーバーや複数クライアントからの接続に使う。自作のMCPサーバーをAPI化して公開したい場合や、ネットワーク越しにエージェントと接続したい場合はこちらを選ぶことになる。

fastmcp run server.py:mcp --transport http --port 8000

サーバーがhttp://localhost:8000でリッスンを開始したら、あとはcurlで叩くだけだ。ペイロードはstdioと同じJSON-RPCだが、HTTPヘッダーにプロトコルバージョンとメソッド名を追加する必要がある点が異なる。

curl -s -X POST http://localhost:8000/mcp \
  -H "Content-Type: application/json" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: server/discover" \
  -d '{ "jsonrpc": "2.0", "id": "discover-1", "method": "server/discover", ... }' | jq

stdioとHTTPでリクエストのボディ構造は共通であり、トランスポート層の差異はヘッダーの有無と接続方式だけだ。この対称性を理解しておくと、ローカル開発はstdio・本番デプロイはHTTPという使い分けの判断が明確になる。


このチュートリアルの核心は「普段は見えない部分を、手慣れたツールだけで可視化する」点にある。curl・jq・Bashだけでプロトコルの全層を追えるため、MCPクライアントやサーバーを自作する際の設計判断に直結する理解が得られる。

詳細はA hands-on, low-level understanding of MCP using command-line toolsを参照していただきたい。