diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 695ac7f..4b4e397 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,17 @@ jobs: with: node-version: "22" - run: node --check mcp-server/server/index.js + - run: node --check mcp-server/server/remote-client.js + # The bridge's deps are installed here, not only in CD: the worker's + # protocol contract test imports mcp-server/server/remote-client.js, which + # resolves @modelcontextprotocol/client out of mcp-server/node_modules. + - name: mcp-server install + run: npm ci + working-directory: mcp-server - name: mcp-server client migration tests run: node --test test/*.test.mjs working-directory: mcp-server - - name: worker install + typecheck + dry-run deploy + OAuth device-flow tests + - name: worker install + typecheck + dry-run deploy + unit and contract tests run: | npm ci npm run typecheck diff --git a/README.md b/README.md index c27ca7f..d7f80a0 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,17 @@ GitHub ──POST──▶ Cloudflare Worker ──▶ Durable Object (SQLite) - **Local MCP bridge** (.mcpb) proxies tool calls to the Worker and optionally connects via WebSocket for real-time channel notifications. - No local webhook receiver or tunnel required. +## Breaking change: MCP protocol revision 2026-07-28 + +From this release the Worker serves **MCP protocol revision 2026-07-28 only**. It keeps no compatibility lane for the previous revision. + +- **Bridge versions older than this release stop working.** They open a session with `initialize`, which the Worker no longer answers. The failure is quiet: the bridge does not crash, it returns the protocol error as tool output text. +- **Real-time channel notifications keep arriving, which hides the breakage.** The `/events` stream is not MCP and is unaffected, so a stale bridge still pushes event summaries while every tool call — including `mark_processed` — fails. The pending queue stops being cleared even though notifications look healthy. +- **Restart the MCP client to pick up the new bridge.** The bridge is launched with `npx`, and `@latest` is resolved at process start — an already-running Claude Desktop, Claude Code, or Codex keeps the copy it started with, however new the published version is. Quit it fully and reopen. +- **Pinning the bridge version leaves you stuck.** If your MCP client config pins a version older than this release, restarting does not help; remove the pin (or move it forward) first. + +The Worker and the bridge ship together, so a bridge from this release or later needs no configuration change. + ## Prerequisites | Component | Required | diff --git a/docs/0-requirements.ja.md b/docs/0-requirements.ja.md index 59a72f8..51e2bec 100644 --- a/docs/0-requirements.ja.md +++ b/docs/0-requirements.ja.md @@ -22,8 +22,8 @@ GitHub --POST--> Cloudflare Worker --> TenantRegistry DO | WebhookStore DO (SQLite) [per-tenant] | | +-- /mcp (Streamable HTTP) +-- WebSocket / SSE real-time stream - | WebhookMcpAgent DO +-- REST endpoints - | [per-tenant] /pending-status + | createMcpHandler +-- REST endpoints + | [stateless, per-request] /pending-status | +-- tools -> WebhookStore /pending-events | /webhook-events +-- /events (WebSocket/SSE) /event @@ -51,10 +51,26 @@ GitHub --POST--> Cloudflare Worker --> TenantRegistry DO 1. **Cloudflare Worker** — Webhook 受信、署名検証、テナントルーティング 2. **TenantRegistry Durable Object** — installation_id → account_id マッピング管理、テナント単位クォータ管理(単一インスタンス) 3. **WebhookStore Durable Object** — SQLite によるイベント永続化、REST/SSE エンドポイント(テナント別インスタンス: `store-{accountId}`) -4. **WebhookMcpAgent Durable Object** — MCP Streamable HTTP サーバー、ツール定義(テナント別インスタンス: `tenant-{accountId}`) +4. **ステートレス MCP ハンドラ** — MCP Streamable HTTP サーバー、ツール定義。プロトコル版 2026-07-28 でセッションが消えたため Durable Object ではなく、リクエストごとに `createMcpHandler` が組み立てる(#249) ローカルブリッジ(mcp-server/)は Worker に対するプロキシであり、データを保持しない。 +### プロトコル版と単レーン切替(#249) + +Worker は MCP プロトコル版 **2026-07-28(ステートレスコア)のみ**を提供する。`createMcpHandler` に `legacy: "reject"` を渡しており、2025 系のリクエスト(`initialize` を含む)は、この endpoint が提供する唯一の版を名指しした unsupported-protocol-version エラーで返る。 + +Worker のプロトコル版は、本 repo の 2 成果物(Worker と npx ブリッジ)のあいだの私的な契約である。`mcp-server/server.json` は stdio トランスポートのみを宣言しており、リモートトランスポートの宣言が無いため、第三者クライアントが Worker に直接到達する経路は存在しない。したがって Claude Desktop 側の対応状況は Worker 側移行の条件ではない。ブリッジは二面を持ち、両面は独立している: + +| 面 | 実装 | 版 | +|---|---|---| +| Claude Desktop に対してサーバー | SDK v1 stdio(`Server` クラス) | 2025 系 | +| Worker に対してクライアント | SDK v2(`@modelcontextprotocol/client`) | 2026-07-28 に pin | + +**互換レーンは実装しない。** 保守されないフォールバック経路は放置されて死にコード化する。畳む条件が観測可能(旧レーンへの到達がゼロになる)であっても、観測できることと観測しに行くことは別であり、運用上その判断は忘れられる。破断の吸収はコードではなく、**minor リリース + README 注意書き**という別チャネルで行う。 + +非対称の所在: Worker は全員共有の 1 デプロイなので一斉に飛ぶ。ブリッジは利用者ごとで、各自の MCP クライアント再起動で追随する(`@latest` 指定でも起動済みプロセスは古い版を保持する — #247 / #248)。版を固定している外部利用者は再起動しても復旧しない。**この層の切り捨てを受け入れる**というのが本決定である。 + + ## 機能要件 ### F1. Webhook 受信 @@ -94,7 +110,7 @@ GitHub --POST--> Cloudflare Worker --> TenantRegistry DO ### F3. MCP ツール -WebhookMcpAgent DO が以下のツールセットを提供する。ローカルブリッジはこれをプロキシする。 +ステートレス MCP ハンドラが以下のツールセットを提供する。ローカルブリッジはこれをプロキシする。 | ID | ツール名 | 引数 | 戻り値 | 要件 | |----|---------|------|--------|------| @@ -183,6 +199,14 @@ Worker は GitHub の web OAuth flow をホストする独自実装を備える | F7.10 | ローカルブリッジは初回ツール呼び出しで web flow が完了していない場合、polling をバックグラウンドに維持したまま、authorize URL と残り有効秒数を本文に含む `isError: true` の構造化ツール応答を即座に返す。2 回目以降の同一ツール呼び出しは、承認完了なら通常処理、未完了なら同じ auth-required 応答を返す(ポーリングは 1 本に serialize) | | F7.11 | ローカルブリッジは refresh 時に `invalid_grant` を受けた場合、直ちに全面 re-auth に遷移せず tokens file を再読み込みする。別プロセスが既に rotation を完了していれば、その最新 refresh_token を採用して再試行する(RC1: refresh desync の最小 fix。file lock は導入しない) | +**Dynamic Client Registration の位置づけ(#249 で確認、撤去は本 issue の範囲外):** + +MCP 2026-07-28 の deprecated レジストリ 6 件のうち本 repo に該当するのは Dynamic Client Registration(F7.2)のみ。移行先は Client ID Metadata Documents。最短撤去は 2027-07-28 以降の最初のリビジョンであり、しかも「最短撤去」は撤去が可能になる時点にすぎず、実際の撤去は Core Maintainer 判断でそれ以降にずれうる。仕様からの撤去は SDK に削除義務を課さない。 + +本移行では撤去しない。理由は相乗りの前提が成立しないこと — 本移行は OAuth の実装に一切触れておらず(ブリッジ側は transport の `authProvider` に繋ぎ直しただけ)、DCR 撤去は両面の認証設計変更になる。プロトコル切替と同一リリースに載せると、利用者が再起動して降りてくる先で破断面が二つ同時に開く。 + +該当しないもの: Roots / Sampling / Logging / `includeContext`(未使用)、HTTP+SSE トランスポート(Streamable HTTP のみを提供しているため未露出)。 + **GitHub App 前提条件:** - 使用する upstream endpoint: `https://github.com/login/oauth/authorize`(web), `POST https://github.com/login/oauth/access_token` @@ -220,10 +244,11 @@ Worker は GitHub の web OAuth flow をホストする独自実装を備える | ID | 制約 | |----|------| -| N3.1 | WebhookStore / McpAgent DO はテナント別インスタンス(`idFromName("store-{accountId}")` / `getAgentByName("tenant-{accountId}")`)で動作する。TenantRegistry DO は単一インスタンスで全テナントの installation-account マッピングを管理する | +| N3.1 | WebhookStore DO はテナント別インスタンス(`idFromName("store-{accountId}")`)で動作する。MCP 側にテナント別インスタンスは無い(2026-07-28 でセッションが消え、リクエストごとの props から store 名を解決する)。TenantRegistry DO は単一インスタンスで全テナントの installation-account マッピングを管理する | | N3.2 | SSE 接続は DO のメモリ内で管理される(DO eviction 時に切断) | -| N3.3 | ローカルブリッジはツール呼び出しごとに Worker セッションを再利用する(セッション失効時は自動リトライ) | -| N3.4 | Web OAuth callback 処理時に `GET /user/installations` で取得した accessible_account_ids(ユーザー + org)を GitHubUserProps に保存し、McpAgent が複数 store を並列クエリして結果をマージする。これにより org インストールのイベントもメンバーの MCP セッションから参照できる | +| N3.3 | Worker とブリッジのあいだにセッションは無い(2026-07-28 ステートレスコア、#249)。ブリッジが再利用するのは MCP クライアントとその transport であり、切断のコストは再接続一回に閉じる | +| N3.5 | Worker は 2026-07-28 のみを提供し、2025 系リクエスト(`initialize` を含む)を `legacy: "reject"` で拒否する。互換レーンは持たない(#249 決定 1)。ブリッジのクライアント面も同じ版に pin する | +| N3.4 | Web OAuth callback 処理時に `GET /user/installations` で取得した accessible_account_ids(ユーザー + org)を GitHubUserProps に保存し、MCP ツールが複数 store を並列クエリして結果をマージする。これにより org インストールのイベントもメンバーからも参照できる。accessible_account_ids はリクエストごとの props から読む | ## CI/CD @@ -280,8 +305,8 @@ npx のキャッシュ解決挙動そのものは本リポジトリの管理外 | パッケージ | 用途 | |-----------|------| -| agents | Cloudflare Agents SDK (McpAgent) | -| @modelcontextprotocol/sdk | MCP SDK | +| agents | Cloudflare Agents SDK (`createMcpHandler` — Worker 向けステートレス MCP ハンドラ) | +| @modelcontextprotocol/server | MCP SDK v2 サーバー(プロトコル版 2026-07-28。`agents` が非 optional peer として exact 2.0.0 を要求するため exact 固定) | | zod | スキーマバリデーション | OAuth 実装は自前(`worker/src/oauth.ts` + `worker/src/oauth-store.ts`)。`@cloudflare/workers-oauth-provider` は v0.11.0 で撤去済み。v0.11.1 で Worker-hosted web OAuth に切り替え(device authorization grant は撤去)。 @@ -290,7 +315,8 @@ OAuth 実装は自前(`worker/src/oauth.ts` + `worker/src/oauth-store.ts`) | パッケージ | 用途 | |-----------|------| -| @modelcontextprotocol/sdk | MCP SDK(`Server` クラス直接使用) | +| @modelcontextprotocol/sdk | MCP SDK v1(Claude Desktop に対するサーバー面。`Server` クラス直接使用) | +| @modelcontextprotocol/client | MCP SDK v2 クライアント(Worker に対するクライアント面。版 2026-07-28 に pin) | | eventsource | SSE クライアント | Node.js >= 18.0.0 が必要。 @@ -300,7 +326,8 @@ Node.js >= 18.0.0 が必要。 | パス | 用途 | |-----|------| | `worker/src/index.ts` | Cloudflare Worker エントリポイント | -| `worker/src/agent.ts` | WebhookMcpAgent DO(MCP ツール定義、テナント別インスタンス) | +| `worker/src/mcp.ts` | MCP サーバー factory(ツール定義。リクエストごとに生成、テナントは props から解決) | +| `worker/src/retired-do.ts` | 退役した WebhookMcpAgent クラス(過去 migration 制約のためだけに残す) | | `worker/src/store.ts` | WebhookStore DO(SQLite + SSE、テナント別インスタンス) | | `worker/src/tenant.ts` | TenantRegistry DO(installation-account マッピング、クォータ管理) | | `worker/src/oauth.ts` | Worker-hosted web OAuth 自前実装(metadata / register / authorize / callback / token / 独自 token 検証 middleware) | diff --git a/docs/0-requirements.md b/docs/0-requirements.md index de6ea15..f1839e6 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -22,8 +22,8 @@ GitHub ──POST──▶ Cloudflare Worker ──▶ TenantRegistry DO │ WebhookStore DO (SQLite) [per-tenant] │ │ ├── /mcp (Streamable HTTP) ├── WebSocket / SSE real-time stream - │ WebhookMcpAgent DO └── REST endpoints - │ [per-tenant] /pending-status + │ createMcpHandler └── REST endpoints + │ [stateless, per-request] /pending-status │ └── tools → WebhookStore /pending-events │ /webhook-events ├── /events (WebSocket/SSE) /event @@ -51,10 +51,26 @@ GitHub ──POST──▶ Cloudflare Worker ──▶ TenantRegistry DO 1. **Cloudflare Worker** — webhook 受信、署名検証、テナントルーティング 2. **TenantRegistry Durable Object** — installation_id → account_id マッピング管理、テナント単位クォータ管理(単一インスタンス) 3. **WebhookStore Durable Object** — SQLite によるイベント永続化、REST/WebSocket/SSE エンドポイント(テナント別インスタンス: `store-{accountId}`) -4. **WebhookMcpAgent Durable Object** — MCP Streamable HTTP サーバー、ツール定義(テナント別インスタンス: `tenant-{accountId}`) +4. **ステートレス MCP ハンドラ** — MCP Streamable HTTP サーバー、ツール定義。プロトコル版 2026-07-28 でセッションが消えたため Durable Object ではなく、リクエストごとに `createMcpHandler` が組み立てる(#249) ローカルブリッジ(mcp-server/)は Worker に対するプロキシであり、データを保持しない。 +### プロトコル版と単レーン切替(#249) + +Worker は MCP プロトコル版 **2026-07-28(ステートレスコア)のみ**を提供する。`createMcpHandler` に `legacy: "reject"` を渡しており、2025 系のリクエスト(`initialize` を含む)は、この endpoint が提供する唯一の版を名指しした unsupported-protocol-version エラーで返る。 + +Worker のプロトコル版は、本 repo の 2 成果物(Worker と npx ブリッジ)のあいだの私的な契約である。`mcp-server/server.json` は stdio トランスポートのみを宣言しており、リモートトランスポートの宣言が無いため、第三者クライアントが Worker に直接到達する経路は存在しない。したがって Claude Desktop 側の対応状況は Worker 側移行の条件ではない。ブリッジは二面を持ち、両面は独立している: + +| 面 | 実装 | 版 | +|---|---|---| +| Claude Desktop に対してサーバー | SDK v1 stdio(`Server` クラス) | 2025 系 | +| Worker に対してクライアント | SDK v2(`@modelcontextprotocol/client`) | 2026-07-28 に pin | + +**互換レーンは実装しない。** 保守されないフォールバック経路は放置されて死にコード化する。畳む条件が観測可能(旧レーンへの到達がゼロになる)であっても、観測できることと観測しに行くことは別であり、運用上その判断は忘れられる。破断の吸収はコードではなく、**minor リリース + README 注意書き**という別チャネルで行う。 + +非対称の所在: Worker は全員共有の 1 デプロイなので一斉に飛ぶ。ブリッジは利用者ごとで、各自の MCP クライアント再起動で追随する(`@latest` 指定でも起動済みプロセスは古い版を保持する — #247 / #248)。版を固定している外部利用者は再起動しても復旧しない。**この層の切り捨てを受け入れる**というのが本決定である。 + + ## Functional Requirements ### F1. Webhook 受信 @@ -94,7 +110,7 @@ GitHub ──POST──▶ Cloudflare Worker ──▶ TenantRegistry DO ### F3. MCP ツール -WebhookMcpAgent DO が以下のツールセットを提供する。ローカルブリッジはこれをプロキシする。 +ステートレス MCP ハンドラが以下のツールセットを提供する。ローカルブリッジはこれをプロキシする。 | ID | ツール名 | 引数 | 戻り値 | 要件 | |----|---------|------|--------|------| @@ -183,6 +199,14 @@ Worker は GitHub の web OAuth flow をホストする独自実装を備える | F7.10 | ローカルブリッジは初回ツール呼び出しで web flow が完了していない場合、polling をバックグラウンドに維持したまま、authorize URL と残り有効秒数を本文に含む `isError: true` の構造化ツール応答を即座に返す。2 回目以降の同一ツール呼び出しは、承認完了なら通常処理、未完了なら同じ auth-required 応答を返す(ポーリングは 1 本に serialize) | | F7.11 | ローカルブリッジは refresh 時に `invalid_grant` を受けた場合、直ちに全面 re-auth に遷移せず tokens file を再読み込みする。別プロセスが既に rotation を完了していれば、その最新 refresh_token を採用して再試行する(RC1: refresh desync の最小 fix。file lock は導入しない) | +**Dynamic Client Registration の位置づけ(#249 で確認、撤去は本 issue の範囲外):** + +MCP 2026-07-28 の deprecated レジストリ 6 件のうち本 repo に該当するのは Dynamic Client Registration(F7.2)のみ。移行先は Client ID Metadata Documents。最短撤去は 2027-07-28 以降の最初のリビジョンであり、しかも「最短撤去」は撤去が可能になる時点にすぎず、実際の撤去は Core Maintainer 判断でそれ以降にずれうる。仕様からの撤去は SDK に削除義務を課さない。 + +本移行では撤去しない。理由は相乗りの前提が成立しないこと — 本移行は OAuth の実装に一切触れておらず(ブリッジ側は transport の `authProvider` に繋ぎ直しただけ)、DCR 撤去は両面の認証設計変更になる。プロトコル切替と同一リリースに載せると、利用者が再起動して降りてくる先で破断面が二つ同時に開く。 + +該当しないもの: Roots / Sampling / Logging / `includeContext`(未使用)、HTTP+SSE トランスポート(Streamable HTTP のみを提供しているため未露出)。 + **GitHub App 前提条件:** - 使用する upstream endpoint: `https://github.com/login/oauth/authorize`(web), `POST https://github.com/login/oauth/access_token` @@ -235,10 +259,11 @@ Worker は GitHub の web OAuth flow をホストする独自実装を備える | ID | 制約 | |----|------| -| N3.1 | WebhookStore / McpAgent DO はテナント別インスタンス(`idFromName("store-{accountId}")` / `getAgentByName("tenant-{accountId}")`)で動作する。TenantRegistry DO は単一インスタンスで全テナントの installation-account マッピングを管理する | -| N3.4 | Web OAuth callback 処理時に `GET /user/installations` で取得した accessible_account_ids(ユーザー + org)を GitHubUserProps に保存し、McpAgent が複数 store を並列クエリして結果をマージする。これにより org インストールのイベントもメンバーの MCP セッションから参照できる | +| N3.1 | WebhookStore DO はテナント別インスタンス(`idFromName("store-{accountId}")`)で動作する。MCP 側にテナント別インスタンスは無い(2026-07-28 でセッションが消え、リクエストごとの props から store 名を解決する)。TenantRegistry DO は単一インスタンスで全テナントの installation-account マッピングを管理する | +| N3.4 | Web OAuth callback 処理時に `GET /user/installations` で取得した accessible_account_ids(ユーザー + org)を GitHubUserProps に保存し、MCP ツールが複数 store を並列クエリして結果をマージする。これにより org インストールのイベントもメンバーからも参照できる。accessible_account_ids はリクエストごとの props から読む | | N3.2 | WebSocket / SSE 接続は DO のメモリ内で管理される(DO eviction 時に切断) | -| N3.3 | ローカルブリッジはツール呼び出しごとに Worker セッションを再利用する(セッション失効時は自動リトライ) | +| N3.3 | Worker とブリッジのあいだにセッションは無い(2026-07-28 ステートレスコア、#249)。ブリッジが再利用するのは MCP クライアントとその transport であり、切断のコストは再接続一回に閉じる | +| N3.5 | Worker は 2026-07-28 のみを提供し、2025 系リクエスト(`initialize` を含む)を `legacy: "reject"` で拒否する。互換レーンは持たない(#249 決定 1)。ブリッジのクライアント面も同じ版に pin する | ## Dependencies @@ -246,8 +271,8 @@ Worker は GitHub の web OAuth flow をホストする独自実装を備える | パッケージ | 用途 | |-----------|------| -| agents | Cloudflare Agents SDK (McpAgent) | -| @modelcontextprotocol/sdk | MCP SDK | +| agents | Cloudflare Agents SDK (`createMcpHandler` — Worker 向けステートレス MCP ハンドラ) | +| @modelcontextprotocol/server | MCP SDK v2 サーバー(プロトコル版 2026-07-28。`agents` が非 optional peer として exact 2.0.0 を要求するため exact 固定) | | zod | スキーマバリデーション | OAuth 実装は自前(`worker/src/oauth.ts` + `worker/src/oauth-store.ts`)。`@cloudflare/workers-oauth-provider` は v0.11.0 で撤去済み。v0.11.1 で Worker-hosted web OAuth に切り替え(device authorization grant は撤去)。 @@ -256,7 +281,8 @@ OAuth 実装は自前(`worker/src/oauth.ts` + `worker/src/oauth-store.ts`) | パッケージ | 用途 | |-----------|------| -| @modelcontextprotocol/sdk | MCP SDK(`Server` クラス直接使用) | +| @modelcontextprotocol/sdk | MCP SDK v1(Claude Desktop に対するサーバー面。`Server` クラス直接使用) | +| @modelcontextprotocol/client | MCP SDK v2 クライアント(Worker に対するクライアント面。版 2026-07-28 に pin) | Node.js >= 18.0.0 が必要。 @@ -344,7 +370,8 @@ npx のキャッシュ解決挙動そのものは本リポジトリの管理外 | パス | 用途 | |-----|------| | `worker/src/index.ts` | Cloudflare Worker エントリポイント | -| `worker/src/agent.ts` | WebhookMcpAgent DO(MCP ツール定義、テナント別インスタンス) | +| `worker/src/mcp.ts` | MCP サーバー factory(ツール定義。リクエストごとに生成、テナントは props から解決) | +| `worker/src/retired-do.ts` | 退役した WebhookMcpAgent クラス(過去 migration 制約のためだけに残す) | | `worker/src/store.ts` | WebhookStore DO(SQLite + SSE、テナント別インスタンス) | | `worker/src/tenant.ts` | TenantRegistry DO(installation-account マッピング、クォータ管理) | | `worker/src/oauth.ts` | Worker-hosted web OAuth 自前実装(metadata / register / authorize / callback / token / 独自 token 検証 middleware) | diff --git a/docs/installation.ja.md b/docs/installation.ja.md index 9ead2a1..56b3758 100644 --- a/docs/installation.ja.md +++ b/docs/installation.ja.md @@ -131,11 +131,12 @@ Cloudflare ダッシュボードから Worker を作成し、GitHub リポジト 接続後は、リポジトリへの push で自動デプロイが行われます。 デプロイにより以下が自動的に作成されます: -- **WebhookMcpAgent** Durable Object — MCP ツール提供(テナント別) - **WebhookStore** Durable Object — イベント永続化(テナント別) - **TenantRegistry** Durable Object — テナント管理(単一インスタンス) - SQLite マイグレーションが自動適用 +MCP ツールは Durable Object ではなくリクエストごとに提供されます(プロトコル版 2026-07-28 のステートレスコア)。`WebhookMcpAgent` クラスは過去の migration 制約のためだけに残っており、トラフィックは受けません。 + ### 3. KV Namespace の作成 OAuth トークンの保存に使用する KV Namespace を作成します。 diff --git a/docs/installation.md b/docs/installation.md index 067f8ca..e332527 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -131,11 +131,12 @@ Cloudflare ダッシュボードから Worker を作成し、GitHub リポジト 接続後は、リポジトリへの push で自動デプロイが行われます。 デプロイにより以下が自動的に作成されます: -- **WebhookMcpAgent** Durable Object — MCP ツール提供(テナント別) - **WebhookStore** Durable Object — イベント永続化(テナント別) - **TenantRegistry** Durable Object — テナント管理(単一インスタンス) - SQLite マイグレーションが自動適用 +MCP ツールは Durable Object ではなくリクエストごとに提供されます(プロトコル版 2026-07-28 のステートレスコア)。`WebhookMcpAgent` クラスは過去の migration 制約のためだけに残っており、トラフィックは受けません。 + ### 3. KV Namespace の作成 OAuth トークンの保存に使用する KV Namespace を作成します。 diff --git a/local-mcp/package-lock.json b/local-mcp/package-lock.json index 9d141dc..42c518b 100644 --- a/local-mcp/package-lock.json +++ b/local-mcp/package-lock.json @@ -8,6 +8,7 @@ "name": "@github-webhook-mcp/local-bridge", "version": "0.0.1", "dependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@modelcontextprotocol/sdk": "^1.0.0", "ws": "^8.18.0" }, @@ -475,6 +476,36 @@ "hono": "^4" } }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.27.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", @@ -515,18 +546,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", @@ -868,6 +887,18 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/eventsource-parser": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", diff --git a/local-mcp/package.json b/local-mcp/package.json index 70f4892..7f9476e 100644 --- a/local-mcp/package.json +++ b/local-mcp/package.json @@ -11,6 +11,7 @@ "start": "npx tsx src/index.ts" }, "dependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@modelcontextprotocol/sdk": "^1.0.0", "ws": "^8.18.0" }, diff --git a/local-mcp/src/index.ts b/local-mcp/src/index.ts index 8593217..c479c66 100644 --- a/local-mcp/src/index.ts +++ b/local-mcp/src/index.ts @@ -17,6 +17,15 @@ * a sibling Claude Code process may have refreshed already, so we adopt * its rotation rather than starting a fresh web flow. * + * The bridge has two independent protocol faces (issue #249): + * + * Claude Desktop -> bridge : SDK v1 stdio server, 2025-era. Unchanged. + * bridge -> Worker : SDK v2 client pinned to protocol revision + * 2026-07-28. Stateless — no `initialize` + * handshake and no `mcp-session-id`; every + * request carries the per-request `_meta` + * envelope the revision requires. + * * Discord MCP pattern: data lives in the cloud, local MCP is a thin bridge. */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; @@ -25,6 +34,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import WebSocket from "ws"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { homedir, platform as osPlatform } from "node:os"; @@ -645,95 +655,88 @@ async function getAccessTokenForToolCall(): Promise { throw new Error("OAuth web flow did not produce an authorize URL in time."); } -async function buildAuthHeaders( - token: string | undefined, - extra?: Record, -): Promise> { - const h: Record = { ...extra }; - if (token) h["Authorization"] = `Bearer ${token}`; - return h; -} - -// ── Remote MCP Session (lazy, reused) ──────────────────────────────────────── - -let _sessionId: string | null = null; - -async function getSessionIdWithToken(token: string): Promise { - if (_sessionId) return _sessionId; - - const res = await fetch(`${WORKER_URL}/mcp`, { - method: "POST", - headers: await buildAuthHeaders(token, { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - }), - body: JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "local-bridge", version: "1.0.0" }, +// ── Remote MCP Client (lazy, reused) ───────────────────────────────────────── +// +// Protocol revision 2026-07-28, pinned (issue #249). The Worker serves that one +// revision and rejects every other, so negotiation would only add a round trip +// and a fallback branch that can never succeed. +// +// There is no session here any more: the revision makes every request +// self-contained, so `getSessionIdWithToken` and the `mcp-session-id` header +// are gone rather than migrated. The 401 retry that used to be wired by hand +// around the session is now the transport's, driven by `onUnauthorized`. What +// stays cached is the client object and its transport, not server state — a +// dropped connection costs a reconnect, never a lost session. +// +// This is the TypeScript twin of `mcp-server/server/remote-client.js`. Keep the +// two in step; the published bridge is the one users run. + +/** The single protocol revision the Worker serves. */ +const WORKER_PROTOCOL_VERSION = "2026-07-28"; + +let _remoteClient: Client | null = null; +let _connecting: Promise | null = null; + +async function getRemoteClient(): Promise { + if (_remoteClient) return _remoteClient; + + // Concurrent tool calls must share one connect attempt, not race two. + _connecting ??= (async () => { + const next = new Client( + { name: "github-webhook-mcp-local-bridge", version: "1.0.0" }, + { versionNegotiation: { mode: { pin: WORKER_PROTOCOL_VERSION } } }, + ); + const transport = new StreamableHTTPClientTransport(new URL(`${WORKER_URL}/mcp`), { + authProvider: { + token: () => getAccessTokenForToolCall(), + onUnauthorized: async () => { + _cachedTokens = null; + await getAccessTokenForToolCall(); + }, }, - id: "init", - }), - }); + }); + await next.connect(transport); + _remoteClient = next; + return next; + })(); - _sessionId = res.headers.get("mcp-session-id") || ""; - return _sessionId; + try { + return await _connecting; + } catch (err) { + // Let the next call retry from scratch rather than inherit the failure. + _connecting = null; + throw err; + } } -async function callRemoteToolWithToken( +/** Forget the cached client. The next call reconnects. */ +async function resetRemoteClient(): Promise { + const stale = _remoteClient; + _remoteClient = null; + _connecting = null; + if (stale) await stale.close().catch(() => {}); +} + +async function callRemoteTool( name: string, args: Record, - token: string, - _retried = false, ): Promise<{ content: Array<{ type: string; text: string }> }> { - const sessionId = await getSessionIdWithToken(token); - - const res = await fetch(`${WORKER_URL}/mcp`, { - method: "POST", - headers: await buildAuthHeaders(token, { - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - "mcp-session-id": sessionId, - }), - body: JSON.stringify({ - jsonrpc: "2.0", - method: "tools/call", - params: { name, arguments: args }, - id: crypto.randomUUID(), - }), - }); + // Resolve credentials first so an authorization requirement surfaces as + // AuthRequiredError from here, where the caller already handles it, rather + // than from inside the transport wrapped as a network failure. + await getAccessTokenForToolCall(); - // 401 = token expired or revoked. Clear session + token cache and retry - // once with a freshly acquired token (refresh or full flow). - if (res.status === 401) { - if (_retried) { - return { content: [{ type: "text", text: "Authentication failed after retry. Please re-authenticate." }] }; - } - _cachedTokens = null; - _sessionId = null; - const freshToken = await getAccessTokenForToolCall(); - return callRemoteToolWithToken(name, args, freshToken, true); - } - - const text = await res.text(); - - // Streamable HTTP may return SSE format - const dataLine = text.split("\n").find(l => l.startsWith("data: ")); - const json = dataLine ? JSON.parse(dataLine.slice(6)) : JSON.parse(text); - - if (json.error) { - // Session expired — retry once with a fresh session - if ((json.error.code === -32600 || json.error.code === -32001) && !_retried) { - _sessionId = null; - return callRemoteToolWithToken(name, args, token, true); - } - return { content: [{ type: "text", text: JSON.stringify(json.error) }] }; + const client = await getRemoteClient(); + try { + return (await client.callTool({ name, arguments: args })) as { + content: Array<{ type: string; text: string }>; + }; + } catch (err) { + // A dead transport would otherwise be cached forever. Dropping it costs + // one reconnect on the next call; keeping it costs every later call. + await resetRemoteClient(); + throw err; } - - return json.result; } // ── MCP Server Setup ───────────────────────────────────────────────────────── @@ -842,8 +845,7 @@ function formatAuthRequiredResponse(pending: PendingWebAuth): string { mcp.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { - const token = await getAccessTokenForToolCall(); - const result = await callRemoteToolWithToken(name, args ?? {}, token); + const result = await callRemoteTool(name, args ?? {}); // First successful tool call confirms OAuth is working markOAuthEstablished(); return result; diff --git a/mcp-server/README.md b/mcp-server/README.md index 6d1cfa5..8da1f56 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -4,6 +4,17 @@ Stdio MCP proxy that bridges local MCP clients (Claude Desktop, Claude Code, Cod This package is the **client-side proxy only**. Webhook ingestion, tenant routing, persistence, and the MCP server itself run on the Worker. See the [main repository](https://github.com/Liplus-Project/github-webhook-mcp) for architecture and self-hosting instructions. +## Breaking change: MCP protocol revision 2026-07-28 + +From this release the Worker serves **MCP protocol revision 2026-07-28 only**, with no compatibility lane for the previous revision. + +- **Proxy versions older than this release stop working.** They open a session with `initialize`, which the Worker no longer answers. The failure is quiet: the proxy does not crash, it returns the protocol error as tool output text. +- **Real-time channel notifications keep arriving, which hides the breakage.** The `/events` WebSocket stream is not MCP and is unaffected, so a stale proxy still pushes event summaries while every tool call — including `mark_processed` — fails. The pending queue stops being cleared even though notifications look healthy. +- **Restart the MCP client to pick up the new proxy.** `npx` resolves `@latest` at process start, so an already-running Claude Desktop, Claude Code, or Codex keeps the copy it launched with however new the published version is. Quit it fully and reopen. +- **Pinning the proxy version leaves you stuck.** If your MCP client config pins a version older than this release, restarting does not help; remove the pin (or move it forward) first. + +The proxy's two protocol faces are independent: it still speaks the 2025-era MCP revision to your client over stdio. Only the face toward the Worker moved. + ## What this proxy does - Speaks stdio MCP locally to your client. @@ -200,6 +211,7 @@ No localhost port is listened on at any point. The flow works the same way on he - **`OAuth state expired before approval. Re-run the client to retry.`** The state token expires after ~10 minutes. Trigger any tool call again to restart the flow. - **Browser lands on "Authorization failed" (Worker 502).** The Worker rejected the GitHub code exchange. On self-hosts this usually means the GitHub App's **Callback URL** does not include `https:///oauth/callback`, or `GITHUB_CLIENT_SECRET` is missing / wrong. - **`Failed to reach worker`.** Check that `WEBHOOK_WORKER_URL` is correct and reachable from your machine. +- **Every tool call returns a protocol error, but channel notifications still arrive.** The running proxy predates the 2026-07-28 revision the Worker now serves. Restart the MCP client so `npx` fetches the current version; if your config pins an older version, move the pin forward first. See the breaking-change note at the top of this page. - **`Authentication failed after retry`.** Cached tokens were rejected and re-authentication did not succeed. Remove `~/.github-webhook-mcp/oauth-tokens.json` and retry. - **Upgrading from v0.10.x / v0.11.0.** Existing tokens files are ignored (flow marker mismatch) and a fresh web-flow authorize URL is emitted on the next tool call. No manual cleanup is required. - **No events arriving.** Confirm that the GitHub App is installed on the target account/organization and that webhook deliveries are succeeding on the GitHub App's *Advanced* → *Recent Deliveries* page. The Worker only sees events for installations linked to your authenticated account. diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 9e2556b..571e716 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -1,14 +1,15 @@ { "name": "github-webhook-mcp", - "version": "0.11.1", + "version": "0.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "github-webhook-mcp", - "version": "0.11.1", + "version": "0.11.2", "license": "Apache-2.0", "dependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@modelcontextprotocol/sdk": "^1.0.0", "ws": "^8.18.0" }, @@ -276,6 +277,54 @@ "node": ">=18" } }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/client/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.27.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", @@ -316,18 +365,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@types/mute-stream": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", @@ -746,6 +783,18 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/eventsource-parser": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", diff --git a/mcp-server/package.json b/mcp-server/package.json index 89f7c5c..6c88ecf 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -18,6 +18,7 @@ "pack:mcpb": "mcpb pack" }, "dependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@modelcontextprotocol/sdk": "^1.0.0", "ws": "^8.18.0" }, diff --git a/mcp-server/server/index.js b/mcp-server/server/index.js index ee7f1c3..0e00f61 100644 --- a/mcp-server/server/index.js +++ b/mcp-server/server/index.js @@ -19,6 +19,19 @@ * a sibling Claude Code process may have refreshed already, so we adopt * its rotation rather than starting a fresh web flow. * + * The bridge has two independent protocol faces (issue #249): + * + * Claude Desktop -> bridge : SDK v1 stdio server, 2025-era. Unchanged. + * bridge -> Worker : SDK v2 client pinned to protocol revision + * 2026-07-28. Stateless — no `initialize` + * handshake and no `mcp-session-id`; every + * request carries the per-request `_meta` + * envelope the revision requires. + * + * The Worker's revision is a private contract between the artifacts of this + * repository (`server.json` declares stdio transport only, so nothing else + * reaches the Worker), so the Desktop face is not bound by it. + * * Discord MCP pattern: data lives in the cloud, local MCP is a thin bridge. */ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; @@ -34,6 +47,7 @@ import { createRequire } from "node:module"; import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import WebSocketClient from "ws"; +import { createRemoteClient } from "./remote-client.js"; const require = createRequire(import.meta.url); const { version: PACKAGE_VERSION } = require("../package.json"); @@ -595,87 +609,37 @@ async function getAccessTokenForToolCall() { throw new Error("OAuth web flow did not produce an authorize URL in time."); } -async function buildAuthHeaders(token, extra) { - const h = { ...extra }; - if (token) h["Authorization"] = `Bearer ${token}`; - return h; -} - -// ── Remote MCP Session (lazy, reused) ──────────────────────────────────────── - -let _sessionId = null; - -async function getSessionIdWithToken(token) { - if (_sessionId) return _sessionId; - - const res = await fetch(`${WORKER_URL}/mcp`, { - method: "POST", - headers: await buildAuthHeaders(token, { - "Content-Type": "application/json", - Accept: "application/json, text/event-stream", - }), - body: JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "local-bridge", version: "1.0.0" }, - }, - id: "init", - }), - }); - - _sessionId = res.headers.get("mcp-session-id") || ""; - return _sessionId; -} - -async function callRemoteToolWithToken(name, args, token, _retried = false) { - const sessionId = await getSessionIdWithToken(token); - - const res = await fetch(`${WORKER_URL}/mcp`, { - method: "POST", - headers: await buildAuthHeaders(token, { - "Content-Type": "application/json", - Accept: "application/json, text/event-stream", - "mcp-session-id": sessionId, - }), - body: JSON.stringify({ - jsonrpc: "2.0", - method: "tools/call", - params: { name, arguments: args }, - id: crypto.randomUUID(), - }), - }); - - // 401 = token expired or revoked. Clear session + token cache and retry - // once with a freshly acquired token (refresh or full flow). - if (res.status === 401) { - if (_retried) { - return { content: [{ type: "text", text: "Authentication failed after retry. Please re-authenticate." }] }; - } - _cachedTokens = null; - _sessionId = null; - const freshToken = await getAccessTokenForToolCall(); - return callRemoteToolWithToken(name, args, freshToken, true); - } - - const text = await res.text(); - - // Streamable HTTP may return SSE format - const dataLine = text.split("\n").find((l) => l.startsWith("data: ")); - const json = dataLine ? JSON.parse(dataLine.slice(6)) : JSON.parse(text); +// ── Remote MCP Client (lazy, reused) ───────────────────────────────────────── +// Construction and caching live in ./remote-client.js so they can be tested +// without importing this module (which connects the stdio transport on import). +// +// There is no session here any more: the 2026-07-28 revision makes every +// request self-contained, so `getSessionIdWithToken` and the `mcp-session-id` +// header are gone rather than migrated. The 401 retry that used to be wired by +// hand around the session is now the transport's, driven by `onUnauthorized`. + +const remote = createRemoteClient({ + workerUrl: WORKER_URL, + clientVersion: PACKAGE_VERSION, + // The OAuth flow above stays the source of tokens; this only hands the + // current one over, and clears the cache when the Worker says it is stale so + // the next `token()` re-mints. + authProvider: { + token: () => getAccessTokenForToolCall(), + onUnauthorized: async () => { + _cachedTokens = null; + await getAccessTokenForToolCall(); + }, + }, +}); - if (json.error) { - // Session expired — retry once with a fresh session - if ((json.error.code === -32600 || json.error.code === -32001) && !_retried) { - _sessionId = null; - return callRemoteToolWithToken(name, args, token, true); - } - return { content: [{ type: "text", text: JSON.stringify(json.error) }] }; - } +async function callRemoteTool(name, args) { + // Resolve credentials first so an authorization requirement surfaces as + // AuthRequiredError from here, where the caller already handles it, rather + // than from inside the transport wrapped as a network failure. + await getAccessTokenForToolCall(); - return json.result; + return await remote.callTool(name, args); } // ── MCP Server Setup ───────────────────────────────────────────────────────── @@ -900,8 +864,7 @@ function formatAuthRequiredResponse(pending) { server.setRequestHandler(CallToolRequestSchema, async (req) => { const { name, arguments: args } = req.params; try { - const token = await getAccessTokenForToolCall(); - const result = await callRemoteToolWithToken(name, args ?? {}, token); + const result = await callRemoteTool(name, args ?? {}); // First successful tool call confirms OAuth is working markOAuthEstablished(); if (name === "get_pending_status") { diff --git a/mcp-server/server/remote-client.js b/mcp-server/server/remote-client.js new file mode 100644 index 0000000..59b8a44 --- /dev/null +++ b/mcp-server/server/remote-client.js @@ -0,0 +1,92 @@ +/** + * The bridge's client face toward the Cloudflare Worker. + * + * Split out of index.js because index.js connects the stdio transport at + * import time, so nothing in it can be exercised by a test. This module has no + * side effects on import, which lets the Worker <-> bridge protocol contract be + * asserted directly (see `worker/test/mcp-stateless-contract.test.ts`). + * + * Protocol revision 2026-07-28, pinned (issue #249). The Worker serves that one + * revision and rejects every other, so negotiation would only add a round trip + * and a fallback branch that can never succeed. Pinning also means there is no + * `initialize` handshake and no `mcp-session-id`: each request carries the + * per-request `_meta` envelope the revision requires, and the SDK client + * attaches it. + * + * There is no session to hold. What is cached here is the client object and its + * transport, not server state — a dropped connection costs a reconnect, never a + * lost session. + * + * This file has a TypeScript twin in `local-mcp/src/index.ts` (the local + * development bridge). Both faces of both bridges must move together; the + * Worker's revision is a private contract between this repository's artifacts. + */ +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; + +/** The single protocol revision the Worker serves. */ +export const WORKER_PROTOCOL_VERSION = "2026-07-28"; + +/** + * Build a lazily-connecting MCP client for the Worker. + * + * @param {object} options + * @param {string} options.workerUrl Worker origin; `/mcp` is appended. + * @param {string} options.clientVersion Reported as this client's version. + * @param {object} [options.authProvider] Bearer credentials, per the SDK's + * `AuthProvider` shape (`token()` plus optional `onUnauthorized()`). + * @param {typeof fetch} [options.fetch] Fetch override; tests route it at an + * in-process handler instead of the network. + */ +export function createRemoteClient({ workerUrl, clientVersion, authProvider, fetch }) { + let client = null; + let connecting = null; + + async function getClient() { + if (client) return client; + + // Concurrent tool calls must share one connect attempt, not race two. + connecting ??= (async () => { + const next = new Client( + { name: "github-webhook-mcp-bridge", version: clientVersion }, + { versionNegotiation: { mode: { pin: WORKER_PROTOCOL_VERSION } } }, + ); + const transport = new StreamableHTTPClientTransport(new URL(`${workerUrl}/mcp`), { + ...(authProvider ? { authProvider } : {}), + ...(fetch ? { fetch } : {}), + }); + await next.connect(transport); + client = next; + return next; + })(); + + try { + return await connecting; + } catch (err) { + // Let the next call retry from scratch rather than inherit the failure. + connecting = null; + throw err; + } + } + + /** Forget the cached client. The next call reconnects. */ + async function reset() { + const stale = client; + client = null; + connecting = null; + if (stale) await stale.close().catch(() => {}); + } + + async function callTool(name, args) { + const active = await getClient(); + try { + return await active.callTool({ name, arguments: args }); + } catch (err) { + // A dead transport would otherwise be cached forever. Dropping it costs + // one reconnect on the next call; keeping it costs every later call. + await reset(); + throw err; + } + } + + return { getClient, callTool, reset }; +} diff --git a/worker/package-lock.json b/worker/package-lock.json index e3c2043..4a64f6a 100644 --- a/worker/package-lock.json +++ b/worker/package-lock.json @@ -8,8 +8,8 @@ "name": "@github-webhook-mcp/worker", "version": "0.0.1", "dependencies": { - "@modelcontextprotocol/sdk": "1.29.0", - "agents": "^0.19.0", + "@modelcontextprotocol/server": "2.0.0", + "agents": "^0.20.1", "zod": "^4.0.0" }, "devDependencies": { @@ -390,36 +390,6 @@ "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", "license": "MIT" }, - "node_modules/@cloudflare/codemode": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@cloudflare/codemode/-/codemode-0.5.1.tgz", - "integrity": "sha512-PcX5+qAvupi8p1bMLKhqvPHziZpDubbrxDIvVH+iuuNUaFyOxxWNS9HplfFqIULqUzDPdFf1w7IiSCKHp7GDgg==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.15", - "acorn": "^8.17.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.0", - "@tanstack/ai": ">=0.8.0 <1.0.0", - "ai": "^6.0.0 || ^7.0.0", - "zod": "^4.0.0" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - }, - "@tanstack/ai": { - "optional": true - }, - "ai": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", @@ -1743,6 +1713,7 @@ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.14.1" }, @@ -2286,13 +2257,45 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", + "peer": true, "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -2326,6 +2329,19 @@ } } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -2702,7 +2718,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -2753,12 +2769,6 @@ "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", "license": "MIT" }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" - }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -2877,6 +2887,7 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", + "peer": true, "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -2885,28 +2896,14 @@ "node": ">= 0.6" } }, - "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agents": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/agents/-/agents-0.19.0.tgz", - "integrity": "sha512-0/4p1eDRRI9PfOzZgpAH3OUQASkKGStqie9HN7KXji91dHnH+81UerYcPsMqFXqwB8qRi8onrvkoV8FL3RJ5tQ==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/agents/-/agents-0.20.1.tgz", + "integrity": "sha512-HQRYMeZpD3k8djYBH7atRPojZMee3NvmXkzsmMWXfdHZ94vMljmWqSsD1XZd70LovHyQrw6/R81AZZIsRiFM6Q==", "license": "MIT", "dependencies": { "@babel/plugin-proposal-decorators": "^8.0.2", "@cfworker/json-schema": "^4.1.1", - "@cloudflare/codemode": "^0.5.0", - "@modelcontextprotocol/sdk": "1.29.0", "@rolldown/plugin-babel": "^0.2.3", "cron-schedule": "^6.0.0", "esbuild": "^0.28.1", @@ -2922,6 +2919,10 @@ }, "peerDependencies": { "@ai-sdk/react": "^3.0.0 || ^4.0.0", + "@cloudflare/codemode": ">=0.5.0", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/sdk": "1.30.0", + "@modelcontextprotocol/server": "2.0.0", "@tanstack/ai": ">=0.10.2 <1.0.0", "@x402/core": "^2.0.0", "@x402/evm": "^2.0.0", @@ -2936,6 +2937,9 @@ "@ai-sdk/react": { "optional": true }, + "@cloudflare/codemode": { + "optional": true + }, "@tanstack/ai": { "optional": true }, @@ -3433,6 +3437,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3449,6 +3454,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -3520,6 +3526,7 @@ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", + "peer": true, "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", @@ -3578,6 +3585,7 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -3587,6 +3595,7 @@ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -3600,6 +3609,7 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", + "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -3668,6 +3678,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -3681,6 +3692,7 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -3696,6 +3708,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -3705,6 +3718,7 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.6.0" } @@ -3725,6 +3739,7 @@ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", + "peer": true, "dependencies": { "object-assign": "^4", "vary": "^1" @@ -3751,6 +3766,7 @@ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", + "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3765,6 +3781,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -3782,6 +3799,7 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -3801,6 +3819,7 @@ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", + "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -3814,7 +3833,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/electron-to-chromium": { "version": "1.5.402", @@ -3844,6 +3864,7 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -3863,6 +3884,7 @@ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } @@ -3872,6 +3894,7 @@ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } @@ -3888,6 +3911,7 @@ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -3950,7 +3974,8 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/estree-walker": { "version": "3.0.3", @@ -3967,6 +3992,7 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -3982,6 +4008,7 @@ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "license": "MIT", + "peer": true, "dependencies": { "eventsource-parser": "^3.0.1" }, @@ -3994,6 +4021,7 @@ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" } @@ -4013,6 +4041,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4056,6 +4085,7 @@ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", "license": "MIT", + "peer": true, "dependencies": { "ip-address": "10.1.0" }, @@ -4073,7 +4103,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fast-uri": { "version": "3.1.0", @@ -4089,7 +4120,8 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/fdir": { "version": "6.5.0", @@ -4114,6 +4146,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", @@ -4135,6 +4168,7 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -4144,6 +4178,7 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -4167,6 +4202,7 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -4207,6 +4243,7 @@ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", + "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -4231,6 +4268,7 @@ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", + "peer": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -4257,6 +4295,7 @@ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" }, @@ -4269,6 +4308,7 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" }, @@ -4281,6 +4321,7 @@ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", + "peer": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -4293,6 +4334,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz", "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -4302,6 +4344,7 @@ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", + "peer": true, "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", @@ -4322,6 +4365,7 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -4348,13 +4392,15 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", + "peer": true, "engines": { "node": ">= 12" } @@ -4364,6 +4410,7 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.10" } @@ -4372,19 +4419,22 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/jose": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -4417,13 +4467,15 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json-schema-typed": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/json5": { "version": "2.2.3", @@ -4735,6 +4787,7 @@ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } @@ -4744,6 +4797,7 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -4753,6 +4807,7 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -4765,6 +4820,7 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -4774,6 +4830,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", + "peer": true, "dependencies": { "mime-db": "^1.54.0" }, @@ -4847,7 +4904,8 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/nanoid": { "version": "5.1.16", @@ -4872,6 +4930,7 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -4891,6 +4950,7 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4900,6 +4960,7 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" }, @@ -4925,6 +4986,7 @@ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", + "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -4937,6 +4999,7 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", + "peer": true, "dependencies": { "wrappy": "1" } @@ -4946,6 +5009,7 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -4972,6 +5036,7 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -4981,6 +5046,7 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -5016,6 +5082,7 @@ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.20.0" } @@ -5073,6 +5140,7 @@ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", + "peer": true, "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -5086,6 +5154,7 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -5101,6 +5170,7 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -5110,6 +5180,7 @@ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", + "peer": true, "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", @@ -5135,6 +5206,7 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5187,6 +5259,7 @@ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", @@ -5202,7 +5275,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { "version": "7.7.4", @@ -5221,6 +5295,7 @@ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", @@ -5247,6 +5322,7 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", + "peer": true, "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", @@ -5265,7 +5341,8 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/sharp": { "version": "0.34.5", @@ -5317,6 +5394,7 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", + "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -5329,6 +5407,7 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -5338,6 +5417,7 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -5357,6 +5437,7 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -5373,6 +5454,7 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -5391,6 +5473,7 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -5434,6 +5517,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -5539,6 +5623,7 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.6" } @@ -5575,6 +5660,7 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", + "peer": true, "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", @@ -5623,6 +5709,7 @@ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -5663,6 +5750,7 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -5840,6 +5928,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", + "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -5951,7 +6040,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ws": { "version": "8.18.0", @@ -6078,6 +6168,7 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", + "peer": true, "peerDependencies": { "zod": "^3.25 || ^4" } diff --git a/worker/package.json b/worker/package.json index d24d6e7..e9e7909 100644 --- a/worker/package.json +++ b/worker/package.json @@ -10,8 +10,8 @@ "test": "tsx --test test/*.test.ts && vitest run --config vitest.workers.config.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "1.29.0", - "agents": "^0.19.0", + "@modelcontextprotocol/server": "2.0.0", + "agents": "^0.20.1", "zod": "^4.0.0" }, "devDependencies": { @@ -23,10 +23,11 @@ "wrangler": "^4.0.0" }, "comments": { - "overrides": "Pin @cloudflare/workers-types across the tree to the version the worker code is written against; the vitest-pool-workers toolchain otherwise pulls a newer one whose stricter binding types can break existing src types (types-only; runtime unaffected). Pin @modelcontextprotocol/sdk to exactly 1.29.0 so the tree holds a single copy: agents@0.19.x declares exactly 1.29.0 as well, and a second copy beside it makes McpServer's private _serverInfo incompatible at the type level (types-only). Keep the exact version rather than a ^ range: 1.30.0 is already published (2026-07-27), and since this overrides entry propagates the direct spec across the tree, ^1.29.0 would carry every copy onto a version never checked against agents@0.19.x." + "overrides": "Pin @cloudflare/workers-types across the tree to the version the worker code is written against; the vitest-pool-workers toolchain otherwise pulls a newer one whose stricter binding types can break existing src types (types-only; runtime unaffected). The pin is load-bearing, not decorative: agents nests partyserver, whose own peer range this version does not satisfy, so npm arranges the tree around it rather than hoisting a newer copy (issue #252 tracks that state).", + "@modelcontextprotocol/server": "Exact, not a range: agents@0.20.x declares this package as a non-optional peer at exactly 2.0.0, so a caret here would let npm resolve a newer copy, nest 2.0.0 under agents/, and reproduce the two-copies type mismatch #239 was opened about. Exact makes the coupling loud — an agents bump that moves the peer fails at install time instead of at tsc.", + "@modelcontextprotocol/sdk": "Deliberately absent. The worker no longer imports SDK v1 anywhere after the 2026-07-28 flip (#249); agents still needs it as a required peer, so npm installs it, but this package declares no range of its own to drift against agents' exact pin. The two bridges keep their own SDK v1 dependency for the Claude Desktop face (mcp-server/package.json, local-mcp/package.json)." }, "overrides": { - "@cloudflare/workers-types": "$@cloudflare/workers-types", - "@modelcontextprotocol/sdk": "$@modelcontextprotocol/sdk" + "@cloudflare/workers-types": "$@cloudflare/workers-types" } } diff --git a/worker/src/agent.ts b/worker/src/agent.ts deleted file mode 100644 index b1de27f..0000000 --- a/worker/src/agent.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * McpAgent Durable Object — exposes MCP tools that read from WebhookStore DO. - * This DO handles MCP protocol; data lives in the separate WebhookStore DO. - * - * Per-tenant: each tenant gets its own McpAgent instance via - * getAgentByName("tenant-{accountId}"). The agent routes getStore() - * to a tenant-specific WebhookStore DO using idFromName("store-{accountId}"). - * - * Multi-account: when accessible_account_ids is set (user + orgs), tools - * aggregate results from all accessible stores to surface org events. - */ -import { McpAgent } from "agents/mcp"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import type { PendingStatus, EventSummary, WebhookEvent } from "../../shared/src/types.js"; -import { mergeMarkResults, type StoreBatchResponse } from "./mark-results.js"; - -interface Env { - MCP_OBJECT: DurableObjectNamespace; - WEBHOOK_STORE: DurableObjectNamespace; - TENANT_REGISTRY: DurableObjectNamespace; -} - -/** - * Upper bound on ids per batched mark_processed call. Matches the 100 ceiling - * the listing tools use for `limit`, so a batch can always clear one full page - * of pending events. - */ -const MARK_BATCH_MAX = 100; - -/** Tenant context passed via props when creating per-tenant instances */ -export type TenantProps = { - account_id?: number; - account_login?: string; - /** All account IDs (user + orgs) whose stores this session can read */ - accessible_account_ids?: number[]; -}; - -export class WebhookMcpAgent extends McpAgent { - server = new McpServer({ - name: "github-webhook-mcp", - version: "1.0.0", - }); - - /** - * Returns all store names this session can access. - * Falls back to a single store derived from account_id or "singleton". - */ - private getStoreNames(): string[] { - const ids = this.props?.accessible_account_ids; - if (ids && ids.length > 0) { - return ids.map((id) => `store-${id}`); - } - const accountId = this.props?.account_id; - if (accountId !== undefined) { - return [`store-${accountId}`]; - } - return ["singleton"]; - } - - private getStores(): DurableObjectStub[] { - return this.getStoreNames().map((name) => { - const id = this.env.WEBHOOK_STORE.idFromName(name); - return this.env.WEBHOOK_STORE.get(id); - }); - } - - /** POST a mark-processed body (singular or batch) to one store. */ - private markRequest(store: DurableObjectStub, body: unknown): Promise { - return store.fetch( - new Request("https://store/mark-processed", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }), - ); - } - - async init() { - this.server.tool( - "get_pending_status", - "Get a lightweight snapshot of pending GitHub webhook events", - {}, - async () => { - const stores = this.getStores(); - const results = await Promise.all( - stores.map((s) => - s.fetch(new Request("https://store/pending-status")).then((r) => r.json() as Promise), - ), - ); - - const merged: PendingStatus = { - pending_count: 0, - latest_received_at: null, - types: {}, - }; - for (const data of results) { - merged.pending_count += data.pending_count; - if (data.latest_received_at) { - if (!merged.latest_received_at || data.latest_received_at > merged.latest_received_at) { - merged.latest_received_at = data.latest_received_at; - } - } - for (const [type, count] of Object.entries(data.types || {})) { - merged.types[type] = (merged.types[type] || 0) + count; - } - } - - return { content: [{ type: "text", text: JSON.stringify(merged, null, 2) }] }; - }, - ); - - this.server.tool( - "list_pending_events", - "List lightweight summaries for pending GitHub webhook events", - { limit: z.number().min(1).max(100).default(20) }, - async ({ limit }) => { - const stores = this.getStores(); - const results = await Promise.all( - stores.map((s) => - s.fetch(new Request(`https://store/pending-events?limit=${limit}`)).then((r) => r.json() as Promise), - ), - ); - - const all = results.flat(); - all.sort((a, b) => b.received_at.localeCompare(a.received_at)); - const data = all.slice(0, limit); - - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, - ); - - this.server.tool( - "get_event", - "Get the full payload for a single webhook event by ID", - { event_id: z.string() }, - async ({ event_id }) => { - for (const store of this.getStores()) { - const res = await store.fetch( - new Request(`https://store/event?id=${encodeURIComponent(event_id)}`), - ); - if (res.ok) { - const data = await res.json(); - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - } - } - return { content: [{ type: "text", text: `Event ${event_id} not found` }], isError: true }; - }, - ); - - this.server.tool( - "get_webhook_events", - "Get pending (unprocessed) GitHub webhook events with full payloads", - { limit: z.number().min(1).max(100).default(20).optional() }, - async ({ limit }) => { - const l = limit ?? 20; - const stores = this.getStores(); - const results = await Promise.all( - stores.map((s) => - s.fetch(new Request(`https://store/webhook-events?limit=${l}`)).then((r) => r.json() as Promise), - ), - ); - - const all = results.flat(); - all.sort((a, b) => b.received_at.localeCompare(a.received_at)); - const data = all.slice(0, l); - - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; - }, - ); - - this.server.tool( - "mark_processed", - "Mark webhook events as processed. Pass event_ids to clear a whole batch in one call (preferred when several events were handled together); event_id marks a single event.", - { - event_id: z.string().optional(), - // min(1) on the ITEM, not just the array: an empty-string id would pass - // schema validation, miss in every store, and reach the caller as - // "not found" — a misleading verdict for what is really a malformed - // request. Rejecting it here keeps "not found" the only per-id error. - event_ids: z.array(z.string().min(1)).min(1).max(MARK_BATCH_MAX).optional(), - }, - async ({ event_id, event_ids }) => { - const stores = this.getStores(); - - // ── Batch form (#245): one round trip for N ids ── - if (event_ids) { - const perStore = await Promise.all( - stores.map((s) => - this.markRequest(s, { event_ids }).then((r) => r.json() as Promise), - ), - ); - - // Per-id verdict resolution across the fan-out lives in - // mark-results.ts (unit-tested there). - const summary = mergeMarkResults(event_ids, perStore); - return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] }; - } - - // ── Singular form: response shape unchanged ── - if (!event_id) { - return { - content: [{ type: "text", text: "mark_processed requires event_id or event_ids" }], - isError: true, - }; - } - // Try all stores — the event lives in exactly one, others are no-ops - const results = await Promise.all( - stores.map((s) => this.markRequest(s, { event_id }).then((r) => r.json())), - ); - // Return the first successful result - return { content: [{ type: "text", text: JSON.stringify(results[0]) }] }; - }, - ); - } -} diff --git a/worker/src/index.ts b/worker/src/index.ts index 4b4859c..f532b51 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -15,10 +15,18 @@ * POST /oauth/token Web-auth polling + refresh_token (oauth.ts) * * POST /webhooks/github Webhook ingest (no auth) - * POST /mcp MCP protocol (Bearer token) + * POST /mcp MCP protocol (Bearer token, revision 2026-07-28, stateless) * GET /events SSE/WebSocket stream (Bearer token) + * + * Durable Objects: + * WebhookStore Per-tenant webhook event store (SQLite-backed) + * TenantRegistry installation_id -> account mapping + * WebhookMcpAgent retired MCP-serving class, exported only to satisfy the + * past-migration constraint (see retired-do.ts) */ -import { WebhookMcpAgent } from "./agent.js"; +import { createMcpHandler, type StatelessMcpHandler } from "agents/mcp/server"; +import { createWebhookMcpServer } from "./mcp.js"; +import { WebhookMcpAgent } from "./retired-do.js"; import { WebhookStore } from "./store.js"; import { TenantRegistry } from "./tenant.js"; import { @@ -104,10 +112,37 @@ async function resolveInstallationTenant( return info; } -// McpAgent.serve() returns a fetch handler for MCP protocol. -// It reads ctx.props (set below from the authenticated grant) and passes them -// to the DO via getAgentByName. -const mcpHandler = WebhookMcpAgent.serve("/mcp"); +/** + * MCP handler for the 2026-07-28 stateless core (issue #249). + * + * `legacy: "reject"` is the single-lane decision made literal: 2025-era + * traffic (`initialize` + `mcp-session-id`) is answered with the + * unsupported-protocol-version error naming the one revision this endpoint + * serves, rather than being routed to a compatibility lane. There is no + * fallback path to go stale. + * + * The handler is memoized per `env` rather than built at module scope, because + * the server factory needs the bindings and `env` only exists inside `fetch`. + * A Worker isolate sees one `env`, so this resolves to a single handler in + * practice; the WeakMap states that rather than assuming it. + * + * Per-request tenant identity does NOT come through here. The handler reads + * `ctx.props` (rewritten below) and republishes it per request, which `mcp.ts` + * reads back via `getMcpAuthContext()`. + */ +const mcpHandlers = new WeakMap(); + +function getMcpHandler(env: Env): StatelessMcpHandler { + let handler = mcpHandlers.get(env); + if (!handler) { + handler = createMcpHandler(() => createWebhookMcpServer(env), { + route: "/mcp", + legacy: "reject", + }); + mcpHandlers.set(env, handler); + } + return handler; +} /** * Top-level fetch handler. Routes OAuth endpoints to oauth.ts, authenticates @@ -235,13 +270,15 @@ export default { } if (isMcpRoute) { - // Rewrite ctx.props to TenantProps shape expected by WebhookMcpAgent. + // Rewrite ctx.props to the TenantProps shape the tool handlers read. (ctx as unknown as { props: { account_id: number; account_login: string; accessible_account_ids: number[] } }).props = { account_id: props.githubUserId, account_login: props.githubLogin, accessible_account_ids: props.accessibleAccountIds ?? [props.githubUserId], }; - return mcpHandler.fetch(request, env, ctx); + // Callable form (not `.fetch`): only this one reads `ctx.props` and + // republishes it as the per-request auth context. + return getMcpHandler(env)(request, env, ctx); } } diff --git a/worker/src/mcp.ts b/worker/src/mcp.ts new file mode 100644 index 0000000..cd7ba1c --- /dev/null +++ b/worker/src/mcp.ts @@ -0,0 +1,286 @@ +/** + * MCP server factory exposing the webhook-inbox tools. + * + * Tools: + * get_pending_status — lightweight pending snapshot + * list_pending_events — pending summaries, no payloads + * get_event — one event's full payload by id + * get_webhook_events — pending events with full payloads + * mark_processed — clear one event or a batch + * + * Protocol revision 2026-07-28 (stateless core, issue #249). The server is + * built fresh per HTTP request by `createMcpHandler` in `index.ts` — there is + * no session, no `initialize`, and no Durable Object in the serving path. What + * `McpAgent` used to provide in two roles is now split: instance resolution + * from a session ID is gone outright, and tenant identity comes from the OAuth + * props of the request being served, read through `getMcpAuthContext()`. + * + * Multi-tenancy is unchanged in meaning. Each request still resolves to the + * caller's own WebhookStore DOs via `store-{accountId}`, and a caller whose + * grant lists several accessible accounts still reads every one of them. What + * changed is where the account set comes from: an instance field on a + * per-tenant agent DO before, the request's own props now. + * + * The Durable Objects that hold real data (`WebhookStore` / `TenantRegistry`) + * are untouched; only the MCP-serving DO left the path. Its retired class stub + * lives in `retired-do.ts` so this module imports nothing from + * `cloudflare:workers` and stays loadable outside workerd — which is what lets + * the serving contract be tested in plain node + * (`test/mcp-stateless-contract.test.ts`). + */ +import { getMcpAuthContext } from "agents/mcp/server"; +import { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import type { PendingStatus, EventSummary, WebhookEvent } from "../../shared/src/types.js"; +import { mergeMarkResults, type StoreBatchResponse } from "./mark-results.js"; + +interface Env { + MCP_OBJECT: DurableObjectNamespace; + WEBHOOK_STORE: DurableObjectNamespace; + TENANT_REGISTRY: DurableObjectNamespace; +} + +/** + * Upper bound on ids per batched mark_processed call. Matches the 100 ceiling + * the listing tools use for `limit`, so a batch can always clear one full page + * of pending events. + */ +const MARK_BATCH_MAX = 100; + +/** Tenant context carried on the request's OAuth props. */ +export type TenantProps = { + account_id?: number; + account_login?: string; + /** All account IDs (user + orgs) whose stores this request can read */ + accessible_account_ids?: number[]; +}; + +/** + * The tenant props for the request being served. + * + * `index.ts` writes them onto `ctx` before handing the request to the MCP + * handler; the handler republishes them per request through an + * AsyncLocalStorage store, which is what `getMcpAuthContext()` reads. There is + * no instance field to hold them any more — the server object itself lives + * only for the duration of one request. + */ +function getTenantProps(): TenantProps | undefined { + return getMcpAuthContext()?.props as TenantProps | undefined; +} + +/** + * Every store name this request may read. + * + * Falls back to a single store derived from account_id, then to "singleton". + * The fallback chain is carried over verbatim from the agent DO: a grant + * predating the multi-account claim carries account_id only, and the + * "singleton" tail is the pre-multi-tenant store name. + */ +function getStoreNames(): string[] { + const props = getTenantProps(); + const ids = props?.accessible_account_ids; + if (ids && ids.length > 0) { + return ids.map((id) => `store-${id}`); + } + const accountId = props?.account_id; + if (accountId !== undefined) { + return [`store-${accountId}`]; + } + return ["singleton"]; +} + +function getStores(env: Env): DurableObjectStub[] { + return getStoreNames().map((name) => { + const id = env.WEBHOOK_STORE.idFromName(name); + return env.WEBHOOK_STORE.get(id); + }); +} + +/** POST a mark-processed body (singular or batch) to one store. */ +function markRequest(store: DurableObjectStub, body: unknown): Promise { + return store.fetch( + new Request("https://store/mark-processed", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +export function createWebhookMcpServer(env: Env): McpServer { + const server = new McpServer({ + name: "github-webhook-mcp", + version: "1.0.0", + }); + + // ── get_pending_status ────────────────────────────── + server.registerTool( + "get_pending_status", + { + description: "Get a lightweight snapshot of pending GitHub webhook events", + inputSchema: z.object({}), + }, + async () => { + const stores = getStores(env); + const results = await Promise.all( + stores.map((s) => + s + .fetch(new Request("https://store/pending-status")) + .then((r) => r.json() as Promise), + ), + ); + + const merged: PendingStatus = { + pending_count: 0, + latest_received_at: null, + types: {}, + }; + for (const data of results) { + merged.pending_count += data.pending_count; + if (data.latest_received_at) { + if (!merged.latest_received_at || data.latest_received_at > merged.latest_received_at) { + merged.latest_received_at = data.latest_received_at; + } + } + for (const [type, count] of Object.entries(data.types || {})) { + merged.types[type] = (merged.types[type] || 0) + count; + } + } + + return { content: [{ type: "text" as const, text: JSON.stringify(merged, null, 2) }] }; + }, + ); + + // ── list_pending_events ───────────────────────────── + server.registerTool( + "list_pending_events", + { + description: "List lightweight summaries for pending GitHub webhook events", + inputSchema: z.object({ + limit: z.number().min(1).max(100).default(20), + }), + }, + async ({ limit }) => { + const stores = getStores(env); + const results = await Promise.all( + stores.map((s) => + s + .fetch(new Request(`https://store/pending-events?limit=${limit}`)) + .then((r) => r.json() as Promise), + ), + ); + + const all = results.flat(); + all.sort((a, b) => b.received_at.localeCompare(a.received_at)); + const data = all.slice(0, limit); + + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + }, + ); + + // ── get_event ─────────────────────────────────────── + server.registerTool( + "get_event", + { + description: "Get the full payload for a single webhook event by ID", + inputSchema: z.object({ + event_id: z.string(), + }), + }, + async ({ event_id }) => { + for (const store of getStores(env)) { + const res = await store.fetch( + new Request(`https://store/event?id=${encodeURIComponent(event_id)}`), + ); + if (res.ok) { + const data = await res.json(); + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + } + } + return { + content: [{ type: "text" as const, text: `Event ${event_id} not found` }], + isError: true, + }; + }, + ); + + // ── get_webhook_events ────────────────────────────── + server.registerTool( + "get_webhook_events", + { + description: "Get pending (unprocessed) GitHub webhook events with full payloads", + inputSchema: z.object({ + limit: z.number().min(1).max(100).default(20).optional(), + }), + }, + async ({ limit }) => { + const l = limit ?? 20; + const stores = getStores(env); + const results = await Promise.all( + stores.map((s) => + s + .fetch(new Request(`https://store/webhook-events?limit=${l}`)) + .then((r) => r.json() as Promise), + ), + ); + + const all = results.flat(); + all.sort((a, b) => b.received_at.localeCompare(a.received_at)); + const data = all.slice(0, l); + + return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] }; + }, + ); + + // ── mark_processed ────────────────────────────────── + server.registerTool( + "mark_processed", + { + description: + "Mark webhook events as processed. Pass event_ids to clear a whole batch in one call (preferred when several events were handled together); event_id marks a single event.", + inputSchema: z.object({ + event_id: z.string().optional(), + // min(1) on the ITEM, not just the array: an empty-string id would pass + // schema validation, miss in every store, and reach the caller as + // "not found" — a misleading verdict for what is really a malformed + // request. Rejecting it here keeps "not found" the only per-id error. + event_ids: z.array(z.string().min(1)).min(1).max(MARK_BATCH_MAX).optional(), + }), + }, + async ({ event_id, event_ids }) => { + const stores = getStores(env); + + // ── Batch form (#245): one round trip for N ids ── + if (event_ids) { + const perStore = await Promise.all( + stores.map((s) => + markRequest(s, { event_ids }).then((r) => r.json() as Promise), + ), + ); + + // Per-id verdict resolution across the fan-out lives in + // mark-results.ts (unit-tested there). + const summary = mergeMarkResults(event_ids, perStore); + return { content: [{ type: "text" as const, text: JSON.stringify(summary, null, 2) }] }; + } + + // ── Singular form: response shape unchanged ── + if (!event_id) { + return { + content: [ + { type: "text" as const, text: "mark_processed requires event_id or event_ids" }, + ], + isError: true, + }; + } + // Try all stores — the event lives in exactly one, others are no-ops + const results = await Promise.all( + stores.map((s) => markRequest(s, { event_id }).then((r) => r.json())), + ); + // Return the first successful result + return { content: [{ type: "text" as const, text: JSON.stringify(results[0]) }] }; + }, + ); + + return server; +} diff --git a/worker/src/retired-do.ts b/worker/src/retired-do.ts new file mode 100644 index 0000000..367f5bb --- /dev/null +++ b/worker/src/retired-do.ts @@ -0,0 +1,30 @@ +/** + * Retired MCP-serving Durable Object class. + * + * `WebhookMcpAgent` is retained solely to satisfy Cloudflare's "class must + * exist in script for classes declared in past migrations" constraint (the + * `v2` migration declares it). It receives no live traffic since the stateless + * flip (issue #249): `/mcp` is served per request by `createMcpHandler`, so no + * session ID resolves to a DO instance any more. The class exists only so + * `wrangler deploy` does not fail with "script does not export class + * 'WebhookMcpAgent'". Deleting it needs a `deleted_classes` migration, which + * is a separate, destructive change. + * + * It lives apart from `mcp.ts` so that file imports nothing from + * `cloudflare:workers` and stays loadable outside workerd — which is what lets + * the stateless serving contract be tested in plain node + * (`test/mcp-stateless-contract.test.ts`). + * + * The Durable Objects that hold real data (`WebhookStore`, `TenantRegistry`) + * are untouched by the flip; only the MCP-serving class left the path. + */ + +import { DurableObject } from "cloudflare:workers"; + +export class WebhookMcpAgent extends DurableObject { + async fetch(): Promise { + return new Response("WebhookMcpAgent has been retired; /mcp is served statelessly", { + status: 410, + }); + } +} diff --git a/worker/test/mcp-stateless-contract.test.ts b/worker/test/mcp-stateless-contract.test.ts new file mode 100644 index 0000000..9ed5fc6 --- /dev/null +++ b/worker/test/mcp-stateless-contract.test.ts @@ -0,0 +1,228 @@ +/** + * Worker <-> bridge protocol contract (issue #249). + * + * The Worker's protocol revision is a private contract between the artifacts of + * this repository: the Worker at `worker/src/` and the npx bridge at + * `mcp-server/`. `server.json` declares stdio transport only, so no third-party + * client reaches the Worker directly — which is exactly why nothing outside + * this repository verifies the contract, and why it is asserted here. + * + * The two sides are wired to each other in-process: the bridge's real client + * module talks to the real `createMcpHandler` wiring over a fetch that lands on + * the handler instead of the network. The stores are faked at the DO stub + * boundary, which is a plain fetch surface — that is what lets `tools/call` be + * covered here, and it is the path that carries the change with the most reach: + * tenant identity moved from an instance field on a per-tenant agent DO to the + * props of the request being served. + * + * `local-mcp/src/index.ts` is the TypeScript twin of the bridge client and is + * NOT covered here; it is the development bridge, and the published one is what + * users run. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createMcpHandler } from "agents/mcp/server"; +import { createRemoteClient } from "../../mcp-server/server/remote-client.js"; +import { createWebhookMcpServer } from "../src/mcp.js"; + +const ENDPOINT = "https://github-webhook.smgjp.com"; + +/** A grant that can read one personal store and one org store. */ +const PROPS = { + account_id: 4242, + account_login: "smileygames", + accessible_account_ids: [4242, 99], +}; + +/** One store's `/pending-status` reply, keyed by the DO name it was built from. */ +const PENDING_BY_STORE: Record = { + "store-4242": { + pending_count: 2, + latest_received_at: "2026-08-06T09:00:00.000Z", + types: { issues: 2 }, + }, + "store-99": { + pending_count: 1, + latest_received_at: "2026-08-06T11:00:00.000Z", + types: { issues: 1, push: 1 }, + }, +}; + +/** + * A WEBHOOK_STORE binding whose stubs answer `/pending-status` from the table + * above and record which DO names were reached. `idFromName` is the only place + * tenant identity turns into a store, so recording there is what pins the + * per-request props path. + */ +function fakeEnv(reached: string[]) { + const namespace = { + idFromName(name: string) { + return { name }; + }, + get(id: { name: string }) { + return { + async fetch(request: Request) { + reached.push(id.name); + const body = PENDING_BY_STORE[id.name] ?? { + pending_count: 0, + latest_received_at: null, + types: {}, + }; + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + }); + }, + }; + }, + }; + return { WEBHOOK_STORE: namespace } as unknown as Parameters< + typeof createWebhookMcpServer + >[0]; +} + +/** The Worker's `/mcp` wiring, exactly as `index.ts` builds it. */ +function workerHandler(env: ReturnType) { + return createMcpHandler(() => createWebhookMcpServer(env), { + route: "/mcp", + legacy: "reject", + }); +} + +/** + * A fetch that lands on the handler. The Host header is set explicitly because + * a `Request` built in-process carries no Host of its own, and `ctx.props` is + * what the handler republishes as the per-request auth context. + */ +function fetchInto(handler: ReturnType): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const base = input instanceof Request ? input : new Request(input, init); + const headers = new Headers(base.headers); + headers.set("host", new URL(base.url).host); + return handler(new Request(base, { headers }), {} as never, { + props: PROPS, + } as unknown as ExecutionContext); + }) as typeof fetch; +} + +test("serves the bridge's pinned client without a session handshake", async () => { + const seen: Array<{ + method: string; + sessionHeader: string | null; + body: { method?: string; params?: { _meta?: Record } } | null; + }> = []; + const into = fetchInto(workerHandler(fakeEnv([]))); + + const recording: typeof fetch = async (input, init) => { + const req = input instanceof Request ? input : new Request(input, init); + const raw = await req.clone().text(); + seen.push({ + method: req.method, + sessionHeader: req.headers.get("mcp-session-id"), + body: raw ? JSON.parse(raw) : null, + }); + return into(req); + }; + + const remote = createRemoteClient({ + workerUrl: ENDPOINT, + clientVersion: "0.0.0-test", + fetch: recording, + }); + + const client = await remote.getClient(); + const tools = await client.listTools(); + assert.deepEqual( + tools.tools.map((t: { name: string }) => t.name).sort(), + [ + "get_event", + "get_pending_status", + "get_webhook_events", + "list_pending_events", + "mark_processed", + ], + ); + + // The whole exchange, connect included, is POST-only and session-free. + assert.ok(seen.length > 0); + for (const call of seen) { + assert.equal(call.method, "POST"); + assert.equal(call.sessionHeader, null); + } + + // No `initialize`: connecting to a pinned modern endpoint probes with + // `server/discover` instead of opening a session. + const methods = seen.map((c) => c.body?.method); + assert.ok(!methods.includes("initialize")); + assert.equal(methods[0], "server/discover"); + + // Every request carries the per-request envelope the revision requires. + for (const call of seen) { + const meta = call.body?.params?._meta; + assert.equal(meta?.["io.modelcontextprotocol/protocolVersion"], "2026-07-28"); + assert.notEqual(meta?.["io.modelcontextprotocol/clientCapabilities"], undefined); + } + + await remote.reset(); +}); + +test("resolves tenant stores from the request's own props, not from instance state", async () => { + const reached: string[] = []; + const remote = createRemoteClient({ + workerUrl: ENDPOINT, + clientVersion: "0.0.0-test", + fetch: fetchInto(workerHandler(fakeEnv(reached))), + }); + + const client = await remote.getClient(); + const result = (await client.callTool({ + name: "get_pending_status", + arguments: {}, + })) as { content: Array<{ type: string; text: string }> }; + + // Both accessible accounts were fanned out to, and only those. + assert.deepEqual(reached.sort(), ["store-4242", "store-99"]); + + // The merge is the same one the agent DO did — counts summed, latest wins, + // per-type counts added. + const merged = JSON.parse(result.content[0].text); + assert.deepEqual(merged, { + pending_count: 3, + latest_received_at: "2026-08-06T11:00:00.000Z", + types: { issues: 3, push: 1 }, + }); + + await remote.reset(); +}); + +test("rejects the pre-flip bridge instead of serving it a compatibility lane", async () => { + const into = fetchInto(workerHandler(fakeEnv([]))); + + // Byte-shape of what the pre-#249 bridge sent as its first request. + const res = await into(`${ENDPOINT}/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "local-bridge", version: "1.0.0" }, + }, + id: "init", + }), + }); + + assert.equal(res.status, 400); + assert.equal(res.headers.get("mcp-session-id"), null); + + const body = (await res.json()) as { + error: { code: number; data?: { supported?: string[] } }; + }; + assert.equal(body.error.code, -32022); + // The endpoint names the one revision it serves — a single lane, stated. + assert.deepEqual(body.error.data?.supported, ["2026-07-28"]); +}); diff --git a/worker/wrangler.toml b/worker/wrangler.toml index 805bec2..bde1975 100644 --- a/worker/wrangler.toml +++ b/worker/wrangler.toml @@ -17,6 +17,13 @@ PURGE_AFTER_DAYS = "3" UNPROCESSED_PURGE_AFTER_DAYS = "90" [durable_objects] +# MCP_OBJECT still names WebhookMcpAgent, but that class is a retired stub since +# the 2026-07-28 stateless flip (#249): /mcp is served per request by +# createMcpHandler and no session ID resolves to a DO any more. Both the binding +# and the class stay because the v2 migration below declares the class, and +# Cloudflare requires every class named by a past migration to exist in the +# script. Removing them needs a deleted_classes migration — a separate, +# destructive change. See worker/src/retired-do.ts. bindings = [ { name = "MCP_OBJECT", class_name = "WebhookMcpAgent" }, { name = "WEBHOOK_STORE", class_name = "WebhookStore" },