Claude Media
MCPのカスタムトランスポートを設計する — Unixソケット/TCP指針

MCPのカスタムトランスポートを設計する — Unixソケット/TCP指針

MCPは標準のstdio・Streamable HTTP以外にカスタムトランスポートの実装を許容します。MUST/SHOULDの要件とstdioフレーミング再利用の設計指針を仕様原文から確認します。

カスタムトランスポートが要る場面と、要らない場面

MCPの仕様は、標準のstdio・Streamable HTTP以外の通信機構を実装してよいと定めています。プロトコルはトランスポートに依存しない設計で、双方向にメッセージを交換できるチャネルなら、どんな経路にも載せられます。

標準の2種で足りないのは、たとえば次の場面です。同一ホスト内でHTTPの層を挟みたくないならUnixドメインソケットが候補です。プライベートネットワークでサブプロセスを起動せずに繋ぐならTCP、社内標準のメッセージング基盤に乗せるなら別の双方向チャネルになります。

実例はClaude CodeのMCPクライアントにあります。v2.1.289のclaude mcp add --helpでは、--transportに渡せる値はstdio・sse・httpの3つです。一方、JSON設定で"type": "ws"を指定すると、WebSocketサーバーにも接続できます。

WebSocketはMCP仕様が定める標準2種に含まれません。Claude Code側が追加した経路です。そのためCLIフラグでは追加できず、.mcp.jsonかclaude mcp add-jsonで書きます。

{"type":"ws","url":"wss://mcp.example.com/socket","headers":{"Authorization":"Bearer YOUR_TOKEN"}}

認証はヘッダーだけで、OAuthは使えません。つまり、独自チャネルを足したホスト側では、認証や追加方法といった周辺の扱いも標準のトランスポートと別になります。以下の各節は、その違いを仕様のどの要件から設計するかの話です。

守るべきMUSTは3つだけ

仕様が逸脱を禁じているのは3点です。フレーミングや接続の張り方は実装者の裁量に残されます。

MUST

カスタムトランスポートがMUSTで守る3点

  • JSON-RPCのメッセージ形式

    フレーミングは変えてよいが、メッセージそのものはJSON-RPCのまま、UTF-8で運びます。

  • メッセージパターン

    リクエスト・通知・レスポンスの往復ルールはコアプロトコルの一部で、どの経路でも同じです。クライアントはリクエストと通知を送り、サーバーはレスポンスと通知を返します。

  • リクエストごとのメタデータモデル

    プロトコルバージョンとクライアント機能は、各リクエストの_metaに載ります。接続単位のセッションに持ち越しません。

違反の典型は、独自の応答形式を発明することと、サーバーからクライアントへJSON-RPCリクエストを送る経路を作ることです。どちらもMCPクライアントの共通実装と噛み合わなくなります。サーバーが追加の入力を必要とする場面は、リクエストではなくレスポンスで表します。仕組みはMRTR(Multi Round-Trip Requests)にあります。

SHOULDの3点: 接続確立・フレーミング・キャンセルの文書化

MUSTの外側で、仕様は次の3点を文書化するようSHOULDで求めています。接続確立の手順、メッセージのフレーミング、キャンセルパターンです。この3つが無いトランスポートは、実装者以外が相互運用を検証できません。

見落とされやすいのはキャンセルです。標準の2種でも、キャンセルの合図は経路ごとに違います。

くらべる

キャンセルとメタデータ照合は経路ごとに違う

共有の1本のチャネル

stdio

キャンセルはnotifications/cancelled通知で伝えます。リクエストごとのストリームが無いので、閉じる操作がそもそもありません。

メタデータは本文の_metaだけで、ヘッダー層はありません。

リクエストごとのストリーム

Streamable HTTP

キャンセルは、そのリクエストのレスポンスストリームを閉じて伝えます。

メタデータは本文が正で、一部をHTTPヘッダーに写します。本文とヘッダーが食い違うと、サーバーは400とHeaderMismatch(JSON-RPCエラーコード-32020)で拒否します。

リクエストごとに別の接続を張る設計なら、接続を閉じることをキャンセルとみなすのか、通知メッセージを送るのかを自分で決めて書くことになります。決めないまま実装すると、クライアントごとに挙動が分かれます。

UnixソケットやTCPなら、stdioのフレーミングをそのまま使う

