diff --git a/.changeset/create-solvapay-mcp-v2-template.md b/.changeset/create-solvapay-mcp-v2-template.md new file mode 100644 index 00000000..9e100f4b --- /dev/null +++ b/.changeset/create-solvapay-mcp-v2-template.md @@ -0,0 +1,5 @@ +--- +'create-solvapay': minor +--- + +The `mcp` scaffold now generates an SDK v2 project: templates depend on `@modelcontextprotocol/core` and `@modelcontextprotocol/server` instead of `@modelcontextprotocol/sdk`, the generated worker builds its server through a per-request factory, and the pinned `@solvapay/mcp` runtime dependency moves to the `0.3.x` line. diff --git a/.changeset/mcp-core-sdk-v2.md b/.changeset/mcp-core-sdk-v2.md new file mode 100644 index 00000000..71ee6938 --- /dev/null +++ b/.changeset/mcp-core-sdk-v2.md @@ -0,0 +1,7 @@ +--- +'@solvapay/mcp-core': minor +--- + +`hideToolsByAudience` now reads `User-Agent` from Web `Request` headers, which is the primary ChatGPT detection path for 2026-era connections where `initialize` never runs. It uses the public `setRequestHandler` where available instead of only reaching into the private handler map. + +The `zod` peer narrows to `^4.2.0` and `engines.node` is now `>=20`. Consumers still on zod 3 must upgrade — this is why the release takes the `0.3.0` boundary rather than a patch, so `^0.2.x` installs are not silently pulled onto it. diff --git a/.changeset/mcp-sdk-v2-migration.md b/.changeset/mcp-sdk-v2-migration.md new file mode 100644 index 00000000..f3baf9de --- /dev/null +++ b/.changeset/mcp-sdk-v2-migration.md @@ -0,0 +1,15 @@ +--- +'@solvapay/mcp': minor +--- + +Migrate to the official MCP TypeScript SDK v2 (`@modelcontextprotocol/server@2.0.0`, `@modelcontextprotocol/core@2.0.0`). The peer dependency swaps from `@modelcontextprotocol/sdk` to the split v2 packages, the `zod` peer is now `^4.2.0`, and `engines.node` is `>=20`. + +**`createSolvaPayMcpFetchHandler` changes shape.** `server: McpServer` is replaced by `factory: McpServerFactory`, so a fresh server is constructed per request via `createMcpHandler`. The `McpHandlerMode` / `mode` option (`sse-stateful`, `json-stateless`, …) is removed — use `responseMode: 'json' | 'sse' | 'auto'` to shape modern-era responses. 2025-era clients are still served through the SDK's default `legacy: 'stateless'` leg, so existing hosts keep working. + +**MCP Apps helpers now come from `@solvapay/mcp`.** `@modelcontextprotocol/ext-apps` has no v2 build, so its three server-side symbols (`registerAppTool`, `registerAppResource`, `RESOURCE_MIME_TYPE`) are vendored here and exported from this package; the ext-apps server peer is dropped. Replace `import { registerAppTool } from '@modelcontextprotocol/ext-apps/server'` with `import { registerAppTool } from '@solvapay/mcp'`. Client-side ext-apps usage inside iframe bundles is unaffected. + +All `registerTool` and intent-tool schemas are explicitly `z.object()`-wrapped for zod 4.2+ compatibility with the SDK's bundled converter. + +Fixes `registerPayable` handler-arg inference: `InferHandlerArgs` resolved a raw-shape `schema` to `Record`, so handlers saw `unknown` args instead of the inferred type. Raw shapes and `z.object()` schemas both infer correctly again. + +The `@solvapay/mcp-core` peer is now the explicit range `^0.3.0` — the two packages ship in lockstep and this release requires the new `hideToolsByAudience` surface. diff --git a/.changeset/react-mcp-core-peer-span.md b/.changeset/react-mcp-core-peer-span.md new file mode 100644 index 00000000..70ae907d --- /dev/null +++ b/.changeset/react-mcp-core-peer-span.md @@ -0,0 +1,5 @@ +--- +'@solvapay/react': patch +--- + +Widen the `@solvapay/mcp-core` peer range to `^0.2.8 || ^0.3.0`. This package consumes only stable type and constant exports from `@solvapay/mcp-core` and does not touch the `hideToolsByAudience` surface or zod schema construction that changed in `0.3.0`, so it stays installable against both lines and `mcp-core@0.3.0` does not force a false-major cascade here or onto `@solvapay/react-supabase`. diff --git a/.changeset/server-zod4-virtual-tools.md b/.changeset/server-zod4-virtual-tools.md new file mode 100644 index 00000000..389d65dd --- /dev/null +++ b/.changeset/server-zod4-virtual-tools.md @@ -0,0 +1,5 @@ +--- +'@solvapay/server': minor +--- + +Virtual MCP tool registration now wraps generated input schemas in `z.object()` so they convert correctly under the MCP SDK v2 schema pipeline. The `zod` peer moves to `^4.2.0` and `engines.node` to `>=20` to match the rest of the MCP surface; nothing in the paywall, nudge, or checkout APIs changes. diff --git a/.cursor/plans/mcp_2026_07_28_sdk_v2_migration.plan.md b/.cursor/plans/mcp_2026_07_28_sdk_v2_migration.plan.md new file mode 100644 index 00000000..e2d721e8 --- /dev/null +++ b/.cursor/plans/mcp_2026_07_28_sdk_v2_migration.plan.md @@ -0,0 +1,450 @@ +--- +name: MCP 2026-07-28 / official SDK v2 migration +overview: "The MCP specification revision 2026-07-28 is now reachable only through the official TypeScript SDK v2, which shipped GA at `2.0.0` and replaces the single `@modelcontextprotocol/sdk` package with a scope of split packages (`@modelcontextprotocol/core`, `/server`, `/client`, `/node`, `/express`, `/hono`, `/fastify`). The wire protocol goes stateless — no `initialize`, no `Mcp-Session-Id`, no server-to-client request channel. Only `@solvapay/mcp` imports the official SDK, so the blast radius inside our packages is small, but the change is breaking for every integrator (the peer dependency is renamed). Decision — single clean cut to v2 and retire every legacy affordance we hand-maintain (the `sse-stateful` mode, the `McpHandlerMode` option, the session plumbing, and the v1 `0.2.x` line, which we deprecate rather than dual-target). Vendor the three ext-apps symbols we use rather than wait for its stalled v2 — lead PR #710 is closed and no v2 prerelease exists. Retiring our legacy code is not the same as refusing 2025-era clients — keep `createMcpHandler`'s zero-cost `legacy: 'stateless'` leg so today's hosts keep working with nothing for us to maintain, and hold `legacy: 'reject'` (modern-only) as a documented future toggle for once hosts ship 2026-07-28." +todos: + - id: prep-audit + content: Pre-flight audit. Bump `engines.node` to `>=20` and the `zod` peer to `^4.2.0` — dropping the `^3.25.0` zod-3 leg, since v2 needs 4.2+ — across `@solvapay/mcp`, `@solvapay/mcp-core`, `@solvapay/server`. Confirm no remaining v1 `@modelcontextprotocol/sdk` references outside `packages/mcp` (CI gates, docs, scaffolding templates, Deno import maps). + status: completed + - id: spike-branch + content: Confirmation spike, not open research — the three unknowns are already answered by the published `2.0.0` types; prove them in code and move on. (1) `createMcpHandler` is fetch-native — it returns a `{ fetch, close, notify, bus }` object, the Workers/Deno/Bun shape, so the fetch-first handler maps cleanly. (2) `hideToolsByAudience` rebuilds on the factory seam — `McpRequestContext.requestInfo` is a web `Request`, so the ChatGPT UA branch runs before the server is built. (3) Vendoring is viable — server-side ext-apps usage is exactly `registerAppTool` / `registerAppResource` / `RESOURCE_MIME_TYPE` across two files. + status: completed + - id: extapps-decision + content: 'Vendor — decided, not open. ext-apps still ships no v2 (`1.7.5`, peer `@modelcontextprotocol/sdk@^1.29.0`); its lead migration PR #710 is closed and #719/#720 are stale drafts. Vendor the ~40 lines of `registerAppTool` / `registerAppResource` / `RESOURCE_MIME_TYPE` into `packages/mcp/src/internal/` and drop the ext-apps server peer from `@solvapay/mcp`. The client-side ext-apps usage in `@solvapay/react`/examples runs in the iframe and is untouched. Because dropping the peer leaves merchants writing MCP Apps tools with no v2-compatible import, the three symbols are re-exported from `@solvapay/mcp` rather than kept private.' + status: completed + - id: codemod + content: Run `npx @modelcontextprotocol/codemod v1-to-v2 packages/mcp` (the codemod is GA at `2.0.0`, no `@beta`) plus each example and the scaffolding template, then resolve every `@mcp-codemod-error` marker. Review the manifest rewrite by hand — this is a pnpm workspace and the codemod only rewrites the nearest manifest. + status: completed + - id: handler-rewrite + content: "Rewrite `packages/mcp/src/fetch/handler.ts` onto `createMcpHandler(factory, options)`. This deletes the per-request transport construction, the `server.connect`/`transport.close` dance, the shared-server mutex, the `buildTransport` escape hatch and the `sessionIdGenerator` option. Reshape `CreateSolvaPayMcpFetchHandlerOptions` from `server: McpServer` to `factory: McpServerFactory`, and retire `McpHandlerMode` entirely — `sse-stateful` has no v2 equivalent (sessions are gone) and the rest collapses onto the SDK's `responseMode` (`json` for edge runtimes that cannot stream, `auto`/`sse` otherwise). Auth becomes `handler.fetch(request, { authInfo })`, dropping the `resolvedAuthInfo as any` cast." + status: completed + - id: legacy-stance + content: "Keep `createMcpHandler`'s default `legacy: 'stateless'` so 2025-era hosts (Claude Desktop, ChatGPT, Cursor) keep working at zero maintenance cost while they ship 2026-07-28 support — retiring our legacy machinery is not the same as refusing legacy clients. Leave `legacy: 'reject'` (modern-only) documented as a deliberate future toggle; flipping it now would leave a server no currently-shipping host can reach, which is its own broken window." + status: completed + - id: hide-tools + content: Rebuild `applyHideToolsByAudience` (`packages/mcp-core/src/hideToolsByAudience.ts`) without the `_requestHandlers` private-map reach-in (still private in v2). Under `createMcpHandler` the ChatGPT bypass reads the User-Agent off `McpRequestContext.requestInfo.headers.get('user-agent')` in the factory and decides what to register, so no handler wrapping is needed; `Protocol.removeRequestHandler(method)` is a public fallback. Fix the latent bug too — the `getClientVersion()` half of the detection returns `undefined` on 2026-era connections (no `initialize`), so the header path must be primary. + status: completed + - id: schemas + content: Move every schema we hand to `registerTool` to an explicitly `z.object()`-wrapped zod >=4.2 schema. Affects `packages/mcp/src/registerPayableTool.ts`, `packages/mcp/src/internal/buildMcpServer.ts`, and `jsonSchemaToZodRawShape` in `packages/server/src/register-virtual-tools-mcp.ts` (raw shapes now get wrapped with the SDK's bundled zod and fail at the first `tools/list`). + status: completed + - id: auth-errors + content: "Align the OAuth bridge with v2: token verifiers must throw `OAuthError(OAuthErrorCode.InvalidToken)` or invalid tokens become HTTP 500, and RFC 9207 `iss` validation is now enforced. Re-check our hand-rolled `-32001` / `-32603` codes against v2's `ProtocolErrorCode` and the new `-32020` (`HeaderMismatch`) / `-32021` / `-32022` — all confirmed present in `2.0.0`; `-32001` does not collide numerically, but confirm the semantics." + status: completed + - id: tests + content: "Re-baseline the MCP test suites. `protocolVersion: '2025-06-18'` fixtures in `packages/mcp/__tests__/fetch/*` need modern-era counterparts (both eras stay in scope — `legacy: 'stateless'` remains on), capability advertisement changed (`listChanged: true` is now default), unknown-tool calls now reject instead of resolving `isError`, and there is no in-memory 2026-era transport — drive `handler.fetch` directly." + status: completed + - id: examples-template + content: Migrate the five MCP examples and the `create-solvapay` MCP template. `examples/mcp-checkout-app`, `examples/mcp-oauth-bridge` and `examples/mcp-time-app` carried hand-rolled session maps and `isInitializeRequest` routing that disappear entirely — all three now mount one `app.all('/mcp', toNodeHandler(mcpHandler))` from `@modelcontextprotocol/node`, which streams SSE and forwards `req.auth` as `authInfo`. `examples/supabase-edge-mcp` needed its Deno import map re-pointed at the split packages. + status: completed + - id: release + content: 'Ship it: `@solvapay/mcp` and `@solvapay/mcp-core` 0.2.x -> 0.3.0 behind a `preview` snapshot tag first, with a migration note in both CHANGELOGs and `docs/guides/mcp.mdx`. Deprecate rather than maintain the v1 line — publish 0.2.x as-is, add a deprecation notice, and do not backport. Changesets are written and `changeset status` confirms the intended plan with no majors: `mcp` and `mcp-core` 0.3.0, `server` 2.1.0, `create-solvapay` 0.6.0, `react` 1.6.1, `react-supabase` untouched. Holding `mcp` at 0.3.0 (rather than the 1.0.0 that a `workspace:^` peer cascade would force) required pinning `mcp`''s `@solvapay/mcp-core` peer to the explicit lockstep range `^0.3.0` and spanning `react`''s to `^0.2.8 || ^0.3.0`; `mcp-core` keeps the 0.3.0 boundary so zod-3 consumers on `^0.2.8` are not silently upgraded into the narrowed peer. The `preview` snapshot publish and the `npm deprecate` on 0.2.x are still outstanding.' + status: pending +isProject: true +--- + +# MCP 2026-07-28 / official SDK v2 migration + +## TL;DR + +The 2026-07-28 protocol revision is a clean break, and the official TypeScript SDK +made it a clean break too: the single `@modelcontextprotocol/sdk` package is gone, +replaced by a scope of split packages, now GA at `2.0.0`. There is no path to +2026-07-28 on v1 — the v1 `@modelcontextprotocol/sdk` line never reaches this revision. + +Our exposure is narrower than it looks. `@solvapay/mcp` is the only package that +imports the official SDK; `@solvapay/mcp-core`, `@solvapay/server` and +`@solvapay/react/mcp` all type MCP structurally and carry no `@modelcontextprotocol/*` +dependency. That boundary (an existing rule in +`.cursor/rules/mcp-apps-sdk.mdc`) is the single biggest reason this migration is +tractable. + +**Recommendation: cut straight to v2, do not dual-target v1.** Supporting both means +maintaining two transport implementations behind a build-time flag, because v1 and v2 +objects cannot cross (`instanceof` and nominal types do not survive the boundary — the +SDK's own migration guide is explicit about this). We are pre-revenue with no paying +customers on the MCP packages, `@solvapay/mcp` is still `0.2.x`, and the integrator +cost of staying on v1 is "keep installing the old peer dependency" — nobody is +stranded. Deprecate the 0.2.x line and move on. + +**Retire legacy on our side; keep serving legacy clients.** Delete every legacy affordance +we hand-maintain — the `sse-stateful` mode, the `mode` option, the session plumbing, the v1 +`0.2.x` line — but keep `createMcpHandler`'s zero-cost `legacy: 'stateless'` leg so today's +hosts still connect while they upgrade. And ext-apps no longer gates us: it has no v2 (its +lead PR is closed), so we vendor the three symbols we use. See +[The ext-apps blocker](#the-ext-apps-blocker). + +## What actually changed + +Two things landed at once, and it is worth keeping them apart. + +### 1. The protocol revision (2026-07-28) + +Sources: the [release candidate +announcement](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) +and the [breaking-changes +guide](https://www.developersdigest.tech/blog/mcp-2026-07-28-breaking-changes). + +The core goes stateless. `initialize`/`initialized` is removed (SEP-2575) and replaced +by a `server/discover` method plus a per-request `_meta` envelope carrying protocol +version, client info and client capabilities. `Mcp-Session-Id` is removed (SEP-2567), +so any request can land on any instance. Streamable HTTP now requires `Mcp-Method` and +`Mcp-Name` headers so gateways can route without reading the body (SEP-2243). List and +resource-read results carry `ttlMs` and `cacheScope` for caching (SEP-2549). + +The server-to-client request channel is gone. Elicitation and sampling become in-band: +a handler returns an `inputRequired(...)` result carrying an opaque `requestState`, and +the client re-issues the original call with the answers. Roots, sampling and +protocol-level logging are deprecated (SEP-2577) with a twelve-month floor before +removal. Tool schemas are lifted to full JSON Schema 2020-12. Extensions (MCP Apps, +Tasks) become first-class and version independently of the spec. + +**None of this directly bites us today.** We do not use sampling, roots, elicitation, +logging or tasks anywhere in the repo. What bites us is the transport rework and the +SDK repackaging that carries it. + +### 2. The SDK v2 repackaging + +| v1 | v2 | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@modelcontextprotocol/sdk` | `@modelcontextprotocol/server` (server implementation) | +| | `@modelcontextprotocol/client` (client implementation) | +| | `@modelcontextprotocol/core` (public Zod `*Schema` constants) | +| `StreamableHTTPServerTransport` | `NodeStreamableHTTPServerTransport` (`/node`); the web-standard `WebStandardStreamableHTTPServerTransport` we use **survives** in `@modelcontextprotocol/server` | +| built-in framework glue | `@modelcontextprotocol/{node,express,hono,fastify}` | +| `SSEServerTransport`, AS helpers | `@modelcontextprotocol/server-legacy` (frozen, deprecated) | + +Status as of 2026-08-05: `2.0.0` is GA. Every split package (`core`, `server`, `client`, +`node`, `express`, `hono`, `fastify`, `server-legacy`) and the codemod are on `2.0.0`, all +sharing one version number. The earlier draft's beta-churn risk is closed — pin exactly and +move. Note the fetch-first `WebStandardStreamableHTTPServerTransport` we build today still +exists in `2.0.0` (re-exported from `@modelcontextprotocol/server`), but `createMcpHandler` +owns transport construction now, so we stop instantiating it directly. + +Beyond the rename, the changes that touch code we have written: + +- **`ctx` replaces `extra`.** Handler second argument is restructured: + `extra.authInfo` -> `ctx.http?.authInfo`, `extra.requestInfo` -> `ctx.http?.req` + (a real Web `Request`, so header reads become `.get()` instead of bracket access), + `extra.sessionId` -> `ctx.sessionId`. +- **`setRequestHandler` takes a method string**, not a Zod schema. +- **Raw Zod shapes are deprecated** on `registerTool`/`registerPrompt`. They still + work, but they get wrapped with the SDK's _bundled_ zod — which fails at the first + `tools/list` when the shape was authored with a different zod copy. Wrap with + `z.object()` yourself. +- **Zod 3 is dropped**; v2 wants `zod ^4.2.0` (4.0–4.1 falls back to the bundled + converter and silently drops `.describe()` descriptions). +- **Node 20+** required. +- **`McpError` -> `ProtocolError`**, `ErrorCode` -> `ProtocolErrorCode`, + `StreamableHTTPError` -> `SdkHttpError`. The `MCP error : ` message prefix is + gone. Unknown/disabled tool calls now _reject_ with `-32602` instead of resolving + `{ isError: true }`. +- **OAuth error classes consolidate** into `OAuthError` + `OAuthErrorCode`. Token + verifiers that throw anything else produce HTTP 500 instead of a 401 challenge. +- **`createMcpHandler(factory, options)`** is the new HTTP entry point — a fresh + server per request, serving both eras from one endpoint. + +The SDK ships a codemod (`@modelcontextprotocol/codemod`) that mechanically handles +the import rewrites, symbol renames, `extra` -> `ctx` remapping and the +`registerTool` call-shape conversion. + +## What this means for our SDK, file by file + +Everything below is scoped to the ~15 files that actually touch the official SDK. + +### `packages/mcp` — the whole migration lives here + +**`src/fetch/handler.ts` is the centre of gravity.** Today it builds a +`WebStandardStreamableHTTPServerTransport` per request against one long-lived shared +`McpServer`, and serialises concurrent requests behind a mutex because +`McpServer._transport` is a single slot: + +```166:192:packages/mcp/src/fetch/handler.ts + const makeTransport = (): WebStandardStreamableHTTPServerTransport => { + if (buildTransport) return buildTransport() + if (mode === 'json-stateless') { + return new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }) + } + // ... + } + + // Serialise server connect/close cycles. `McpServer._transport` is a + // single slot — the protocol's `connect()` throws "Already connected + // to a transport" if it's set ... + let serverMutex: Promise = Promise.resolve() +``` + +`createMcpHandler` deletes this entire class of problem. It takes a factory +`(ctx: McpRequestContext) => McpServer | Server` and builds a fresh instance per +request, so the mutex, the connect/close cycle and the "fine for the low-throughput +edge-function case" caveat in that comment all go away. Concurrency stops being +serialised. + +The mapping for our three modes: + +| Our `McpHandlerMode` | v2 equivalent | +| -------------------- | ----------------------------------------------------------------------------------------------- | +| `'json-stateless'` | `createMcpHandler(factory)` default (`legacy: 'stateless'`, `responseMode: 'auto'` or `'json'`) | +| `'sse-stateless'` | `createMcpHandler(factory, { responseMode: 'sse' })` | +| `'sse-stateful'` | no direct equivalent — sessions are gone from the protocol | + +**Decision: retire `McpHandlerMode` outright.** `'sse-stateful'` — the current default — has +no v2 equivalent because sessions are gone from the protocol, and the other two collapse +onto the SDK's `responseMode`. `CreateSolvaPayMcpFetchHandlerOptions.server: McpServer` +becomes a `factory` in the same breaking change, so nothing is left for `mode` to select. +Edge runtimes that cannot hold a stream pass `responseMode: 'json'` (single JSON body, +mid-call notifications dropped — we emit none); everything else takes the `'auto'` default. +This retires _our_ legacy plumbing, not legacy _clients_: `createMcpHandler`'s +`legacy: 'stateless'` default (kept) still answers 2025-era hosts on the same endpoint at +zero cost to us — see "Backwards compatibility" below. + +The auth plumbing gets _cleaner_. Today we cast our auth envelope through `any` to +satisfy the SDK's `AuthInfo`: + +```252:262:packages/mcp/src/fetch/handler.ts + const response = await transport.handleRequest( + req, + resolvedAuthInfo + ? { + // `AuthInfo` from the SDK is structurally identical to our + // envelope — cast away the brand so the types line up. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authInfo: resolvedAuthInfo as any, + } + : undefined, + ) +``` + +`handler.fetch(request, { authInfo })` takes the same strictly-pass-through `AuthInfo`, +and the SDK's migration guide explicitly calls out removing v1-era double casts. This +is also a chance to delete two of the four `no-explicit-any` suppressions in the +package, which the workspace TypeScript rule wants gone anyway. + +**`src/registerPayableTool.ts` and `src/internal/buildMcpServer.ts`** need the schema +treatment: every `inputSchema` we forward must be an explicitly `z.object()`-wrapped +zod >=4.2 schema, and the `toolConfig as any` casts at lines 232 and 240 should +resolve now that `registerTool` accepts Standard Schema. + +**`src/express/oauth-bridge.ts` and `src/fetch/oauth-bridge.ts`** are hand-rolled +fetch/Express handlers with no SDK imports, so they survive the rename. What they need +is a conformance pass: RFC 9207 `iss` validation, and confirming our `-32001` +unauthorized code does not now collide with v2's renumbered `-32020` (`HeaderMismatch`). + +### `packages/mcp-core` — one file, one real problem + +`applyHideToolsByAudience` reaches into the SDK's private handler map: + +```174:191:packages/mcp-core/src/hideToolsByAudience.ts + const inner = (server as McpServerLike).server + if (!inner || typeof inner !== 'object' || !(inner._requestHandlers instanceof Map)) { + return + } + const handlers = inner._requestHandlers + const original = handlers.get('tools/list') + if (!original) return + // ... + handlers.set('tools/list', async (req, extra) => { +``` + +The file's own comment calls this "the one piece of SDK-internal knowledge we live +with until the SDK ships a first-class 'replace handler' affordance." v2 ships two +things that retire it: + +1. `Protocol.removeRequestHandler(method)` is now public. +2. More usefully, `createMcpHandler`'s factory receives + `McpRequestContext { era, authInfo?, requestInfo?: Request }`. The ChatGPT + User-Agent detection this helper exists for can read + `ctx.requestInfo?.headers.get('user-agent')` _before_ the server is built, and the + factory simply decides which tools to register. + +That turns a handler-wrapping hack into a branch in the factory. It also fixes a +latent correctness issue: the current `defaultIsChatGptRequest` falls back to +`server.getClientVersion()`, which returns `undefined` on 2026-era connections because +`initialize` never runs. The header path keeps working; the fallback silently stops. + +Also note `ApplyHideToolsByAudienceExtra` mirrors v1's `IsomorphicHeaders` +(`Record`). v2 uses the Web Standard `Headers` +object, so `readHeader` becomes `headers.get(name)`. + +### `packages/server` — no SDK dependency, one schema problem + +`register-virtual-tools-mcp.ts` converts JSON Schema to a Zod raw shape and hands it +straight to `registerTool`: + +```140:143:packages/server/src/register-virtual-tools-mcp.ts + inputSchema: jsonSchemaToZodRawShape( + mappedDefinition.inputSchema.properties as Record, + mappedDefinition.inputSchema.required || [], + ), +``` + +This is exactly the failure mode the migration guide warns about: a raw shape built +with _our_ zod, wrapped by the SDK's _bundled_ zod, registering fine and then failing +on the first `tools/list`. Fix is one line — wrap in `z.object()` — plus the +`zod ^4.2.0` bump. We deliberately do not reach for the SDK's `fromJsonSchema()` here, +because `@solvapay/server` must stay free of `@modelcontextprotocol/*` per the package +boundary rule. + +### `packages/react/mcp` — nothing, probably + +All types are structural; the only MCP SDK contact is in example `mcp-app.tsx` files +that import `@modelcontextprotocol/ext-apps` directly. Those move whenever ext-apps +moves. + +### Examples and scaffolding + +`examples/mcp-checkout-app` and `examples/mcp-oauth-bridge` both hand-roll the +sessionful pattern — a `Map` of transports keyed by session id, `isInitializeRequest` +routing, `onsessioninitialized`. All of that deletes down to a `createMcpHandler` +factory. `examples/supabase-edge-mcp` pins the SDK through a Deno import map +(`supabase/functions/mcp/deno.json`) that needs re-pointing at the split packages. +`packages/create-solvapay/templates/mcp/_base/package.json` pins +`@modelcontextprotocol/sdk@^1.29.0` and has a scaffold test asserting that pin. + +## The ext-apps blocker + +This was billed as the scheduling constraint. It no longer is — we vendor, so ext-apps +stops gating us. + +`@modelcontextprotocol/ext-apps@1.7.5` still declares +`"@modelcontextprotocol/sdk": "^1.29.0"` as a peer dependency, and its v2 migration has +stalled: the lead PR [#710](https://github.com/modelcontextprotocol/ext-apps/pull/710) +(`App` subclasses the v2 `Client`) is **closed unmerged**, while +[#719](https://github.com/modelcontextprotocol/ext-apps/pull/719) (draft, core-only Apps +protocol) and [#720](https://github.com/modelcontextprotocol/ext-apps/pull/720) (draft, +official `Protocol` base with role-isolated peers) linger as stale drafts behind an +unresolved question about whether server-only consumers must bundle the client role. No v2 +prerelease is published. + +If we adopt SDK v2 while ext-apps is still on v1, we get two zod copies, two SDK +copies and an unsatisfiable peer graph. + +**We are not actually that dependent on it.** We use exactly three symbols: +`registerAppTool`, `registerAppResource` and `RESOURCE_MIME_TYPE`. Reading the +upstream source, `registerAppTool` is a `_meta.ui.resourceUri` <-> legacy-flat-key +normaliser followed by `server.registerTool`, and `registerAppResource` is a +`mimeType` default followed by `server.registerResource`. Roughly 40 lines of real +logic. + +**Decision: vendor.** With #710 closed there is no near-term v2 to wait for, and waiting +would re-couple our schedule to an upstream with an unresolved architecture debate. Vendor +the three symbols into `packages/mcp/src/internal/` behind a thin wrapper, drop the ext-apps +server peer from `@solvapay/mcp` entirely, and revisit adopting upstream only if a real +reason appears — it is ~40 lines and cheap to reverse. The client-side ext-apps usage in +`packages/react/mcp` and the example widgets is unaffected: it runs in the iframe and never +touches the server SDK, so it stays on the ext-apps client entry regardless. + +## Backwards compatibility: don't + +The question in the brief was whether to keep supporting the previous major. Working +through what that would actually cost: + +**Dual-targeting v1 and v2 in one package is not a conditional import.** The SDK's +migration guide is explicit that v1 and v2 modules have separate classes and types, +and that objects must not flow between them — `instanceof` and nominal types do not +cross. In practice that means `@solvapay/mcp` would need two parallel implementations +of `fetch/handler.ts`, `registerPayableTool.ts` and `buildMcpServer.ts`, selected at +build time via separate entry points, with both peer dependency sets declared +optional. Every bug fix lands twice. Every test runs twice. The public types diverge, +because `createSolvaPayMcpServer` returns an `McpServer` whose identity differs between +the two. + +**What we get for that: nothing anyone is asking for.** No paying customers on these +packages. `@solvapay/mcp` is at `0.2.8` — pre-1.0, where breaking changes in a minor +are the documented convention. And integrators who cannot move yet are not stranded: +the v1 package still exists under its own name, `0.2.8` stays on npm forever, and +`@modelcontextprotocol/sdk@1.29.x` keeps working against every 2025-era host. The +"stay put" story is genuinely fine. + +**So: single clean cut.** Concretely — + +- `@solvapay/mcp` and `@solvapay/mcp-core` go `0.2.8` -> `0.3.0`. Peer dependencies + swap from `@modelcontextprotocol/sdk` to `@modelcontextprotocol/server` + (+ `/core`, + `/node` where a Node transport is needed). `engines.node` -> `>=20`, + `zod` peer -> `^4.2.0`. +- The `0.2.x` line is **deprecated, not maintained**. Publish an `npm deprecate` + notice pointing at the migration note. No backports, no security-only branch. + Revisit only if a real integrator with a real constraint shows up. +- Everything else in the workspace (`@solvapay/server` at `2.0.0`, `@solvapay/react` + at `1.6.0`) takes a minor for the zod and Node bumps. Those are not MCP-breaking. + +One distinction to be deliberate about, because it is easy to conflate with "retire +legacy": **we retire every legacy affordance _we_ hand-maintain, but we still _answer_ +2025-era clients.** Those are different things. Retired: the `sse-stateful` mode, the +session-id plumbing, the connect/close mutex, the hand-rolled session maps in the examples, +and the v1 `0.2.x` line. Kept: `createMcpHandler`'s default `legacy: 'stateless'`, which +answers 2025-era clients on the same endpoint as 2026-era ones at **zero code and zero +maintenance** for us. Claude Desktop, ChatGPT connectors and Cursor do not speak 2026-07-28 +yet (the spec finalised days ago), so keeping that default is what lets the packages talk to +any real host at all. `legacy: 'reject'` (modern-only) is a deliberate future toggle for +once hosts have shipped 2026-07-28 support — flipping it today would leave a server no +current client can reach, which is a broken window, not a clean cut. Backwards compatibility +lives in the protocol layer, at zero cost — never in our package matrix. + +## Phasing + +Sequenced by dependency, not by calendar. + +**Phase 0 — pre-flight.** Node and zod bumps, dependency audit, confirm nothing +outside `packages/mcp` names the v1 package. Independently useful and safe to land +before anything else. Touches: every `packages/*/package.json`, the scaffold template, +the Deno import maps. + +**Phase 1 — confirmation spike.** The three `spike-branch` questions are already answered by +the published `2.0.0` types; this is a short branch to prove them in code, not open +research. The one design decision it used to carry — whether to retire `sse-stateful` / the +`mode` option — is now made: retire it, and keep `legacy: 'stateless'` for 2025-era clients. +Output is a green `handler.fetch` smoke test, not a decision record. + +**Phase 2 — vendor ext-apps symbols.** The decision is made (vendor; #710 is closed). This +is the ~40-line lift into `packages/mcp/src/internal/` plus dropping the server peer. Blocks +Phase 3 and nothing else. + +**Phase 3 — `@solvapay/mcp` + `@solvapay/mcp-core`.** Codemod, then the manual work: +handler rewrite, `hideToolsByAudience` rebuild, schema wrapping, auth error alignment. +This is the invasive phase — `fetch/handler.ts` is effectively rewritten rather than +patched, and `CreateSolvaPayMcpFetchHandlerOptions` changes shape. + +**Phase 4 — tests.** Substantial re-baselining, not a sweep. The `protocolVersion: +'2025-06-18'` fixtures across `packages/mcp/__tests__/fetch/*` need modern-era +counterparts; capability advertisement changed (a declared `tools: {}` is now +advertised with `listChanged: true`, so golden tests move); unknown-tool calls reject +instead of resolving; and there is no in-memory 2026-era transport, so era coverage +means driving `handler.fetch` through a `StreamableHTTPClientTransport` with a +`fetch` override. Follow the RED-GREEN-REFACTOR workflow in `.cursor/rules/tdd.mdc`. + +**Phase 5 — examples, template, docs.** Five examples, one scaffold template, +`docs/guides/mcp.mdx` and `docs/guides/mcp-app.mdx`. The examples get materially +simpler, which makes them the best place to sanity-check the new public API before +it is frozen. + +**Phase 6 — release.** Snapshot preview first (`pnpm changeset:snapshot` publishes +under the `preview` tag), validate against a real host, then `0.3.0`. + +## Risks and open questions + +- **~~The SDK is still `2.0.0-beta.5`.~~ Resolved:** `2.0.0` is GA across all split + packages and the codemod. Pin exactly and move — no beta churn left to absorb. +- **~~ext-apps timing is genuinely unknown.~~ Resolved by decision:** we vendor, so upstream + timing and the three-way PR race no longer gate us (and #710, the lead PR, is now closed). + Residual risk is drift from whatever normalisation upstream adds later — cheap to reconcile + against ~40 vendored lines. +- **~~`'sse-stateful'` removal needs a real answer.~~ Decided — retire it** (and the whole + `mode` option). Pre-revenue on these packages, no integrator on session stickiness, and + 2025-era _clients_ stay served by the kept `legacy: 'stateless'` leg, so removing the mode + is not a client-facing break. The only client-facing lever left is `legacy: 'reject'`, + which we deliberately do **not** flip yet. +- **ChatGPT's connector gateway is the riskiest host.** The bypass in + `hideToolsByAudience` exists because ChatGPT re-validates iframe-initiated + `tools/call` against a cached `tools/list`. Now that the spec has real cache + semantics (`ttlMs`, `cacheScope`), the workaround may need rethinking rather than + porting — and the `getClientVersion()` half of the detection stops working on the + modern era regardless. +- **The `_meta` conventions we rely on need an explicit test against 2026-07-28.** We stamp + `_meta.ui.*`, `_meta.audience` and `_meta["openai/widgetSessionId"]`. Confirmed in the + `2.0.0` types: the SDK lifts the reserved `io.modelcontextprotocol/*` keys out of the + `_meta` handlers see into `ctx.mcpReq.envelope`, leaving everything else (all of ours, + which are outside that prefix) in `ctx.mcpReq._meta`. The convention should hold — assert + it with a test that round-trips a tool call carrying our keys rather than trusting the + prefix rule. diff --git a/.cursor/rules/mcp-apps-sdk.mdc b/.cursor/rules/mcp-apps-sdk.mdc index 4a326c7f..1c09148b 100644 --- a/.cursor/rules/mcp-apps-sdk.mdc +++ b/.cursor/rules/mcp-apps-sdk.mdc @@ -59,14 +59,14 @@ If you're building a UI surface that doesn't match one of these three, stop and ### Package boundaries - **`@solvapay/mcp` has zero `@modelcontextprotocol/*` runtime dependencies.** This invariant is load-bearing. Do not violate it. -- **`@solvapay/mcp` is the only package that imports the official `@modelcontextprotocol/sdk`.** If you need MCP types elsewhere, re-export them through `@solvapay/mcp-core` as structural aliases. `@solvapay/mcp-core` is intentionally framework-neutral with zero `@modelcontextprotocol/*` runtime dep — OAuth bridge middleware lives in `@solvapay/mcp-express` (Node) and `@solvapay/mcp-fetch` (fetch-first runtimes). +- **`@solvapay/mcp` is the only package that imports the official SDK (`@modelcontextprotocol/core` / `/server`).** If you need MCP types elsewhere, re-export them through `@solvapay/mcp-core` as structural aliases. `@solvapay/mcp-core` is intentionally framework-neutral with zero `@modelcontextprotocol/*` runtime dep — OAuth bridge middleware lives in `@solvapay/mcp-express` (Node) and `@solvapay/mcp-fetch` (fetch-first runtimes). - **`@solvapay/react/mcp` is a subpath export.** Merchants using SolvaPay for non-MCP React surfaces do not pay the ext-apps peer dep cost. - **Do not ship a `@solvapay/sdk` umbrella package.** Three-package imports are fine. An umbrella adds maintenance without clarity. ### Spec compliance - **Every tool has annotations.** `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` are required. Not optional. Default for `registerPayable` is `{ readOnlyHint: true, openWorldHint: true }`. -- **Every UI resource uses `mimeType: RESOURCE_MIME_TYPE`.** Never hardcode the string. Import from `@modelcontextprotocol/ext-apps/server`. +- **Every UI resource uses `mimeType: RESOURCE_MIME_TYPE`.** Never hardcode the string. Import it — along with `registerAppTool` / `registerAppResource` — from `@solvapay/mcp`, which vendors the server-side ext-apps helpers because `@modelcontextprotocol/ext-apps` has no SDK v2 build. The client-side `@modelcontextprotocol/ext-apps` entrypoint that runs inside the iframe is unaffected and stays a direct dependency. - **`_meta.ui.resourceUri` lives only on the three intent-tool descriptors.** Merchant payable tools (`registerPayable`) deliberately do NOT advertise it — hosts MUST open the iframe on every call per SEP-1865 when the descriptor advertises it, which means auto-stamping flashed an empty widget on every silent data-tool success. Paywall / nudge / activation responses ship as plain-text narrations on `content[0].text` (naming the recovery intent tool) with `structuredContent = gate` for programmatic consumers. SolvaPay intent tools (`/upgrade`, `/manage_account`, `/topup`) keep descriptor-level `_meta.ui.resourceUri` because calling them is the user's explicit intent to open the UI. - **Stripe.js is loaded from `js.stripe.com/v3` at runtime.** Never bundled. The CSP baseline allows this origin. diff --git a/docs/contributing/mcp-apps-sdk-rules.md b/docs/contributing/mcp-apps-sdk-rules.md index 05e4563c..d32d5a55 100644 --- a/docs/contributing/mcp-apps-sdk-rules.md +++ b/docs/contributing/mcp-apps-sdk-rules.md @@ -52,14 +52,14 @@ If you're building a UI surface that doesn't match one of these three, stop and ### Package boundaries - **`@solvapay/mcp` has zero `@modelcontextprotocol/*` runtime dependencies.** This invariant is load-bearing. Do not violate it. -- **`@solvapay/mcp` is the only package that imports the official `@modelcontextprotocol/sdk`.** If you need MCP types elsewhere, re-export them through `@solvapay/mcp-core` as structural aliases. `@solvapay/mcp-core` is intentionally framework-neutral with zero `@modelcontextprotocol/*` runtime dep — OAuth bridge middleware lives in `@solvapay/mcp-express` (Node) and `@solvapay/mcp-fetch` (fetch-first runtimes). +- **`@solvapay/mcp` is the only package that imports the official SDK (`@modelcontextprotocol/core` / `/server`).** If you need MCP types elsewhere, re-export them through `@solvapay/mcp-core` as structural aliases. `@solvapay/mcp-core` is intentionally framework-neutral with zero `@modelcontextprotocol/*` runtime dep — OAuth bridge middleware lives in `@solvapay/mcp-express` (Node) and `@solvapay/mcp-fetch` (fetch-first runtimes). - **`@solvapay/react/mcp` is a subpath export.** Merchants using SolvaPay for non-MCP React surfaces do not pay the ext-apps peer dep cost. - **Do not ship a `@solvapay/sdk` umbrella package.** Three-package imports are fine. An umbrella adds maintenance without clarity. ### Spec compliance - **Every tool has annotations.** `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint` are required. Not optional. Default for `registerPayable` is `{ readOnlyHint: true, openWorldHint: true }`. -- **Every UI resource uses `mimeType: RESOURCE_MIME_TYPE`.** Never hardcode the string. Import from `@modelcontextprotocol/ext-apps/server`. +- **Every UI resource uses `mimeType: RESOURCE_MIME_TYPE`.** Never hardcode the string. Import it — along with `registerAppTool` / `registerAppResource` — from `@solvapay/mcp`, which vendors the server-side ext-apps helpers because `@modelcontextprotocol/ext-apps` has no SDK v2 build. The client-side `@modelcontextprotocol/ext-apps` entrypoint that runs inside the iframe is unaffected and stays a direct dependency. - **`_meta.ui.resourceUri` lives only on the three intent-tool descriptors.** Merchant payable tools (`registerPayable`) deliberately do NOT advertise it — SEP-1865 says hosts MUST open the iframe on every call when the descriptor advertises it, which means auto-stamping flashed an empty widget on every silent data-tool success. Paywall / nudge / activation responses ship as plain-text narrations on `content[0].text` (naming the recovery intent tool) with `structuredContent = gate` for programmatic consumers. SolvaPay intent tools (`/upgrade`, `/manage_account`, `/topup`) keep descriptor-level `_meta.ui.resourceUri` because calling them is the user's explicit intent to open the UI. - **Stripe.js is loaded from `js.stripe.com/v3` at runtime.** Never bundled. The CSP baseline allows this origin. diff --git a/docs/guides/mcp-app.mdx b/docs/guides/mcp-app.mdx index 9927e7ee..4a375799 100644 --- a/docs/guides/mcp-app.mdx +++ b/docs/guides/mcp-app.mdx @@ -72,8 +72,7 @@ Your server only needs to implement the tools the UI actually uses. Unimplemente On the server, register each tool with the canonical name so the client adapter can find it. Import the constants so you never hand-type a string. ```ts -import { registerAppTool } from '@modelcontextprotocol/ext-apps/server' -import { MCP_TOOL_NAMES } from '@solvapay/mcp' +import { MCP_TOOL_NAMES, registerAppTool } from '@solvapay/mcp' import { checkPurchaseCore, createCheckoutSessionCore } from '@solvapay/server' registerAppTool( diff --git a/docs/guides/mcp.mdx b/docs/guides/mcp.mdx index 1438de87..df3a0f44 100644 --- a/docs/guides/mcp.mdx +++ b/docs/guides/mcp.mdx @@ -20,11 +20,11 @@ description: "Protect Model Context Protocol (MCP) tools with SolvaPay using cre Install the SDK packages plus the official MCP SDK and Zod: ```bash -npm install @solvapay/mcp @solvapay/server @modelcontextprotocol/sdk zod +npm install @solvapay/mcp @solvapay/server @modelcontextprotocol/server @modelcontextprotocol/core zod # or -pnpm add @solvapay/mcp @solvapay/server @modelcontextprotocol/sdk zod +pnpm add @solvapay/mcp @solvapay/server @modelcontextprotocol/server @modelcontextprotocol/core zod # or -yarn add @solvapay/mcp @solvapay/server @modelcontextprotocol/sdk zod +yarn add @solvapay/mcp @solvapay/server @modelcontextprotocol/server @modelcontextprotocol/core zod ``` `@solvapay/mcp` is the only package that imports `@modelcontextprotocol/*`. The framework-neutral contracts (bearer helpers, paywall envelope) live in `@solvapay/mcp-core`, which is installed transitively. @@ -141,7 +141,7 @@ Deno.serve( readHtml: () => Deno.readTextFile('./mcp-app.html'), publicBaseUrl, apiBaseUrl, - mode: 'json-stateless', + responseMode: 'json', }), ) ``` @@ -251,8 +251,8 @@ import 'dotenv/config' import path from 'node:path' import { randomUUID } from 'node:crypto' import express, { type Request, type Response } from 'express' -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' -import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' +import { createMcpHandler } from '@modelcontextprotocol/server' +import { isLegacyRequest } from '@modelcontextprotocol/server' import { createSolvaPayMcpServer } from '@solvapay/mcp' import { createMcpOAuthBridge } from '@solvapay/mcp/express' import { createSolvaPay } from '@solvapay/server' @@ -335,7 +335,7 @@ app.listen(3006, () => { ### Stateless and text-only deployments -- **Stateless edge runtimes** — use `createSolvaPayMcpFetch({ ..., mode: 'json-stateless' })` from `@solvapay/mcp/fetch` so each request is handled without a persistent session store. +- **Stateless edge runtimes** — use `createSolvaPayMcpFetch({ ..., responseMode: 'json' })` from `@solvapay/mcp/fetch` so each request returns a single JSON body (required on Workers / Supabase Edge). - **Text-only hosts** — pass `hideToolsByAudience: ['ui']` to keep the LLM-facing `tools/list` narrow to the four intent tools plus your own data tools, while leaving the UI transport tools callable from the SolvaPay iframe. ChatGPT-originated `tools/list` requests are auto-detected and still receive the full catalog. ## Low-level Adapter (escape hatch) @@ -345,7 +345,7 @@ Prefer `createSolvaPayMcpServer` + `registerPayable`. Reach for the low-level ad `solvaPay.payable({ product }).mcp(fn)` wraps a single business-logic function with the paywall and returns an MCP tool result. When you want full control over the gate response shape, call `solvaPay.paywall.decide(...)` and format the gate with `paywallToolResult` from `@solvapay/mcp-core`: ```typescript -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { McpServer } from '@modelcontextprotocol/server' import { paywallToolResult } from '@solvapay/mcp-core' const server = new McpServer({ name: 'my-server', version: '1.0.0' }) diff --git a/examples/cloudflare-workers-mcp/README.md b/examples/cloudflare-workers-mcp/README.md index 1a33e90f..9481b501 100644 --- a/examples/cloudflare-workers-mcp/README.md +++ b/examples/cloudflare-workers-mcp/README.md @@ -209,7 +209,7 @@ The widget iframe payload (`mcp-app.html`, `src/mcp-app.tsx`, `vite.config.ts`) ## Known limits -- Bundle size: the Workers free tier caps at 1MB post-gzip. `@modelcontextprotocol/sdk` + `@solvapay/mcp` + `@solvapay/server` sit close to that ceiling. On the paid tier (10MB), there's plenty of headroom. +- Bundle size: the Workers free tier caps at 1MB post-gzip. `@modelcontextprotocol/server` + `@solvapay/mcp` + `@solvapay/server` sit close to that ceiling. On the paid tier (10MB), there's plenty of headroom. - Cold start: expect ~50-150ms on the first request per isolate. Warm requests are sub-20ms. Measure for your own geography before committing. ## Upstream diff --git a/examples/cloudflare-workers-mcp/package.json b/examples/cloudflare-workers-mcp/package.json index e1befcb0..391a8995 100644 --- a/examples/cloudflare-workers-mcp/package.json +++ b/examples/cloudflare-workers-mcp/package.json @@ -15,7 +15,8 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20251124.0", "@modelcontextprotocol/ext-apps": "^1.7.1", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/core": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", "@solvapay/mcp": "workspace:*", "@solvapay/mcp-core": "workspace:*", "@solvapay/react": "workspace:*", diff --git a/examples/cloudflare-workers-mcp/src/demo-tools.ts b/examples/cloudflare-workers-mcp/src/demo-tools.ts index 42daaa02..c3da05d3 100644 --- a/examples/cloudflare-workers-mcp/src/demo-tools.ts +++ b/examples/cloudflare-workers-mcp/src/demo-tools.ts @@ -46,7 +46,7 @@ import { z } from 'zod' import type { AdditionalToolsContext } from '@solvapay/mcp' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { McpServer } from '@modelcontextprotocol/server' interface McpServerWithPrompts { registerPrompt: McpServer['registerPrompt'] diff --git a/examples/cloudflare-workers-mcp/src/worker.ts b/examples/cloudflare-workers-mcp/src/worker.ts index 5c50552a..89fe0b2e 100644 --- a/examples/cloudflare-workers-mcp/src/worker.ts +++ b/examples/cloudflare-workers-mcp/src/worker.ts @@ -3,11 +3,10 @@ * * Single call into `createSolvaPayMcpFetch` from `@solvapay/mcp/fetch` * gives us a paywalled MCP server over the Workers runtime with the - * full `@modelcontextprotocol/sdk` wiring, `hideToolsByAudience` for + * full `@modelcontextprotocol/server` wiring, `hideToolsByAudience` for * a trim LLM-facing catalogue (with auto-bypass on ChatGPT so the - * iframe still works), and the `WebStandardStreamableHTTPServerTransport` - * stateless-JSON preset (correct shape for Workers isolates, which - * don't pin across requests). + * iframe still works), and `responseMode: 'json'` (correct shape for + * Workers isolates, which don't pin across requests). * * The only extra plumbing on top of the SDK handler is **browser-origin * CORS** — native-scheme clients (Cursor / VS Code / Claude Desktop) @@ -91,7 +90,7 @@ function getHandler(env: Env): (req: Request) => Promise { readHtml: async () => mcpAppHtml, publicBaseUrl: requireEnv(env, 'MCP_PUBLIC_BASE_URL'), apiBaseUrl, - mode: 'json-stateless', + responseMode: 'json', // Hide UI-only transport tools from the LLM-facing `tools/list` // (text hosts: Claude Desktop, MCPJam, Cursor) — keeps the model's // tool catalogue narrow to the four intent tools (`upgrade`, diff --git a/examples/cloudflare-workers-mcp/wrangler.jsonc b/examples/cloudflare-workers-mcp/wrangler.jsonc index 0dcc54c3..4ce1df0c 100644 --- a/examples/cloudflare-workers-mcp/wrangler.jsonc +++ b/examples/cloudflare-workers-mcp/wrangler.jsonc @@ -9,7 +9,7 @@ * binding required. Vite produces src/assets/mcp-app.html via the * `build` script (see package.json). * - `nodejs_compat` is a safety net for any transitively Node-ish path - * in `@modelcontextprotocol/sdk`. The SolvaPay MCP surface itself is + * in `@modelcontextprotocol/server`. The SolvaPay MCP surface itself is * fetch-first; this flag just prevents runtime surprises. * - `SOLVAPAY_SECRET_KEY` is a secret, not a var. Set with: * wrangler secret put SOLVAPAY_SECRET_KEY diff --git a/examples/mcp-checkout-app/package.json b/examples/mcp-checkout-app/package.json index 40efb48b..0df96332 100644 --- a/examples/mcp-checkout-app/package.json +++ b/examples/mcp-checkout-app/package.json @@ -9,8 +9,10 @@ "tunnel": "node ../shared/tunnel.mjs" }, "dependencies": { + "@modelcontextprotocol/core": "^2.0.0", "@modelcontextprotocol/ext-apps": "^1.7.1", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", "@solvapay/core": "workspace:*", "@solvapay/mcp": "workspace:*", "@solvapay/mcp-core": "workspace:*", diff --git a/examples/mcp-checkout-app/src/demo-tools.ts b/examples/mcp-checkout-app/src/demo-tools.ts index e274f233..d6914647 100644 --- a/examples/mcp-checkout-app/src/demo-tools.ts +++ b/examples/mcp-checkout-app/src/demo-tools.ts @@ -33,7 +33,7 @@ import { z } from 'zod' import type { AdditionalToolsContext } from '@solvapay/mcp' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { McpServer } from '@modelcontextprotocol/server' interface McpServerWithPrompts { registerPrompt: McpServer['registerPrompt'] diff --git a/examples/mcp-checkout-app/src/index.ts b/examples/mcp-checkout-app/src/index.ts index 52f7e073..dd618d0c 100644 --- a/examples/mcp-checkout-app/src/index.ts +++ b/examples/mcp-checkout-app/src/index.ts @@ -1,8 +1,7 @@ import 'dotenv/config' -import express, { type Request, type Response } from 'express' -import { randomUUID } from 'node:crypto' -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' -import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' +import express from 'express' +import { createMcpHandler } from '@modelcontextprotocol/server' +import { toNodeHandler } from '@modelcontextprotocol/node' import { createMcpOAuthBridge } from '@solvapay/mcp/express' import type { SolvaPayMerchantBranding } from '@solvapay/mcp-core' import { createServer, fetchBranding } from './server' @@ -15,27 +14,12 @@ import { solvapayProductRef, } from './config' -type JsonRpcId = string | number | null -type SessionEntry = { - transport: StreamableHTTPServerTransport -} - -const sessions: Record = {} - -// Fetched once at startup and reused across every MCP `initialize` -// handshake. Cached so we don't re-hit `GET /v1/sdk/merchant` on every -// new session — branding rarely changes and the trade-off favours a -// snappy handshake. A long-running deployment can fail over to an -// empty branding snapshot via `process.emitWarning` if the prefetch -// errored. let cachedBranding: SolvaPayMerchantBranding | undefined +const mcpHandler = createMcpHandler(() => createServer(cachedBranding)) + const app = express() app.use(express.json()) -// OAuth token + revoke endpoints submit `application/x-www-form-urlencoded` -// per RFC 6749 §4.1.3 — without this parser, `req.body` stays empty and the -// bridge forwards an empty body upstream, which the backend rejects with -// `grant_type is required`. `express.json()` alone is NOT enough. app.use(express.urlencoded({ extended: false })) app.use( ...createMcpOAuthBridge({ @@ -51,89 +35,12 @@ app.get('/health', (_req, res) => { res.json({ status: 'ok', server: 'mcp-checkout-app' }) }) -app.post('/mcp', async (req: Request, res: Response) => { - const id = (req.body as { id?: JsonRpcId } | undefined)?.id ?? null - const sessionId = - (req.headers['mcp-session-id'] as string | undefined) || - (typeof req.query.sessionId === 'string' ? req.query.sessionId : '') || - '' - - let transport: StreamableHTTPServerTransport | null = null - if (sessionId && sessions[sessionId]) { - transport = sessions[sessionId].transport - } - - if (!transport && isInitializeRequest(req.body)) { - const server = createServer(cachedBranding) - - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (sid: string) => { - sessions[sid] = { transport: transport! } - }, - }) - - transport.onclose = () => { - const sid = transport?.sessionId - if (sid && sessions[sid]) { - delete sessions[sid] - } - } - - await server.connect(transport) - } - - if (!transport) { - res.status(400).json({ - jsonrpc: '2.0', - id, - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided', - }, - }) - return - } - - await transport.handleRequest(req, res, req.body) -}) - -app.get('/mcp', async (req: Request, res: Response) => { - const sessionId = - (req.headers['mcp-session-id'] as string | undefined) || - (typeof req.query.sessionId === 'string' ? req.query.sessionId : '') || - '' - - if (!sessionId || !sessions[sessionId]) { - res.status(400).json({ error: 'Missing or invalid MCP-Session-Id' }) - return - } - - await sessions[sessionId].transport.handleRequest(req, res) -}) - -app.delete('/mcp', async (req: Request, res: Response) => { - const sessionId = - (req.headers['mcp-session-id'] as string | undefined) || - (typeof req.query.sessionId === 'string' ? req.query.sessionId : '') || - '' - - if (!sessionId || !sessions[sessionId]) { - res.status(400).json({ error: 'Missing or invalid MCP-Session-Id' }) - return - } - - await sessions[sessionId].transport.handleRequest(req, res) -}) - +// `toNodeHandler` streams SSE straight through and forwards `req.auth` (set by +// the OAuth bridge) as the handler's `authInfo`. +app.all('/mcp', toNodeHandler(mcpHandler)) -// Warm the branding cache before accepting traffic so the very first -// `initialize` handshake carries merchant identity (brand name on the -// Implementation + iconUrl on tools/list) instead of the generic -// SolvaPay fallback. Failure is silent — the server still boots with -// the default identity. fetchBranding() - .then((branding) => { + .then(branding => { cachedBranding = branding if (branding) { console.error('[mcp-checkout-app] branding', { @@ -143,8 +50,8 @@ fetchBranding() }) } }) - .catch(() => { - /* ignore — server boots with default identity. */ + .catch(error => { + console.error('[mcp-checkout-app] branding fetch failed, using default identity', error) }) app.listen(port, host, () => { @@ -153,11 +60,6 @@ app.listen(port, host, () => { publicBaseUrl: mcpPublicBaseUrl, apiBaseUrl: solvapayApiBaseUrl, productRef: solvapayProductRef, - // Surface the dev-only asset origins so it's obvious from the - // startup log whether `MCP_ASSET_ORIGINS` landed in the process - // env. If a merchant logo is CSP-blocked at `http://localhost:...` - // and this array is empty, the .env wasn't reloaded — restart dev - // (tsx watch picks up `.env` via `--include .env`). mcpAssetOrigins, }) }) diff --git a/examples/mcp-checkout-app/src/server.ts b/examples/mcp-checkout-app/src/server.ts index d46613d1..2c171a73 100644 --- a/examples/mcp-checkout-app/src/server.ts +++ b/examples/mcp-checkout-app/src/server.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { McpServer } from '@modelcontextprotocol/server' import { createSolvaPayMcpServer } from '@solvapay/mcp' import type { SolvaPayMerchantBranding } from '@solvapay/mcp-core' import { getMerchantCore, isErrorResult } from '@solvapay/server' @@ -101,7 +101,7 @@ export function createServer(branding?: SolvaPayMerchantBranding): McpServer { }) if (process.env.SOLVAPAY_DEBUG === 'true') { - // Deliberate escape hatch into `@modelcontextprotocol/sdk`'s private + // Deliberate escape hatch into `@modelcontextprotocol/server`'s private // `_registeredTools` bag so `SOLVAPAY_DEBUG=true` can dump the // effective `tools/list` descriptor shape (`_meta.ui.resourceUri`, // icons, annotations) without routing through an actual `tools/list` diff --git a/examples/mcp-oauth-bridge/package.json b/examples/mcp-oauth-bridge/package.json index 816ae31e..14cc2d3f 100644 --- a/examples/mcp-oauth-bridge/package.json +++ b/examples/mcp-oauth-bridge/package.json @@ -9,12 +9,14 @@ "oauth:flow": "tsx scripts/run-oauth-flow.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/core": "^2.0.0", + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", "@solvapay/auth": "workspace:^", "@solvapay/core": "workspace:*", "@solvapay/demo-services": "workspace:*", "@solvapay/mcp": "workspace:*", - "@solvapay/server": "*", + "@solvapay/server": "workspace:*", "dotenv": "^17.4.2", "express": "^5.2.1", "zod": "^4.1.11" diff --git a/examples/mcp-oauth-bridge/src/index.ts b/examples/mcp-oauth-bridge/src/index.ts index cee1da61..3b2706be 100644 --- a/examples/mcp-oauth-bridge/src/index.ts +++ b/examples/mcp-oauth-bridge/src/index.ts @@ -1,8 +1,7 @@ import 'dotenv/config' import express, { Request, Response } from 'express' -import { randomUUID } from 'node:crypto' -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' -import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' +import { createMcpHandler } from '@modelcontextprotocol/server' +import { toNodeHandler } from '@modelcontextprotocol/node' import { createMcpOAuthBridge } from '@solvapay/mcp/express' import { verifyWebhook } from '@solvapay/server' import { createMCPServer } from './server' @@ -16,13 +15,9 @@ import { solvapayWebhookSecret, } from './config' -type JsonRpcId = string | number | null - -type SessionEntry = { - transport: StreamableHTTPServerTransport -} - -const sessions: Record = {} +// SDK v2 is stateless: no session map, no `initialize` routing. The factory +// runs per request and `createMcpHandler` owns the transport lifecycle. +const mcpHandler = createMcpHandler(() => createMCPServer()) const app = express() // Use raw body for signature verification before JSON middleware transforms it. @@ -82,85 +77,9 @@ app.get('/health', (_req, res) => { res.json({ status: 'ok', server: 'mcp-oauth-bridge' }) }) -app.post('/mcp', async (req: Request, res: Response) => { - const id = (req.body as { id?: JsonRpcId } | undefined)?.id ?? null - - const sessionId = - (req.headers['mcp-session-id'] as string | undefined) || - (typeof req.query.sessionId === 'string' ? req.query.sessionId : '') || - '' - let transport: StreamableHTTPServerTransport | null = null - let session: SessionEntry | undefined - - if (sessionId && sessions[sessionId]) { - session = sessions[sessionId] - transport = session.transport - } - - if (!transport && isInitializeRequest(req.body)) { - const server = createMCPServer() - - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (sid: string) => { - sessions[sid] = { transport: transport! } - }, - }) - - transport.onclose = () => { - const sid = transport?.sessionId - if (sid && sessions[sid]) { - delete sessions[sid] - } - } - - await server.connect(transport) - } - - if (!transport) { - res.status(400).json({ - jsonrpc: '2.0', - id, - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided', - }, - }) - return - } - - await transport.handleRequest(req, res, req.body) -}) - -app.get('/mcp', async (req: Request, res: Response) => { - const sessionId = - (req.headers['mcp-session-id'] as string | undefined) || - (typeof req.query.sessionId === 'string' ? req.query.sessionId : '') || - '' - - if (!sessionId || !sessions[sessionId]) { - res.status(400).json({ error: 'Missing or invalid MCP-Session-Id' }) - return - } - - await sessions[sessionId].transport.handleRequest(req, res) -}) - -app.delete('/mcp', async (req: Request, res: Response) => { - const sessionId = - (req.headers['mcp-session-id'] as string | undefined) || - (typeof req.query.sessionId === 'string' ? req.query.sessionId : '') || - '' - - if (!sessionId || !sessions[sessionId]) { - res.status(400).json({ - error: 'Missing or invalid MCP-Session-Id', - }) - return - } - - await sessions[sessionId].transport.handleRequest(req, res) -}) +// `toNodeHandler` streams SSE straight through and forwards `req.auth` (set by +// the OAuth bridge) as the handler's `authInfo`. +app.all('/mcp', toNodeHandler(mcpHandler)) app.listen(port, host, () => { const displayHost = host === '0.0.0.0' ? 'localhost' : host diff --git a/examples/mcp-oauth-bridge/src/server.ts b/examples/mcp-oauth-bridge/src/server.ts index eb78fa66..28f712f9 100644 --- a/examples/mcp-oauth-bridge/src/server.ts +++ b/examples/mcp-oauth-bridge/src/server.ts @@ -1,4 +1,4 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { McpServer } from '@modelcontextprotocol/server' import { createTask, deleteTask, getTask, listTasks } from '@solvapay/demo-services' import type { McpServerLike } from '@solvapay/server' import { z } from 'zod' @@ -74,8 +74,8 @@ export function createMCPServer(): McpServer { ) if (solvaPay) { - // MCP SDK 1.28 registerTool typing is broader than the current SolvaPay McpServerLike contract. - // Runtime shape is compatible; cast keeps this example type-safe until the SDK types are widened. + // The SDK's registerTool typing is broader than the SolvaPay McpServerLike contract. + // Runtime shape is compatible; the cast keeps this example type-safe. void solvaPay.registerVirtualToolsMcp(server as unknown as McpServerLike, { product: solvapayProductRef, }) @@ -86,10 +86,10 @@ export function createMCPServer(): McpServer { 'create_task', { description: 'Create a new task (OAuth bearer token required)', - inputSchema: { + inputSchema: z.object({ title: z.string().describe('Task title'), description: z.string().optional().describe('Task description'), - }, + }), }, async (args, extra) => createTaskHandler(args, extra), ) @@ -99,9 +99,9 @@ export function createMCPServer(): McpServer { 'get_task', { description: 'Get task by ID (OAuth bearer token required)', - inputSchema: { + inputSchema: z.object({ id: z.string().describe('Task id'), - }, + }), }, async (args, extra) => getTaskHandler(args, extra), ) @@ -111,10 +111,10 @@ export function createMCPServer(): McpServer { 'list_tasks', { description: 'List tasks (OAuth bearer token required)', - inputSchema: { + inputSchema: z.object({ limit: z.number().optional(), offset: z.number().optional(), - }, + }), }, async (args, extra) => listTasksHandler(args, extra), ) @@ -124,9 +124,9 @@ export function createMCPServer(): McpServer { 'delete_task', { description: 'Delete task by ID (OAuth bearer token required)', - inputSchema: { + inputSchema: z.object({ id: z.string().describe('Task id'), - }, + }), }, async (args, extra) => deleteTaskHandler(args, extra), ) diff --git a/examples/mcp-time-app/package.json b/examples/mcp-time-app/package.json index b8f2da92..0bd1e4f7 100644 --- a/examples/mcp-time-app/package.json +++ b/examples/mcp-time-app/package.json @@ -8,13 +8,15 @@ "dev": "concurrently \"cross-env INPUT=mcp-app.html vite build --watch\" \"nodemon --watch src --watch dist/mcp-app.html --watch ../../packages/server/dist --watch ../../packages/core/dist --ext ts,css,html --exec \\\"tsx src/index.ts\\\"\"" }, "dependencies": { + "@modelcontextprotocol/core": "^2.0.0", "@modelcontextprotocol/ext-apps": "^1.7.1", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", "@solvapay/auth": "workspace:^", "@solvapay/core": "workspace:*", "@solvapay/mcp": "workspace:*", "@solvapay/mcp-core": "workspace:*", - "@solvapay/server": "*", + "@solvapay/server": "workspace:*", "dotenv": "^17.4.2", "express": "^5.2.1", "zod": "^4.3.6" diff --git a/examples/mcp-time-app/src/index.ts b/examples/mcp-time-app/src/index.ts index b37f8e1e..d806b19b 100644 --- a/examples/mcp-time-app/src/index.ts +++ b/examples/mcp-time-app/src/index.ts @@ -1,8 +1,7 @@ import 'dotenv/config' -import express, { type Request, type Response } from 'express' -import { randomUUID } from 'node:crypto' -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' -import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' +import express from 'express' +import { createMcpHandler } from '@modelcontextprotocol/server' +import { toNodeHandler } from '@modelcontextprotocol/node' import { createMcpOAuthBridge } from '@solvapay/mcp/express' import { createServer } from './server' import { @@ -12,12 +11,9 @@ import { solvapayProductRef, } from './config' -type JsonRpcId = string | number | null -type SessionEntry = { - transport: StreamableHTTPServerTransport -} - -const sessions: Record = {} +// SDK v2 is stateless: no session map, no `initialize` routing. The factory +// runs per request and `createMcpHandler` owns the transport lifecycle. +const mcpHandler = createMcpHandler(() => createServer()) const app = express() app.use(express.json()) @@ -37,80 +33,9 @@ app.get('/health', (_req, res) => { res.json({ status: 'ok', server: 'mcp-time-app' }) }) -app.post('/mcp', async (req: Request, res: Response) => { - const id = (req.body as { id?: JsonRpcId } | undefined)?.id ?? null - const sessionId = - (req.headers['mcp-session-id'] as string | undefined) || - (typeof req.query.sessionId === 'string' ? req.query.sessionId : '') || - '' - - let transport: StreamableHTTPServerTransport | null = null - if (sessionId && sessions[sessionId]) { - transport = sessions[sessionId].transport - } - - if (!transport && isInitializeRequest(req.body)) { - const server = createServer() - - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (sid: string) => { - sessions[sid] = { transport: transport! } - }, - }) - - transport.onclose = () => { - const sid = transport?.sessionId - if (sid && sessions[sid]) { - delete sessions[sid] - } - } - - await server.connect(transport) - } - - if (!transport) { - res.status(400).json({ - jsonrpc: '2.0', - id, - error: { - code: -32000, - message: 'Bad Request: No valid session ID provided', - }, - }) - return - } - - await transport.handleRequest(req, res, req.body) -}) - -app.get('/mcp', async (req: Request, res: Response) => { - const sessionId = - (req.headers['mcp-session-id'] as string | undefined) || - (typeof req.query.sessionId === 'string' ? req.query.sessionId : '') || - '' - - if (!sessionId || !sessions[sessionId]) { - res.status(400).json({ error: 'Missing or invalid MCP-Session-Id' }) - return - } - - await sessions[sessionId].transport.handleRequest(req, res) -}) - -app.delete('/mcp', async (req: Request, res: Response) => { - const sessionId = - (req.headers['mcp-session-id'] as string | undefined) || - (typeof req.query.sessionId === 'string' ? req.query.sessionId : '') || - '' - - if (!sessionId || !sessions[sessionId]) { - res.status(400).json({ error: 'Missing or invalid MCP-Session-Id' }) - return - } - - await sessions[sessionId].transport.handleRequest(req, res) -}) +// `toNodeHandler` streams SSE straight through and forwards `req.auth` (set by +// the OAuth bridge) as the handler's `authInfo`. +app.all('/mcp', toNodeHandler(mcpHandler)) const port = parseInt(process.env.MCP_PORT || '3005', 10) const host = process.env.MCP_HOST || 'localhost' diff --git a/examples/mcp-time-app/src/mcp-app.ts b/examples/mcp-time-app/src/mcp-app.ts index 5848fc32..259730a8 100644 --- a/examples/mcp-time-app/src/mcp-app.ts +++ b/examples/mcp-time-app/src/mcp-app.ts @@ -5,10 +5,13 @@ import { applyHostStyleVariables, type McpUiHostContext, } from '@modelcontextprotocol/ext-apps' -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' import './global.css' import './mcp-app.css' +// Derived from the ext-apps client rather than imported from the server SDK: +// the iframe bundle has no dependency on the official SDK packages. +type CallToolResult = Awaited> + type TimeResult = { currentTime?: string } diff --git a/examples/mcp-time-app/src/server.ts b/examples/mcp-time-app/src/server.ts index 18e0c5db..60c828a3 100644 --- a/examples/mcp-time-app/src/server.ts +++ b/examples/mcp-time-app/src/server.ts @@ -1,8 +1,8 @@ import fs from 'node:fs/promises' import path from 'node:path' -import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server' -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js' +import { McpServer } from '@modelcontextprotocol/server' +import type { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/server' +import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE } from '@solvapay/mcp' import { z } from 'zod' import { payable, paywallEnabled, solvaPay, solvapayProductRef } from './config' import type { McpToolExtra } from '@solvapay/mcp-core' @@ -38,10 +38,12 @@ function registerVirtualAppTools(server: McpServer) { }) for (const tool of virtualTools) { - const inputSchema = jsonSchemaToZodRawShape( - tool.inputSchema.properties as Record, - tool.inputSchema.required || [], - ) as unknown as Record + const inputSchema = z.object( + jsonSchemaToZodRawShape( + tool.inputSchema.properties as Record, + tool.inputSchema.required || [], + ) as unknown as Record, + ) registerAppTool( server, @@ -119,7 +121,7 @@ export function createServer() { { title: 'Get current time', description: 'Returns the current server time in a readable local format.', - inputSchema: {}, + inputSchema: z.object({}), outputSchema: timeOutputSchema, _meta: { ui: { diff --git a/examples/supabase-edge-mcp/README.md b/examples/supabase-edge-mcp/README.md index 2dcd16e0..5f12a04e 100644 --- a/examples/supabase-edge-mcp/README.md +++ b/examples/supabase-edge-mcp/README.md @@ -215,6 +215,6 @@ The root [`.github/workflows/publish-preview.yml`](../../.github/workflows/publi ## See also - [`packages/mcp/src/fetch/`](../../packages/mcp/src/fetch/) — full fetch-first handler reference (the `@solvapay/mcp/fetch` subpath export) -- [`packages/mcp/README.md`](../../packages/mcp/README.md) — the `@modelcontextprotocol/sdk` adapter used inside the handler +- [`packages/mcp/README.md`](../../packages/mcp/README.md) — the `@modelcontextprotocol/server` adapter used inside the handler - [`examples/mcp-checkout-app/README.md`](../mcp-checkout-app/README.md) — same toolbox, Express transport - [`examples/supabase-edge/README.md`](../supabase-edge/README.md) — checkout REST functions (the non-MCP companion) diff --git a/examples/supabase-edge-mcp/supabase/functions/mcp/demo-tools.ts b/examples/supabase-edge-mcp/supabase/functions/mcp/demo-tools.ts index 20087dd9..d85e8b9e 100644 --- a/examples/supabase-edge-mcp/supabase/functions/mcp/demo-tools.ts +++ b/examples/supabase-edge-mcp/supabase/functions/mcp/demo-tools.ts @@ -48,7 +48,7 @@ import { z } from 'zod' import type { AdditionalToolsContext } from '@solvapay/mcp' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { McpServer } from '@modelcontextprotocol/server' interface McpServerWithPrompts { registerPrompt: McpServer['registerPrompt'] diff --git a/examples/supabase-edge-mcp/supabase/functions/mcp/deno.json b/examples/supabase-edge-mcp/supabase/functions/mcp/deno.json index ed39124b..aa55b82c 100644 --- a/examples/supabase-edge-mcp/supabase/functions/mcp/deno.json +++ b/examples/supabase-edge-mcp/supabase/functions/mcp/deno.json @@ -2,8 +2,9 @@ "minimumDependencyAge": 0, "nodeModulesDir": "auto", "imports": { - "@modelcontextprotocol/sdk/": "npm:/@modelcontextprotocol/sdk@^1.29.0/", - "@modelcontextprotocol/ext-apps/": "npm:/@modelcontextprotocol/ext-apps@^1.5.0/", + "@modelcontextprotocol/core/": "npm:/@modelcontextprotocol/core@^2.0.0/", + "@modelcontextprotocol/server/": "npm:/@modelcontextprotocol/server@^2.0.0/", + "@modelcontextprotocol/ext-apps/": "npm:/@modelcontextprotocol/ext-apps@^1.7.1/", "@solvapay/mcp": "npm:@solvapay/mcp@preview", "@solvapay/mcp/": "npm:/@solvapay/mcp@preview/", "@solvapay/mcp-core": "npm:@solvapay/mcp-core@preview", diff --git a/examples/supabase-edge-mcp/supabase/functions/mcp/deno.local.json b/examples/supabase-edge-mcp/supabase/functions/mcp/deno.local.json index c52a2b07..37aa410a 100644 --- a/examples/supabase-edge-mcp/supabase/functions/mcp/deno.local.json +++ b/examples/supabase-edge-mcp/supabase/functions/mcp/deno.local.json @@ -8,8 +8,9 @@ "@solvapay/mcp-core": "npm:@solvapay/mcp-core@preview", "@solvapay/server": "npm:@solvapay/server@preview", "@solvapay/core": "npm:@solvapay/core@preview", - "@modelcontextprotocol/sdk/": "npm:/@modelcontextprotocol/sdk@1.29.0/", - "@modelcontextprotocol/ext-apps/": "npm:/@modelcontextprotocol/ext-apps@1.5.0/", + "@modelcontextprotocol/core/": "npm:/@modelcontextprotocol/core@^2.0.0/", + "@modelcontextprotocol/server/": "npm:/@modelcontextprotocol/server@^2.0.0/", + "@modelcontextprotocol/ext-apps/": "npm:/@modelcontextprotocol/ext-apps@^1.7.1/", "zod": "npm:zod@4.3.6" } } diff --git a/examples/supabase-edge-mcp/supabase/functions/mcp/index.ts b/examples/supabase-edge-mcp/supabase/functions/mcp/index.ts index eb1265ac..fa1b9d28 100644 --- a/examples/supabase-edge-mcp/supabase/functions/mcp/index.ts +++ b/examples/supabase-edge-mcp/supabase/functions/mcp/index.ts @@ -3,11 +3,10 @@ * * Single call into `createSolvaPayMcpFetch` from `@solvapay/mcp/fetch` * gives us a paywalled MCP server over Deno with the full - * `@modelcontextprotocol/sdk` wiring, `hideToolsByAudience` for a + * `@modelcontextprotocol/server` wiring, `hideToolsByAudience` for a * trim LLM-facing catalogue (with auto-bypass on ChatGPT so the - * iframe still works), and the `WebStandardStreamableHTTPServerTransport` - * stateless-JSON preset. The only things the Edge deployment still - * hand-rolls are: + * iframe still works), and `responseMode: 'json'`. The only things the + * Edge deployment still hand-rolls are: * * 1. **Supabase mount-prefix rewrite** — the edge gateway strips * `/functions/v1` but still delivers paths beginning with `/mcp` diff --git a/packages/create-solvapay/src/types/mcp/scaffold.test.ts b/packages/create-solvapay/src/types/mcp/scaffold.test.ts index 2fc6e82d..9d33a0c4 100644 --- a/packages/create-solvapay/src/types/mcp/scaffold.test.ts +++ b/packages/create-solvapay/src/types/mcp/scaffold.test.ts @@ -325,7 +325,8 @@ describe('patchSolvapayVersions', () => { name: 'demo-mcp', private: true, dependencies: { - '@modelcontextprotocol/sdk': '^1.29.0', + '@modelcontextprotocol/core': '^2.0.0', + '@modelcontextprotocol/server': '^2.0.0', '@solvapay/mcp': '^0.2.5', '@solvapay/react': '^1.2.0', '@solvapay/server': '^1.1.0', @@ -347,7 +348,8 @@ describe('patchSolvapayVersions', () => { dependencies: Record } expect(patched.dependencies).toEqual({ - '@modelcontextprotocol/sdk': '^1.29.0', + '@modelcontextprotocol/core': '^2.0.0', + '@modelcontextprotocol/server': '^2.0.0', '@solvapay/mcp': '0.3.7', '@solvapay/react': '1.3.0', '@solvapay/server': '1.2.0', @@ -416,7 +418,7 @@ describe('patchSolvapayVersions', () => { expect(pkg.dependencies['@solvapay/react']).toBe('1.3.0') expect(pkg.dependencies['@solvapay/server']).toBe('1.2.0') // Unrelated deps stay on their caret ranges (resolved at npm install time). - expect(pkg.dependencies['@modelcontextprotocol/sdk']).toMatch(/^\^/) + expect(pkg.dependencies['@modelcontextprotocol/server']).toMatch(/^\^/) expect(pkg.dependencies['zod']).toMatch(/^\^/) }) }) diff --git a/packages/create-solvapay/src/types/mcp/scaffold.ts b/packages/create-solvapay/src/types/mcp/scaffold.ts index e9b8ff23..8c0270af 100644 --- a/packages/create-solvapay/src/types/mcp/scaffold.ts +++ b/packages/create-solvapay/src/types/mcp/scaffold.ts @@ -405,7 +405,7 @@ export function printConnectionSnippets(options: ConnectionSnippetsOptions): voi */ export const SOLVAPAY_RUNTIME_DEPS: ReadonlyArray<{ name: string; fallback: string }> = Object.freeze([ - { name: '@solvapay/mcp', fallback: '0.2.5' }, + { name: '@solvapay/mcp', fallback: '0.3.0' }, { name: '@solvapay/server', fallback: '1.1.0' }, { name: '@solvapay/react', fallback: '1.2.0' }, ]) diff --git a/packages/create-solvapay/templates/mcp/_base/package.json b/packages/create-solvapay/templates/mcp/_base/package.json index 22d19ee7..aaccfdc7 100644 --- a/packages/create-solvapay/templates/mcp/_base/package.json +++ b/packages/create-solvapay/templates/mcp/_base/package.json @@ -15,8 +15,9 @@ }, "dependencies": { "@modelcontextprotocol/ext-apps": "^1.7.1", - "@modelcontextprotocol/sdk": "^1.29.0", - "@solvapay/mcp": "^0.2.5", + "@modelcontextprotocol/core": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "@solvapay/mcp": "^0.3.0", "@solvapay/react": "^1.2.0", "@solvapay/server": "^1.1.0", "react": "^19.2.5", diff --git a/packages/create-solvapay/templates/mcp/_base/src/worker.ts b/packages/create-solvapay/templates/mcp/_base/src/worker.ts index 66a6bc6f..06c95c0e 100644 --- a/packages/create-solvapay/templates/mcp/_base/src/worker.ts +++ b/packages/create-solvapay/templates/mcp/_base/src/worker.ts @@ -3,11 +3,10 @@ * * Single call into `createSolvaPayMcpFetch` from `@solvapay/mcp/fetch` * gives us a paywalled MCP server over the Workers runtime with the - * full `@modelcontextprotocol/sdk` wiring, `hideToolsByAudience` for a + * full `@modelcontextprotocol/server` wiring, `hideToolsByAudience` for a * trim LLM-facing catalogue (with auto-bypass on ChatGPT so the - * iframe still works), and the `WebStandardStreamableHTTPServerTransport` - * stateless-JSON preset (correct shape for Workers isolates, which - * don't pin across requests). + * iframe still works), and `responseMode: 'json'` (correct shape for + * Workers isolates, which don't pin across requests). * * The only extra plumbing on top of the SDK handler is **browser-origin * CORS** — native-scheme clients (Cursor / VS Code / Claude Desktop) diff --git a/packages/create-solvapay/templates/mcp/_base/wrangler.jsonc b/packages/create-solvapay/templates/mcp/_base/wrangler.jsonc index 064485ca..eca35e9b 100644 --- a/packages/create-solvapay/templates/mcp/_base/wrangler.jsonc +++ b/packages/create-solvapay/templates/mcp/_base/wrangler.jsonc @@ -10,7 +10,7 @@ * binding required. Vite produces src/assets/mcp-app.html via the * `build` script (see package.json). * - `nodejs_compat` is a safety net for any transitively Node-ish path - * in `@modelcontextprotocol/sdk`. The SolvaPay MCP surface itself is + * in `@modelcontextprotocol/server`. The SolvaPay MCP surface itself is * fetch-first; this flag prevents runtime surprises. * - `SOLVAPAY_SECRET_KEY`, `UPSTREAM_API_KEY`, `UPSTREAM_API_HEADERS`, * and the `UPSTREAM_OAUTH_*` keys (`TOKEN_URL` / `CLIENT_ID` / diff --git a/packages/mcp-core/README.md b/packages/mcp-core/README.md index 734c342d..55119a46 100644 --- a/packages/mcp-core/README.md +++ b/packages/mcp-core/README.md @@ -16,7 +16,7 @@ SEP-1865; merchant payable tools do NOT), the `BootstrapPayload` shape carried on `structuredContent`, Stripe CSP baseline, pure OAuth discovery JSON builders, and JWT bearer helpers. -It does **not** depend on `@modelcontextprotocol/sdk`, +It does **not** depend on `@modelcontextprotocol/core` / `@modelcontextprotocol/server`, `@modelcontextprotocol/ext-apps`, or any runtime-specific HTTP plumbing. The official `@modelcontextprotocol/*` adapter lives in [`@solvapay/mcp`](../mcp); Node `(req, res, next)` OAuth middleware ships @@ -63,7 +63,7 @@ intent tool, which mounts the widget. ## Typical usage If you're starting a new SolvaPay MCP server on the official -`@modelcontextprotocol/sdk`, don't use this package directly — use +`@modelcontextprotocol/server`, don't use this package directly — use [`@solvapay/mcp`](../mcp), which wraps it with one-call ergonomics. If you need HTTP-level OAuth handlers, pair this package with either @@ -99,7 +99,7 @@ myAdapter.registerResource(resource) ## See also -- [`@solvapay/mcp`](../mcp) — official `@modelcontextprotocol/sdk` + `ext-apps` adapter (`createSolvaPayMcpServer`) +- [`@solvapay/mcp`](../mcp) — official `@modelcontextprotocol/server` adapter, with the MCP Apps helpers vendored in (`createSolvaPayMcpServer`) - [`@solvapay/mcp/express`](../mcp/src/express) — Node `(req, res, next)` OAuth middleware stack - [`@solvapay/mcp/fetch`](../mcp/src/fetch) — fetch-first OAuth handlers + turnkey `createSolvaPayMcpFetchHandler` / `createSolvaPayMcpFetch` - [`@solvapay/server`](../server) — core SDK (paywall, webhooks, `*Core` helpers) diff --git a/packages/mcp-core/package.json b/packages/mcp-core/package.json index 05b89394..aff92e63 100644 --- a/packages/mcp-core/package.json +++ b/packages/mcp-core/package.json @@ -26,7 +26,7 @@ "directory": "packages/mcp-core" }, "engines": { - "node": ">=18.17" + "node": ">=20" }, "sideEffects": false, "scripts": { @@ -43,7 +43,7 @@ }, "peerDependencies": { "@solvapay/server": "^1.4.0 || ^2.0.0", - "zod": "^3.25.0 || ^4.0.0" + "zod": "^4.2.0" }, "devDependencies": { "@solvapay/server": "workspace:*", diff --git a/packages/mcp-core/src/hideToolsByAudience.ts b/packages/mcp-core/src/hideToolsByAudience.ts index 77b68672..c948631a 100644 --- a/packages/mcp-core/src/hideToolsByAudience.ts +++ b/packages/mcp-core/src/hideToolsByAudience.ts @@ -1,6 +1,6 @@ /** * `applyHideToolsByAudience(server, audiences, options?)` — wraps the - * `tools/list` request handler on an `@modelcontextprotocol/sdk` + * `tools/list` request handler on an `@modelcontextprotocol/server` * `McpServer` so tool descriptors whose `_meta.audience` matches one * of the supplied values are filtered out of the response. * @@ -23,35 +23,17 @@ * the iframe still works on ChatGPT, the default behaviour * automatically returns the **full** unfiltered catalog when the * incoming `tools/list` request originates from ChatGPT — detected by - * matching `request.headers['user-agent']` and the post-`initialize` - * `server.getClientVersion().name` against `/openai-mcp/i`. The first - * triggers on the discovery `tools/list` ChatGPT issues before - * `initialize` (so `getClientVersion()` is empty); the second is a - * defence-in-depth fallback for any future relay that strips the - * client UA. - * - * The User-Agent shape was confirmed live against ChatGPT's MCP - * runtime (`openai-mcp/1.0.0 (ChatGPT)` as of 2026-05). The pattern - * is intentionally broad so a UA bump to `openai-mcp/2.x` keeps - * working without code changes. + * matching `requestInfo.headers.get('user-agent')` against + * `/openai-mcp/i`. On 2026-era connections there is no `initialize` + * handshake, so the User-Agent header is the primary signal; the + * post-`initialize` `getClientVersion()` fallback remains for legacy-era + * relays that strip the client UA. * * Override the detection by passing `bypassWhen` — useful when a * future iframe-capable host needs the same treatment, or when * ChatGPT-served deployments want the LLM-narrow catalog regardless * (`bypassWhen: () => false`). * - * # Internal-map reach-in rationale - * - * `Protocol.setRequestHandler` contains an `assertCanSetRequestHandler` - * guard that fails when the SDK has already registered a handler for - * the method during registration — i.e. `tools/list` after any tool - * registration. The guard only fires through the public wrapper; the - * underlying `_requestHandlers` map accepts a replacement silently. - * Touching the map directly therefore sidesteps the guard without - * going through public API surface that could change in a minor SDK - * bump. The one piece of SDK-internal knowledge we live with until - * the SDK ships a first-class "replace handler" affordance. - * * No-op when `audiences` is empty or falsy. * * This helper is exported from `@solvapay/mcp-core` so both @@ -63,14 +45,19 @@ /** * Structural shape of the subset of `McpServer` we need. Typed * structurally so `@solvapay/mcp-core` stays free of any - * `@modelcontextprotocol/sdk` runtime or type dependency. + * `@modelcontextprotocol/server` runtime or type dependency. */ interface McpServerLike { server: { _requestHandlers: Map< string, - (req: unknown, extra: unknown) => Promise + (req: unknown, ctx: unknown) => Promise > + removeRequestHandler?: (method: string) => void + setRequestHandler?: ( + method: string, + handler: (req: unknown, ctx: unknown) => Promise, + ) => void getClientVersion?: () => { name?: unknown } | undefined } } @@ -85,19 +72,15 @@ interface ToolsListResponseLike { [key: string]: unknown } -/** - * Headers shape exposed by the MCP SDK's `RequestHandlerExtra.requestInfo`. - * Mirrors `IsomorphicHeaders` from the SDK without taking a type dep. - */ -type IsomorphicHeaders = Record +/** Headers from a Web `Request` or legacy isomorphic record shape. */ +type RequestHeaders = Headers | Record /** - * Subset of `RequestHandlerExtra` (from `@modelcontextprotocol/sdk`) - * that the bypass predicate inspects. Typed loosely so we don't - * couple to the SDK's exact shape. + * Subset of the MCP SDK handler context that the bypass predicate inspects. + * Typed loosely so we don't couple to the SDK's exact shape. */ export interface ApplyHideToolsByAudienceExtra { - requestInfo?: { headers?: IsomorphicHeaders } | undefined + requestInfo?: Request | { headers?: RequestHeaders } | undefined [key: string]: unknown } @@ -105,9 +88,8 @@ export interface ApplyHideToolsByAudienceContext { /** The MCP server instance the filter is being applied to. */ server: unknown /** - * The `RequestHandlerExtra` the SDK passed to the wrapped - * `tools/list` handler. May be undefined for non-HTTP transports - * (e.g. stdio) where there's no request information. + * Handler context the SDK passed to the wrapped `tools/list` handler. + * May be undefined for non-HTTP transports (e.g. stdio). */ extra?: ApplyHideToolsByAudienceExtra } @@ -120,41 +102,37 @@ export interface ApplyHideToolsByAudienceOptions { * request, the audience filter is skipped and the full catalog is * returned. Defaults to `defaultIsChatGptRequest` — see the file * header for the rationale. - * - * Pass an integrator-supplied predicate to extend the bypass to - * another host (e.g. a future iframe-capable client), or `() => - * false` to apply the filter unconditionally. */ bypassWhen?: HideToolsByAudienceBypass } -/** - * Liberal pattern matching ChatGPT's MCP runtime client. Verified - * live against `openai-mcp/1.0.0 (ChatGPT)`; written to also match a - * future `openai-mcp/2.x` or `openai-mcp-experimental` without code - * change. - */ +/** Liberal pattern matching ChatGPT's MCP runtime client. */ const CHATGPT_CLIENT_RE = /openai-mcp/i -function readHeader(headers: IsomorphicHeaders | undefined, name: string): string | undefined { +function readHeader(headers: RequestHeaders | undefined, name: string): string | undefined { if (!headers) return undefined + if (headers instanceof Headers) { + return headers.get(name) ?? headers.get(name.toLowerCase()) ?? undefined + } const raw = headers[name] ?? headers[name.toLowerCase()] if (Array.isArray(raw)) return raw[0] return typeof raw === 'string' ? raw : undefined } +function readUserAgentFromContext(ctx: ApplyHideToolsByAudienceContext): string | undefined { + const requestInfo = ctx.extra?.requestInfo + if (requestInfo instanceof Request) { + return requestInfo.headers.get('user-agent') ?? undefined + } + return readHeader(requestInfo?.headers, 'user-agent') +} + /** * Default `bypassWhen` — returns true when the incoming request looks - * like it's coming from ChatGPT's MCP runtime. Two signals: - * - * 1. HTTP `User-Agent` header (works on the pre-`initialize` - * discovery `tools/list`, so we have a signal even before the - * SDK's `getClientVersion()` is populated). - * 2. `server.getClientVersion()?.name` (covers any host that relays - * ChatGPT requests without forwarding the upstream UA). + * like it's coming from ChatGPT's MCP runtime. */ export function defaultIsChatGptRequest(ctx: ApplyHideToolsByAudienceContext): boolean { - const ua = readHeader(ctx.extra?.requestInfo?.headers, 'user-agent') + const ua = readUserAgentFromContext(ctx) if (ua && CHATGPT_CLIENT_RE.test(ua)) return true const clientVersion = (ctx.server as McpServerLike | undefined)?.server?.getClientVersion?.() @@ -179,27 +157,20 @@ export function applyHideToolsByAudience( const original = handlers.get('tools/list') if (!original) return - // Throttle the bypass log to one warning per server instance per - // bypass-context so the tail stays readable; flip back to noisy for - // debugging by clearing the set. Context is derived from the - // request itself (User-Agent if present, else `unknown`) — not from - // what the predicate matched on, so the message stays accurate - // whether the default ChatGPT detection or an integrator-supplied - // `bypassWhen` triggered it. const warned = new Set() - handlers.set('tools/list', async (req, extra) => { - const res = await original(req, extra) + const wrapped = async (req: unknown, ctx: unknown) => { + const res = await original(req, ctx) if ( bypassWhen({ server, - extra: extra as ApplyHideToolsByAudienceExtra | undefined, + extra: ctx as ApplyHideToolsByAudienceExtra | undefined, }) ) { - const ua = readHeader( - (extra as ApplyHideToolsByAudienceExtra | undefined)?.requestInfo?.headers, - 'user-agent', - ) + const ua = readUserAgentFromContext({ + server, + extra: ctx as ApplyHideToolsByAudienceExtra | undefined, + }) const context = ua ? `ua=${ua}` : 'no user-agent' if (!warned.has(context)) { warned.add(context) @@ -217,5 +188,12 @@ export function applyHideToolsByAudience( return !hidden.has(typeof audience === 'string' ? audience : '') }), } - }) + } + + if (typeof inner.setRequestHandler === 'function') { + inner.setRequestHandler('tools/list', wrapped) + return + } + + handlers.set('tools/list', wrapped) } diff --git a/packages/mcp-core/src/index.ts b/packages/mcp-core/src/index.ts index 147653b5..da77d0d5 100644 --- a/packages/mcp-core/src/index.ts +++ b/packages/mcp-core/src/index.ts @@ -8,7 +8,7 @@ * * This package is the single source of truth for shapes that cross the * server↔client boundary (bootstrap payload, tool names, view map). It - * has no runtime dependency on `@modelcontextprotocol/sdk`, + * has no runtime dependency on `@modelcontextprotocol/core` / `/server`, * `@modelcontextprotocol/ext-apps`, Express, or any runtime-specific OAuth * middleware — those live in `@solvapay/mcp` (root entry), * `@solvapay/mcp/express`, and `@solvapay/mcp/fetch` respectively. diff --git a/packages/mcp-core/src/types.ts b/packages/mcp-core/src/types.ts index e0fea559..18888a8a 100644 --- a/packages/mcp-core/src/types.ts +++ b/packages/mcp-core/src/types.ts @@ -58,7 +58,7 @@ export interface BootstrapCustomer { /** * MCP tool call result — a structural subset of the official SDK's * `CallToolResult` that every framework produces. Kept local to avoid - * coupling to `@modelcontextprotocol/sdk/types.js` type churn. + * coupling to `@modelcontextprotocol/core` type churn. */ /** * Routing hint surfaced on individual content blocks. Mirrors the MCP @@ -337,7 +337,7 @@ export interface SolvaPayBootstrapResourceDescriptor { /** * One MCP prompt — rendered as `/` in hosts with slash-command - * support. Kept framework-neutral so every adapter (`@modelcontextprotocol/sdk`, + * support. Kept framework-neutral so every adapter (`@modelcontextprotocol/server`, * `fastmcp`, raw JSON-RPC) can map it to their own `registerPrompt` * shape. * @@ -357,7 +357,7 @@ export interface SolvaPayPromptDescriptor { /** * Minimal `GetPromptResult` shape — structural subset of the official * SDK's type so adapters can forward it without importing - * `@modelcontextprotocol/sdk/types.js`. + * `@modelcontextprotocol/core`. */ export interface SolvaPayPromptResult { messages: Array<{ diff --git a/packages/mcp/README.md b/packages/mcp/README.md index d9d13656..abb2bcc9 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -3,7 +3,7 @@ [![npm version](https://img.shields.io/npm/v/@solvapay/mcp.svg)](https://www.npmjs.com/package/@solvapay/mcp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -Official `@modelcontextprotocol/sdk` + `@modelcontextprotocol/ext-apps` adapter for SolvaPay MCP servers. +Official `@modelcontextprotocol/server` adapter for SolvaPay MCP servers (v2 SDK; MCP protocol 2026-07-28). **When to use this package:** add paywalled tools and SolvaPay transport tools to an MCP server. For a **new** app, prefer the scaffolder first: @@ -19,7 +19,7 @@ Guides: [MCP](https://docs.solvapay.com/sdks/typescript/guides/mcp) · [MCP app] ```bash pnpm add @solvapay/mcp @solvapay/server \ - @modelcontextprotocol/sdk @modelcontextprotocol/ext-apps zod + @modelcontextprotocol/server @modelcontextprotocol/core zod ``` ## Quickstart (advanced) diff --git a/packages/mcp/__tests__/fetch/createSolvaPayMcpFetch.spec.ts b/packages/mcp/__tests__/fetch/createSolvaPayMcpFetch.spec.ts index 7b6b4fe3..be6271cc 100644 --- a/packages/mcp/__tests__/fetch/createSolvaPayMcpFetch.spec.ts +++ b/packages/mcp/__tests__/fetch/createSolvaPayMcpFetch.spec.ts @@ -62,7 +62,7 @@ function buildHandler( publicBaseUrl, apiBaseUrl, requireAuth: false, - mode: 'json-stateless', + responseMode: 'json', ...overrides, }) } @@ -312,7 +312,8 @@ describe('createSolvaPayMcpFetch', () => { it('invokes the additionalTools hook with { server, solvaPay, resourceUri, productRef }', async () => { const additional = vi.fn() - buildHandler({ additionalTools: additional }) + const handler = buildHandler({ additionalTools: additional }) + await initialize(handler) expect(additional).toHaveBeenCalledOnce() const ctx = additional.mock.calls[0][0] expect(ctx.productRef).toBe(productRef) @@ -367,8 +368,8 @@ describe('createSolvaPayMcpFetch', () => { expect(source).not.toMatch(/from\s+['"]@solvapay\/mcp['"]/) }) - it('passes mode: json-stateless through to the underlying handler (no sessionId header on initialize)', async () => { - const handler = buildHandler({ mode: 'json-stateless' }) + it('defaults responseMode to json (no sessionId header on initialize)', async () => { + const handler = buildHandler({ responseMode: 'json' }) const res = await handler( new Request(`${publicBaseUrl}/mcp`, { method: 'POST', diff --git a/packages/mcp/__tests__/fetch/handler-stateless-json.spec.ts b/packages/mcp/__tests__/fetch/handler-stateless-json.spec.ts index e2e8b31f..2af1833f 100644 --- a/packages/mcp/__tests__/fetch/handler-stateless-json.spec.ts +++ b/packages/mcp/__tests__/fetch/handler-stateless-json.spec.ts @@ -1,56 +1,59 @@ /** - * End-to-end coverage for `createSolvaPayMcpFetchHandler({ mode: - * 'json-stateless' })`. Unlike `handler.spec.ts` which mocks the - * transport to cover the OAuth / CORS / auth-guard surface, this suite - * exercises the real `WebStandardStreamableHTTPServerTransport` + - * `McpServer` wiring so the mode/close/mutex interaction is - * regression-tested end-to-end (initialize → notifications/initialized - * → tools/list → tools/call, plus a concurrency check to guard against - * "Already connected to a transport" re-entry bugs). + * End-to-end coverage for `createSolvaPayMcpFetchHandler({ responseMode: 'json' })`. */ import { describe, expect, it } from 'vitest' import { z } from 'zod' -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + McpServer, + PROTOCOL_VERSION_META_KEY, +} from '@modelcontextprotocol/server' import { createSolvaPayMcpFetchHandler } from '../../src/fetch/handler' +/** The 2026-era revision this SDK serves on the modern path. */ +const MODERN_PROTOCOL_VERSION = '2026-07-28' + const publicBaseUrl = 'https://mcp.example.com' const apiBaseUrl = 'https://api.solvapay.com' const productRef = 'prd_test_stateless' -function buildEchoServer(): McpServer { - const server = new McpServer({ name: 'test-stateless', version: '0.0.0' }) - server.registerTool( - 'echo', - { - title: 'Echo', - description: 'Echoes back the provided message.', - inputSchema: { message: z.string() }, - }, - async ({ message }) => ({ - content: [{ type: 'text' as const, text: message }], - }), - ) - server.registerTool( - 'reverse', - { - title: 'Reverse', - description: 'Reverses the provided message.', - inputSchema: { message: z.string() }, - }, - async ({ message }) => ({ - content: [{ type: 'text' as const, text: message.split('').reverse().join('') }], - }), - ) - return server +function buildEchoFactory() { + return () => { + const server = new McpServer({ name: 'test-stateless', version: '0.0.0' }) + server.registerTool( + 'echo', + { + title: 'Echo', + description: 'Echoes back the provided message.', + inputSchema: z.object({ message: z.string() }), + }, + async ({ message }) => ({ + content: [{ type: 'text' as const, text: message }], + }), + ) + server.registerTool( + 'reverse', + { + title: 'Reverse', + description: 'Reverses the provided message.', + inputSchema: z.object({ message: z.string() }), + }, + async ({ message }) => ({ + content: [{ type: 'text' as const, text: message.split('').reverse().join('') }], + }), + ) + return server + } } function buildHandler(): (req: Request) => Promise { return createSolvaPayMcpFetchHandler({ - server: buildEchoServer(), + factory: buildEchoFactory(), publicBaseUrl, apiBaseUrl, productRef, - mode: 'json-stateless', + responseMode: 'json', requireAuth: false, }) } @@ -76,8 +79,19 @@ function rpcRequest(body: unknown): Request { async function callRpc( handler: (req: Request) => Promise, body: unknown, + extraHeaders: Record = {}, ): Promise<{ status: number; json: JsonRpcResponse }> { - const res = await handler(rpcRequest(body)) + const res = await handler( + new Request(`${publicBaseUrl}/mcp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...extraHeaders, + }, + body: JSON.stringify(body), + }), + ) return { status: res.status, json: (await res.json()) as JsonRpcResponse } } @@ -95,11 +109,10 @@ interface ToolsCallResult { content: Array<{ type: string; text?: string }> } -describe('createSolvaPayMcpFetchHandler — mode: json-stateless', () => { - it('completes initialize → initialized → tools/list → tools/call against a real McpServer', async () => { +describe('createSolvaPayMcpFetchHandler — responseMode: json (legacy era)', () => { + it('completes initialize → initialized → tools/list → tools/call', async () => { const handler = buildHandler() - // 1) initialize → 200 + JSON body with serverInfo const init = await callRpc(handler, { jsonrpc: '2.0', id: 1, @@ -113,8 +126,6 @@ describe('createSolvaPayMcpFetchHandler — mode: json-stateless', () => { expect(init.status).toBe(200) expect(init.json.result?.serverInfo?.name).toBe('test-stateless') - // 2) notifications/initialized is a JSON-RPC notification (no id) → - // 202 Accepted with no body per the MCP transport spec. const initializedRes = await handler( rpcRequest({ jsonrpc: '2.0', @@ -123,7 +134,6 @@ describe('createSolvaPayMcpFetchHandler — mode: json-stateless', () => { ) expect(initializedRes.status).toBe(202) - // 3) tools/list → 200 + JSON body with the echo tool present const list = await callRpc(handler, { jsonrpc: '2.0', id: 2, @@ -132,7 +142,6 @@ describe('createSolvaPayMcpFetchHandler — mode: json-stateless', () => { expect(list.status).toBe(200) expect(list.json.result?.tools?.map(t => t.name)).toContain('echo') - // 4) tools/call → 200 + JSON body with the echo result const call = await callRpc(handler, { jsonrpc: '2.0', id: 3, @@ -143,15 +152,7 @@ describe('createSolvaPayMcpFetchHandler — mode: json-stateless', () => { expect(call.json.result?.content?.[0]).toMatchObject({ type: 'text', text: 'hello' }) }) - it('chains two tools/call requests in one session without minting Mcp-Session-Id (Goldberg regression)', async () => { - // Regression for the Goldberg ChatGPT topup failure - // (see solvapay-frontend/.cursor/plans/investigate_goldberg_topup_failure_ff1187a7.plan.md). - // ChatGPT's MCP connector was returning `-32000 MCP Resource not found` - // on the second `tools/call` of a session against a json-stateless - // Cloudflare Worker. The probe confirmed the worker itself routes - // every chained call cleanly; this test locks that SDK invariant in - // CI so a future handler refactor can't silently start dropping the - // second tools/call, even before any host-side bug is fixed. + it('chains two tools/call requests without minting Mcp-Session-Id', async () => { const handler = buildHandler() const init = await callRpc(handler, { @@ -166,9 +167,6 @@ describe('createSolvaPayMcpFetchHandler — mode: json-stateless', () => { }) expect(init.status).toBe(200) - // Stateless mode must never mint an Mcp-Session-Id — any value here - // would trick a stateful host into reusing it across stateless - // isolate restarts and reproducing the upstream failure shape. const initHeaderRes = await handler( rpcRequest({ jsonrpc: '2.0', @@ -212,11 +210,9 @@ describe('createSolvaPayMcpFetchHandler — mode: json-stateless', () => { expect(callB.json.result?.content?.[0]).toMatchObject({ type: 'text', text: 'olleh' }) }) - it('survives 50 concurrent tools/list calls without "Already connected" errors', async () => { + it('survives 50 concurrent tools/list calls without transport errors', async () => { const handler = buildHandler() - // Initialize first so subsequent calls aren't rejected by the - // transport's session validator on the first fan-out request. const init = await callRpc(handler, { jsonrpc: '2.0', id: 1, @@ -240,13 +236,119 @@ describe('createSolvaPayMcpFetchHandler — mode: json-stateless', () => { for (const r of results) { expect(r.status).toBe(200) - // A re-entry bug surfaces as a JSON-RPC error with - // `Already connected to a transport` on `result.error.message`. - // The mutex + transport.close() in the handler's finally block - // keeps the server's `_transport` slot free for the next request, - // so every call here should carry a clean `tools` result. expect(r.json.error).toBeUndefined() expect(r.json.result?.tools?.map(t => t.name)).toContain('echo') } }) }) + +describe('createSolvaPayMcpFetchHandler — modern era (2026-07-28)', () => { + /** + * Modern requests carry no `initialize` handshake — each one self-describes + * through the namespaced `_meta` envelope, and the presence of that claim is + * what routes the request to the modern path instead of the legacy leg. + */ + function modernMeta(): Record { + return { + [PROTOCOL_VERSION_META_KEY]: MODERN_PROTOCOL_VERSION, + [CLIENT_INFO_META_KEY]: { name: 'modern-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {}, + } + } + + /** + * The modern path cross-checks routing headers against the body and answers + * `-32020` on any disagreement — including an *absent* header for a value the + * body carries. `tools/call` therefore needs `Mcp-Name` alongside + * `Mcp-Method`, mirroring `params.name`. + */ + function modernHeaders(method: string, name?: string): Record { + return { + 'mcp-method': method, + 'mcp-protocol-version': MODERN_PROTOCOL_VERSION, + ...(name !== undefined ? { 'mcp-name': name } : {}), + } + } + + it('round-trips tools/list with no initialize handshake', async () => { + const handler = buildHandler() + + const list = await callRpc( + handler, + { + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: { _meta: modernMeta() }, + }, + modernHeaders('tools/list'), + ) + + expect(list.status).toBe(200) + expect(list.json.error).toBeUndefined() + expect(list.json.result?.tools?.map(t => t.name)).toEqual( + expect.arrayContaining(['echo', 'reverse']), + ) + }) + + it('round-trips tools/call', async () => { + const handler = buildHandler() + + const call = await callRpc( + handler, + { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'reverse', + arguments: { message: 'hello' }, + _meta: modernMeta(), + }, + }, + modernHeaders('tools/call', 'reverse'), + ) + + expect(call.status).toBe(200) + expect(call.json.error).toBeUndefined() + expect(call.json.result?.content?.[0]).toMatchObject({ type: 'text', text: 'olleh' }) + }) + + it('rejects a body whose method disagrees with the Mcp-Method header', async () => { + const handler = buildHandler() + + const list = await callRpc( + handler, + { + jsonrpc: '2.0', + id: 4, + method: 'tools/list', + params: { _meta: modernMeta() }, + }, + modernHeaders('tools/call'), + ) + + expect(list.status).toBe(400) + expect(list.json.error?.code).toBe(-32020) + }) + + it('rejects an envelope naming a revision the endpoint does not serve', async () => { + const handler = buildHandler() + + const list = await callRpc( + handler, + { + jsonrpc: '2.0', + id: 5, + method: 'tools/list', + params: { + _meta: { ...modernMeta(), [PROTOCOL_VERSION_META_KEY]: '2999-01-01' }, + }, + }, + { 'mcp-method': 'tools/list' }, + ) + + expect(list.json.result).toBeUndefined() + expect(list.json.error).toBeDefined() + }) +}) diff --git a/packages/mcp/__tests__/fetch/handler.spec.ts b/packages/mcp/__tests__/fetch/handler.spec.ts index 876371c1..630ff5f7 100644 --- a/packages/mcp/__tests__/fetch/handler.spec.ts +++ b/packages/mcp/__tests__/fetch/handler.spec.ts @@ -1,40 +1,37 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createSolvaPayMcpFetchHandler } from '../../src/fetch/handler' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' - -vi.mock('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js', () => { - return { - WebStandardStreamableHTTPServerTransport: class MockTransport { - sessionIdGenerator?: () => string - enableJsonResponse?: boolean - constructor(opts: { sessionIdGenerator?: () => string; enableJsonResponse?: boolean } = {}) { - this.sessionIdGenerator = opts.sessionIdGenerator - this.enableJsonResponse = opts.enableJsonResponse - } - async handleRequest(req: Request) { - const sid = this.sessionIdGenerator?.() - return new Response( - JSON.stringify({ jsonrpc: '2.0', id: 1, result: { ok: true, sid, url: req.url } }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ) - } - // Handler closes the transport per-request so the server's - // `_transport` slot is released for the next call. - async close() {} - }, - } -}) +import type { McpServerFactory } from '@modelcontextprotocol/server' const publicBaseUrl = 'https://mcp.example.com' const apiBaseUrl = 'https://api.solvapay.com' const productRef = 'prd_test_123' -function mockServer(): McpServer { - return { +function mockFactory(): McpServerFactory { + return vi.fn().mockReturnValue({ connect: vi.fn().mockResolvedValue(undefined), - } as unknown as McpServer + close: vi.fn().mockResolvedValue(undefined), + }) } +vi.mock('@modelcontextprotocol/server', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + createMcpHandler: vi.fn((factory: McpServerFactory) => ({ + fetch: vi.fn(async (req: Request) => { + factory({ era: 'legacy', requestInfo: req }) + return new Response( + JSON.stringify({ jsonrpc: '2.0', id: 1, result: { ok: true, url: req.url } }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + }), + close: vi.fn().mockResolvedValue(undefined), + notify: {}, + bus: {}, + })), + } +}) + describe('createSolvaPayMcpFetchHandler', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) @@ -45,7 +42,7 @@ describe('createSolvaPayMcpFetchHandler', () => { it('responds to CORS preflight on /mcp', async () => { const handler = createSolvaPayMcpFetchHandler({ - server: mockServer(), + factory: mockFactory(), publicBaseUrl, apiBaseUrl, productRef, @@ -62,7 +59,7 @@ describe('createSolvaPayMcpFetchHandler', () => { it('serves OAuth discovery via the fetch router', async () => { const handler = createSolvaPayMcpFetchHandler({ - server: mockServer(), + factory: mockFactory(), publicBaseUrl, apiBaseUrl, productRef, @@ -77,7 +74,7 @@ describe('createSolvaPayMcpFetchHandler', () => { it('returns 401 + WWW-Authenticate when no bearer is present on /mcp', async () => { const handler = createSolvaPayMcpFetchHandler({ - server: mockServer(), + factory: mockFactory(), publicBaseUrl, apiBaseUrl, productRef, @@ -95,10 +92,10 @@ describe('createSolvaPayMcpFetchHandler', () => { expect(body.id).toBe(7) }) - it('forwards authenticated requests to the transport', async () => { - const server = mockServer() + it('forwards authenticated requests to createMcpHandler.fetch', async () => { + const factory = mockFactory() const handler = createSolvaPayMcpFetchHandler({ - server, + factory, publicBaseUrl, apiBaseUrl, productRef, @@ -120,15 +117,14 @@ describe('createSolvaPayMcpFetchHandler', () => { }), ) expect(res.status).toBe(200) - expect(server.connect).toHaveBeenCalledTimes(1) - const body = (await res.json()) as { result: { ok: boolean; sid?: string } } + expect(factory).toHaveBeenCalledTimes(1) + const body = (await res.json()) as { result: { ok: boolean } } expect(body.result.ok).toBe(true) - expect(typeof body.result.sid).toBe('string') }) it('skips auth when requireAuth=false and no Authorization header is present', async () => { const handler = createSolvaPayMcpFetchHandler({ - server: mockServer(), + factory: mockFactory(), publicBaseUrl, apiBaseUrl, productRef, @@ -146,7 +142,7 @@ describe('createSolvaPayMcpFetchHandler', () => { it('returns 405 for unsupported methods on /mcp', async () => { const handler = createSolvaPayMcpFetchHandler({ - server: mockServer(), + factory: mockFactory(), publicBaseUrl, apiBaseUrl, productRef, @@ -158,7 +154,7 @@ describe('createSolvaPayMcpFetchHandler', () => { it('returns 404 for unknown paths', async () => { const handler = createSolvaPayMcpFetchHandler({ - server: mockServer(), + factory: mockFactory(), publicBaseUrl, apiBaseUrl, productRef, diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 3f153046..c43cb327 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,7 +1,7 @@ { "name": "@solvapay/mcp", "version": "0.2.8", - "description": "Official @modelcontextprotocol/sdk + @modelcontextprotocol/ext-apps adapter for the SolvaPay MCP toolbox (createSolvaPayMcpServer, registerPayableTool) with ./fetch + ./express subpath exports for runtime-specific OAuth bridges.", + "description": "Official @modelcontextprotocol/server adapter for the SolvaPay MCP toolbox (createSolvaPayMcpServer, registerPayableTool) with ./fetch + ./express subpath exports for runtime-specific OAuth bridges.", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -36,7 +36,7 @@ "directory": "packages/mcp" }, "engines": { - "node": ">=18.17" + "node": ">=20" }, "sideEffects": false, "scripts": { @@ -49,11 +49,11 @@ "lint:fix": "eslint src --fix" }, "peerDependencies": { - "@modelcontextprotocol/ext-apps": "^1.7.1", - "@modelcontextprotocol/sdk": "^1.28.0", - "@solvapay/mcp-core": "workspace:^", + "@modelcontextprotocol/core": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "@solvapay/mcp-core": "^0.3.0", "@solvapay/server": "^1.4.0 || ^2.0.0", - "zod": "^3.25.0 || ^4.0.0" + "zod": "^4.2.0" }, "peerDependenciesMeta": { "zod": { @@ -61,8 +61,8 @@ } }, "devDependencies": { - "@modelcontextprotocol/ext-apps": "^1.7.1", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "@solvapay/mcp-core": "workspace:*", "@solvapay/server": "workspace:*", "@solvapay/test-utils": "workspace:^", diff --git a/packages/mcp/src/fetch/createSolvaPayMcpFetch.ts b/packages/mcp/src/fetch/createSolvaPayMcpFetch.ts index 61ba944a..f2741b81 100644 --- a/packages/mcp/src/fetch/createSolvaPayMcpFetch.ts +++ b/packages/mcp/src/fetch/createSolvaPayMcpFetch.ts @@ -1,21 +1,10 @@ /** * `createSolvaPayMcpFetch` — descriptor-accepting unified factory for - * Web-standards runtimes. Collapses the two-step dance (build - * `McpServer` via `createSolvaPayMcpServer`, wrap in - * `createSolvaPayMcpFetchHandler`) into a single call so edge - * consumers (Supabase Edge, Cloudflare Workers, Vercel Edge, Deno, - * Bun) can import ONLY from `@solvapay/mcp/fetch`. - * - * The registration loop is shared with the root `.` entry via - * `../internal/buildMcpServer`. `AdditionalToolsContext` is - * re-exported from the root `@solvapay/mcp` entry so merchants can - * move the same `additionalTools` callback between - * `createSolvaPayMcpServer` and `createSolvaPayMcpFetch` without - * touching the handler's signature — including the bound - * `registerPayable` helper. + * Web-standards runtimes. */ import type { BuildSolvaPayDescriptorsOptions } from '@solvapay/mcp-core' +import { defaultIsChatGptRequest } from '@solvapay/mcp-core' import { applyHideToolsByAudience, buildSolvaPayMcpServer, @@ -24,54 +13,84 @@ import { } from '../internal/buildMcpServer' import { registerPayableTool, type RegisterPayableToolOptions } from '../registerPayableTool' import type { AdditionalToolsContext } from '../server' -import { createSolvaPayMcpFetchHandler, type CreateSolvaPayMcpFetchHandlerOptions } from './handler' +import { + createSolvaPayMcpFetchHandler, + type CreateSolvaPayMcpFetchHandlerOptions, + type McpRequestContext, +} from './handler' export type { AdditionalToolsContext } from '../server' export interface CreateSolvaPayMcpFetchOptions extends Omit, - Omit { - /** - * Register non-SolvaPay tools on the freshly-built server. Receives - * `{ server, solvaPay, resourceUri, productRef, registerPayable }` - * — same shape as `createSolvaPayMcpServer`'s hook so merchant tool - * callbacks are portable between the two factories. - */ + Omit { additionalTools?: (ctx: AdditionalToolsContext) => void - /** - * Hide tools whose `_meta.audience` matches one of these values - * from `tools/list`. See `CreateSolvaPayMcpServerOptions` for the - * full rationale and the ChatGPT auto-bypass behaviour. - */ hideToolsByAudience?: HideToolsByAudienceConfig - /** - * Register the slash-command prompts built from the descriptor - * bundle. Defaults to `true`. - */ registerPrompts?: boolean - /** - * Register the narrated `docs://solvapay/overview.md` resource so - * agents can `resources/read` before trying a tool. Defaults to `true`. - */ registerDocsResources?: boolean - /** Overrides the default `McpServer` name. */ serverName?: string - /** Overrides the default `McpServer` version. */ serverVersion?: string } -/** - * Build a fetch-first MCP handler with the full SolvaPay tool surface - * registered in-place. Returns a `(req: Request) => Promise` - * suitable for `Deno.serve`, `addEventListener('fetch', …)`, Cloudflare - * Workers' `fetch` export, or any other Web-standards runtime. - */ +function buildServerForRequest( + ctx: McpRequestContext, + options: { + descriptorOptions: BuildSolvaPayDescriptorsOptions & { + registerPrompts: boolean + registerDocsResources: boolean + serverName?: string + serverVersion: string + } + additionalTools?: (ctx: AdditionalToolsContext) => void + hideToolsByAudience?: HideToolsByAudienceConfig + bypassWarned: Set + }, +) { + const { descriptorOptions, additionalTools, hideToolsByAudience, bypassWarned } = options + + const { server, descriptors } = buildSolvaPayMcpServer(descriptorOptions) + + if (additionalTools) { + const { solvaPay, productRef, resourceUri } = descriptorOptions + const registerPayable: AdditionalToolsContext['registerPayable'] = (name, opts) => { + registerPayableTool(server, name, { + solvaPay, + ...opts, + product: opts.product ?? productRef, + buildBootstrap: opts.buildBootstrap ?? descriptors.buildBootstrapPayload, + } as RegisterPayableToolOptions) + } + additionalTools({ server, solvaPay, resourceUri, productRef, registerPayable }) + } + + const { audiences, options: filterOptions } = normaliseHideToolsByAudience(hideToolsByAudience) + if (audiences && audiences.length > 0) { + const bypass = (filterOptions.bypassWhen ?? defaultIsChatGptRequest)({ + server, + extra: ctx.requestInfo ? { requestInfo: ctx.requestInfo } : undefined, + }) + if (bypass) { + const ua = ctx.requestInfo?.headers.get('user-agent') ?? undefined + const context = ua ? `ua=${ua}` : 'no user-agent' + if (!bypassWarned.has(context)) { + bypassWarned.add(context) + console.warn( + `[solvapay/mcp] hideToolsByAudience filter bypassed (${context}); returning full tools/list catalog.`, + ) + } + } else { + applyHideToolsByAudience(server, audiences, filterOptions) + } + } + + return server +} + export function createSolvaPayMcpFetch( options: CreateSolvaPayMcpFetchOptions, ): (req: Request) => Promise { const { - // Descriptor options. solvaPay, productRef, resourceUri, @@ -84,26 +103,19 @@ export function createSolvaPayMcpFetch( onToolCall, onToolResult, branding, - // Server / registration options. additionalTools, hideToolsByAudience, registerPrompts = true, registerDocsResources = true, serverName, serverVersion = '1.0.0', - // Handler options — everything in - // `CreateSolvaPayMcpFetchHandlerOptions` except `server`. ...handlerRest } = options - // `apiBaseUrl` lives on the handler-options extension (it's required - // there for the OAuth proxy upstream), but we also forward it to the - // descriptor builder so the CSP auto-includes the configured API - // origin. Read from `handlerRest` without removing it — the handler - // call below still needs its copy to build the OAuth router. const apiBaseUrl = handlerRest.apiBaseUrl + const bypassWarned = new Set() - const { server, descriptors } = buildSolvaPayMcpServer({ + const descriptorOptions = { solvaPay, productRef, resourceUri, @@ -121,33 +133,19 @@ export function createSolvaPayMcpFetch( registerDocsResources, ...(serverName !== undefined ? { serverName } : {}), serverVersion, - }) - - if (additionalTools) { - // Mirror the root entry's `registerPayable` binding so merchant - // callbacks are portable between `createSolvaPayMcpServer` and - // this factory without code changes. - const registerPayable: AdditionalToolsContext['registerPayable'] = (name, opts) => { - registerPayableTool(server, name, { - solvaPay, - ...opts, - product: opts.product ?? productRef, - buildBootstrap: opts.buildBootstrap ?? descriptors.buildBootstrapPayload, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as unknown as RegisterPayableToolOptions) - } - additionalTools({ server, solvaPay, resourceUri, productRef, registerPayable }) } - // Filter UI-audience tools from `tools/list` last so we see every - // tool the descriptor loop + `additionalTools` registered. - const { audiences, options: filterOptions } = normaliseHideToolsByAudience(hideToolsByAudience) - applyHideToolsByAudience(server, audiences, filterOptions) - return createSolvaPayMcpFetchHandler({ - server, + factory: ctx => + buildServerForRequest(ctx, { + descriptorOptions, + bypassWarned, + ...(additionalTools !== undefined ? { additionalTools } : {}), + ...(hideToolsByAudience !== undefined ? { hideToolsByAudience } : {}), + }), publicBaseUrl, productRef, + responseMode: handlerRest.responseMode ?? 'json', ...handlerRest, }) } diff --git a/packages/mcp/src/fetch/handler.ts b/packages/mcp/src/fetch/handler.ts index 166aa7ea..7ed1bee0 100644 --- a/packages/mcp/src/fetch/handler.ts +++ b/packages/mcp/src/fetch/handler.ts @@ -1,6 +1,6 @@ /** * Turnkey fetch-first MCP handler: composes OAuth routing + - * `WebStandardStreamableHTTPServerTransport` + an `McpServer` into a + * `createMcpHandler` from `@modelcontextprotocol/server` into a * single `(req: Request) => Promise`. Runs on any * Web-standards runtime (Deno, Supabase Edge, Cloudflare Workers, Bun, * Next edge, Vercel Functions, Node via undici/polyfilled Web APIs). @@ -12,32 +12,23 @@ import { type BuildAuthInfoFromBearerOptions, type OAuthBridgePaths, } from '@solvapay/mcp-core' -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import { + type CreateMcpHandlerOptions, + type AuthInfo, + type McpHandlerRequestOptions, + type McpRequestContext, + type McpServerFactory, +} from '@modelcontextprotocol/server' +import { buildMcpHandlerFace } from './legacyJsonFallback' import { applyNativeCors, authChallenge, corsPreflight } from './cors' import { createOAuthFetchRouter } from './oauth-bridge' -/** - * Transport wiring preset. - * - * - `'sse-stateful'` — default. SSE streaming + UUID `mcp-session-id` - * on initialize. Matches the original helper behaviour and is what - * Node / Express / Bun deployments expect. - * - `'json-stateless'` — `{ sessionIdGenerator: undefined, - * enableJsonResponse: true }`. Required on stateless fetch runtimes - * (Supabase Edge, Cloudflare Workers, Vercel Edge, Deno Deploy) that - * can't keep per-session state across invocations and need a - * single-JSON-response wire shape so the response body is assembled - * before the per-request transport is closed. - * - `'sse-stateless'` — SSE streaming without session IDs. Advanced / - * hypothetical; provided for symmetry. Most stateless runtimes want - * `'json-stateless'` instead (a cut SSE stream drops the response - * frame). - */ -export type McpHandlerMode = 'sse-stateful' | 'json-stateless' | 'sse-stateless' +/** Response shaping for modern (2026-07-28) request exchanges. */ +export type McpResponseMode = NonNullable export interface CreateSolvaPayMcpFetchHandlerOptions { - server: McpServer + /** Per-request factory that builds a fresh `McpServer` instance. */ + factory: McpServerFactory publicBaseUrl: string apiBaseUrl: string productRef: string @@ -48,44 +39,22 @@ export interface CreateSolvaPayMcpFetchHandlerOptions { authorizationServerPath?: string oauthPaths?: OAuthBridgePaths /** - * Transport wiring preset. Defaults to `'sse-stateful'` to preserve - * the Node / Express / Bun behaviour of earlier versions. Stateless - * fetch runtimes (Supabase Edge, Cloudflare Workers, Vercel Edge) - * should pass `'json-stateless'`. + * Response shaping for modern (2026-07-28) traffic. Edge runtimes that + * cannot hold a stream should pass `'json'` (single JSON body; mid-call + * notifications are dropped). Defaults to `'auto'`. * - * Ignored when `buildTransport` is provided. - */ - mode?: McpHandlerMode - /** - * Escape hatch: bring your own transport builder. When provided, - * `mode` and `sessionIdGenerator` are ignored — the caller owns the - * transport's configuration. The handler still manages - * `server.connect(transport)` + `transport.close()` per request and - * serialises concurrent requests through the shared-server mutex. + * Legacy (2025-era) traffic uses single-JSON responses when + * `responseMode: 'json'` (edge runtimes). Otherwise the SDK's built-in + * `legacy: 'stateless'` SSE fallback applies. */ - buildTransport?: () => WebStandardStreamableHTTPServerTransport + responseMode?: McpResponseMode /** - * Optional session-id generator for the underlying - * `WebStandardStreamableHTTPServerTransport`. Only honoured in the - * default `'sse-stateful'` mode; ignored in stateless modes (which - * pass `sessionIdGenerator: undefined` to disable session tracking) - * and when `buildTransport` is provided. - * - * Defaults to `crypto.randomUUID`. + * How 2025-era traffic is served. Defaults to `'stateless'` so today's + * hosts (Claude Desktop, ChatGPT, Cursor) keep working at zero cost. */ - sessionIdGenerator?: () => string -} - -function defaultSessionIdGenerator(): string { - const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto - if (c?.randomUUID) return c.randomUUID() - // Fallback: RFC4122-ish v4 (non-cryptographic, last-resort for runtimes - // without globalThis.crypto — shouldn't happen on any modern Web runtime). - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, ch => { - const r = Math.floor(Math.random() * 16) - const v = ch === 'x' ? r : (r & 0x3) | 0x8 - return v.toString(16) - }) + legacy?: CreateMcpHandlerOptions['legacy'] + /** Forwarded to `createMcpHandler` for out-of-band error reporting. */ + onerror?: CreateMcpHandlerOptions['onerror'] } function getJsonRpcId(body: unknown): string | number | null { @@ -115,23 +84,14 @@ async function readJsonRpcId(req: Request): Promise { * 3. Enforces bearer-token auth on the MCP path (default `/mcp`) and * returns `401 + WWW-Authenticate: Bearer resource_metadata="…"` * when auth is missing. - * 4. Forwards the request to a fresh - * `WebStandardStreamableHTTPServerTransport` wired to the provided - * `McpServer`. The transport's `close()` runs in a `finally` block - * so the server's `_transport` slot is released for the next - * request; concurrent requests serialise through a shared mutex so - * two overlapping calls never double-connect the same `McpServer`. - * - * A fresh transport is created per request — that's the recommended - * pattern for stateless fetch runtimes (Workers, Deno, Supabase Edge). - * For long-lived session reuse, consume the low-level - * {@link createOAuthFetchRouter} + instantiate the transport yourself. + * 4. Forwards authenticated MCP requests to `createMcpHandler`'s + * `{ fetch }` face with `{ authInfo }` pass-through. */ export function createSolvaPayMcpFetchHandler( options: CreateSolvaPayMcpFetchHandlerOptions, ): (req: Request) => Promise { const { - server, + factory, publicBaseUrl, apiBaseUrl, productRef, @@ -141,9 +101,9 @@ export function createSolvaPayMcpFetchHandler( protectedResourcePath, authorizationServerPath, oauthPaths, - mode = 'sse-stateful', - buildTransport, - sessionIdGenerator, + responseMode, + legacy, + onerror, } = options const oauthRouter = createOAuthFetchRouter({ @@ -155,70 +115,33 @@ export function createSolvaPayMcpFetchHandler( oauthPaths, }) - // Construct the per-request transport based on `mode`. The stateless - // modes pass `sessionIdGenerator: undefined` explicitly so the - // transport's `validateSession()` early-return branch is reachable - // for clients that don't echo `mcp-session-id` back. `json-stateless` - // also flips the response wire shape to a single JSON body, which is - // what makes the `transport.close()` in the finally block safe for - // stateless fetch runtimes (an SSE stream would otherwise be cut off - // before the final tool-result frame is written). - const makeTransport = (): WebStandardStreamableHTTPServerTransport => { - if (buildTransport) return buildTransport() - if (mode === 'json-stateless') { - return new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true, - }) - } - if (mode === 'sse-stateless') { - return new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - }) - } - return new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: sessionIdGenerator ?? defaultSessionIdGenerator, - }) - } - - // Serialise server connect/close cycles. `McpServer._transport` is a - // single slot — the protocol's `connect()` throws "Already connected - // to a transport" if it's set, and only `transport.close()` (which - // fires the protocol's `_onclose` handler) nulls it. Two overlapping - // requests would therefore race on this slot; we queue each request - // behind the previous one's close to sidestep the race entirely. - // Fine for the low-throughput edge-function case; high-throughput - // deployments should fan out to multiple `McpServer` instances. - let serverMutex: Promise = Promise.resolve() + const mcpHandler = buildMcpHandlerFace(factory, { + ...(responseMode !== undefined ? { responseMode } : {}), + ...(legacy !== undefined ? { legacy } : {}), + ...(onerror !== undefined ? { onerror } : {}), + }) return async (req: Request): Promise => { const url = new URL(req.url) const pathname = url.pathname - // 1) Root-level OPTIONS preflight for native origins outside OAuth paths. if (req.method === 'OPTIONS' && pathname === mcpPath) { return corsPreflight(req) } - // 2) OAuth routes. const oauthResponse = await oauthRouter(req) if (oauthResponse) return oauthResponse - // 3) Only handle the MCP path. if (pathname !== mcpPath) { return new Response('not_found', { status: 404 }) } - // 4) GET /mcp — Cursor-style SSE back-channel probe. Stateless - // servers can't serve it; respond 405 so the client doesn't - // transition to failed on a 400. if (req.method && req.method !== 'POST' && req.method !== 'OPTIONS') { const headers = new Headers({ Allow: 'POST, OPTIONS' }) applyNativeCors(req.headers, headers) return new Response(null, { status: 405, headers }) } - // 5) Bearer auth guard. const authHeader = req.headers.get('authorization') let resolvedAuthInfo: ReturnType = null if (authHeader || requireAuth) { @@ -237,30 +160,13 @@ export function createSolvaPayMcpFetchHandler( } } - // 6) Serialise behind the previous in-flight request's close cycle. - const previous = serverMutex - let releaseMutex: () => void = () => {} - serverMutex = new Promise(resolve => { - releaseMutex = resolve - }) - await previous + const fetchOptions: McpHandlerRequestOptions | undefined = + resolvedAuthInfo && typeof resolvedAuthInfo.token === 'string' + ? { authInfo: resolvedAuthInfo as AuthInfo } + : undefined - // 7) Spin up a fresh transport per request and connect the server. - const transport = makeTransport() try { - await server.connect(transport) - const response = await transport.handleRequest( - req, - resolvedAuthInfo - ? { - // `AuthInfo` from the SDK is structurally identical to our - // envelope — cast away the brand so the types line up. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - authInfo: resolvedAuthInfo as any, - } - : undefined, - ) - + const response = await mcpHandler.fetch(req, fetchOptions) const merged = new Headers(response.headers) applyNativeCors(req.headers, merged) return new Response(response.body, { status: response.status, headers: merged }) @@ -279,15 +185,8 @@ export function createSolvaPayMcpFetchHandler( }), { status: 500, headers }, ) - } finally { - // `close()` is idempotent on every mode (see - // `webStandardStreamableHttp.js` — `close()` walks the stream - // map and calls `_onclose`, which triggers the protocol's - // `_onclose` to null the server's `_transport` slot). Swallow - // errors so a failed close never masks the real response / - // error above. - await transport.close().catch(() => {}) - releaseMutex() } } } + +export type { McpRequestContext, McpServerFactory } diff --git a/packages/mcp/src/fetch/index.ts b/packages/mcp/src/fetch/index.ts index 757ca240..2547f367 100644 --- a/packages/mcp/src/fetch/index.ts +++ b/packages/mcp/src/fetch/index.ts @@ -19,7 +19,7 @@ * readHtml: () => Deno.readTextFile('./mcp-app.html'), * publicBaseUrl, * apiBaseUrl, - * mode: 'json-stateless', + * responseMode: 'json', * // Trim the LLM-facing catalogue to the four intent tools. * // The seven UI transport tools stay callable from the * // SolvaPay iframe; ChatGPT-originated tools/list requests are @@ -38,7 +38,7 @@ * const server = createSolvaPayMcpServer({ …descriptorOptions }) * Deno.serve( * createSolvaPayMcpFetchHandler({ - * server, + * factory: () => createSolvaPayMcpServer({ …descriptorOptions }), * publicBaseUrl, * apiBaseUrl, * productRef, @@ -68,7 +68,8 @@ export { } from './cors' export { createSolvaPayMcpFetchHandler } from './handler' -export type { CreateSolvaPayMcpFetchHandlerOptions, McpHandlerMode } from './handler' +export type { CreateSolvaPayMcpFetchHandlerOptions, McpResponseMode } from './handler' +export type { McpRequestContext, McpServerFactory } from './handler' export { createSolvaPayMcpFetch } from './createSolvaPayMcpFetch' export type { CreateSolvaPayMcpFetchOptions } from './createSolvaPayMcpFetch' diff --git a/packages/mcp/src/fetch/legacyJsonFallback.ts b/packages/mcp/src/fetch/legacyJsonFallback.ts new file mode 100644 index 00000000..4df2da08 --- /dev/null +++ b/packages/mcp/src/fetch/legacyJsonFallback.ts @@ -0,0 +1,122 @@ +/** + * Legacy (2025-era) stateless fallback with `enableJsonResponse: true`. + * + * `createMcpHandler`'s built-in legacy path uses SSE streaming, which + * breaks on stateless edge runtimes that close the isolate before the + * stream completes. This mirrors the SDK's `createLegacyStatelessFallback` + * but opts into single-JSON responses — the old `json-stateless` preset. + */ + +import { + createMcpHandler, + isLegacyRequest, + WebStandardStreamableHTTPServerTransport, + type CreateMcpHandlerOptions, + type McpHandlerRequestOptions, + type McpHttpHandler, + type McpServerFactory, +} from '@modelcontextprotocol/server' + +function echoableRequestId(parsedBody: unknown): string | number | null { + if (parsedBody && typeof parsedBody === 'object' && 'id' in parsedBody) { + const id = (parsedBody as { id?: string | number | null }).id + return id ?? null + } + return null +} + +export function createLegacyJsonStatelessFallback( + factory: McpServerFactory, + onerror?: (error: Error) => void, +): (request: Request, options?: McpHandlerRequestOptions) => Promise { + return async (request, options) => { + if (request.method.toUpperCase() !== 'POST') { + return new Response( + JSON.stringify({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed.' }, + }), + { status: 405, headers: { 'content-type': 'application/json' } }, + ) + } + + try { + const product = await factory({ + era: 'legacy', + ...(options?.authInfo !== undefined ? { authInfo: options.authInfo } : {}), + requestInfo: request, + }) + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }) + + await product.connect(transport) + + try { + return await transport.handleRequest(request, { + ...(options?.authInfo !== undefined ? { authInfo: options.authInfo } : {}), + ...(options?.parsedBody !== undefined ? { parsedBody: options.parsedBody } : {}), + }) + } finally { + await transport.close().catch(() => {}) + await product.close().catch(() => {}) + } + } catch (error) { + try { + onerror?.(error instanceof Error ? error : new Error(String(error))) + } catch { + /* ignore */ + } + const id = echoableRequestId(options?.parsedBody) + return new Response( + JSON.stringify({ + jsonrpc: '2.0', + id, + error: { code: -32603, message: 'Internal error' }, + }), + { status: 500, headers: { 'content-type': 'application/json' } }, + ) + } + } +} + +/** + * When `responseMode` is `'json'`, compose a strict modern handler with a + * legacy JSON fallback so both eras return single JSON bodies on edge. + */ +export function buildMcpHandlerFace( + factory: McpServerFactory, + options: { + responseMode?: CreateMcpHandlerOptions['responseMode'] + legacy?: CreateMcpHandlerOptions['legacy'] + onerror?: CreateMcpHandlerOptions['onerror'] + }, +): Pick { + const { responseMode, legacy, onerror } = options + + if (responseMode === 'json' && legacy !== 'reject') { + const modernHandler = createMcpHandler(factory, { + legacy: 'reject', + responseMode: 'json', + ...(onerror !== undefined ? { onerror } : {}), + }) + const legacyHandler = createLegacyJsonStatelessFallback(factory, onerror) + + return { + fetch: async (request, fetchOptions) => { + if (await isLegacyRequest(request)) { + return legacyHandler(request, fetchOptions) + } + return modernHandler.fetch(request, fetchOptions) + }, + } + } + + return createMcpHandler(factory, { + ...(responseMode !== undefined ? { responseMode } : {}), + ...(legacy !== undefined ? { legacy } : {}), + ...(onerror !== undefined ? { onerror } : {}), + }) +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index bb6b6903..9bd6b1a8 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,6 +1,5 @@ /** - * `@solvapay/mcp` — official `@modelcontextprotocol/sdk` + - * `@modelcontextprotocol/ext-apps` adapter for the SolvaPay MCP + * `@solvapay/mcp` — official `@modelcontextprotocol/server` adapter for the SolvaPay MCP * toolbox. * * This is the only SolvaPay package that imports @@ -50,6 +49,18 @@ export type { export { registerPayableTool } from './registerPayableTool' export type { RegisterPayableToolOptions } from './registerPayableTool' +// ---- MCP Apps server helpers ---- +// `@modelcontextprotocol/ext-apps` has no SDK v2 build, so the three server-side +// symbols it provided are vendored here. Import them from `@solvapay/mcp` +// instead of `@modelcontextprotocol/ext-apps/server`; the client-side ext-apps +// entrypoint that runs inside the iframe is unaffected. +export { + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, + RESOURCE_URI_META_KEY, +} from './internal/extAppsServer' + // ---- Merchant-facing types re-exported from @solvapay/mcp-core ---- // Everything a merchant needs to type a `registerPayable` handler for the // 90% path. Avoids forcing a second install of `@solvapay/mcp-core` just diff --git a/packages/mcp/src/internal/buildMcpServer.ts b/packages/mcp/src/internal/buildMcpServer.ts index 00c669ce..29ccdb7c 100644 --- a/packages/mcp/src/internal/buildMcpServer.ts +++ b/packages/mcp/src/internal/buildMcpServer.ts @@ -3,22 +3,11 @@ * by both the `.` entry (`createSolvaPayMcpServer` in `../server.ts`) * and the `./fetch` entry (`createSolvaPayMcpFetch` in * `../fetch/createSolvaPayMcpFetch.ts`). - * - * Lifted here so the two public factories stop duplicating the same - * 80-line registration wiring. Both entrypoints get the exact same - * server shape (11 tools + prompts + docs resource + UI resource) - * without importing from each other — Node consumers keep their - * existing import path, fetch-first consumers keep theirs, and the - * diff-risk from the previous sibling copies is gone. */ -import { - registerAppResource, - registerAppTool, - RESOURCE_MIME_TYPE, -} from '@modelcontextprotocol/ext-apps/server' -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js' +import { McpServer } from '@modelcontextprotocol/server' +import type { CallToolResult, ReadResourceResult } from '@modelcontextprotocol/server' +import { z } from 'zod' import { applyHideToolsByAudience, buildSolvaPayDescriptors, @@ -31,6 +20,11 @@ import { type SolvaPayPromptDescriptor, type SolvaPayToolDescriptor, } from '@solvapay/mcp-core' +import { + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, +} from './extAppsServer' export interface BuildSolvaPayMcpServerOptions extends BuildSolvaPayDescriptorsOptions { registerPrompts?: boolean @@ -46,18 +40,15 @@ export type HideToolsByAudienceConfig = bypassWhen?: ApplyHideToolsByAudienceOptions['bypassWhen'] } -/** - * Normalise the public `hideToolsByAudience` shape to the - * `(audiences, options)` pair `applyHideToolsByAudience` consumes. - * Splits the array shorthand from the object form so factories can - * accept either without each unwrapping by hand. - */ export function normaliseHideToolsByAudience( config: HideToolsByAudienceConfig | undefined, ): { audiences: readonly string[] | undefined; options: ApplyHideToolsByAudienceOptions } { if (!config) return { audiences: undefined, options: {} } if (Array.isArray(config)) return { audiences: config, options: {} } - const obj = config as { audiences: readonly string[]; bypassWhen?: ApplyHideToolsByAudienceOptions['bypassWhen'] } + const obj = config as { + audiences: readonly string[] + bypassWhen?: ApplyHideToolsByAudienceOptions['bypassWhen'] + } return { audiences: obj.audiences, options: obj.bypassWhen !== undefined ? { bypassWhen: obj.bypassWhen } : {}, @@ -69,12 +60,14 @@ export interface BuiltSolvaPayMcpServer { descriptors: SolvaPayDescriptorBundle } +function wrapInputSchema( + schema: SolvaPayToolDescriptor['inputSchema'], +): z.ZodObject | undefined { + if (schema === undefined) return undefined + return z.object(schema) +} + function registerDescriptor(server: McpServer, tool: SolvaPayToolDescriptor): void { - // Merge brand icons into `_meta.ui.icons` so ext-apps-aware hosts - // can discover them alongside the UI resource URI. Newer MCP SDKs - // may also surface `icons` as a top-level Tool field — we include - // them on the config root as well so forward-compatible hosts pick - // them up without a server change. const baseMeta = (tool.meta as Record | undefined) ?? {} const baseUi = (baseMeta.ui as Record | undefined) ?? {} const metaWithIcons = @@ -88,19 +81,15 @@ function registerDescriptor(server: McpServer, tool: SolvaPayToolDescriptor): vo { ...(tool.title !== undefined ? { title: tool.title } : {}), description: tool.description, - inputSchema: tool.inputSchema, + inputSchema: wrapInputSchema(tool.inputSchema), _meta: metaWithIcons, ...(tool.annotations !== undefined ? { annotations: tool.annotations } : {}), ...(tool.icons !== undefined ? { icons: tool.icons } : {}), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, - // `SolvaPayCallToolResult` is a structural subset of the official - // SDK's `CallToolResult`; cast to erase the extra-narrow `resource` - // block typing the SDK expects on `{ type: 'resource' }` content. - async (args: Record, extra?: unknown): Promise => + }, + async (args: Record, ctx?: unknown): Promise => (await tool.handler( args, - extra as Parameters[1], + ctx as Parameters[1], )) as unknown as CallToolResult, ) } @@ -109,23 +98,14 @@ function registerPromptDescriptor(server: McpServer, prompt: SolvaPayPromptDescr const config: { title?: string description?: string - // Cast through `any` — the framework-neutral `argsSchema` is a - // `Record` and the SDK expects a compatible - // raw shape. The SDK's types disagree at the generic level, but - // the runtime shape is identical. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - argsSchema?: any + argsSchema?: ReturnType } = { description: prompt.description } if (prompt.title !== undefined) config.title = prompt.title - if (prompt.argsSchema !== undefined) config.argsSchema = prompt.argsSchema + if (prompt.argsSchema !== undefined) { + config.argsSchema = z.object(prompt.argsSchema) + } - server.registerPrompt( - prompt.name, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - config as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async (args: any) => (await prompt.handler(args ?? {})) as any, - ) + server.registerPrompt(prompt.name, config, async args => (await prompt.handler(args ?? {})) as never) } function registerDocsResource(server: McpServer, docs: SolvaPayDocsResourceDescriptor): void { @@ -161,32 +141,18 @@ function registerBootstrapResource( description: bootstrap.description, mimeType: bootstrap.mimeType, }, - async (_uri, extra): Promise => ({ + async (_uri, ctx): Promise => ({ contents: [ { uri: bootstrap.uri, mimeType: bootstrap.mimeType, - text: JSON.stringify(await bootstrap.readPayload(extra)), + text: JSON.stringify(await bootstrap.readPayload(ctx)), }, ], }), ) } -/** - * Build the `McpServer` from a `BuildSolvaPayDescriptorsOptions` - * bundle, register every SolvaPay tool / prompt / docs resource / UI - * resource, and return the server alongside the descriptor bundle. - * - * Callers apply their own `additionalTools` hook and the - * `hideToolsByAudience` filter on the returned server — those two - * steps intentionally live on the public factories so the filter - * always runs after the caller's `additionalTools` callback, and the - * factory's binding of `registerPayable` can stay flavour-specific - * (the root `.` entry binds it via `registerPayableTool`; the - * fetch-first entry deliberately leaves it unbound per its - * architectural guarantee). - */ export function buildSolvaPayMcpServer( options: BuildSolvaPayMcpServerOptions, ): BuiltSolvaPayMcpServer { @@ -200,16 +166,6 @@ export function buildSolvaPayMcpServer( const descriptors = buildSolvaPayDescriptors(descriptorOptions) - // Prefer the merchant's brand name + icon for the MCP - // `Implementation` payload returned at `initialize` — hosts render - // both in the chrome strip next to the tool name (Claude Web / - // Desktop swap the default globe for `serverInfo.icons[0]`), so - // surfacing the merchant there is what gives the widget its "native - // merchant app" look. Explicit `serverName` still wins when the - // integrator needs a stable protocol identifier distinct from the - // brand. `deriveIcons` returns `undefined` when branding has neither - // `iconUrl` nor `logoUrl`; we omit the field in that case so the - // serialised handshake matches the zero-branding baseline. const effectiveServerName = serverName ?? descriptorOptions.branding?.brandName ?? 'solvapay-mcp-server' const serverIcons = deriveIcons(descriptorOptions.branding) @@ -248,14 +204,6 @@ export function buildSolvaPayMcpServer( _meta: { ui: { csp: resource.csp, - // `false` asks the host to skip painting its own outer card / - // border around the iframe. The widget paints its own frame - // via `.solvapay-mcp-card`, and `` renders the - // merchant mark at the top; a host-painted card on top of - // that produced a nested-container look (visible on MCP Jam - // with the earlier `true` default). Hosts that honour the - // preference (per the MCP Apps spec) now render us flush - // inside their conversation surface. prefersBorder: false, }, }, diff --git a/packages/mcp/src/internal/extAppsServer.ts b/packages/mcp/src/internal/extAppsServer.ts new file mode 100644 index 00000000..f713f513 --- /dev/null +++ b/packages/mcp/src/internal/extAppsServer.ts @@ -0,0 +1,91 @@ +/** + * Vendored subset of `@modelcontextprotocol/ext-apps/server` — the three + * symbols `@solvapay/mcp` needs until ext-apps ships a v2-compatible build. + * Logic mirrors upstream `registerAppTool` / `registerAppResource` / + * `RESOURCE_MIME_TYPE` (ext-apps@1.7.5). + */ + +import type { McpServer } from '@modelcontextprotocol/server' +import type { + CacheHint, + CallToolResult, + Icon, + ReadResourceCallback, + ReadResourceResult, + RegisteredResource, + RegisteredTool, + ResourceMetadata, + StandardSchemaWithJSON, + ToolAnnotations, + ToolCallback, +} from '@modelcontextprotocol/server' + +/** Legacy flat `_meta` key for UI resource URIs on pre-2026-01-26 hosts. */ +export const RESOURCE_URI_META_KEY = 'ui/resourceUri' + +/** MIME type every MCP Apps UI resource must advertise. */ +export const RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app' + +// `registerTool` and `registerResource` are both overloaded, and `Parameters<>` +// resolves to the *last* overload — the deprecated raw-shape `registerTool` and +// the `ResourceTemplate` `registerResource`. Both signatures below therefore +// restate the modern overload: schemas are Standard Schema (`z.object(...)`), +// and MCP Apps UI resources always use a fixed `ui://` string URI. +type RegisterAppToolConfig< + InputArgs extends StandardSchemaWithJSON | undefined, + OutputArgs extends StandardSchemaWithJSON, +> = { + title?: string + description?: string + inputSchema?: InputArgs + outputSchema?: OutputArgs + annotations?: ToolAnnotations + icons?: Icon[] + _meta?: Record +} + +type RegisterAppResourceConfig = ResourceMetadata & { cacheHint?: CacheHint } + +/** + * Register a tool that may open an MCP Apps iframe. Normalises + * `_meta.ui.resourceUri` ↔ legacy `_meta["ui/resourceUri"]`. + */ +export function registerAppTool< + OutputArgs extends StandardSchemaWithJSON, + InputArgs extends StandardSchemaWithJSON | undefined = undefined, +>( + server: McpServer, + name: string, + config: RegisterAppToolConfig, + handler: ToolCallback, +): RegisteredTool { + const meta = config._meta as Record | undefined + const ui = meta?.ui as Record | undefined + const legacyUri = meta?.[RESOURCE_URI_META_KEY] + let mergedMeta = meta + + if (ui?.resourceUri && !legacyUri) { + mergedMeta = { ...meta, [RESOURCE_URI_META_KEY]: ui.resourceUri } + } else if (legacyUri && !ui?.resourceUri) { + mergedMeta = { ...meta, ui: { ...ui, resourceUri: legacyUri } } + } + + return server.registerTool( + name, + mergedMeta === meta ? config : { ...config, _meta: mergedMeta }, + handler, + ) +} + +/** Register an MCP Apps UI resource with the canonical MIME type default. */ +export function registerAppResource( + server: McpServer, + name: string, + uri: string, + config: RegisterAppResourceConfig, + handler: ReadResourceCallback, +): RegisteredResource { + return server.registerResource(name, uri, { mimeType: RESOURCE_MIME_TYPE, ...config }, handler) +} + +export type { CallToolResult, ReadResourceResult } diff --git a/packages/mcp/src/registerPayableTool.ts b/packages/mcp/src/registerPayableTool.ts index c9dcf8be..3991194f 100644 --- a/packages/mcp/src/registerPayableTool.ts +++ b/packages/mcp/src/registerPayableTool.ts @@ -1,35 +1,12 @@ /** * `registerPayableTool(server, name, options)` — one-liner for registering - * a paywall-protected MCP tool on the official `@modelcontextprotocol/sdk` + * a paywall-protected MCP tool on the official `@modelcontextprotocol/server` * `McpServer`. - * - * Payable data tools do NOT advertise `_meta.ui.resourceUri` at the - * descriptor level by default. Per SEP-1865 / MCP Apps (2026-01-26), - * descriptor-advertising means the host MUST open the iframe on every - * call — so auto-stamping would flash an empty widget next to every - * successful `search_knowledge` / `predict_direction` result. Paywall - * / nudge / activation responses are text-only narrations instead, - * with the recovery intent tool (`upgrade` / `topup` / `activate_plan`) - * named in `content[0].text` and `checkoutUrl` inlined for - * terminal-first hosts. - * - * Merchants who deliberately want the widget opened on every call - * (e.g. the tool's UX genuinely is the iframe — rare) can opt in with - * `meta: { ui: { resourceUri } }`. - * - * Mirrors the positional-`name` shape of `registerAppTool` to keep the - * convention consistent across the ecosystem. */ -import { registerAppTool } from '@modelcontextprotocol/ext-apps/server' -import type { McpServer, RegisteredTool } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { - AnySchema, - SchemaOutput, - ShapeOutput, - ZodRawShapeCompat, -} from '@modelcontextprotocol/sdk/server/zod-compat.js' -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' +import type { McpServer, RegisteredTool } from '@modelcontextprotocol/server' +import type { CallToolResult } from '@modelcontextprotocol/server' +import { z } from 'zod' import { buildPayableHandler, type BuildBootstrapPayloadFn, @@ -39,110 +16,56 @@ import { type SolvaPayToolIcon, } from '@solvapay/mcp-core' import type { SolvaPay } from '@solvapay/server' +import { registerAppTool } from './internal/extAppsServer' + +type ZodObjectSchema = ReturnType + +/** Accepted `schema` forms: a `z.object()` schema or a raw `{ field: z.string() }` shape. */ +export type InputSchemaOption = ZodObjectSchema | Record | undefined /** - * Projects the tool's `schema` (raw shape or already-constructed - * schema) into the `args` type the handler receives. When no schema - * is provided, falls back to `Record` so handlers - * can still destructure without losing type-check. + * Projects the tool's `schema` (raw shape or already-constructed schema) into + * the `args` type the handler receives. With no schema, falls back to + * `Record` so handlers can still destructure. */ export type InferHandlerArgs = [InputSchema] extends [undefined] ? Record - : InputSchema extends ZodRawShapeCompat - ? ShapeOutput - : InputSchema extends AnySchema - ? SchemaOutput + : InputSchema extends ZodObjectSchema + ? z.infer + : InputSchema extends Record + ? z.infer> : Record +function wrapInputSchema(schema: InputSchemaOption): ZodObjectSchema | undefined { + if (schema === undefined) return undefined + if (typeof schema === 'object' && schema !== null && 'safeParse' in schema) { + return schema as ZodObjectSchema + } + return z.object(schema) +} + export interface RegisterPayableToolOptions< - InputSchema extends ZodRawShapeCompat | AnySchema | undefined = undefined, + InputSchema extends InputSchemaOption = undefined, TData = unknown, > { - /** The initialised SolvaPay instance used to build `payable({ product }).mcp(handler)`. */ solvaPay: SolvaPay - /** Zod-compatible input schema (raw shape or discriminated schema). */ schema?: InputSchema - /** SolvaPay product ref to protect this tool against. */ product: string - /** Optional human-readable tool title for MCP listings. */ title?: string - /** Optional tool description surfaced to the model. */ description?: string - /** - * Business logic that runs once the caller is within limits. Receives - * parsed `args` (inferred from `schema` when provided) and a - * `ResponseContext`; must return the branded envelope produced by - * `ctx.respond(data, options?)`. - * - * `ctx` surfaces: - * - `ctx.customer` — cached snapshot (`balance`, `remaining`, `plan`, - * `.fresh()` for a round-trip). - * - `ctx.product` — bootstrap product projection. - * - `ctx.respond(data, options?)` — return an envelope. `options` - * carries `text` (override `content[0].text`), `nudge` (inline - * text-suffix upsell copy), and the reserved `units` (V1.1 - * variable billing — V1 silently ignores). - * - `ctx.gate(reason?)` — stops handler execution and emits a - * paywall response through the adapter's `formatGate` channel. - * Rare — the SDK normally fires the paywall automatically via - * `payable().mcp()` pre-check. - * - `ctx.emit(block)` / `ctx.progress(...)` / `ctx.signal` — reserved - * streaming surface. V1 queues (emit) or no-ops (progress / signal); - * V1.1 wires them to SSE and transport cancellation. - * - * Throwing anything other than `PaywallError` surfaces as a tool-level - * error via `formatError`. - */ handler: PayableHandler, TData> - /** - * Builds the full `BootstrapPayload`. Accepted for forward - * compatibility with intent-tool reuse, but the text-only payable - * branch does NOT invoke it — gate responses ride through as - * `structuredContent = gate` + `content[0].text = gate.message`. - */ buildBootstrap?: BuildBootstrapPayloadFn - /** - * Override customer-ref extraction. Defaults to the MCP adapter's - * behavior (reads `extra.authInfo.extra.customer_ref`). - */ getCustomerRef?: ( args: Record, extra?: McpToolExtra, ) => string | Promise - /** - * Additional `_meta` merged onto the tool **descriptor** (the tool - * advertisement returned by `tools/list`). - * - * `registerPayableTool` does NOT inject `ui.resourceUri` by default: - * per SEP-1865 descriptor-advertising means hosts MUST open the - * iframe on every call, so auto-stamping produced empty widgets on - * silent success. Merchants who want the widget opened for every - * call can opt in explicitly via - * `meta: { ui: { resourceUri: 'ui://...' } }`. - */ meta?: Record - /** - * Portable MCP tool annotations. Defaults to - * `{ readOnlyHint: true, openWorldHint: true }` — sensible for a - * paywalled *data* tool that reads from the merchant's backend. - * Override for tools that mutate state (e.g. `submit_order`) with - * `annotations: { readOnlyHint: false, destructiveHint: true }`. - */ annotations?: SolvaPayToolAnnotations - /** - * Brand icons surfaced on `tools/list`. Hosts that read tool - * metadata for the chrome strip (ChatGPT, Claude Desktop) swap the - * default placeholder for this asset. Pass a square logomark for - * best results. - */ icons?: SolvaPayToolIcon[] } -/** - * Register a paywall-protected tool on an MCP server. - */ export function registerPayableTool< - InputSchema extends ZodRawShapeCompat | AnySchema | undefined = undefined, + InputSchema extends InputSchemaOption = undefined, TData = unknown, >( server: McpServer, @@ -169,14 +92,6 @@ export function registerPayableTool< handler as unknown as Parameters[2], ) - // Descriptor-level `_meta`: - // - No default `ui.resourceUri` — see the module-level comment on - // the text-only paywall rationale. - // - Merchant-supplied `meta.ui.resourceUri` (if any) passes through - // untouched — merchants opting in are honoured verbatim. - // - Brand icons are merged under `_meta.ui.icons` (the ext-apps - // discovery slot) when supplied, regardless of whether - // `ui.resourceUri` is present. const baseMeta = meta ?? {} const baseUi = (baseMeta.ui as Record | undefined) ?? {} const hasIcons = icons !== undefined && icons.length > 0 @@ -189,31 +104,19 @@ export function registerPayableTool< ? { ...baseMeta, ui: mergedUi } : { ...baseMeta } - // Sensible default: paywalled data tools are most often read-only - // queries (search, fetch, quote). State-mutating merchant tools - // override via `annotations: { readOnlyHint: false, destructiveHint: - // true }`. `openWorldHint` is always true — a paywalled tool by - // definition talks to the merchant's backend and SolvaPay's backend. const effectiveAnnotations: SolvaPayToolAnnotations = { readOnlyHint: true, openWorldHint: true, ...annotations, } - // `registerAppTool` is the right surface when a tool advertises a UI - // resource — it normalises `_meta.ui.resourceUri` into the legacy - // `_meta["ui/resourceUri"]` slot for pre-2026-01-26 hosts. For - // text-only payable tools (the default since the SEP-1865 refactor) - // there's no UI resource to normalise, so we go through the base - // SDK's `registerTool` to avoid `registerAppTool` dereferencing an - // absent `_meta.ui`. const hasUiResource = hasUi && typeof (mergedUi as { resourceUri?: unknown }).resourceUri === 'string' const toolConfig = { ...(title !== undefined ? { title } : {}), ...(description !== undefined ? { description } : {}), - ...(schema !== undefined ? { inputSchema: schema } : {}), + ...(schema !== undefined ? { inputSchema: wrapInputSchema(schema) } : {}), ...(Object.keys(toolMeta).length > 0 ? { _meta: toolMeta } : {}), annotations: effectiveAnnotations, ...(icons !== undefined && icons.length > 0 ? { icons } : {}), @@ -225,20 +128,8 @@ export function registerPayableTool< ): Promise => (await protectedHandler(args, extra)) as CallToolResult if (hasUiResource) { - return registerAppTool( - server, - name, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - toolConfig as any, - toolCallback, - ) + return registerAppTool(server, name, toolConfig, toolCallback) } - return server.registerTool( - name, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - toolConfig as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - toolCallback as any, - ) + return server.registerTool(name, toolConfig, toolCallback) } diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 5d76ee4a..5bedbb9f 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -1,7 +1,7 @@ /** * `createSolvaPayMcpServer` — batteries-included factory that * registers the full SolvaPay transport + bootstrap tool surface on a - * fresh `McpServer` from the official `@modelcontextprotocol/sdk`, + * fresh `McpServer` from the official `@modelcontextprotocol/server`, * plus the UI resource the `open_*` tools reference. * * Internals delegate to `internal/buildMcpServer` (shared with the @@ -10,11 +10,7 @@ * bundle without duplicating the registration loop. */ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import type { - AnySchema, - ZodRawShapeCompat, -} from '@modelcontextprotocol/sdk/server/zod-compat.js' +import type { McpServer } from '@modelcontextprotocol/server' import type { BuildSolvaPayDescriptorsOptions } from '@solvapay/mcp-core' import type { SolvaPay } from '@solvapay/server' import { @@ -27,6 +23,7 @@ import { export type { HideToolsByAudienceConfig } from './internal/buildMcpServer' import { registerPayableTool, + type InputSchemaOption, type RegisterPayableToolOptions, } from './registerPayableTool' @@ -46,10 +43,7 @@ export interface AdditionalToolsContext { * Zod `schema` flows through to the handler's `args` parameter so * merchants get inferred arg types without a second declaration. */ - registerPayable: < - InputSchema extends ZodRawShapeCompat | AnySchema | undefined = undefined, - TData = unknown, - >( + registerPayable: ( name: string, options: Omit, 'solvaPay' | 'product'> & { product?: string @@ -153,8 +147,7 @@ export function createSolvaPayMcpServer(options: CreateSolvaPayMcpServerOptions) ...opts, product: opts.product ?? productRef, buildBootstrap: opts.buildBootstrap ?? descriptors.buildBootstrapPayload, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) + }) } additionalTools({ server, solvaPay, resourceUri, productRef, registerPayable }) } diff --git a/packages/mcp/tsup.config.ts b/packages/mcp/tsup.config.ts index cc5a1ff2..755eb3af 100644 --- a/packages/mcp/tsup.config.ts +++ b/packages/mcp/tsup.config.ts @@ -7,8 +7,8 @@ export default defineConfig({ tsconfig: 'tsconfig.build.json', clean: true, external: [ - '@modelcontextprotocol/sdk', - '@modelcontextprotocol/ext-apps', + '@modelcontextprotocol/core', + '@modelcontextprotocol/server', '@solvapay/mcp-core', '@solvapay/server', '@solvapay/core', diff --git a/packages/react/package.json b/packages/react/package.json index b21d167c..38f78b72 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -57,7 +57,7 @@ "directory": "packages/react" }, "peerDependencies": { - "@solvapay/mcp-core": "workspace:^", + "@solvapay/mcp-core": "^0.2.8 || ^0.3.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0" }, diff --git a/packages/react/src/mcp/adapter.ts b/packages/react/src/mcp/adapter.ts index 64f24b7a..c675c911 100644 --- a/packages/react/src/mcp/adapter.ts +++ b/packages/react/src/mcp/adapter.ts @@ -26,8 +26,8 @@ import type { SolvaPayTransport } from '../transport/types' import { MCP_TOOL_NAMES } from '@solvapay/mcp-core' /** - * Minimal shape of `@modelcontextprotocol/sdk` `CallToolResult` — kept here - * so consumers don't need `@modelcontextprotocol/sdk` installed just to + * Minimal shape of the official SDK's `CallToolResult` — kept here + * so consumers don't need `@modelcontextprotocol/core` installed just to * satisfy TypeScript when they use the adapter. */ interface CallToolResultLike { diff --git a/packages/server/package.json b/packages/server/package.json index e958d4da..9685681f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -38,7 +38,7 @@ "directory": "packages/server" }, "engines": { - "node": ">=18.17" + "node": ">=20" }, "sideEffects": false, "scripts": { @@ -64,7 +64,7 @@ }, "peerDependencies": { "@solvapay/auth": "workspace:^", - "zod": "^3.25.0 || ^4.0.0" + "zod": "^4.2.0" }, "peerDependenciesMeta": { "zod": { diff --git a/packages/server/src/register-virtual-tools-mcp.ts b/packages/server/src/register-virtual-tools-mcp.ts index d3bd3ee5..0b1f293f 100644 --- a/packages/server/src/register-virtual-tools-mcp.ts +++ b/packages/server/src/register-virtual-tools-mcp.ts @@ -133,13 +133,16 @@ export function registerVirtualToolsMcpImpl( ? wrapHandler(mappedDefinition.handler, mappedDefinition) : mappedDefinition.handler + const z = getZod() server.registerTool( mappedDefinition.name, { description: mappedDefinition.description, - inputSchema: jsonSchemaToZodRawShape( - mappedDefinition.inputSchema.properties as Record, - mappedDefinition.inputSchema.required || [], + inputSchema: (z as { object: (shape: ZodRawShape) => unknown }).object( + jsonSchemaToZodRawShape( + mappedDefinition.inputSchema.properties as Record, + mappedDefinition.inputSchema.required || [], + ), ), }, wrappedHandler, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 231c0de1..2e543455 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,12 +253,15 @@ importers: '@cloudflare/workers-types': specifier: ^4.20251124.0 version: 4.20260425.1 + '@modelcontextprotocol/core': + specifier: ^2.0.0 + version: 2.0.0 '@modelcontextprotocol/ext-apps': specifier: ^1.7.1 version: 1.7.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6) - '@modelcontextprotocol/sdk': - specifier: ^1.29.0 - version: 1.29.0(zod@4.3.6) + '@modelcontextprotocol/server': + specifier: ^2.0.0 + version: 2.0.0 '@solvapay/mcp': specifier: workspace:* version: link:../../packages/mcp @@ -469,12 +472,18 @@ importers: examples/mcp-checkout-app: dependencies: + '@modelcontextprotocol/core': + specifier: ^2.0.0 + version: 2.0.0 '@modelcontextprotocol/ext-apps': specifier: ^1.7.1 version: 1.7.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6) - '@modelcontextprotocol/sdk': - specifier: ^1.29.0 - version: 1.29.0(zod@4.3.6) + '@modelcontextprotocol/node': + specifier: ^2.0.0 + version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.12.12) + '@modelcontextprotocol/server': + specifier: ^2.0.0 + version: 2.0.0 '@solvapay/core': specifier: workspace:* version: link:../../packages/core @@ -548,9 +557,15 @@ importers: examples/mcp-oauth-bridge: dependencies: - '@modelcontextprotocol/sdk': - specifier: ^1.29.0 - version: 1.29.0(zod@4.3.6) + '@modelcontextprotocol/core': + specifier: ^2.0.0 + version: 2.0.0 + '@modelcontextprotocol/node': + specifier: ^2.0.0 + version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.12.12) + '@modelcontextprotocol/server': + specifier: ^2.0.0 + version: 2.0.0 '@solvapay/auth': specifier: workspace:^ version: link:../../packages/auth @@ -564,8 +579,8 @@ importers: specifier: workspace:* version: link:../../packages/mcp '@solvapay/server': - specifier: '*' - version: 1.1.0(@solvapay/auth@packages+auth)(zod@4.3.6) + specifier: workspace:* + version: link:../../packages/server dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -594,12 +609,18 @@ importers: examples/mcp-time-app: dependencies: + '@modelcontextprotocol/core': + specifier: ^2.0.0 + version: 2.0.0 '@modelcontextprotocol/ext-apps': specifier: ^1.7.1 version: 1.7.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6) - '@modelcontextprotocol/sdk': - specifier: ^1.29.0 - version: 1.29.0(zod@4.3.6) + '@modelcontextprotocol/node': + specifier: ^2.0.0 + version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.12.12) + '@modelcontextprotocol/server': + specifier: ^2.0.0 + version: 2.0.0 '@solvapay/auth': specifier: workspace:^ version: link:../../packages/auth @@ -613,8 +634,8 @@ importers: specifier: workspace:* version: link:../../packages/mcp-core '@solvapay/server': - specifier: '*' - version: 1.1.0(@solvapay/auth@packages+auth)(zod@4.3.6) + specifier: workspace:* + version: link:../../packages/server dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -1052,12 +1073,12 @@ importers: packages/mcp: devDependencies: - '@modelcontextprotocol/ext-apps': - specifier: ^1.7.1 - version: 1.7.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6) - '@modelcontextprotocol/sdk': - specifier: ^1.29.0 - version: 1.29.0(zod@4.3.6) + '@modelcontextprotocol/core': + specifier: 2.0.0 + version: 2.0.0 + '@modelcontextprotocol/server': + specifier: 2.0.0 + version: 2.0.0 '@solvapay/mcp-core': specifier: workspace:* version: link:../mcp-core @@ -2814,6 +2835,10 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + '@modelcontextprotocol/ext-apps@1.7.1': resolution: {integrity: sha512-J3WdG1A4JSSKnSWKyU+895dBVYBV2Utgtf7fUsUK45mlkETm53a/1DR6Pm3hUGKqLLQthZLmpxOg8VPzJi/lyg==} engines: {node: '>=20'} @@ -2828,6 +2853,16 @@ packages: react-dom: optional: true + '@modelcontextprotocol/node@2.0.0': + resolution: {integrity: sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/server': ^2.0.0 + hono: ^4.11.4 + peerDependenciesMeta: + hono: + optional: true + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -2838,6 +2873,10 @@ packages: '@cfworker/json-schema': optional: true + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + '@napi-rs/wasm-runtime@1.1.2': resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} peerDependencies: @@ -9115,6 +9154,10 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.3.6 + '@modelcontextprotocol/ext-apps@1.7.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) @@ -9124,6 +9167,13 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) + '@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.12.12)': + dependencies: + '@hono/node-server': 1.19.13(hono@4.12.12) + '@modelcontextprotocol/server': 2.0.0 + optionalDependencies: + hono: 4.12.12 + '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.13(hono@4.12.12) @@ -9146,6 +9196,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.3.6 + '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)': dependencies: '@emnapi/core': 1.9.1 diff --git a/scripts/validate-fetch-runtime.ts b/scripts/validate-fetch-runtime.ts index c51f8202..79b5d27d 100644 --- a/scripts/validate-fetch-runtime.ts +++ b/scripts/validate-fetch-runtime.ts @@ -159,7 +159,7 @@ async function main(): Promise { main() .then(() => { - // Some transport dependencies (e.g. `@modelcontextprotocol/sdk`) + // Some transport dependencies (e.g. `@modelcontextprotocol/server`) // initialise lazy timers that keep the event loop alive past the // smoke. Force-exit so the pre-publish gate finishes in CI. process.exit(0)