mppx 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/Errors.d.ts +13 -0
  3. package/dist/Errors.d.ts.map +1 -1
  4. package/dist/Errors.js +12 -0
  5. package/dist/Errors.js.map +1 -1
  6. package/dist/cli/cli.d.ts +2 -1
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +35 -4
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/plugins/tempo.d.ts +15 -0
  11. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  12. package/dist/cli/plugins/tempo.js +124 -14
  13. package/dist/cli/plugins/tempo.js.map +1 -1
  14. package/dist/client/Mppx.d.ts +2 -0
  15. package/dist/client/Mppx.d.ts.map +1 -1
  16. package/dist/client/Mppx.js +2 -1
  17. package/dist/client/Mppx.js.map +1 -1
  18. package/dist/client/Transport.d.ts.map +1 -1
  19. package/dist/client/Transport.js +11 -12
  20. package/dist/client/Transport.js.map +1 -1
  21. package/dist/client/internal/Fetch.d.ts +2 -0
  22. package/dist/client/internal/Fetch.d.ts.map +1 -1
  23. package/dist/client/internal/Fetch.js +55 -50
  24. package/dist/client/internal/Fetch.js.map +1 -1
  25. package/dist/client/internal/protocols/Mcp.d.ts +6 -0
  26. package/dist/client/internal/protocols/Mcp.d.ts.map +1 -1
  27. package/dist/client/internal/protocols/Mcp.js +28 -10
  28. package/dist/client/internal/protocols/Mcp.js.map +1 -1
  29. package/dist/client/internal/protocols/X402.d.ts.map +1 -1
  30. package/dist/client/internal/protocols/X402.js +18 -12
  31. package/dist/client/internal/protocols/X402.js.map +1 -1
  32. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  33. package/dist/stripe/server/internal/html.gen.js +1 -1
  34. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  35. package/dist/tempo/internal/fee-payer.js +57 -1
  36. package/dist/tempo/internal/fee-payer.js.map +1 -1
  37. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  38. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  39. package/dist/tempo/server/internal/html.gen.js +1 -1
  40. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  41. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  42. package/dist/tempo/session/client/SessionManager.js +47 -5
  43. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  44. package/dist/x402/Header.d.ts +10 -1
  45. package/dist/x402/Header.d.ts.map +1 -1
  46. package/dist/x402/Header.js +27 -1
  47. package/dist/x402/Header.js.map +1 -1
  48. package/package.json +3 -3
  49. package/src/Errors.test.ts +28 -0
  50. package/src/Errors.ts +19 -0
  51. package/src/cli/cli.test.ts +194 -0
  52. package/src/cli/cli.ts +43 -7
  53. package/src/cli/plugins/tempo.ts +160 -17
  54. package/src/client/Mppx.test-d.ts +9 -0
  55. package/src/client/Mppx.test.ts +41 -0
  56. package/src/client/Mppx.ts +4 -0
  57. package/src/client/Transport.test.ts +124 -0
  58. package/src/client/Transport.ts +11 -9
  59. package/src/client/internal/Fetch.test-d.ts +9 -0
  60. package/src/client/internal/Fetch.test.ts +186 -4
  61. package/src/client/internal/Fetch.ts +85 -76
  62. package/src/client/internal/protocols/Mcp.ts +39 -15
  63. package/src/client/internal/protocols/X402.ts +9 -5
  64. package/src/server/Mppx.test.ts +3 -0
  65. package/src/stripe/server/internal/html.gen.ts +1 -1
  66. package/src/tempo/internal/fee-payer.test.ts +106 -0
  67. package/src/tempo/internal/fee-payer.ts +71 -1
  68. package/src/tempo/server/internal/html/package.json +2 -2
  69. package/src/tempo/server/internal/html.gen.ts +1 -1
  70. package/src/tempo/session/client/SessionManager.test.ts +57 -4
  71. package/src/tempo/session/client/SessionManager.ts +50 -6
  72. package/src/x402/Header.ts +45 -0
@@ -2,6 +2,7 @@ import { Challenge, Credential, Errors, Mcp, Receipt } from 'mppx'
2
2
  import { tempo } from 'mppx/client'
3
3
  import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
4
4
  import { Header as x402_Header, Types as x402_Types, type PaymentRequired } from 'mppx/x402'