信頼できる双方向バイトストリームの上に作るなら、新しいフレーミングを考えず、stdioのフレーミングを再利用するのがSHOULDです。stdioバインディングは、改行区切りのJSON-RPCをバイトストリームに流すだけの仕組みで、標準ストリームに依存する部分はプロセスのライフサイクルに限られます。

stdioのルールでは、1メッセージは1行で、本文に改行を含めてはいけません。サーバーはstdoutにMCPメッセージ以外を書かず、ログはstderrに出します。

くらべる

stdioからそのまま持ち込む部分と、作り直す部分

メッセージの規則

そのまま再利用

  • 改行区切りのJSON-RPC、1行1メッセージ
  • メタデータは本文の_metaだけ(ヘッダー層なし)
  • 方向のルール(クライアントがリクエストと通知、サーバーがレスポンスと通知)
  • サーバーは通信用の出力にMCPメッセージ以外を書かない
プロセスの規則

チャネルごとに作り直す

  • 接続の確立(Unixソケットのbind/connect、TCPのlisten/accept)
  • stderr相当のログ経路(子プロセス専用の概念)
  • シャットダウンの合図(stdioでは入力ストリームを閉じる)
  • 異常終了後の復帰(子プロセスが無いなら再接続の条件を自分で決める)
  • 切断時の後始末(プロトコルはステートレスなので、処理中のリクエストは失われて再試行できます。subscriptions/listenのストリームは再確立が必要です)

Unixソケットでの往復を、手元のPython 3で試すと次のようになります。サーバーとクライアントを同じスクリプトに置き、tools/listを1行送って1行受け取るだけの最小例です。

import socket, os, json, threading
P = "/tmp/mcp.sock"
if os.path.exists(P): os.remove(P)
s = socket.socket(socket.AF_UNIX); s.bind(P); s.listen(1)
def serve():
    c, _ = s.accept(); f = c.makefile("rwb")
    for line in f:
        req = json.loads(line)
        res = {"jsonrpc": "2.0", "id": req["id"], "result": {"tools": []}}
        f.write((json.dumps(res) + "\n").encode()); f.flush()
threading.Thread(target=serve, daemon=True).start()
c = socket.socket(socket.AF_UNIX); c.connect(P); g = c.makefile("rwb")
g.write(b'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}\n'); g.flush()
print(g.readline().decode(), end="")

実行すると、レスポンスが1行で返ります。

{"jsonrpc": "2.0", "id": 1, "result": {"tools": []}}

通信路がソケットに変わっても、流れるのはstdioと同じ1行1メッセージです。なお、この例は紙幅のため_metaを省いています。実際のリクエストはプロトコルバージョンとクライアント機能を_metaに載せます。

メタデータをヘッダー相当の層に写してよいか

迷いやすいのが、_metaの内容をチャネル固有の「ヘッダー相当の層」へ複製してよいかです。仕様の答えは、複製は任意(MAY)で、本文が常に正、というものです。

Streamable HTTPが実例です。本文の一部(MCP-Protocol-Version・Mcp-Method・Mcp-NameとMcp-Param-*)をHTTPヘッダーにも写し、中継装置が本文を読まずにルーティングや検査をできるようにしています。複製する以上、食い違ったときの拒否の仕方はバインディングが定義する責任を負います。HTTPでは前節の表のとおり、400とHeaderMismatchで返します。

素のバイトストリームにこの層を作るなら、同じ原則を踏襲できます。本文を正とし、不一致は明示的なエラーで拒否します。複製層を作らないのも正当で、stdioはそうしています。

古い改訂との互換: 判定の考え方はコア、手順は経路ごと

古いプロトコル改訂には、接続単位のセッションとinitializeハンドシェイクがあり、サーバーからクライアントへJSON-RPCリクエストを送ることも許されていました。現行の仕様と相互運用する実装は、相手がどちらの世代かを見分けてフォールバックします。世代の考え方はコアのバージョニングの章にあり、検知の具体手順は各バインディングのページに書く決まりです。

stdioの例では、クライアントは他のリクエストより先にserver/discoverでプローブするのがSHOULDです。結果は3通りに分かれます。

3通り

