From 28137abdd3e6a8aeede8d476c302269a8823d5d3 Mon Sep 17 00:00:00 2001 From: Parv Ahuja <17094219+parvahuja@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:07:53 -0700 Subject: [PATCH] fix: handle MCP payment result metadata --- .changeset/mcp-result-payment-metadata.md | 5 ++ src/client/Transport.test.ts | 46 +++++++++++++++++ src/client/Transport.ts | 20 +++---- src/client/internal/Fetch.test.ts | 63 +++++++++++++++++++++++ src/client/internal/protocols/Mcp.ts | 54 +++++++++++++------ 5 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 .changeset/mcp-result-payment-metadata.md diff --git a/.changeset/mcp-result-payment-metadata.md b/.changeset/mcp-result-payment-metadata.md new file mode 100644 index 00000000..6e6451e8 --- /dev/null +++ b/.changeset/mcp-result-payment-metadata.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Fixed MCP payment-aware fetch retries for tool results with payment-required metadata. diff --git a/src/client/Transport.test.ts b/src/client/Transport.test.ts index e647e491..02c9a6b9 100644 --- a/src/client/Transport.test.ts +++ b/src/client/Transport.test.ts @@ -400,8 +400,24 @@ describe('http (MCP-over-HTTP)', () => { data: { challenges: [challenge] }, }, } + const resultMessage = { + jsonrpc: '2.0', + id: 1, + result: { + content: [{ type: 'text', text: 'Payment Required' }], + isError: true, + _meta: { + [Mcp.paymentRequiredMetaKey]: { + httpStatus: 402, + challenges: [challenge], + }, + }, + }, + } const jsonBody = () => new Response(JSON.stringify(errorMessage), { headers: { 'content-type': 'application/json' } }) + const jsonResultBody = () => + new Response(JSON.stringify(resultMessage), { headers: { 'content-type': 'application/json' } }) const sseBody = () => new Response(`event: message\ndata: ${JSON.stringify(errorMessage)}\n\n`, { headers: { 'content-type': 'text/event-stream' }, @@ -410,6 +426,9 @@ describe('http (MCP-over-HTTP)', () => { test('detects -32042 in a JSON body (JSON-RPC request)', async () => { expect(await Transport.http().isPaymentRequired(jsonBody(), jsonRpcRequest)).toBe(true) }) + test('detects payment-required metadata in a JSON-RPC result', async () => { + expect(await Transport.http().isPaymentRequired(jsonResultBody(), jsonRpcRequest)).toBe(true) + }) test('ignores a JSON-RPC response for a different request id', async () => { const response = new Response(JSON.stringify({ ...errorMessage, id: 2 }), { headers: { 'content-type': 'application/json' }, @@ -481,6 +500,10 @@ describe('http (MCP-over-HTTP)', () => { const challenges = await Transport.http().getChallenges!(sseBody(), jsonRpcRequest) expect(challenges.map((entry) => entry.id)).toEqual([challenge.id]) }) + test('getChallenges extracts an MCP result metadata challenge', async () => { + const challenges = await Transport.http().getChallenges!(jsonResultBody(), jsonRpcRequest) + expect(challenges.map((entry) => entry.id)).toEqual([challenge.id]) + }) test('setCredential routes the MCP challenge into the JSON-RPC _meta', async () => { const transport = Transport.http() const [mcpChallenge] = await transport.getChallenges!(sseBody(), jsonRpcRequest) @@ -551,6 +574,20 @@ describe('mcp', () => { }, }, } + const mcpResult: Mcp.Response = { + jsonrpc: '2.0', + id: 1, + result: { + content: [], + isError: true, + _meta: { + [Mcp.paymentRequiredMetaKey]: { + httpStatus: 402, + challenges: [challenge], + }, + }, + }, + } test('extracts payment-required challenges from JSON-RPC errors', async () => { const transport = Transport.mcp() @@ -561,6 +598,15 @@ describe('mcp', () => { ]) }) + test('extracts payment-required challenges from tool result metadata', async () => { + const transport = Transport.mcp() + + expect(await transport.isPaymentRequired(mcpResult)).toBe(true) + expect((await transport.getChallenges?.(mcpResult))?.map((entry) => entry.id)).toEqual([ + challenge.id, + ]) + }) + test('sets credentials in JSON-RPC _meta', () => { const result = Transport.mcp().setCredential(mcpRequest, Credential.serialize(credential)) diff --git a/src/client/Transport.ts b/src/client/Transport.ts index ebb43a01..5a554fbe 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -1,7 +1,7 @@ import * as Challenge from '../Challenge.js' import * as Credential from '../Credential.js' import * as Mcp from '../Mcp.js' -import { mcp as mcpProtocol } from './internal/protocols/Mcp.js' +import { mcp as mcpProtocol, paymentRequiredData } from './internal/protocols/Mcp.js' import { mpp as mppProtocol } from './internal/protocols/Mpp.js' import type { Protocol } from './internal/protocols/Protocol.js' import { paymentRequiredStatus } from './internal/protocols/Shared.js' @@ -144,6 +144,12 @@ export function http(): Transport { }) } +function mcpPaymentRequiredChallenges(response: Mcp.Response) { + const data = paymentRequiredData(response) + if (!data) throw new Error('No challenge in response.') + return data.challenges +} + /** * MCP protocol transport for direct JSON-RPC objects. * @@ -155,20 +161,16 @@ export function mcp() { name: 'mcp', isPaymentRequired(response) { - return 'error' in response && response.error?.code === Mcp.paymentRequiredCode + return !!paymentRequiredData(response) }, getChallenges(response) { - if (!('error' in response) || !response.error) throw new Error('Response is not an error.') - const challenges = response.error.data?.challenges - if (!challenges?.length) throw new Error('No challenge in error response.') - return challenges + return mcpPaymentRequiredChallenges(response) }, getChallenge(response) { - if (!('error' in response) || !response.error) throw new Error('Response is not an error.') - const challenge = response.error.data?.challenges[0] - if (!challenge) throw new Error('No challenge in error response.') + const challenge = mcpPaymentRequiredChallenges(response)[0] + if (!challenge) throw new Error('No challenge in response.') return challenge }, diff --git a/src/client/internal/Fetch.test.ts b/src/client/internal/Fetch.test.ts index 53ee931c..bde1f2d7 100644 --- a/src/client/internal/Fetch.test.ts +++ b/src/client/internal/Fetch.test.ts @@ -786,6 +786,69 @@ describe('Fetch.from: 402 retry path', () => { expect(calls).toHaveLength(2) }) + test('settles MCP-over-HTTP result metadata payment challenges at the fetch boundary', async () => { + const mcpChallenge = Challenge.from({ + id: 'mcp-result-challenge', + intent: 'test', + method: 'test', + realm: 'test', + request: { amount: '1' }, + }) + const method = { + ...noopMethod, + createCredential: async ({ challenge }: { challenge: Challenge.Challenge }) => + Credential.serialize({ challenge, payload: { source: 'mcp-result' } }), + } + const initialBody = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'paid-tool' }, + }) + let callCount = 0 + const calls: { init: RequestInit | undefined }[] = [] + const mockFetch: typeof globalThis.fetch = async (_input, init) => { + calls.push({ init }) + callCount++ + if (callCount === 1) + return Response.json({ + jsonrpc: '2.0', + id: 1, + result: { + content: [{ type: 'text', text: 'Payment Required' }], + isError: true, + _meta: { + [Mcp.paymentRequiredMetaKey]: { + httpStatus: 402, + challenges: [mcpChallenge], + }, + }, + }, + }) + + const body = JSON.parse(init?.body as string) + expect(new Headers(init?.headers).get('Authorization')).toBeNull() + expect(body.params['_meta'][Mcp.credentialMetaKey]).toMatchObject({ + payload: { source: 'mcp-result' }, + }) + return Response.json({ ok: true }) + } + + const fetch = Fetch.from({ + fetch: mockFetch, + methods: [method], + }) + + const response = await fetch('https://example.com/mcp', { + method: 'POST', + headers: { accept: 'application/json, text/event-stream' }, + body: initialBody, + }) + + expect(response.status).toBe(200) + expect(calls).toHaveLength(2) + }) + test('settles MCP-over-HTTP when the JSON-RPC request body is carried by Request input', async () => { const mcpChallenge = Challenge.from({ id: 'mcp-request-input-challenge', diff --git a/src/client/internal/protocols/Mcp.ts b/src/client/internal/protocols/Mcp.ts index 02dd9946..b4a398e5 100644 --- a/src/client/internal/protocols/Mcp.ts +++ b/src/client/internal/protocols/Mcp.ts @@ -21,6 +21,11 @@ function jsonRpcRequestId(body: unknown): number | string | undefined { const responseCache = new WeakMap>() +type CorePaymentRequiredData = NonNullable + +export type PaymentRequiredData = Pick & + Partial> + function mcpHttpRequestId(request?: RequestInit): number | string | undefined { const id = jsonRpcRequestId(request?.body) if (id === undefined) return undefined @@ -55,26 +60,45 @@ function parseMessage(value: unknown): Mcp.Response | undefined { : undefined } -function paymentRequiredChallenges( - message: Mcp.Response | undefined, - id: number | string, -): Challenge.Challenge[] { - if ( - !message || - message.id !== id || - !('error' in message) || - message.error?.code !== Mcp.paymentRequiredCode - ) - return [] - const challenges = message.error?.data?.challenges - if (!Array.isArray(challenges) || challenges.length === 0) return [] +function paymentRequiredDataFromValue(data: unknown): PaymentRequiredData | undefined { + if (!data || typeof data !== 'object') return undefined + const challenges = (data as { challenges?: unknown } | undefined)?.challenges + if (!Array.isArray(challenges) || challenges.length === 0) return undefined const parsed: Challenge.Challenge[] = [] for (const challenge of challenges) { const result = Challenge.Schema.safeParse(challenge) - if (!result.success) return [] + if (!result.success) return undefined parsed.push(result.data as Challenge.Challenge) } - return parsed + const { httpStatus, problem } = data as { + httpStatus?: unknown + problem?: PaymentRequiredData['problem'] + } + return { + challenges: parsed, + ...(typeof httpStatus === 'number' ? { httpStatus } : {}), + ...(problem !== undefined ? { problem } : {}), + } +} + +/** Extracts validated payment-required data from MCP errors or tool result metadata. */ +export function paymentRequiredData( + message: Mcp.Response | undefined, +): PaymentRequiredData | undefined { + if (!message) return undefined + if ('error' in message) { + if (message.error?.code !== Mcp.paymentRequiredCode) return undefined + return paymentRequiredDataFromValue(message.error.data) + } + return paymentRequiredDataFromValue(message.result?._meta?.[Mcp.paymentRequiredMetaKey]) +} + +function paymentRequiredChallenges( + message: Mcp.Response | undefined, + id: number | string, +): Challenge.Challenge[] { + if (!message || message.id !== id) return [] + return paymentRequiredData(message)?.challenges ?? [] } async function parseSseJsonRpcResponse(response: Response): Promise {