From 4658a6e1c8c76114180344aec408d9aeea07e2e0 Mon Sep 17 00:00:00 2001 From: Parv Ahuja Date: Thu, 11 Jun 2026 10:12:16 -0700 Subject: [PATCH 1/2] feat: move mcp entrypoints to mppx/mcp/* Renames src/mcp-sdk to src/mcp and adds the mppx/mcp/client and mppx/mcp/server entrypoints. The published mppx/mcp-sdk/client and mppx/mcp-sdk/server specifiers remain as aliases resolving to the same dist files, so existing imports keep working. --- .changeset/mcp-entrypoints.md | 5 +++++ package.json | 22 ++++++++++++++----- .../client/McpClient.integration.test.ts | 0 .../client/McpClient.test-d.ts | 0 src/{mcp-sdk => mcp}/client/McpClient.test.ts | 0 src/{mcp-sdk => mcp}/client/McpClient.ts | 0 .../client/McpClient.unit.test.ts | 0 src/{mcp-sdk => mcp}/client/index.ts | 0 src/{mcp-sdk => mcp}/server/Transport.test.ts | 0 src/{mcp-sdk => mcp}/server/Transport.ts | 0 src/{mcp-sdk => mcp}/server/index.ts | 0 src/server/Transport.ts | 2 +- test/tsconfig.json | 6 +++-- vite.config.ts | 6 +++-- 14 files changed, 30 insertions(+), 11 deletions(-) create mode 100644 .changeset/mcp-entrypoints.md rename src/{mcp-sdk => mcp}/client/McpClient.integration.test.ts (100%) rename src/{mcp-sdk => mcp}/client/McpClient.test-d.ts (100%) rename src/{mcp-sdk => mcp}/client/McpClient.test.ts (100%) rename src/{mcp-sdk => mcp}/client/McpClient.ts (100%) rename src/{mcp-sdk => mcp}/client/McpClient.unit.test.ts (100%) rename src/{mcp-sdk => mcp}/client/index.ts (100%) rename src/{mcp-sdk => mcp}/server/Transport.test.ts (100%) rename src/{mcp-sdk => mcp}/server/Transport.ts (100%) rename src/{mcp-sdk => mcp}/server/index.ts (100%) diff --git a/.changeset/mcp-entrypoints.md b/.changeset/mcp-entrypoints.md new file mode 100644 index 00000000..524378c4 --- /dev/null +++ b/.changeset/mcp-entrypoints.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Moved the MCP entrypoints to `mppx/mcp/client` and `mppx/mcp/server`; the `mppx/mcp-sdk/client` and `mppx/mcp-sdk/server` specifiers remain as aliases. diff --git a/package.json b/package.json index 1d908bf3..d7608104 100644 --- a/package.json +++ b/package.json @@ -115,15 +115,25 @@ "types": "./dist/evm/server/index.d.ts", "default": "./dist/evm/server/index.js" }, + "./mcp/client": { + "src": "./src/mcp/client/index.ts", + "types": "./dist/mcp/client/index.d.ts", + "default": "./dist/mcp/client/index.js" + }, + "./mcp/server": { + "src": "./src/mcp/server/index.ts", + "types": "./dist/mcp/server/index.d.ts", + "default": "./dist/mcp/server/index.js" + }, "./mcp-sdk/client": { - "src": "./src/mcp-sdk/client/index.ts", - "types": "./dist/mcp-sdk/client/index.d.ts", - "default": "./dist/mcp-sdk/client/index.js" + "src": "./src/mcp/client/index.ts", + "types": "./dist/mcp/client/index.d.ts", + "default": "./dist/mcp/client/index.js" }, "./mcp-sdk/server": { - "src": "./src/mcp-sdk/server/index.ts", - "types": "./dist/mcp-sdk/server/index.d.ts", - "default": "./dist/mcp-sdk/server/index.js" + "src": "./src/mcp/server/index.ts", + "types": "./dist/mcp/server/index.d.ts", + "default": "./dist/mcp/server/index.js" }, "./proxy": { "src": "./src/proxy/index.ts", diff --git a/src/mcp-sdk/client/McpClient.integration.test.ts b/src/mcp/client/McpClient.integration.test.ts similarity index 100% rename from src/mcp-sdk/client/McpClient.integration.test.ts rename to src/mcp/client/McpClient.integration.test.ts diff --git a/src/mcp-sdk/client/McpClient.test-d.ts b/src/mcp/client/McpClient.test-d.ts similarity index 100% rename from src/mcp-sdk/client/McpClient.test-d.ts rename to src/mcp/client/McpClient.test-d.ts diff --git a/src/mcp-sdk/client/McpClient.test.ts b/src/mcp/client/McpClient.test.ts similarity index 100% rename from src/mcp-sdk/client/McpClient.test.ts rename to src/mcp/client/McpClient.test.ts diff --git a/src/mcp-sdk/client/McpClient.ts b/src/mcp/client/McpClient.ts similarity index 100% rename from src/mcp-sdk/client/McpClient.ts rename to src/mcp/client/McpClient.ts diff --git a/src/mcp-sdk/client/McpClient.unit.test.ts b/src/mcp/client/McpClient.unit.test.ts similarity index 100% rename from src/mcp-sdk/client/McpClient.unit.test.ts rename to src/mcp/client/McpClient.unit.test.ts diff --git a/src/mcp-sdk/client/index.ts b/src/mcp/client/index.ts similarity index 100% rename from src/mcp-sdk/client/index.ts rename to src/mcp/client/index.ts diff --git a/src/mcp-sdk/server/Transport.test.ts b/src/mcp/server/Transport.test.ts similarity index 100% rename from src/mcp-sdk/server/Transport.test.ts rename to src/mcp/server/Transport.test.ts diff --git a/src/mcp-sdk/server/Transport.ts b/src/mcp/server/Transport.ts similarity index 100% rename from src/mcp-sdk/server/Transport.ts rename to src/mcp/server/Transport.ts diff --git a/src/mcp-sdk/server/index.ts b/src/mcp/server/index.ts similarity index 100% rename from src/mcp-sdk/server/index.ts rename to src/mcp/server/index.ts diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 491a0897..6e595e64 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -9,7 +9,7 @@ import * as Receipt from '../Receipt.js' import * as Html from './internal/html/config.js' import { serviceWorker } from './internal/html/serviceWorker.gen.js' -export { type McpSdk, mcpSdk } from '../mcp-sdk/server/Transport.js' +export { type McpSdk, mcpSdk } from '../mcp/server/Transport.js' /** * Server-side transport adapter. diff --git a/test/tsconfig.json b/test/tsconfig.json index 91df9f6a..e2d4e4c1 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -20,8 +20,10 @@ "mppx/stripe": ["../src/stripe/index.ts"], "mppx/stripe/client": ["../src/stripe/client/index.ts"], "mppx/stripe/server": ["../src/stripe/server/index.ts"], - "mppx/mcp-sdk/client": ["../src/mcp-sdk/client/index.ts"], - "mppx/mcp-sdk/server": ["../src/mcp-sdk/server/index.ts"] + "mppx/mcp/client": ["../src/mcp/client/index.ts"], + "mppx/mcp/server": ["../src/mcp/server/index.ts"], + "mppx/mcp-sdk/client": ["../src/mcp/client/index.ts"], + "mppx/mcp-sdk/server": ["../src/mcp/server/index.ts"] } }, "include": ["env.d.ts", "./**/*.ts", "../src/**/*.test.ts", "../src/**/*.test-d.ts"] diff --git a/vite.config.ts b/vite.config.ts index c923ed11..81b8d288 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,8 +7,10 @@ import { playwright } from 'vp/test/browser-playwright' const alias = { 'mppx/client': path.resolve(import.meta.dirname, 'src/client'), 'mppx/discovery': path.resolve(import.meta.dirname, 'src/discovery'), - 'mppx/mcp-sdk/client': path.resolve(import.meta.dirname, 'src/mcp-sdk/client'), - 'mppx/mcp-sdk/server': path.resolve(import.meta.dirname, 'src/mcp-sdk/server'), + 'mppx/mcp/client': path.resolve(import.meta.dirname, 'src/mcp/client'), + 'mppx/mcp/server': path.resolve(import.meta.dirname, 'src/mcp/server'), + 'mppx/mcp-sdk/client': path.resolve(import.meta.dirname, 'src/mcp/client'), + 'mppx/mcp-sdk/server': path.resolve(import.meta.dirname, 'src/mcp/server'), 'mppx/proxy': path.resolve(import.meta.dirname, 'src/proxy'), 'mppx/server': path.resolve(import.meta.dirname, 'src/server'), 'mppx/tempo': path.resolve(import.meta.dirname, 'src/tempo'), From a6ea1ae39d39220eebc499d5be58bd2354a8f02c Mon Sep 17 00:00:00 2001 From: Parv Ahuja Date: Thu, 11 Jun 2026 14:52:19 -0700 Subject: [PATCH 2/2] feat!: unify mcp client payment wrapping into a single McpClient.wrap Collapses McpClient.wrap and wrapClient: wrap now mutates the client in place and returns the same reference, preserves the MCP SDK callTool(params, resultSchema?, options?) signature (context and a per-call onPaymentRequired ride in the options argument), handles payment challenges from both payment-required errors and tool-result metadata, accepts orderChallenges/paymentPreferences, and validates challenge payloads with the Challenge schema. --- .changeset/mcp-client-inject.md | 9 + .changeset/mcp-entrypoints.md | 5 - src/Mcp.ts | 4 + src/mcp/client/McpClient.integration.test.ts | 12 +- src/mcp/client/McpClient.test-d.ts | 63 +++- src/mcp/client/McpClient.test.ts | 214 +++++++++++- src/mcp/client/McpClient.ts | 337 ++++++++++++------- src/mcp/client/McpClient.unit.test.ts | 14 +- 8 files changed, 494 insertions(+), 164 deletions(-) create mode 100644 .changeset/mcp-client-inject.md delete mode 100644 .changeset/mcp-entrypoints.md diff --git a/.changeset/mcp-client-inject.md b/.changeset/mcp-client-inject.md new file mode 100644 index 00000000..94bb2bc3 --- /dev/null +++ b/.changeset/mcp-client-inject.md @@ -0,0 +1,9 @@ +--- +'mppx': minor +--- + +**Breaking:** Collapsed `McpClient.wrap` and the in-place `wrapClient` variant into a single `McpClient.wrap` API on the `mppx/mcp/client` entrypoint. + +`McpClient.wrap` now adds payment handling to an MCP SDK client in place: the client is mutated and the same reference is returned, so surfaces that keep using the original client become payment-aware (e.g. when another SDK owns the client reference, like Cloudflare Agents). The MCP SDK `callTool(params, resultSchema?, options?)` signature is preserved, payment challenges are handled whether they arrive as payment-required errors or as tool results carrying `org.paymentauth/payment-required` metadata, and the config accepts `orderChallenges` and `paymentPreferences` alongside `methods` and `onPaymentRequired`. Calling `wrap` on the same client again replaces its payment configuration. + +Migration: move per-call options from the second argument to the third — `mcp.callTool(params, undefined, { context, timeout })` — and replace the approval-first overload `mcp.callTool(onPaymentRequired, params, options)` with the `onPaymentRequired` option: `mcp.callTool(params, undefined, { onPaymentRequired })` (pass `null` to bypass a configured hook). The MCP entrypoints moved to `mppx/mcp/client` and `mppx/mcp/server`; the `mppx/mcp-sdk/*` specifiers remain as aliases. diff --git a/.changeset/mcp-entrypoints.md b/.changeset/mcp-entrypoints.md deleted file mode 100644 index 524378c4..00000000 --- a/.changeset/mcp-entrypoints.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'mppx': patch ---- - -Moved the MCP entrypoints to `mppx/mcp/client` and `mppx/mcp/server`; the `mppx/mcp-sdk/client` and `mppx/mcp-sdk/server` specifiers remain as aliases. diff --git a/src/Mcp.ts b/src/Mcp.ts index 5d5230be..dabac5a1 100644 --- a/src/Mcp.ts +++ b/src/Mcp.ts @@ -13,6 +13,9 @@ export const paymentVerificationFailedCode = -32043 /** MCP metadata key for credentials. */ export const credentialMetaKey = 'org.paymentauth/credential' +/** MCP metadata key for payment-required tool results. */ +export const paymentRequiredMetaKey = 'org.paymentauth/payment-required' + /** MCP metadata key for receipts. */ export const receiptMetaKey = 'org.paymentauth/receipt' @@ -45,6 +48,7 @@ export type JsonRpcRequest = Request & { */ export type Result = { _meta?: { + [paymentRequiredMetaKey]?: ErrorObject['data'] [receiptMetaKey]?: Receipt [key: string]: unknown } diff --git a/src/mcp/client/McpClient.integration.test.ts b/src/mcp/client/McpClient.integration.test.ts index 3781e813..a61055c5 100644 --- a/src/mcp/client/McpClient.integration.test.ts +++ b/src/mcp/client/McpClient.integration.test.ts @@ -410,6 +410,7 @@ describe.runIf(isLocalnet)('McpClient.wrap integration', () => { type WrappedClient = { callTool: ( params: { name: string; arguments?: Record; _meta?: Record }, + resultSchema?: undefined, options?: { context?: unknown; timeout?: number }, ) => Promise } @@ -545,9 +546,14 @@ async function createHarness(options?: { const clientTransport = new StreamableHTTPClientTransport(new URL(`${httpServer.url}/mcp`)) await sdkClient.connect(clientTransport as never) - const mcp = McpClient.wrap(sdkClient, { - methods: [chargeMethod, sessionMethod], - }) + // Wrap a facade over the same session so `sdkClient` keeps raw (challenge- + // throwing) behavior for the credential-level scenarios below. + const mcp = McpClient.wrap( + { callTool: sdkClient.callTool.bind(sdkClient) as Client['callTool'] }, + { + methods: [chargeMethod, sessionMethod], + }, + ) return { async close() { diff --git a/src/mcp/client/McpClient.test-d.ts b/src/mcp/client/McpClient.test-d.ts index 82be9d6a..d7bf9a32 100644 --- a/src/mcp/client/McpClient.test-d.ts +++ b/src/mcp/client/McpClient.test-d.ts @@ -6,7 +6,7 @@ import { describe, expectTypeOf, test } from 'vp/test' import * as McpClient from './McpClient.js' describe('McpClient.wrap', () => { - test('returns wrapped client with callTool', () => { + test('returns the original client with payment-aware callTool', () => { const client = {} as Client const wrapped = McpClient.wrap(client, { methods: [ @@ -16,6 +16,9 @@ describe('McpClient.wrap', () => { ], }) + expectTypeOf(wrapped).toEqualTypeOf< + McpClient.wrap.McpClient]> + >() expectTypeOf(wrapped.callTool).toBeFunction() expectTypeOf(wrapped.callTool).returns.toExtend>() }) @@ -50,19 +53,36 @@ describe('McpClient.wrap', () => { expectTypeOf(wrapped.customProp).toEqualTypeOf() }) - test('callTool accepts context when method has context', () => { + test('callTool keeps the MCP SDK result schema and options positions', () => { + const client = {} as Client + const wrapped = McpClient.wrap(client, { + methods: [ + tempo({ + account: {} as Account, + }), + ], + }) + + expectTypeOf(wrapped.callTool).toBeCallableWith({ name: 'tool' }) + expectTypeOf(wrapped.callTool).toBeCallableWith({ name: 'tool' }, undefined, {}) + expectTypeOf(wrapped.callTool).toBeCallableWith({ name: 'tool' }, undefined, { + timeout: 5000, + }) + }) + + test('callTool accepts context in the options position', () => { const client = {} as Client const wrapped = McpClient.wrap(client, { methods: [tempo({})], }) - expectTypeOf(wrapped.callTool).toBeCallableWith( - { name: 'tool' }, - { context: { account: {} as Account } }, - ) + expectTypeOf(wrapped.callTool).toBeCallableWith({ name: 'tool' }, undefined, { + context: { account: {} as Account }, + timeout: 5000, + }) }) - test('callTool context is optional when account provided at creation', () => { + test('callTool accepts a per-call approval hook in the options position', () => { const client = {} as Client const wrapped = McpClient.wrap(client, { methods: [ @@ -72,16 +92,12 @@ describe('McpClient.wrap', () => { ], }) - expectTypeOf(wrapped.callTool).toBeCallableWith({ name: 'tool' }) - expectTypeOf(wrapped.callTool).toBeCallableWith(null, { name: 'tool' }) - expectTypeOf(wrapped.callTool).toBeCallableWith(() => true, { name: 'tool' }) - expectTypeOf(wrapped.callTool).toBeCallableWith({ name: 'tool' }, {}) - expectTypeOf(wrapped.callTool).toBeCallableWith({ name: 'tool' }, { timeout: 5000 }) - expectTypeOf(wrapped.callTool).toBeCallableWith( - async (challenge) => challenge.intent === 'charge', - { name: 'tool' }, - { timeout: 5000 }, - ) + expectTypeOf(wrapped.callTool).toBeCallableWith({ name: 'tool' }, undefined, { + onPaymentRequired: async (challenge) => challenge.intent === 'charge', + }) + expectTypeOf(wrapped.callTool).toBeCallableWith({ name: 'tool' }, undefined, { + onPaymentRequired: null, + }) }) test('callTool result includes receipt', () => { @@ -97,6 +113,16 @@ describe('McpClient.wrap', () => { expectTypeOf(wrapped.callTool({} as never)).resolves.toHaveProperty('receipt') expectTypeOf(wrapped.callTool({} as never)).resolves.toHaveProperty('content') }) + + test('can store an inferred client as the exported client type', () => { + const client = {} as Client + + const wrapped = McpClient.wrap(client, { + methods: [tempo({ account: {} as Account })], + }) + + expectTypeOf(wrapped).toMatchTypeOf() + }) }) describe('McpClient.wrap.McpClient', () => { @@ -108,10 +134,11 @@ describe('McpClient.wrap.McpClient', () => { }) describe('McpClient.wrap.CallToolOptions', () => { - test('has context and timeout properties', () => { + test('has context, approval hook, and timeout properties', () => { type Options = McpClient.wrap.CallToolOptions expectTypeOf().toHaveProperty('context') + expectTypeOf().toHaveProperty('onPaymentRequired') expectTypeOf().toHaveProperty('timeout') }) }) diff --git a/src/mcp/client/McpClient.test.ts b/src/mcp/client/McpClient.test.ts index 72ac1757..9b7a05dd 100644 --- a/src/mcp/client/McpClient.test.ts +++ b/src/mcp/client/McpClient.test.ts @@ -16,6 +16,30 @@ import * as McpClient from './McpClient.js' const realm = 'api.example.com' const secretKey = 'test-secret-key' +function createChallenge() { + return Challenge.fromMethod(Methods.charge, { + realm, + secretKey, + expires: new Date(Date.now() + 60_000).toISOString(), + request: { + amount: '1', + currency: asset, + decimals: 6, + recipient: accounts[0].address, + }, + }) +} + +function createReceipt(challenge: Challenge.Challenge): core_Mcp.Receipt { + return { + challengeId: challenge.id, + method: 'tempo', + reference: 'test', + status: 'success', + timestamp: new Date().toISOString(), + } +} + describe('McpClient.wrap', () => { let client: Client let server: McpServer @@ -97,10 +121,9 @@ describe('McpClient.wrap', () => { ], }) - const result = await mcp.callTool( - { name: 'premium_tool', arguments: {} }, - { context: { account: accounts[1] } }, - ) + const result = await mcp.callTool({ name: 'premium_tool', arguments: {} }, undefined, { + context: { account: accounts[1] }, + }) expect(result.content).toEqual([{ type: 'text', text: 'Premium tool executed' }]) expect(result.receipt?.status).toBe('success') @@ -122,6 +145,25 @@ describe('McpClient.wrap', () => { expect(result.receipt).toBeUndefined() }) + test('behavior: does not forward empty options', async () => { + const rawCallTool = vi.fn(async () => ({ + content: [{ type: 'text' as const, text: 'Free tool executed' }], + })) + const mcp = McpClient.wrap( + { callTool: rawCallTool as Client['callTool'] }, + { methods: [Method.toClient(Methods.charge, { createCredential: vi.fn() })] }, + ) + + const result = await mcp.callTool({ name: 'free_tool', arguments: {} }, undefined, {}) + + expect(result.content).toEqual([{ type: 'text', text: 'Free tool executed' }]) + expect(rawCallTool).toHaveBeenCalledWith( + { name: 'free_tool', arguments: {} }, + undefined, + undefined, + ) + }) + test('behavior: throws when no account provided', async () => { const mcp = McpClient.wrap(client, { methods: [ @@ -226,6 +268,170 @@ describe('McpClient.wrap', () => { }) }) +describe('McpClient.wrap (in-place)', () => { + test('default: mutates the existing client and handles payment', async () => { + const challenge = createChallenge() + const createCredential = vi.fn(async ({ challenge }) => + Credential.serialize({ + challenge, + payload: { signature: '0xsignature', type: 'transaction' }, + }), + ) + const rawCallTool = vi + .fn() + .mockRejectedValueOnce( + new McpError(core_Mcp.paymentRequiredCode, 'Payment Required', { + httpStatus: 402, + challenges: [challenge], + }), + ) + .mockResolvedValueOnce({ + _meta: { [core_Mcp.receiptMetaKey]: createReceipt(challenge) }, + content: [{ type: 'text', text: 'Premium tool executed' }], + }) + const fakeClient = { callTool: rawCallTool as Client['callTool'] } + + const wrapped = McpClient.wrap(fakeClient, { + methods: [Method.toClient(Methods.charge, { createCredential })], + }) + + expect(wrapped).toBe(fakeClient) + + const result = await wrapped.callTool({ name: 'premium_tool', arguments: {} }) + + expect(result.content).toEqual([{ type: 'text', text: 'Premium tool executed' }]) + expect(result.isError).toBeUndefined() + expect(result.receipt).toBeDefined() + expect(result.receipt?.status).toBe('success') + expect(rawCallTool).toHaveBeenCalledTimes(2) + expect(createCredential).toHaveBeenCalledOnce() + }) + + test('behavior: preserves the MCP SDK callTool argument shape', async () => { + const rawCallTool = vi.fn(async () => ({ + content: [{ type: 'text' as const, text: 'Free tool executed' }], + })) + const createCredential = vi.fn() + const fakeClient = { callTool: rawCallTool as Client['callTool'] } + + const wrapped = McpClient.wrap(fakeClient, { + methods: [Method.toClient(Methods.charge, { createCredential })], + }) + + const result = await wrapped.callTool({ name: 'free_tool', arguments: {} }, undefined, { + timeout: 30_000, + }) + + expect(result.content).toEqual([{ type: 'text', text: 'Free tool executed' }]) + expect(result.receipt).toBeUndefined() + expect(rawCallTool).toHaveBeenCalledWith({ name: 'free_tool', arguments: {} }, undefined, { + timeout: 30_000, + }) + expect(createCredential).not.toHaveBeenCalled() + }) + + test('behavior: strips payment context from MCP SDK request options', async () => { + const rawCallTool = vi.fn(async () => ({ + content: [{ type: 'text' as const, text: 'Free tool executed' }], + })) + const fakeClient = { callTool: rawCallTool as Client['callTool'] } + + const wrapped = McpClient.wrap(fakeClient, { + methods: [tempo_client({})], + }) + + await wrapped.callTool({ name: 'free_tool', arguments: {} }, undefined, { + context: { account: accounts[1] }, + timeout: 30_000, + }) + + expect(rawCallTool).toHaveBeenCalledWith({ name: 'free_tool', arguments: {} }, undefined, { + timeout: 30_000, + }) + }) + + test('behavior: re-wrapping replaces config without stacking wrappers', async () => { + const challenge = createChallenge() + + const rawCallTool = vi + .fn() + .mockRejectedValueOnce( + new McpError(core_Mcp.paymentRequiredCode, 'Payment Required', { + httpStatus: 402, + challenges: [challenge], + }), + ) + .mockResolvedValueOnce({ + _meta: { [core_Mcp.receiptMetaKey]: createReceipt(challenge) }, + content: [{ type: 'text', text: 'paid' }], + }) + + const fakeClient = { callTool: rawCallTool as Client['callTool'] } + const staleCreateCredential = vi.fn(async () => { + throw new Error('stale config used') + }) + const freshCreateCredential = vi.fn(async ({ challenge }) => + Credential.serialize({ + challenge, + payload: { signature: '0xsignature', type: 'transaction' }, + }), + ) + + McpClient.wrap(fakeClient, { + methods: [Method.toClient(Methods.charge, { createCredential: staleCreateCredential })], + }) + const wrapped = McpClient.wrap(fakeClient, { + methods: [Method.toClient(Methods.charge, { createCredential: freshCreateCredential })], + }) + + const result = await wrapped.callTool({ name: 'premium_tool', arguments: {} }) + + expect(result.content).toEqual([{ type: 'text', text: 'paid' }]) + expect(result.receipt?.status).toBe('success') + expect(staleCreateCredential).not.toHaveBeenCalled() + expect(freshCreateCredential).toHaveBeenCalledOnce() + expect(rawCallTool).toHaveBeenCalledTimes(2) + }) + + test('behavior: handles payment challenge metadata returned as a tool result', async () => { + const challenge = createChallenge() + const createCredential = vi.fn(async ({ challenge }) => + Credential.serialize({ + challenge, + payload: { signature: '0xsignature', type: 'transaction' }, + }), + ) + const rawCallTool = vi + .fn() + .mockResolvedValueOnce({ + _meta: { + [core_Mcp.paymentRequiredMetaKey]: { + challenges: [challenge], + httpStatus: 402, + }, + }, + content: [{ type: 'text', text: 'Payment Required' }], + isError: true, + }) + .mockResolvedValueOnce({ + _meta: { [core_Mcp.receiptMetaKey]: createReceipt(challenge) }, + content: [{ type: 'text', text: 'Premium tool executed' }], + }) + + const wrapped = McpClient.wrap( + { callTool: rawCallTool as Client['callTool'] }, + { methods: [Method.toClient(Methods.charge, { createCredential })] }, + ) + + const result = await wrapped.callTool({ name: 'premium_tool', arguments: {} }) + + expect(result.content).toEqual([{ type: 'text', text: 'Premium tool executed' }]) + expect(result.receipt?.status).toBe('success') + expect(createCredential).toHaveBeenCalledOnce() + expect(rawCallTool).toHaveBeenCalledTimes(2) + }) +}) + describe('isPaymentRequiredError', () => { test('returns true for McpError with payment code and challenges', () => { const error = new McpError(core_Mcp.paymentRequiredCode, 'Payment Required', { diff --git a/src/mcp/client/McpClient.ts b/src/mcp/client/McpClient.ts index 608e65fa..ba6f4a65 100644 --- a/src/mcp/client/McpClient.ts +++ b/src/mcp/client/McpClient.ts @@ -1,21 +1,23 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import type { McpError } from '@modelcontextprotocol/sdk/types.js' -import type * as Challenge from '../../Challenge.js' +import * as Challenge from '../../Challenge.js' import * as Credential from '../../Credential.js' import * as Expires from '../../Expires.js' import * as AcceptPayment from '../../internal/AcceptPayment.js' import * as core_Mcp from '../../Mcp.js' import type * as Method from '../../Method.js' -import type * as z from '../../zod.js' +import * as z from '../../zod.js' type AnyClient = Method.Client +type Methods = readonly (Method.AnyClient | readonly Method.AnyClient[])[] +type DefaultMethods = readonly [Method.AnyClient | readonly Method.AnyClient[]] +type CallToolParams = Parameters[0] +type CallToolResultSchema = Parameters[1] +type CallToolRequestOptions = Parameters[2] +type PaymentRequiredData = NonNullable -export type CallToolParameters = { - name: string - arguments?: Record - _meta?: Record -} +const MPPX_MCP_CLIENT_WRAPPER = Symbol.for('mppx.mcp.client.wrapper') export type OnPaymentRequired = (challenge: Challenge.Challenge) => boolean | Promise @@ -29,21 +31,30 @@ export type CallToolResult = Awaited> & { } /** - * Creates a payment-aware wrapper around an MCP SDK client. + * Adds automatic payment handling to an MCP SDK client. * - * Similar to `Fetch.from()` for HTTP, this wraps an MCP client's `callTool` - * method to automatically handle payment challenges. + * The client's `callTool` method is replaced in place and the same reference + * is returned, so surfaces that keep using the original client become + * payment-aware — including when another SDK owns the client reference (e.g. + * Cloudflare Agents). The MCP SDK `callTool(params, resultSchema?, options?)` + * signature is preserved; pass a method's `context` or a per-call + * `onPaymentRequired` approval hook via the options argument, where they are + * stripped before the remaining request options are forwarded to the SDK. + * Payment challenges are handled whether they arrive as payment-required + * errors or as tool results carrying payment-required metadata. Calling + * `wrap()` again replaces the payment configuration. * * @example * ```ts * import { Client } from '@modelcontextprotocol/sdk/client' - * import { McpClient, tempo } from 'mppx/mcp-sdk/client' + * import { tempo } from 'mppx/client' + * import { McpClient } from 'mppx/mcp/client' * import { privateKeyToAccount } from 'viem/accounts' * * const client = new Client({ name: 'my-client', version: '1.0.0' }) * await client.connect(transport) * - * const mcp = McpClient.wrap(client, { + * McpClient.wrap(client, { * methods: [ * tempo({ * account: privateKeyToAccount('0x...'), @@ -52,155 +63,96 @@ export type CallToolResult = Awaited> & { * }) * * // Automatically handles payment challenges - * const result = await mcp.callTool({ name: 'premium_tool', arguments: {} }) + * const result = await client.callTool({ name: 'premium_tool', arguments: {} }) * console.log(result.content, result.receipt) * ``` */ -export function wrap< - const client extends Pick, - const methods extends readonly Method.AnyClient[], ->(client: client, config: wrap.Config): wrap.McpClient { - const { methods } = config - const paymentPreferences = AcceptPayment.resolve(methods) - - const callTool = (async ( - first: CallToolParameters | OnPaymentRequired | null | undefined, - second?: CallToolParameters | wrap.CallToolOptions, - third?: wrap.CallToolOptions, - ) => { - const hasApprovalArgument = typeof first === 'function' || first === null || first === undefined - const params = (hasApprovalArgument ? second : first) as CallToolParameters - const options = (hasApprovalArgument ? third : second) as - | wrap.CallToolOptions - | undefined - const onPaymentRequired = - first === null - ? undefined - : hasApprovalArgument - ? ((first as OnPaymentRequired | undefined) ?? config.onPaymentRequired) - : config.onPaymentRequired - const context = options?.context - const timeout = options?.timeout +export function wrap, const methods extends Methods>( + client: client, + config: wrap.Config, +): wrap.McpClient { + const target = client as client & { [MPPX_MCP_CLIENT_WRAPPER]?: Client['callTool'] } + const originalCallTool = target[MPPX_MCP_CLIENT_WRAPPER] ?? target.callTool + const callTool = createPaymentAwareCallTool(originalCallTool.bind(client), config) - try { - const result = await client.callTool( - params, - undefined, - timeout !== undefined ? { timeout } : undefined, - ) + Object.defineProperty(target, MPPX_MCP_CLIENT_WRAPPER, { + configurable: true, + value: originalCallTool, + }) - return { - ...result, - receipt: result._meta?.[core_Mcp.receiptMetaKey] as core_Mcp.Receipt | undefined, - } - } catch (error) { - // Check if this is a payment required error - if (!isPaymentRequiredError(error)) throw error - - const challenges = (error.data as { challenges?: Challenge.Challenge[] })?.challenges - if (!challenges?.length) throw error - - const selected = AcceptPayment.selectChallenge( - challenges, - methods, - paymentPreferences.entries, - ) - if (!selected) { - const available = challenges.map((c) => `${c.method}.${c.intent}`).join(', ') - const installed = methods.map((m) => `${m.name}.${m.intent}`).join(', ') - throw new Error( - `No compatible payment method. Server offers: ${available}. Client has: ${installed}`, - { cause: error }, - ) - } - - if (selected.challenge.expires) - Expires.assert(selected.challenge.expires, selected.challenge.id) - - if (onPaymentRequired) { - const approved = await onPaymentRequired(selected.challenge) - if (!approved) throw new Error('Payment declined.', { cause: error }) - } - - const credential = await createCredential(selected.challenge, { + Object.defineProperty(target, 'callTool', { + configurable: true, + enumerable: false, + value: ( + params: CallToolParams, + resultSchema?: CallToolResultSchema, + options?: wrap.CallToolOptions, + ) => { + const { context, onPaymentRequired, ...requestOptions } = + options ?? ({} as wrap.CallToolOptions) + return callTool(params, { context, - methods, + onPaymentRequired: + onPaymentRequired === null ? undefined : (onPaymentRequired ?? config.onPaymentRequired), + requestOptions: Object.keys(requestOptions).length + ? (requestOptions as CallToolRequestOptions) + : undefined, + resultSchema, }) - const parsed = Credential.deserialize(credential) - - const retryResult = await client.callTool( - { - ...params, - _meta: { - ...params._meta, - [core_Mcp.credentialMetaKey]: parsed, - }, - }, - undefined, - timeout !== undefined ? { timeout } : undefined, - ) + }, + writable: true, + }) - return { - ...retryResult, - receipt: retryResult._meta?.[core_Mcp.receiptMetaKey] as core_Mcp.Receipt | undefined, - } - } - }) as wrap.McpClient['callTool'] - - return { ...client, callTool } as wrap.McpClient + return target as unknown as wrap.McpClient } -/** Union of all context types from all methods that have context schemas. */ -type AnyContextFor = { - [key in keyof methods]: methods[key] extends Method.Client - ? context extends z.ZodMiniType - ? z.input - : undefined - : undefined -}[number] - export declare namespace wrap { - type Config = { - /** Array of methods to use. */ - methods: methods + type Config = { /** Optional approval hook called before creating a payment credential. */ onPaymentRequired?: OnPaymentRequired + /** Filters and sorts supported Challenges before Credential creation. */ + orderChallenges?: AcceptPayment.OrderChallenges> | undefined + /** Client-declared supported payment methods, keyed by typed `method/intent` strings. */ + paymentPreferences?: AcceptPayment.Config> | undefined + /** Array of methods to use. Accepts individual clients or tuples (e.g. from `tempo()`). */ + methods: methods } type McpClient< client extends Pick = Pick, - methods extends readonly AnyClient[] = readonly AnyClient[], + methods extends Methods = DefaultMethods, > = Omit & { - /** Call a tool with automatic payment handling. */ - callTool: { - (params: CallToolParameters, options?: CallToolOptions): Promise - ( - onPaymentRequired: OnPaymentRequired | null | undefined, - params: CallToolParameters, - options?: CallToolOptions, - ): Promise - } + /** Call a tool with automatic payment handling. Preserves the MCP SDK signature. */ + callTool: ( + params: CallToolParams, + resultSchema?: CallToolResultSchema, + options?: CallToolOptions, + ) => Promise } - type CallToolOptions = { + type CallToolOptions = CallToolRequestOptions & { /** Context to pass to the method intent's createCredential. */ - context?: AnyContextFor - /** Request timeout in milliseconds. */ - timeout?: number + context?: AnyContextForMethods + /** Per-call approval hook; overrides the configured hook. Pass `null` to bypass it. */ + onPaymentRequired?: OnPaymentRequired | null } } +/** Minimal wire shape of payment-required data; challenges are validated, extra fields pass through. */ +const PaymentRequiredSchema = z.object({ + challenges: z.array(Challenge.Schema).check(z.minLength(1)), +}) + /** * Checks if an error is a payment required error. */ export function isPaymentRequiredError( error: unknown, -): error is McpError & { data: { challenges: Challenge.Challenge[] } } { +): error is McpError & { data: PaymentRequiredData } { if (typeof error !== 'object' || error === null) return false if (!('code' in error) || !('message' in error)) return false if ((error as { code: unknown }).code !== core_Mcp.paymentRequiredCode) return false - const data = (error as { data?: { challenges?: unknown } }).data - return Array.isArray(data?.challenges) && data.challenges.length > 0 + return isPaymentRequiredData((error as { data?: unknown }).data) } /** @internal */ @@ -226,3 +178,130 @@ async function createCredential( parsedContext !== undefined ? { challenge, context: parsedContext } : ({ challenge } as never), ) } + +/** Normalized per-call inputs for the payment-aware adapter. @internal */ +type CallToolCall = { + context?: unknown + onPaymentRequired?: OnPaymentRequired | undefined + requestOptions?: CallToolRequestOptions | undefined + resultSchema?: CallToolResultSchema | undefined +} + +function createPaymentAwareCallTool( + callTool: Client['callTool'], + config: wrap.Config, +): (params: CallToolParams, call: CallToolCall) => Promise { + const methods = config.methods.flat() as unknown as FlattenMethods + const paymentPreferences = AcceptPayment.resolve(methods, config.paymentPreferences) + + const retryWithPayment = async ( + params: CallToolParams, + call: CallToolCall, + paymentRequired: PaymentRequiredData, + cause: unknown, + ) => { + const challenges = paymentRequired.challenges + const candidates = AcceptPayment.selectChallengeCandidates( + challenges, + methods, + paymentPreferences.entries, + ) + const orderedCandidates = config.orderChallenges + ? await config.orderChallenges(candidates) + : candidates + const selected = orderedCandidates[0] + + if (!selected) { + const available = challenges.map((challenge) => `${challenge.method}.${challenge.intent}`) + const installed = methods.map((method) => `${method.name}.${method.intent}`) + throw new Error( + `No compatible payment method. Server offers: ${available.join(', ')}. Client has: ${installed.join(', ')}`, + { cause }, + ) + } + + if (selected.challenge.expires) + Expires.assert(selected.challenge.expires, selected.challenge.id) + + if (call.onPaymentRequired) { + const approved = await call.onPaymentRequired(selected.challenge) + if (!approved) throw new Error('Payment declined.', { cause }) + } + + const credential = await createCredential(selected.challenge, { + context: call.context, + methods, + }) + const parsed = Credential.deserialize(credential) + + const retryResult = await callTool( + { + ...params, + _meta: { + ...params._meta, + [core_Mcp.credentialMetaKey]: parsed, + }, + }, + call.resultSchema, + call.requestOptions, + ) + + return withReceipt(retryResult) + } + + return async (params, call) => { + try { + const result = await callTool(params, call.resultSchema, call.requestOptions) + const paymentRequired = getPaymentRequiredMeta(result) + if (paymentRequired) return retryWithPayment(params, call, paymentRequired, result) + return withReceipt(result) + } catch (error) { + if (!isPaymentRequiredError(error)) throw error + return retryWithPayment(params, call, error.data, error) + } + } +} + +function getPaymentRequiredMeta( + result: Awaited>, +): PaymentRequiredData | undefined { + const data = result._meta?.[core_Mcp.paymentRequiredMetaKey] + return isPaymentRequiredData(data) ? data : undefined +} + +function isPaymentRequiredData(value: unknown): value is PaymentRequiredData { + return PaymentRequiredSchema.safeParse(value).success +} + +function withReceipt(result: Awaited>): CallToolResult { + return { + ...result, + receipt: result._meta?.[core_Mcp.receiptMetaKey] as core_Mcp.Receipt | undefined, + } +} + +/** Union of all context types from all methods that have context schemas. */ +type AnyContextFor = { + [key in keyof methods]: methods[key] extends Method.Client + ? context extends z.ZodMiniType + ? z.input + : undefined + : undefined +}[number] + +/** Union of all context types across a methods config, flattening tuples. @internal */ +type AnyContextForMethods = + FlattenMethods extends infer flattened extends readonly AnyClient[] + ? AnyContextFor + : never + +type FlattenMethods = methods extends readonly [ + infer head, + ...infer tail extends Methods, +] + ? head extends readonly Method.AnyClient[] + ? readonly [...head, ...FlattenMethods] + : head extends Method.AnyClient + ? readonly [head, ...FlattenMethods] + : never + : readonly [] diff --git a/src/mcp/client/McpClient.unit.test.ts b/src/mcp/client/McpClient.unit.test.ts index 6a9feb4a..a72c9b87 100644 --- a/src/mcp/client/McpClient.unit.test.ts +++ b/src/mcp/client/McpClient.unit.test.ts @@ -48,7 +48,9 @@ describe('MCP client payment approval', () => { methods: [Method.toClient(Methods.charge, { createCredential })], }) - const result = await mcp.callTool(onPaymentRequired, { name: 'paid_tool', arguments: {} }) + const result = await mcp.callTool({ name: 'paid_tool', arguments: {} }, undefined, { + onPaymentRequired, + }) expect(result.content).toEqual([{ type: 'text', text: 'ok' }]) expect(onPaymentRequired).toHaveBeenCalledWith(challenge) @@ -82,9 +84,9 @@ describe('MCP client payment approval', () => { methods: [Method.toClient(Methods.charge, { createCredential })], }) - await expect(mcp.callTool(() => false, { name: 'paid_tool' })).rejects.toThrow( - 'Payment declined.', - ) + await expect( + mcp.callTool({ name: 'paid_tool' }, undefined, { onPaymentRequired: () => false }), + ).rejects.toThrow('Payment declined.') expect(createCredential).not.toHaveBeenCalled() }) @@ -122,7 +124,9 @@ describe('MCP client payment approval', () => { onPaymentRequired, }) - await expect(mcp.callTool(null, { name: 'paid_tool' })).resolves.toMatchObject({ + await expect( + mcp.callTool({ name: 'paid_tool' }, undefined, { onPaymentRequired: null }), + ).resolves.toMatchObject({ content: [{ type: 'text', text: 'ok' }], }) expect(onPaymentRequired).not.toHaveBeenCalled()