server/discoverへの応答と判定

  • DiscoverResultが返る

    モダンなサーバーです。supportedVersionsから双方が使える版を選んで進みます。

  • UnsupportedProtocolVersionErrorなど、認識できるモダンのエラー

    モダンだが要求した版を持たないサーバーです。返されたsupportedの版から選び、initializeへは戻りません。

  • それ以外のエラー、またはタイムアウト

    レガシーなサーバーです。initializeハンドシェイクにフォールバックします。

フォールバックの条件を、特定の1つのエラーコードに結びつけてはいけません。レガシーなサーバーは未知のリクエストに、実装ごとに違うエラー(-32601や-32602が多い)を返すか、何も返さないからです。モダンのみ対応のクライアントでも、プローブは推奨されています。tools/callのような世代をまたぐメソッドをレガシー側が誤って処理するのを、決まった失敗に変えられるためです。

カスタムトランスポートでも考え方は同じで、軽いプローブを送り、応答の形で世代を判定します。ただし手順そのものは、自分のトランスポート専用に文書化します。

標準トランスポートとの使い分け

トランスポート主な用途特徴
stdio主な用途クライアントが起動するローカルサーバー特徴サブプロセスの標準入出力、設定が最も単純
Streamable HTTP主な用途複数クライアントを相手にするリモートサーバー特徴単一のPOSTエンドポイント、Origin検証などWeb向けの要件
カスタム(Unixソケット/TCP等)主な用途同一ホスト内の高頻度IPC、社内基盤への統合特徴stdioフレーミングを再利用し、接続確立とライフサイクルだけ独自定義

stdioの個別仕様はMCP stdioトランスポートの仕様を実装レベルで確認するにあります。Streamable HTTPはGETストリームとセッションが消えた理由の記事が扱っています。標準で要件を満たせるなら、独自実装の保守コストを負う理由はありません。先にこの2本で足りるかを見てください。

ホスト側が経路ごとに持つ前提 — Claude Codeの例

カスタムチャネルを足すと、プロトコルの外側にも経路ごとの扱いが付いてきます。Claude Codeの公式ドキュメントから、WebSocketを例に挙げます。

  • 認証はヘッダーだけです。静的トークンをheadersに書くか、接続のたびにheadersHelperで生成します
  • WebSocketサーバーはclaude mcp listの出力に現れません。状態はclaude mcp get <name>か/mcpパネルで見ます
  • ツール呼び出しが無応答で、進捗通知もない状態が続いたときの打ち切りは、HTTP・SSE・WebSocketが5分、stdioが30分です。CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT(ミリ秒)で変更でき、0で無効になります
  • リクエストごとの時間監視(最初の応答バイトまで)は、HTTPとSSEにあってstdioとWebSocketにはありません

自作のトランスポートをこうしたホストに載せるなら、無応答時の振る舞いやステータスの見え方が、ホストの既存の経路と同じになるとは限りません。仕様が文書化を求めるのは、この種の差を実装者以外が把握できるようにするためでもあります。

設計時に崩れやすい点

  • キャンセルの意味を決め忘れる。接続を切ればキャンセルなのか、専用通知が要るのかを書かないと、クライアントごとに挙動が分かれます
  • 互換検知を丸ごと作り込む。世代の考え方はコアにあるので、バインディングが書くのは検知の手順だけです。独自の互換ロジックを重ねる必要はありません
  • 文書化を後回しにする。接続確立・フレーミング・キャンセルが未記載だと、後から別チームが接続するときに仕様を推測するしかなくなります
  • HTTP向けの防御をそのまま流用したつもりになる。Streamable HTTPのサーバーは、Originヘッダーを検証してDNSリバインディングを防ぎます(MUST)。ローカル実行では、0.0.0.0でなく127.0.0.1にのみ待ち受けるのがSHOULDです。これらはHTTP固有の脅威モデルに基づく要件で、Unixソケットには自動では引き継がれません。チャネルが変われば脅威モデルも変わるので、アクセス制御はチャネルごとに設計し直します

まとめ

JSON-RPCの形式、メッセージパターン、リクエストごとの_metaの3点さえ守れば、チャネルは自由に選べます。バイトストリームの上に作るなら、フレーミングはstdioのものを使い、作り込むのは接続確立・終了・再接続・キャンセルの取り決めだけに絞れます。その取り決めを文書にして初めて、動くだけでなく他の実装と相互運用できるトランスポートになります。

この記事を共有:XはてブLinkedIn
MCP をもっと見る →