Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/mcp-result-payment-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Fixed MCP payment-aware fetch retries for tool results with payment-required metadata.
46 changes: 46 additions & 0 deletions src/client/Transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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' },
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -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))

Expand Down
20 changes: 11 additions & 9 deletions src/client/Transport.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -144,6 +144,12 @@ export function http(): Transport<RequestInit, Response> {
})
}

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.
*
Expand All @@ -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
},

Expand Down
63 changes: 63 additions & 0 deletions src/client/internal/Fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
54 changes: 39 additions & 15 deletions src/client/internal/protocols/Mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@

const responseCache = new WeakMap<Response, Promise<Mcp.Response | undefined>>()

type CorePaymentRequiredData = NonNullable<Mcp.ErrorObject['data']>

export type PaymentRequiredData = Pick<CorePaymentRequiredData, 'challenges'> &
Partial<Pick<CorePaymentRequiredData, 'httpStatus' | 'problem'>>

function mcpHttpRequestId(request?: RequestInit): number | string | undefined {
const id = jsonRpcRequestId(request?.body)
if (id === undefined) return undefined
Expand Down Expand Up @@ -55,26 +60,45 @@
: 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])

Check warning on line 93 in src/client/internal/protocols/Mcp.ts

View workflow job for this annotation

GitHub Actions / Verify / Checks

eslint(no-underscore-dangle)

Unexpected dangling '_' in '`_meta`'.

Check warning on line 93 in src/client/internal/protocols/Mcp.ts

View workflow job for this annotation

GitHub Actions / Verify / Checks

eslint(no-underscore-dangle)

Unexpected dangling '_' in '`_meta`'.
}

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<Mcp.Response | undefined> {
Expand Down
Loading