Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
450 changes: 450 additions & 0 deletions .cursor/plans/mcp_2026_07_28_sdk_v2_migration.plan.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions .cursor/rules/mcp-apps-sdk.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/contributing/mcp-apps-sdk-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 1 addition & 2 deletions docs/guides/mcp-app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
16 changes: 8 additions & 8 deletions docs/guides/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -141,7 +141,7 @@ Deno.serve(
readHtml: () => Deno.readTextFile('./mcp-app.html'),
publicBaseUrl,
apiBaseUrl,
mode: 'json-stateless',
responseMode: 'json',
}),
)
```
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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' })
Expand Down
2 changes: 1 addition & 1 deletion examples/cloudflare-workers-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion examples/cloudflare-workers-mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
2 changes: 1 addition & 1 deletion examples/cloudflare-workers-mcp/src/demo-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
9 changes: 4 additions & 5 deletions examples/cloudflare-workers-mcp/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -91,7 +90,7 @@ function getHandler(env: Env): (req: Request) => Promise<Response> {
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`,
Expand Down
2 changes: 1 addition & 1 deletion examples/cloudflare-workers-mcp/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion examples/mcp-checkout-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
2 changes: 1 addition & 1 deletion examples/mcp-checkout-app/src/demo-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
120 changes: 11 additions & 109 deletions examples/mcp-checkout-app/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -15,27 +14,12 @@ import {
solvapayProductRef,
} from './config'

type JsonRpcId = string | number | null
type SessionEntry = {
transport: StreamableHTTPServerTransport
}

const sessions: Record<string, SessionEntry> = {}

// 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({
Expand All @@ -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', {
Expand All @@ -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, () => {
Expand All @@ -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,
})
})
4 changes: 2 additions & 2 deletions examples/mcp-checkout-app/src/server.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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`
Expand Down
Loading
Loading