Skip to content

Commit a719dbc

Browse files
authored
Fix HTTP MCP auth token precedence (#3156) (#3285)
1 parent 8a7ed16 commit a719dbc

8 files changed

Lines changed: 175 additions & 47 deletions

File tree

DEVELOPER_GUIDE.md

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1743,10 +1743,15 @@ Piping `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` into
17431743
extracted but *before* dispatch. The default `LocalStdioAuthenticator`
17441744
is permissive (matches the historical stdio behaviour and tags every
17451745
caller as `stdio` / `local`). Setting `CDIDX_MCP_AUTH_TOKEN` swaps in
1746-
`TokenMcpAuthenticator`, which requires every responded request to
1747-
carry a matching `params.auth.token` and compares it in constant time
1748-
via `CryptographicOperations.FixedTimeEquals`. Failures uniformly
1749-
return JSON-RPC `-32001 "Unauthorized"` (per #1530 sanitization — the
1746+
`TokenMcpAuthenticator` for stdio, which requires every responded request
1747+
to carry a matching `params.auth.token` and compares it in constant time
1748+
via `CryptographicOperations.FixedTimeEquals`. HTTP does not also use this
1749+
body-token gate: `ProgramRunner` resolves a bearer secret for the HTTP
1750+
transport from `CDIDX_MCP_HTTP_TOKEN`, falling back to `CDIDX_MCP_AUTH_TOKEN`
1751+
when the HTTP-specific variable is unset, and then relies on the
1752+
`Authorization: Bearer ...` transport check (#3156). For the JSON-RPC
1753+
body-token gate, failures uniformly return JSON-RPC `-32001 "Unauthorized"`
1754+
(per #1530 sanitization — the
17501755
wire never distinguishes missing-from-wrong), and `BuildAuthFailureLog`
17511756
emits the detailed reason to stderr. Notifications
17521757
(`notifications/initialized`, `notifications/cancelled`) short-circuit
@@ -1828,9 +1833,11 @@ return `-32600`.
18281833
The wildcard hosts `+` / `*` are rejected at parse time.
18291834
- Optional shared-secret auth: when `CDIDX_MCP_HTTP_TOKEN` is set the
18301835
listener requires `Authorization: Bearer <token>` on every request
1831-
and compares the token in constant time. The CLI refuses to bind to
1832-
a non-loopback host without a token to keep the MCP catalog off the
1833-
LAN by default.
1836+
and compares the token in constant time. If `CDIDX_MCP_HTTP_TOKEN` is unset,
1837+
HTTP falls back to `CDIDX_MCP_AUTH_TOKEN` as the bearer secret; when both
1838+
are set, `CDIDX_MCP_HTTP_TOKEN` wins. HTTP clients never need to also send
1839+
`params.auth.token`. The CLI refuses to bind to a non-loopback host without
1840+
either token to keep the MCP catalog off the LAN by default.
18341841
- Optional request-loop logging: `ProgramRunner` connects `HttpMcpTransport`
18351842
to `GlobalToolLog`, so persistent logging records one `mcp_http_request`
18361843
line per HTTP request when the lifecycle log is enabled. The record includes
@@ -1844,9 +1851,10 @@ return `-32600`.
18441851

18451852
Wire selection happens in `ProgramRunner.RunMcp`:
18461853
`--transport stdio|http` and `--http-listen <host:port>` are stripped
1847-
from the args before downstream parsing, the bearer token is read from
1848-
`CDIDX_MCP_HTTP_TOKEN`, and the dispatch lands in either the legacy
1849-
stdio path or `RunMcpHttp`. The pluggable seam keeps the JSON-RPC
1854+
from the args before downstream parsing, HTTP bearer-token resolution uses
1855+
`CDIDX_MCP_HTTP_TOKEN` first and `CDIDX_MCP_AUTH_TOKEN` as a fallback, and
1856+
the dispatch lands in either the legacy stdio path or `RunMcpHttp`. The
1857+
pluggable seam keeps the JSON-RPC
18501858
ordering invariant identical across both transports, so the existing
18511859
McpServer test surface (which exercises `ProcessLineAsync`) continues
18521860
to cover the per-method behavior, while `HttpMcpTransportTests` cover
@@ -3462,7 +3470,7 @@ sequenceDiagram
34623470
- `initialize` レスポンスは `protocolVersion`、`capabilities`、`serverInfo.name`、`serverInfo.version`(`ConsoleUi.LoadVersion()` — `version.json` が源)、および AI クライアントにツール選択を案内する長い `instructions` 文字列を返す。レスポンスを書き終えた後、サーバーはセッションごとに 1 回だけ互換性用の `notifications/initialized` ready signal を送るため、サーバー側の ready signal を待つクライアントも optimistic polling なしで進める(#1780)。MCP は `notifications/initialized` を client-to-server 通知としても定義しており、cdidx はその方向も no-op として受理する。非 HTTP transport では、この互換性 signal が唯一の server-origin emission です。HTTP session は `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` を設定した場合、opt-in の keep-alive notification も `/events` で受け取れる。HTTP transport では out-of-band 通知は接続済みの `/events` SSE stream にだけ配送され、POST のみのクライアントは initialize response だけを受け取り、別通知 frame は受け取らない。
34633471
- advertised capability には `tools``resources``prompts``logging` が含まれる。`logging` は MCP `notifications/message` を示し、`logging/setLevel``debug``info``notice``warning``error``critical``alert``emergency` を受け付ける。
34643472
- `protocolVersion`**ハードコードではなく交渉**で決まる(#1554)。サーバーは `McpServer.SupportedProtocolVersions`(新しい順: `2025-03-26`, `2024-11-05`)を保持し、`initialize` パラメータからクライアント要求バージョンを読み取って、対応集合にあればそれを返し(合意)、未指定/非文字列なら既定の最新バージョンに fallback し、対応外なら `error.data``requestedVersion``supportedVersions` を入れた JSON-RPC `-32602` で拒否する。これにより将来 MCP 仕様が改訂されても、wire format が黙ってずれるのではなく actionable な handshake 失敗として表面化する。配列を新バージョンで更新する際は `ProtocolVersion` を先頭エントリと揃えて意図的に bump する。
3465-
- **認証ミドルウェア**(#1559)。`McpServer` はパース済み JSON-RPC リクエストごとに、メソッド抽出 *後*・dispatch *前* で `IMcpAuthenticator` を呼ぶ。既定の `LocalStdioAuthenticator` は permissive で(従来の stdio 動作を維持し、呼び出し元を `stdio` / `local` でタグ付けする)、`CDIDX_MCP_AUTH_TOKEN` を設定すると `TokenMcpAuthenticator` に切り替わる。`TokenMcpAuthenticator` は応答が必要な全リクエストに対し、`params.auth.token` が一致することを要求し、比較は `CryptographicOperations.FixedTimeEquals` による定数時間比較で行う。失敗は統一された JSON-RPC `-32001 "Unauthorized"` を返し(#1530 の sanitization 方針に従い、ワイヤでは未提示と不一致を区別しない)、`BuildAuthFailureLog` が詳細を stderr に書き出す。通知(`notifications/initialized`、`notifications/cancelled`)は応答もエラーコードも持たないため、ゲート *より前* で short-circuit する。このミドルウェアが将来 transport の差し替え seam になる — ネットワーク listener は別の `IMcpAuthenticator` を提供しつつ、`McpCallerIdentity`(`Source` + `Subject`)の形を保ち、監査ログ(#1562)から再利用できる。
3473+
- **認証ミドルウェア**(#1559)。`McpServer` はパース済み JSON-RPC リクエストごとに、メソッド抽出 *後*・dispatch *前* で `IMcpAuthenticator` を呼ぶ。既定の `LocalStdioAuthenticator` は permissive で(従来の stdio 動作を維持し、呼び出し元を `stdio` / `local` でタグ付けする)、stdio では `CDIDX_MCP_AUTH_TOKEN` を設定すると `TokenMcpAuthenticator` に切り替わる。`TokenMcpAuthenticator` は応答が必要な全リクエストに対し、`params.auth.token` が一致することを要求し、比較は `CryptographicOperations.FixedTimeEquals` による定数時間比較で行う。HTTP はこの body token ゲートを重ねず、`ProgramRunner` が `CDIDX_MCP_HTTP_TOKEN` を優先し、未設定なら `CDIDX_MCP_AUTH_TOKEN` を fallback として bearer secret に解決して、`Authorization: Bearer ...` の transport check に一本化する(#3156)。JSON-RPC body token ゲートの失敗は統一された JSON-RPC `-32001 "Unauthorized"` を返し(#1530 の sanitization 方針に従い、ワイヤでは未提示と不一致を区別しない)、`BuildAuthFailureLog` が詳細を stderr に書き出す。通知(`notifications/initialized`、`notifications/cancelled`)は応答もエラーコードも持たないため、ゲート *より前* で short-circuit する。このミドルウェアが将来 transport の差し替え seam になる — ネットワーク listener は別の `IMcpAuthenticator` を提供しつつ、`McpCallerIdentity`(`Source` + `Subject`)の形を保ち、監査ログ(#1562)から再利用できる。
34663474

34673475
MCP は独立したシリアライズ戦略(オブジェクトを JSON などの転送形式に変換する方式のこと。CLI の `--json` 側は .NET 標準の `JsonSerializer` に任せる方式、MCP 側は `JsonObject` を手で組み立てる方式と、別の手段を採っている)を採るため、「そもそもバイナリは走るのか?」を確かめる最も頑健なスモークテスト(デプロイや起動直後に行う、基本動作だけを短時間で確認する簡易テストのこと。詳細な正しさではなく「煙が出ていないか=致命的に壊れていないか」を見るためこの名で呼ばれる)となる — .NET ホスト、`Program.Main`、CLI ルーティング、`ConsoleUi.LoadVersion()` に負荷をかけるが、SQLite には触れない(`search` など MCP の*ツール呼び出し*は SQLite に触れるが、`initialize` 単独では触れない)。
34683476

@@ -3482,11 +3490,11 @@ MCP は独立したシリアライズ戦略(オブジェクトを JSON など
34823490
- HTTP POST 1 件 = JSON-RPC フレーム 1 件で、対応する応答は HTTP レスポンスのボディ(`200 OK` / `application/json; charset=utf-8`)に乗る。通知は `204 No Content`。`GET /events` は将来のサーバー→クライアント frame 用に独立した `text/event-stream` subscription を開く。サーバーは `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` で keep-alive notification が opt-in された場合を除き、自発的な frame を送信しない。長寿命の event stream は通常の POST リクエストを塞がない。`/` への POST 以外は `405 Method Not Allowed`。空 / 空白のみのボディは stdio の空行と同じ扱いで `204 No Content` を返し、ループは殺さない — クライアントの誤動作で junk フレームに引っかからないため。リクエスト本文は `CDIDX_MCP_HTTP_MAX_REQUEST_BYTES`(既定: 1,000,000 bytes、最大: 16,777,216 bytes)で制限し、超過時は全量を buffer する前に `413 Payload Too Large` を返す。保留中 request queue は `CDIDX_MCP_HTTP_MAX_QUEUE_DEPTH`(既定: 64、最大: 1,024)で制限し、満杯時は無制限に work を保持せず `Retry-After: 1` 付きの `429 Too Many Requests` を返す。正でない値や数値でない環境変数値は既定にフォールバックし、最大値を超える値は listener 起動前に拒否する。
34833491
- SSE stream lifetime は active stream registry だけで表現し、その registry entry が削除された後に完了済み stream task を保持しない。
34843492
- `ResolveListenSpec("host:port")` は prefix を事前に解決するため、CLI が stderr に `Listening on http://...` を出せる。ポート `0` は一時 `TcpListener` を probe して空きポートを取得する。probe から `HttpListener.Start()` までの TOCTOU window は、本トランスポートが local-only / single-tenant 想定であるため許容する。ワイルドカードホスト `+` / `*` はパース時点で拒否する。
3485-
- 任意の共有秘密による認証: `CDIDX_MCP_HTTP_TOKEN` が設定されていれば、listener はすべてのリクエストに `Authorization: Bearer <token>` を要求し、定数時間で比較する。トークン未指定で非 loopback ホストへ bind しようとした場合、CLI は MCP カタログを LAN に漏らさないよう既定で拒否する。
3493+
- 任意の共有秘密による認証: `CDIDX_MCP_HTTP_TOKEN` が設定されていれば、listener はすべてのリクエストに `Authorization: Bearer <token>` を要求し、定数時間で比較する。`CDIDX_MCP_HTTP_TOKEN` が未設定なら HTTP は `CDIDX_MCP_AUTH_TOKEN` を bearer secret として fallback し、両方が設定されている場合は `CDIDX_MCP_HTTP_TOKEN` を優先する。HTTP クライアントが `params.auth.token` も送る必要はない。どちらのトークンも未指定で非 loopback ホストへ bind しようとした場合、CLI は MCP カタログを LAN に漏らさないよう既定で拒否する。
34863494
- 任意のリクエストループログ: `ProgramRunner``HttpMcpTransport``GlobalToolLog` に接続するため、lifecycle log が有効な場合は HTTP リクエストごとに `mcp_http_request` 行を 1 件記録する。記録内容は method、path、status、duration、auth outcome、remote peer、correlation id、利用可能な JSON-RPC request id で、リクエスト/レスポンス本文は含めない。
34873495
- キャンセルは `_listener.Stop()` に接続するため、シャットダウン時に `GetContextAsync()` が unblock する。`HttpListenerException` / `ObjectDisposedException` は EOS と同じ扱いで MCP ループを stdin クローズと同じ経路で終了させる。
34883496

3489-
ワイヤー選択は `ProgramRunner.RunMcp` で行う。`--transport stdio|http``--http-listen <host:port>` は下流の引数解析より前に取り除かれ、bearer token `CDIDX_MCP_HTTP_TOKEN` から読み、ディスパッチは旧来の stdio 経路または `RunMcpHttp` に着地する。プラガブルなシームは JSON-RPC 順序不変条件を両トランスポートで同一に保つので、既存の McpServer テスト群(`ProcessLineAsync` を叩く)は引き続きメソッド単位の挙動をカバーし、新トランスポートのワイヤーレベル契約は `HttpMcpTransportTests` がカバーする。
3497+
ワイヤー選択は `ProgramRunner.RunMcp` で行う。`--transport stdio|http``--http-listen <host:port>` は下流の引数解析より前に取り除かれ、HTTP bearer token 解決は `CDIDX_MCP_HTTP_TOKEN` を先に見て、未設定なら `CDIDX_MCP_AUTH_TOKEN` に fallback する。ディスパッチは旧来の stdio 経路または `RunMcpHttp` に着地する。プラガブルなシームは JSON-RPC 順序不変条件を両トランスポートで同一に保つので、既存の McpServer テスト群(`ProcessLineAsync` を叩く)は引き続きメソッド単位の挙動をカバーし、新トランスポートのワイヤーレベル契約は `HttpMcpTransportTests` がカバーする。
34903498

34913499
#### 構造化エラーエンベロープとサーバーコード — issue #1581
34923500

SECURITY.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ unless you explicitly add the optional token controls below.
1010

1111
The optional HTTP transport is also a local operator surface, not a public
1212
multi-tenant service. It rejects wildcard listen hosts, refuses non-loopback
13-
binds unless `CDIDX_MCP_HTTP_TOKEN` is set, and requires
14-
`Authorization: Bearer <token>` on every HTTP request when that token is
15-
configured.
13+
binds unless `CDIDX_MCP_HTTP_TOKEN` or `CDIDX_MCP_AUTH_TOKEN` is set, and
14+
requires `Authorization: Bearer <token>` on every HTTP request when a bearer
15+
secret is configured. `CDIDX_MCP_HTTP_TOKEN` takes precedence when both
16+
variables are set; `CDIDX_MCP_AUTH_TOKEN` is the HTTP bearer fallback and does
17+
not make HTTP clients also send `params.auth.token`.
1618

1719
Stdio requests can require a shared secret by setting `CDIDX_MCP_AUTH_TOKEN`.
1820
When the variable is unset, stdio keeps the historical local-trusted-client

0 commit comments

Comments
 (0)