diff --git a/.changeset/mcp-client-inject.md b/.changeset/mcp-client-inject.md new file mode 100644 index 00000000..ef2b4ba9 --- /dev/null +++ b/.changeset/mcp-client-inject.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added `wrapClient` to the new `mppx/mcp/client` entrypoint for adding automatic payment handling to an SDK-owned MCP client in place: the client is mutated and the same reference is returned, the MCP SDK `callTool(params, resultSchema?, options?)` signature is preserved (method `context` is passed via the options argument), and payment challenges returned as tool results via `org.paymentauth/payment-required` metadata are handled alongside payment-required errors. The MCP entrypoints moved 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 3c81367a..53b6c6f4 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.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-sdk/client/McpClient.ts b/src/mcp-sdk/client/McpClient.ts deleted file mode 100644 index 608e65fa..00000000 --- a/src/mcp-sdk/client/McpClient.ts +++ /dev/null @@ -1,228 +0,0 @@ -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 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' - -type AnyClient = Method.Client - -export type CallToolParameters = { - name: string - arguments?: Record - _meta?: Record -} - -export type OnPaymentRequired = (challenge: Challenge.Challenge) => boolean | Promise - -/** - * Result of a tool call with payment handling. - * Extends the SDK's callTool return type with an optional payment receipt. - */ -export type CallToolResult = Awaited> & { - /** Payment receipt if payment was made. */ - receipt: core_Mcp.Receipt | undefined -} - -/** - * Creates a payment-aware wrapper around an MCP SDK client. - * - * Similar to `Fetch.from()` for HTTP, this wraps an MCP client's `callTool` - * method to automatically handle payment challenges. - * - * @example - * ```ts - * import { Client } from '@modelcontextprotocol/sdk/client' - * import { McpClient, tempo } from 'mppx/mcp-sdk/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, { - * methods: [ - * tempo({ - * account: privateKeyToAccount('0x...'), - * }), - * ], - * }) - * - * // Automatically handles payment challenges - * const result = await mcp.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 - - try { - const result = await client.callTool( - params, - undefined, - timeout !== undefined ? { timeout } : undefined, - ) - - 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, { - context, - methods, - }) - const parsed = Credential.deserialize(credential) - - const retryResult = await client.callTool( - { - ...params, - _meta: { - ...params._meta, - [core_Mcp.credentialMetaKey]: parsed, - }, - }, - undefined, - timeout !== undefined ? { timeout } : undefined, - ) - - return { - ...retryResult, - receipt: retryResult._meta?.[core_Mcp.receiptMetaKey] as core_Mcp.Receipt | undefined, - } - } - }) as wrap.McpClient['callTool'] - - return { ...client, callTool } 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 - /** Optional approval hook called before creating a payment credential. */ - onPaymentRequired?: OnPaymentRequired - } - - type McpClient< - client extends Pick = Pick, - methods extends readonly AnyClient[] = readonly AnyClient[], - > = Omit & { - /** Call a tool with automatic payment handling. */ - callTool: { - (params: CallToolParameters, options?: CallToolOptions): Promise - ( - onPaymentRequired: OnPaymentRequired | null | undefined, - params: CallToolParameters, - options?: CallToolOptions, - ): Promise - } - } - - type CallToolOptions = { - /** Context to pass to the method intent's createCredential. */ - context?: AnyContextFor - /** Request timeout in milliseconds. */ - timeout?: number - } -} - -/** - * Checks if an error is a payment required error. - */ -export function isPaymentRequiredError( - error: unknown, -): error is McpError & { data: { challenges: Challenge.Challenge[] } } { - 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 -} - -/** @internal */ -async function createCredential( - challenge: Challenge.Challenge, - config: { - context?: unknown - methods: methods - }, -): Promise { - const { context, methods } = config - - const mi = methods.find((m) => m.name === challenge.method && m.intent === challenge.intent) - if (!mi) - throw new Error( - `No method found for "${challenge.method}.${challenge.intent}". Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`, - ) - - if (challenge.expires) Expires.assert(challenge.expires, challenge.id) - - const parsedContext = mi.context && context !== undefined ? mi.context.parse(context) : undefined - return mi.createCredential( - parsedContext !== undefined ? { challenge, context: parsedContext } : ({ challenge } as never), - ) -} 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 62% rename from src/mcp-sdk/client/McpClient.test-d.ts rename to src/mcp/client/McpClient.test-d.ts index 82be9d6a..2c67b005 100644 --- a/src/mcp-sdk/client/McpClient.test-d.ts +++ b/src/mcp/client/McpClient.test-d.ts @@ -115,3 +115,76 @@ describe('McpClient.wrap.CallToolOptions', () => { expectTypeOf().toHaveProperty('timeout') }) }) + +describe('McpClient.wrapClient', () => { + test('returns the original client with payment-aware callTool', () => { + const client = {} as Client + const wrapped = McpClient.wrapClient(client, { + methods: [ + tempo({ + account: {} as Account, + }), + ], + }) + + expectTypeOf(wrapped).toEqualTypeOf< + McpClient.wrapClient.McpClient]> + >() + expectTypeOf(wrapped.callTool).toBeFunction() + expectTypeOf(wrapped.callTool).returns.toExtend>() + }) + + test('callTool keeps the MCP SDK result schema and options positions', () => { + const client = {} as Client + const wrapped = McpClient.wrapClient(client, { + methods: [ + tempo({ + account: {} as Account, + }), + ], + }) + + expectTypeOf(wrapped.callTool).toBeCallableWith({ name: 'tool' }, undefined, { + timeout: 5000, + }) + }) + + test('callTool accepts context in the options position', () => { + const client = {} as Client + const wrapped = McpClient.wrapClient(client, { + methods: [tempo({})], + }) + + expectTypeOf(wrapped.callTool).toBeCallableWith({ name: 'tool' }, undefined, { + context: { account: {} as Account }, + timeout: 5000, + }) + }) + + test('can store an inferred client as the exported client type', () => { + const client = {} as Client + + const wrapped = McpClient.wrapClient(client, { + methods: [tempo({ account: {} as Account })], + }) + + expectTypeOf(wrapped).toMatchTypeOf() + }) +}) + +describe('McpClient.wrapClient.McpClient', () => { + test('has callTool with correct signature', () => { + type WrappedClient = McpClient.wrapClient.McpClient + + expectTypeOf().toHaveProperty('callTool') + }) +}) + +describe('McpClient.wrapClient.CallToolOptions', () => { + test('has context and timeout properties', () => { + type Options = McpClient.wrapClient.CallToolOptions + + expectTypeOf().toHaveProperty('context') + expectTypeOf().toHaveProperty('timeout') + }) +}) diff --git a/src/mcp-sdk/client/McpClient.test.ts b/src/mcp/client/McpClient.test.ts similarity index 50% rename from src/mcp-sdk/client/McpClient.test.ts rename to src/mcp/client/McpClient.test.ts index 72ac1757..23a8baf6 100644 --- a/src/mcp-sdk/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 @@ -122,6 +146,52 @@ describe('McpClient.wrap', () => { expect(result.receipt).toBeUndefined() }) + test('behavior: passes through payment challenge metadata returned as a tool result', async () => { + const challenge = createChallenge() + const createCredential = vi.fn() + const rawCallTool = vi.fn(async () => ({ + _meta: { + [core_Mcp.paymentRequiredMetaKey]: { + challenges: [challenge], + httpStatus: 402, + }, + }, + content: [{ type: 'text' as const, text: 'Payment Required' }], + isError: true, + })) + const mcp = McpClient.wrap( + { callTool: rawCallTool as Client['callTool'] }, + { methods: [Method.toClient(Methods.charge, { createCredential })] }, + ) + + const result = await mcp.callTool({ name: 'premium_tool', arguments: {} }) + + expect(result.content).toEqual([{ type: 'text', text: 'Payment Required' }]) + expect(result.isError).toBe(true) + expect(result.receipt).toBeUndefined() + expect(rawCallTool).toHaveBeenCalledTimes(1) + expect(createCredential).not.toHaveBeenCalled() + }) + + test('behavior: keeps empty callTool options backward compatible', 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: {} }, {}) + + 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 +296,170 @@ describe('McpClient.wrap', () => { }) }) +describe('McpClient.wrapClient', () => { + 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.wrapClient(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.wrapClient(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.wrapClient(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.wrapClient(fakeClient, { + methods: [Method.toClient(Methods.charge, { createCredential: staleCreateCredential })], + }) + const wrapped = McpClient.wrapClient(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.wrapClient( + { 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 new file mode 100644 index 00000000..61b98e07 --- /dev/null +++ b/src/mcp/client/McpClient.ts @@ -0,0 +1,398 @@ +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 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' + +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 + +const MPPX_MCP_CLIENT_WRAPPER = Symbol.for('mppx.mcp.client.wrapper') + +export type CallToolParameters = { + name: string + arguments?: Record + _meta?: Record +} + +export type OnPaymentRequired = (challenge: Challenge.Challenge) => boolean | Promise + +/** + * Result of a tool call with payment handling. + * Extends the SDK's callTool return type with an optional payment receipt. + */ +export type CallToolResult = Awaited> & { + /** Payment receipt if payment was made. */ + receipt: core_Mcp.Receipt | undefined +} + +/** + * Creates a payment-aware wrapper around an MCP SDK client. + * + * Similar to `Fetch.from()` for HTTP, this wraps an MCP client's `callTool` + * method to automatically handle payment challenges. + * + * @example + * ```ts + * import { Client } from '@modelcontextprotocol/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, { + * methods: [ + * tempo({ + * account: privateKeyToAccount('0x...'), + * }), + * ], + * }) + * + * // Automatically handles payment challenges + * const result = await mcp.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 callTool = createPaymentAwareCallTool(client.callTool.bind(client), config, { + handlePaymentRequiredMeta: false, + }) + + const wrappedCallTool = (( + 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 + + return callTool(params as CallToolParams, { + context: options?.context, + onPaymentRequired, + requestOptions: options?.timeout !== undefined ? { timeout: options.timeout } : undefined, + }) + }) as wrap.McpClient['callTool'] + + return { ...client, callTool: wrappedCallTool } +} + +/** 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 + /** Optional approval hook called before creating a payment credential. */ + onPaymentRequired?: OnPaymentRequired + } + + type McpClient< + client extends Pick = Pick, + methods extends readonly AnyClient[] = readonly AnyClient[], + > = Omit & { + /** Call a tool with automatic payment handling. */ + callTool: { + (params: CallToolParameters, options?: CallToolOptions): Promise + ( + onPaymentRequired: OnPaymentRequired | null | undefined, + params: CallToolParameters, + options?: CallToolOptions, + ): Promise + } + } + + type CallToolOptions = { + /** Context to pass to the method intent's createCredential. */ + context?: AnyContextFor + /** Request timeout in milliseconds. */ + timeout?: number + } +} + +/** + * Adds automatic payment handling to an MCP SDK client in place. + * + * Use this when another SDK owns the MCP client reference (e.g. Cloudflare + * Agents): the client is mutated and the same reference is returned, so + * surfaces that keep using the original client become payment-aware. The MCP + * SDK `callTool(params, resultSchema?, options?)` signature is preserved; pass + * a method's `context` via the options argument, where it is stripped before + * the remaining request options are forwarded to the SDK. Calling + * `wrapClient()` again replaces the payment configuration. + * + * @example + * ```ts + * import { tempo } from 'mppx/client' + * import { wrapClient } from 'mppx/mcp/client' + * + * wrapClient(client, { + * methods: [tempo({ account })], + * }) + * + * await client.callTool({ name: 'premium_tool', arguments: {} }) + * ``` + */ +export function wrapClient< + const client extends Pick, + const methods extends Methods, +>(client: client, config: wrapClient.Config): wrapClient.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, { + handlePaymentRequiredMeta: true, + }) + + Object.defineProperty(target, MPPX_MCP_CLIENT_WRAPPER, { + configurable: true, + value: originalCallTool, + }) + + Object.defineProperty(target, 'callTool', { + configurable: true, + enumerable: false, + value: ( + params: CallToolParams, + resultSchema?: CallToolResultSchema, + options?: wrapClient.CallToolOptions, + ) => { + const { context, ...requestOptions } = options ?? ({} as wrapClient.CallToolOptions) + return callTool(params, { + context, + onPaymentRequired: config.onPaymentRequired, + requestOptions: Object.keys(requestOptions).length + ? (requestOptions as CallToolRequestOptions) + : undefined, + resultSchema, + }) + }, + writable: true, + }) + + return target as unknown as wrapClient.McpClient +} + +export declare namespace wrapClient { + 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 Methods = DefaultMethods, + > = Omit & { + /** Call a tool with automatic payment handling. Preserves the MCP SDK signature. */ + callTool: ( + params: CallToolParams, + resultSchema?: CallToolResultSchema, + options?: CallToolOptions, + ) => Promise + } + + type CallToolOptions = CallToolRequestOptions & { + /** Context to pass to the method intent's createCredential. */ + context?: AnyContextForMethods + } +} + +/** + * Checks if an error is a payment required error. + */ +export function isPaymentRequiredError( + error: unknown, +): 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 + return isPaymentRequiredData((error as { data?: unknown }).data) +} + +/** @internal */ +async function createCredential( + challenge: Challenge.Challenge, + config: { + context?: unknown + methods: methods + }, +): Promise { + const { context, methods } = config + + const mi = methods.find((m) => m.name === challenge.method && m.intent === challenge.intent) + if (!mi) + throw new Error( + `No method found for "${challenge.method}.${challenge.intent}". Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`, + ) + + if (challenge.expires) Expires.assert(challenge.expires, challenge.id) + + const parsedContext = mi.context && context !== undefined ? mi.context.parse(context) : undefined + return mi.createCredential( + parsedContext !== undefined ? { challenge, context: parsedContext } : ({ challenge } as never), + ) +} + +/** Normalized per-call inputs shared by the `wrap` and `wrapClient` adapters. @internal */ +type CallToolCall = { + context?: unknown + onPaymentRequired?: OnPaymentRequired | undefined + requestOptions?: CallToolRequestOptions | undefined + resultSchema?: CallToolResultSchema | undefined +} + +function createPaymentAwareCallTool( + callTool: Client['callTool'], + config: wrapClient.Config, + options: { + /** + * Also retries payment challenges returned as successful tool results via + * `paymentRequiredMetaKey` metadata. Enabled for `wrapClient`; disabled for + * the released `wrap` API, which keeps its error-only retry behavior. + */ + handlePaymentRequiredMeta: boolean + }, +): (params: CallToolParams, call: CallToolCall) => Promise { + const { handlePaymentRequiredMeta } = options + 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 = handlePaymentRequiredMeta ? getPaymentRequiredMeta(result) : undefined + 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 { + if (typeof value !== 'object' || value === null) return false + const challenges = (value as { challenges?: unknown }).challenges + return Array.isArray(challenges) && challenges.length > 0 +} + +function withReceipt(result: Awaited>): CallToolResult { + return { + ...result, + receipt: result._meta?.[core_Mcp.receiptMetaKey] as core_Mcp.Receipt | undefined, + } +} + +/** 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-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 50% rename from src/mcp-sdk/client/index.ts rename to src/mcp/client/index.ts index 4a398fef..fc7c8b7e 100644 --- a/src/mcp-sdk/client/index.ts +++ b/src/mcp/client/index.ts @@ -1,2 +1,4 @@ export * as tempo from '../../tempo/client/index.js' +export type { CallToolResult } from './McpClient.js' +export { wrapClient } from './McpClient.js' export * as McpClient from './McpClient.js' 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'),