5
+ import { Base64 } from 'ox'
5
6
  import { createClient, defineChain } from 'viem'
6
7
  import { describe, expect, test, vi } from 'vp/test'
7
8
  import * as Http from '~test/Http.js'
@@ -351,7 +352,8 @@ const x402PaymentRequired = {
351
352
  } satisfies PaymentRequired
352
353
 
353
354
  /** Builds a valid 402 response with a WWW-Authenticate header. */
354
- function make402(overrides?: { expires?: string; intent?: string; method?: string }) {
355
+ function make402(overrides?: { expires?: string; id?: string; intent?: string; method?: string }) {
356
+ const id = overrides?.id ?? 'abc'
355
357
  const method = overrides?.method ?? 'test'
356
358
  const intent = overrides?.intent ?? 'test'
357
359
  const expires = overrides?.expires ? `, expires="${overrides.expires}"` : ''
@@ -359,7 +361,7 @@ function make402(overrides?: { expires?: string; intent?: string; method?: strin
359
361
  .replace(/\+/g, '-')
360
362
  .replace(/\//g, '_')
361
363
  .replace(/=+$/, '')
362
- const header = `Payment id="abc", realm="test", method="${method}", intent="${intent}", request="${request}"${expires}`
364
+ const header = `Payment id="${id}", realm="test", method="${method}", intent="${intent}", request="${request}"${expires}`
363
365
  return new Response(null, {
364
366
  status: 402,
365
367
  headers: { 'WWW-Authenticate': header },
@@ -379,6 +381,11 @@ function makeCombined402() {
379
381
  })
380
382
  }
381
383
 
384
+ // Strict x402 encoding rejects unsupported accepts before the transport can filter them.
385
+ function encodeRawPaymentRequiredHeader(value: unknown): string {
386
+ return Base64.fromString(JSON.stringify(value))
387
+ }
388
+
382
389
  describe('Fetch.from: init passthrough (non-402)', () => {
383
390
  test('preserves init object identity while adding Accept-Payment', async () => {
384
391
  const receivedInits: (RequestInit | undefined)[] = []
@@ -780,6 +787,69 @@ describe('Fetch.from: 402 retry path', () => {
780
787
  expect(calls).toHaveLength(2)
781
788
  })
782
789
 
790
+ test('settles MCP-over-HTTP result metadata payment challenges at the fetch boundary', async () => {
791
+ const mcpChallenge = Challenge.from({
792
+ id: 'mcp-result-challenge',
793
+ intent: 'test',
794
+ method: 'test',
795
+ realm: 'test',
796
+ request: { amount: '1' },
797
+ })
798
+ const method = {
799
+ ...noopMethod,
800
+ createCredential: async ({ challenge }: { challenge: Challenge.Challenge }) =>
801
+ Credential.serialize({ challenge, payload: { source: 'mcp-result' } }),
802
+ }
803
+ const initialBody = JSON.stringify({
804
+ jsonrpc: '2.0',
805
+ id: 1,
806
+ method: 'tools/call',
807
+ params: { name: 'paid-tool' },
808
+ })
809
+ let callCount = 0
810
+ const calls: { init: RequestInit | undefined }[] = []
811
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
812
+ calls.push({ init })
813
+ callCount++
814
+ if (callCount === 1)
815
+ return Response.json({
816
+ jsonrpc: '2.0',
817
+ id: 1,
818
+ result: {
819
+ content: [{ type: 'text', text: 'Payment Required' }],
820
+ isError: true,
821
+ _meta: {
822
+ [Mcp.paymentRequiredMetaKey]: {
823
+ httpStatus: 402,
824
+ challenges: [mcpChallenge],
825
+ },
826
+ },
827
+ },
828
+ })
829
+
830
+ const body = JSON.parse(init?.body as string)
831
+ expect(new Headers(init?.headers).get('Authorization')).toBeNull()
832
+ expect(body.params['_meta'][Mcp.credentialMetaKey]).toMatchObject({
833
+ payload: { source: 'mcp-result' },
834
+ })
835
+ return Response.json({ ok: true })
836
+ }
837
+
838
+ const fetch = Fetch.from({
839
+ fetch: mockFetch,
840
+ methods: [method],
841
+ })
842
+
843
+ const response = await fetch('https://example.com/mcp', {
844
+ method: 'POST',
845
+ headers: { accept: 'application/json, text/event-stream' },
846
+ body: initialBody,
847
+ })
848
+
849
+ expect(response.status).toBe(200)
850
+ expect(calls).toHaveLength(2)
851
+ })
852
+
783
853
  test('settles MCP-over-HTTP when the JSON-RPC request body is carried by Request input', async () => {
784
854
  const mcpChallenge = Challenge.from({
785
855
  id: 'mcp-request-input-challenge',
@@ -1356,6 +1426,63 @@ describe('Fetch.from: 402 retry path', () => {
1356
1426
  expect(response.status).toBe(200)
1357
1427
  })
1358
1428
 
1429
+ test('ignores unsupported x402 accepts when selecting a Payment auth challenge', async () => {
1430
+ let callCount = 0
1431
+ const createCredential = vi.fn(async () => 'tempo-credential')
1432
+ const tempoChallenge = Challenge.from({
1433
+ id: 'tempo',
1434
+ realm: 'test',
1435
+ method: 'tempo',
1436
+ intent: 'charge',
1437
+ request: { amount: '1' },
1438
+ })
1439
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
1440
+ callCount++
1441
+ if (callCount === 1) {
1442
+ return new Response(null, {
1443
+ status: 402,
1444
+ headers: {
1445
+ [x402_Types.paymentRequiredHeader]: encodeRawPaymentRequiredHeader({
1446
+ accepts: [
1447
+ {
1448
+ amount: '10000',
1449
+ asset: 'USDC',
1450
+ maxTimeoutSeconds: 60,
1451
+ network: 'solana:mainnet',
1452
+ payTo: '9xQeWvG816bUx9EPjHmaT23yvVM2ZWdYqPxfowV5n2kg',
1453
+ scheme: 'exact',
1454
+ },
1455
+ ],
1456
+ resource: { url: 'https://example.com/api' },
1457
+ x402Version: 2,
1458
+ }),
1459
+ 'WWW-Authenticate': Challenge.serialize(tempoChallenge),
1460
+ },
1461
+ })
1462
+ }
1463
+
1464
+ expect(new Headers(init?.headers).get('Authorization')).toBe('tempo-credential')
1465
+ return new Response('OK', { status: 200 })
1466
+ }
1467
+
1468
+ const fetch = Fetch.from({
1469
+ fetch: mockFetch,
1470
+ methods: [
1471
+ {
1472
+ name: 'tempo',
1473
+ intent: 'charge',
1474
+ context: undefined,
1475
+ createCredential,
1476
+ },
1477
+ ] as const,
1478
+ })
1479
+
1480
+ const response = await fetch('https://example.com/api')
1481
+
1482
+ expect(response.status).toBe(200)
1483
+ expect(createCredential).toHaveBeenCalledOnce()
1484
+ })
1485
+
1359
1486
  test('orderChallenges filters and sorts supported challenges before signing', async () => {
1360
1487
  let callCount = 0
1361
1488
  const pathUsd = Challenge.from({
@@ -1544,8 +1671,42 @@ describe('Fetch.from: 402 retry path', () => {
1544
1671
  )
1545
1672
  })
1546
1673
 
1547
- test('retries exactly once does not loop on repeated 402', async () => {
1674
+ test('creates a fresh credential for each incremental 402 challenge', async () => {
1675
+ const createCredential = vi.fn(
1676
+ async ({ challenge }: { challenge: Challenge.Challenge }) => `credential-${challenge.id}`,
1677
+ )
1678
+ const calls: { init: RequestInit | undefined }[] = []
1679
+ let callCount = 0
1680
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
1681
+ calls.push({ init })
1682
+ callCount++
1683
+ if (callCount === 1) return make402({ id: 'first' })
1684
+ if (callCount === 2) return make402({ id: 'second' })
1685
+ if (callCount === 3) return make402({ id: 'third' })
1686
+ return new Response('OK', { status: 200 })
1687
+ }
1688
+
1689
+ const fetch = Fetch.from({
1690
+ fetch: mockFetch,
1691
+ methods: [{ ...noopMethod, createCredential }],
1692
+ })
1693
+
1694
+ const response = await fetch('https://example.com/api')
1695
+
1696
+ expect(response.status).toBe(200)
1697
+ expect(createCredential.mock.calls.map((call) => call[0].challenge.id)).toEqual([
1698
+ 'first',
1699
+ 'second',
1700
+ 'third',
1701
+ ])
1702
+ expect(
1703
+ calls.slice(1).map((call) => new Headers(call.init?.headers).get('Authorization')),
1704
+ ).toEqual(['credential-first', 'credential-second', 'credential-third'])
1705
+ })
1706
+
1707
+ test('caps repeated 402 retries at three', async () => {
1548
1708
  let callCount = 0
1709
+ const createCredential = vi.fn(async () => 'credential')
1549
1710
  const mockFetch: typeof globalThis.fetch = async () => {
1550
1711
  callCount++
1551
1712
  return make402()
@@ -1553,11 +1714,32 @@ describe('Fetch.from: 402 retry path', () => {
1553
1714
 
1554
1715
  const fetch = Fetch.from({
1555
1716
  fetch: mockFetch,
1556
- methods: [noopMethod],
1717
+ methods: [{ ...noopMethod, createCredential }],
1718
+ })
1719
+
1720
+ const response = await fetch('https://example.com/api')
1721
+ expect(callCount).toBe(4)
1722
+ expect(createCredential).toHaveBeenCalledTimes(3)
1723
+ expect(response.status).toBe(402)
1724
+ })
1725
+
1726
+ test('respects configured retry cap', async () => {
1727
+ let callCount = 0
1728
+ const createCredential = vi.fn(async () => 'credential')
1729
+ const mockFetch: typeof globalThis.fetch = async () => {
1730
+ callCount++
1731
+ return make402()
1732
+ }
1733
+
1734
+ const fetch = Fetch.from({
1735
+ fetch: mockFetch,
1736
+ maxPaymentRetries: 1,
1737
+ methods: [{ ...noopMethod, createCredential }],
1557
1738
  })
1558
1739
 
1559
1740
  const response = await fetch('https://example.com/api')
1560
1741
  expect(callCount).toBe(2)
1742
+ expect(createCredential).toHaveBeenCalledOnce()
1561
1743
  expect(response.status).toBe(402)
1562
1744
  })
1563
1745
  })
@@ -11,6 +11,7 @@ import * as Transport from '../Transport.js'
11
11
  // even across multiple module instances/bundles. This lets restore() avoid clobbering
12
12
  // an unrelated fetch installed by user code or another library.
13
13
  const MPPX_FETCH_WRAPPER = Symbol.for('mppx.fetch.wrapper')
14
+ const defaultMaxPaymentRetries = 3
14
15
 
15
16
  type WrappedFetch = typeof globalThis.fetch & {
16
17
  [MPPX_FETCH_WRAPPER]?: typeof globalThis.fetch
@@ -161,6 +162,7 @@ export function from<const methods extends readonly Method.AnyClient[]>(
161
162
  acceptPayment,
162
163
  acceptPaymentPolicy = 'always',
163
164
  fetch = globalThis.fetch,
165
+ maxPaymentRetries = defaultMaxPaymentRetries,
164
166
  methods,
165
167
  onChallenge,
166
168
  orderChallenges,
@@ -185,7 +187,7 @@ export function from<const methods extends readonly Method.AnyClient[]>(
185
187
  hasExplicitAcceptPayment,
186
188
  acceptPaymentPolicy,
187
189
  )
188
- const response = await baseFetch(cloneRequestInput(initialRequest.input), initialRequest.init)
190
+ let response = await baseFetch(cloneRequestInput(initialRequest.input), initialRequest.init)
189
191
  const transportRequest = withCapturedBody(initialRequest.init, await capturedBody)
190
192
 
191
193
  if (!(await transport.isPaymentRequired(response, transportRequest as never))) return response
@@ -203,91 +205,96 @@ export function from<const methods extends readonly Method.AnyClient[]>(
203
205
  let mi: methods[number] | undefined
204
206
 
205
207
  try {
206
- challenges = transport.getChallenges
207
- ? await transport.getChallenges(response, transportRequest as never)
208
- : [await transport.getChallenge(response, transportRequest as never)]
209
-
210
- const candidates = AcceptPayment.selectChallengeCandidates(
211
- challenges,
212
- methods,
213
- paymentPreferences.entries,
214
- )
215
- const orderedCandidates = await resolveChallengeOrder(
216
- candidates,
217
- (requestOrderChallenges as AcceptPayment.OrderChallenges<methods> | undefined) ??
218
- orderChallenges,
219
- )
220
- const selected = orderedCandidates[0]
221
- if (!selected)
222
- throw new Error(
223
- `No method found for challenges: ${challenges.map((c) => `${c.method}.${c.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
224
- )
225
-
226
- const selectedChallenge = selected.challenge
227
- challenge = selectedChallenge
228
- mi = selected.method
229
- if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
208
+ for (let retry = 0; retry < maxPaymentRetries; retry++) {
209
+ challenges = transport.getChallenges
210
+ ? await transport.getChallenges(response, transportRequest as never)
211
+ : [await transport.getChallenge(response, transportRequest as never)]
230
212
 
231
- const createCredential = memoizeCreateCredential((overrideContext?: AnyContextFor<methods>) =>
232
- resolveCredential(selectedChallenge, selected.method, overrideContext ?? context),
233
- )
234
- const eventCredential = await events.emit(
235
- 'challenge.received',
236
- createChallengeReceivedPayload({
237
- challenge: selectedChallenge,
213
+ const candidates = AcceptPayment.selectChallengeCandidates(
238
214
  challenges,
239
- createCredential,
240
- init,
241
- input,
242
- method: selected.method,
243
- response,
244
- }),
245
- )
246
- const onChallengeCredential =
247
- eventCredential ??
248
- (onChallenge
249
- ? await onChallenge(challenge, {
250
- createCredential,
251
- })
252
- : undefined)
253
- const credential = onChallengeCredential ?? (await createCredential())
254
- validateCredentialHeaderValue(credential)
255
- await events.emit(
256
- 'credential.created',
257
- createCredentialCreatedPayload({
258
- challenge: selectedChallenge,
259
- credential,
260
- init,
261
- input,
262
- method: selected.method,
263
- response,
264
- }),
265
- )
266
-
267
- const paymentResponse = await baseFetch(
268
- resolvePaymentRetryInput(response, initialRequest.input, initialRequest.input),
269
- transport.setCredential(
270
- {
271
- ...fetchInit,
272
- headers: initialRequest.headers,
273
- },
274
- credential,
275
- { challenge: selectedChallenge },
276
- ),
277
- )
278
- if (paymentResponse.ok)
215
+ methods,
216
+ paymentPreferences.entries,
217
+ )
218
+ const orderedCandidates = await resolveChallengeOrder(
219
+ candidates,
220
+ (requestOrderChallenges as AcceptPayment.OrderChallenges<methods> | undefined) ??
221
+ orderChallenges,
222
+ )
223
+ const selected = orderedCandidates[0]
224
+ if (!selected)
225
+ throw new Error(
226
+ `No method found for challenges: ${challenges.map((c) => `${c.method}.${c.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
227
+ )
228
+
229
+ const selectedChallenge = selected.challenge
230
+ challenge = selectedChallenge
231
+ mi = selected.method
232
+ if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
233
+
234
+ const createCredential = memoizeCreateCredential(
235
+ (overrideContext?: AnyContextFor<methods>) =>
236
+ resolveCredential(selectedChallenge, selected.method, overrideContext ?? context),
237
+ )
238
+ const eventCredential = await events.emit(
239
+ 'challenge.received',
240
+ createChallengeReceivedPayload({
241
+ challenge: selectedChallenge,
242
+ challenges,
243
+ createCredential,
244
+ init,
245
+ input,
246
+ method: selected.method,
247
+ response,
248
+ }),
249
+ )
250
+ const onChallengeCredential =
251
+ eventCredential ??
252
+ (onChallenge
253
+ ? await onChallenge(challenge, {
254
+ createCredential,
255
+ })
256
+ : undefined)
257
+ const credential = onChallengeCredential ?? (await createCredential())
258
+ validateCredentialHeaderValue(credential)
279
259
  await events.emit(
280
- 'payment.response',
281
- createPaymentResponsePayload({
260
+ 'credential.created',
261
+ createCredentialCreatedPayload({
282
262
  challenge: selectedChallenge,
283
263
  credential,
284
264
  init,
285
265
  input,
286
266
  method: selected.method,
287
- response: paymentResponse,
267
+ response,
288
268
  }),
289
269
  )
290
- return paymentResponse
270
+
271
+ response = await baseFetch(
272
+ resolvePaymentRetryInput(response, initialRequest.input, initialRequest.input),
273
+ transport.setCredential(
274
+ {
275
+ ...fetchInit,
276
+ headers: initialRequest.headers,
277
+ },
278
+ credential,
279
+ { challenge: selectedChallenge },
280
+ ),
281
+ )
282
+ if (response.ok)
283
+ await events.emit(
284
+ 'payment.response',
285
+ createPaymentResponsePayload({
286
+ challenge: selectedChallenge,
287
+ credential,
288
+ init,
289
+ input,
290
+ method: selected.method,
291
+ response,
292
+ }),
293
+ )
294
+ if (!(await transport.isPaymentRequired(response, transportRequest as never)))
295
+ return response
296
+ }
297
+ return response
291
298
  } catch (error) {
292
299
  await events.emit(
293
300
  'payment.failed',
@@ -335,6 +342,8 @@ export declare namespace from {
335
342
  fetch?: typeof globalThis.fetch
336
343
  /** Advanced shared event dispatcher. `challenge.received` handlers run before `onChallenge`; the first non-empty credential returned by a handler skips `onChallenge`. */
337
344
  eventDispatcher?: ClientEventDispatcher<methods, any> | undefined
345
+ /** Maximum number of payment challenge retries after the initial response. @default 3 */
346
+ maxPaymentRetries?: number | undefined
338
347
  /** Array of methods to use. */
339
348
  methods: methods
340
349
  /** Called when a 402 challenge is received and no event handler supplies a credential. */
@@ -21,6 +21,11 @@ function jsonRpcRequestId(body: unknown): number | string | undefined {
21
21
 
22
22
  const responseCache = new WeakMap<Response, Promise<Mcp.Response | undefined>>()
23
23
 
24
+ type CorePaymentRequiredData = NonNullable<Mcp.ErrorObject['data']>
25
+
26
+ export type PaymentRequiredData = Pick<CorePaymentRequiredData, 'challenges'> &
27
+ Partial<Pick<CorePaymentRequiredData, 'httpStatus' | 'problem'>>
28
+
24
29
  function mcpHttpRequestId(request?: RequestInit): number | string | undefined {
25
30
  const id = jsonRpcRequestId(request?.body)
26
31
  if (id === undefined) return undefined
@@ -55,26 +60,45 @@ function parseMessage(value: unknown): Mcp.Response | undefined {
55
60
  : undefined
56
61
  }
57
62
 
58
- function paymentRequiredChallenges(
59
- message: Mcp.Response | undefined,
60
- id: number | string,
61
- ): Challenge.Challenge[] {
62
- if (
63
- !message ||
64
- message.id !== id ||
65
- !('error' in message) ||
66
- message.error?.code !== Mcp.paymentRequiredCode
67
- )
68
- return []
69
- const challenges = message.error?.data?.challenges
70
- if (!Array.isArray(challenges) || challenges.length === 0) return []
63
+ function paymentRequiredDataFromValue(data: unknown): PaymentRequiredData | undefined {
64
+ if (!data || typeof data !== 'object') return undefined
65
+ const challenges = (data as { challenges?: unknown } | undefined)?.challenges
66
+ if (!Array.isArray(challenges) || challenges.length === 0) return undefined
71
67
  const parsed: Challenge.Challenge[] = []
72
68
  for (const challenge of challenges) {
73
69
  const result = Challenge.Schema.safeParse(challenge)
74
- if (!result.success) return []
70
+ if (!result.success) return undefined
75
71
  parsed.push(result.data as Challenge.Challenge)
76
72
  }
77
- return parsed
73
+ const { httpStatus, problem } = data as {
74
+ httpStatus?: unknown
75
+ problem?: PaymentRequiredData['problem']
76
+ }
77
+ return {
78
+ challenges: parsed,
79
+ ...(typeof httpStatus === 'number' ? { httpStatus } : {}),
80
+ ...(problem !== undefined ? { problem } : {}),
81
+ }
82
+ }
83
+
84
+ /** Extracts validated payment-required data from MCP errors or tool result metadata. */
85
+ export function paymentRequiredData(
86
+ message: Mcp.Response | undefined,
87
+ ): PaymentRequiredData | undefined {
88
+ if (!message) return undefined
89
+ if ('error' in message) {
90
+ if (message.error?.code !== Mcp.paymentRequiredCode) return undefined
91
+ return paymentRequiredDataFromValue(message.error.data)
92
+ }
93
+ return paymentRequiredDataFromValue(message.result?._meta?.[Mcp.paymentRequiredMetaKey])
94
+ }
95
+
96
+ function paymentRequiredChallenges(
97
+ message: Mcp.Response | undefined,
98
+ id: number | string,
99
+ ): Challenge.Challenge[] {
100
+ if (!message || message.id !== id) return []
101
+ return paymentRequiredData(message)?.challenges ?? []
78
102
  }
79
103
 
80
104
  async function parseSseJsonRpcResponse(response: Response): Promise<Mcp.Response | undefined> {
@@ -16,11 +16,15 @@ export function x402(): Protocol {
16
16
  if (response.status !== paymentRequiredStatus) return []
17
17
  const header = response.headers.get(x402_Types.paymentRequiredHeader)
18
18
  if (!header) return []
19
- const paymentRequired = x402_Header.decodePaymentRequired(header)
19
+ const paymentRequired = x402_Header.decodePaymentRequiredEnvelope(header)
20
20
  if (response.url && paymentRequired.resource.url !== response.url)
21
21
  throw new Error('x402 payment-required resource does not match response URL.')
22
- return paymentRequired.accepts.map((accepted, index) =>
23
- x402_ChallengeBrand.mark(
22
+ return paymentRequired.accepts.flatMap((rawAccepted, index) => {
23
+ const parsed = x402_Types.PaymentRequirementsSchema.safeParse(rawAccepted)
24
+ if (!parsed.success) return []
25
+
26
+ const accepted = parsed.data
27
+ return x402_ChallengeBrand.mark(
24
28
  Challenge.from({
25
29
  id: `${x402_Types.syntheticChallengeIdPrefix}${index}`,
26
30
  intent: x402_Types.exactIntent,
@@ -32,8 +36,8 @@ export function x402(): Protocol {
32
36
  resource: paymentRequired.resource,
33
37
  },
34
38
  }),
35
- ),
36
- )
39
+ )
40
+ })
37
41
  },
38
42
  setCredential(request, credential) {
39
43
  return setCredentialHeader(request, x402_Types.paymentSignatureHeader, credential)
@@ -87,6 +87,7 @@ describe('request handler', () => {
87
87
  {
88
88
  "challengeId": "[challengeId]",
89
89
  "detail": "Payment is required.",
90
+ "hint": "Use a supported wallet to pay for this resource using one of the supported payment methods returned in the WWW-Authenticate header. See https://mpp.dev/tools/wallet.md",
90
91
  "instance": "[instance]",
91
92
  "status": 402,
92
93
  "title": "Payment Required",
@@ -119,6 +120,7 @@ describe('request handler', () => {
119
120
  {
120
121
  "challengeId": "[challengeId]",
121
122
  "detail": "Credential is malformed: Invalid base64url or JSON..",
123
+ "hint": "Use a supported wallet to construct valid credentials for one of the supported payment methods returned in the WWW-Authenticate header. See https://mpp.dev/tools/wallet.md",
122
124
  "instance": "[instance]",
123
125
  "status": 402,
124
126
  "title": "Malformed Credential",
@@ -806,6 +808,7 @@ describe('request handler (node)', () => {
806
808
  {
807
809
  "challengeId": "[challengeId]",
808
810
  "detail": "Payment is required.",
811
+ "hint": "Use a supported wallet to pay for this resource using one of the supported payment methods returned in the WWW-Authenticate header. See https://mpp.dev/tools/wallet.md",
809
812
  "instance": "[instance]",
810
813
  "status": 402,
811
814
  "title": "Payment Required",