mppx 0.5.11 → 0.5.13

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 (123) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +41 -16
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/config.d.ts +6 -4
  6. package/dist/cli/config.d.ts.map +1 -1
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/internal.d.ts +8 -0
  9. package/dist/cli/internal.d.ts.map +1 -1
  10. package/dist/cli/internal.js +33 -3
  11. package/dist/cli/internal.js.map +1 -1
  12. package/dist/cli/plugins/plugin.d.ts +2 -0
  13. package/dist/cli/plugins/plugin.d.ts.map +1 -1
  14. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  15. package/dist/cli/plugins/stripe.js +3 -0
  16. package/dist/cli/plugins/stripe.js.map +1 -1
  17. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  18. package/dist/cli/plugins/tempo.js +3 -0
  19. package/dist/cli/plugins/tempo.js.map +1 -1
  20. package/dist/client/Mppx.d.ts +10 -1
  21. package/dist/client/Mppx.d.ts.map +1 -1
  22. package/dist/client/Mppx.js +17 -5
  23. package/dist/client/Mppx.js.map +1 -1
  24. package/dist/client/Transport.d.ts +2 -0
  25. package/dist/client/Transport.d.ts.map +1 -1
  26. package/dist/client/Transport.js +11 -0
  27. package/dist/client/Transport.js.map +1 -1
  28. package/dist/client/internal/Fetch.d.ts +3 -0
  29. package/dist/client/internal/Fetch.d.ts.map +1 -1
  30. package/dist/client/internal/Fetch.js +65 -19
  31. package/dist/client/internal/Fetch.js.map +1 -1
  32. package/dist/internal/AcceptPayment.d.ts +72 -0
  33. package/dist/internal/AcceptPayment.d.ts.map +1 -0
  34. package/dist/internal/AcceptPayment.js +185 -0
  35. package/dist/internal/AcceptPayment.js.map +1 -0
  36. package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
  37. package/dist/mcp-sdk/client/McpClient.js +8 -4
  38. package/dist/mcp-sdk/client/McpClient.js.map +1 -1
  39. package/dist/server/Mppx.d.ts +1 -1
  40. package/dist/server/Mppx.d.ts.map +1 -1
  41. package/dist/server/Mppx.js +33 -24
  42. package/dist/server/Mppx.js.map +1 -1
  43. package/dist/server/internal/html/config.d.ts.map +1 -1
  44. package/dist/server/internal/html/config.js +8 -1
  45. package/dist/server/internal/html/config.js.map +1 -1
  46. package/dist/stripe/internal/constants.d.ts +8 -0
  47. package/dist/stripe/internal/constants.d.ts.map +1 -0
  48. package/dist/stripe/internal/constants.js +8 -0
  49. package/dist/stripe/internal/constants.js.map +1 -0
  50. package/dist/stripe/server/Charge.d.ts.map +1 -1
  51. package/dist/stripe/server/Charge.js +23 -5
  52. package/dist/stripe/server/Charge.js.map +1 -1
  53. package/dist/tempo/Methods.d.ts +8 -0
  54. package/dist/tempo/Methods.d.ts.map +1 -1
  55. package/dist/tempo/Methods.js +6 -2
  56. package/dist/tempo/Methods.js.map +1 -1
  57. package/dist/tempo/Proof.d.ts +12 -0
  58. package/dist/tempo/Proof.d.ts.map +1 -0
  59. package/dist/tempo/Proof.js +10 -0
  60. package/dist/tempo/Proof.js.map +1 -0
  61. package/dist/tempo/client/Charge.d.ts +11 -1
  62. package/dist/tempo/client/Charge.d.ts.map +1 -1
  63. package/dist/tempo/client/Charge.js +14 -2
  64. package/dist/tempo/client/Charge.js.map +1 -1
  65. package/dist/tempo/client/Methods.d.ts +6 -0
  66. package/dist/tempo/client/Methods.d.ts.map +1 -1
  67. package/dist/tempo/index.d.ts +1 -0
  68. package/dist/tempo/index.d.ts.map +1 -1
  69. package/dist/tempo/index.js +1 -0
  70. package/dist/tempo/index.js.map +1 -1
  71. package/dist/tempo/internal/fee-payer.d.ts +8 -0
  72. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  73. package/dist/tempo/internal/fee-payer.js +29 -3
  74. package/dist/tempo/internal/fee-payer.js.map +1 -1
  75. package/dist/tempo/server/Charge.d.ts +17 -0
  76. package/dist/tempo/server/Charge.d.ts.map +1 -1
  77. package/dist/tempo/server/Charge.js +69 -4
  78. package/dist/tempo/server/Charge.js.map +1 -1
  79. package/dist/tempo/server/Methods.d.ts +6 -0
  80. package/dist/tempo/server/Methods.d.ts.map +1 -1
  81. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  82. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  83. package/dist/tempo/server/internal/html.gen.js +1 -1
  84. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  85. package/package.json +2 -2
  86. package/src/cli/cli.test.ts +278 -0
  87. package/src/cli/cli.ts +47 -16
  88. package/src/cli/config.ts +10 -4
  89. package/src/cli/internal.ts +59 -3
  90. package/src/cli/plugins/plugin.ts +3 -0
  91. package/src/cli/plugins/stripe.ts +3 -0
  92. package/src/cli/plugins/tempo.ts +3 -0
  93. package/src/client/Mppx.test-d.ts +33 -0
  94. package/src/client/Mppx.test.ts +130 -1
  95. package/src/client/Mppx.ts +35 -5
  96. package/src/client/Transport.test.ts +88 -55
  97. package/src/client/Transport.ts +13 -0
  98. package/src/client/internal/Fetch.browser.test.ts +16 -13
  99. package/src/client/internal/Fetch.test.ts +307 -10
  100. package/src/client/internal/Fetch.ts +85 -19
  101. package/src/internal/AcceptPayment.test.ts +211 -0
  102. package/src/internal/AcceptPayment.ts +304 -0
  103. package/src/mcp-sdk/client/McpClient.ts +11 -5
  104. package/src/server/Mppx.test.ts +141 -44
  105. package/src/server/Mppx.ts +43 -23
  106. package/src/server/Transport.test.ts +20 -0
  107. package/src/server/internal/html/config.ts +9 -1
  108. package/src/stripe/internal/constants.ts +7 -0
  109. package/src/stripe/server/Charge.ts +22 -4
  110. package/src/tempo/Methods.test.ts +25 -0
  111. package/src/tempo/Methods.ts +30 -22
  112. package/src/tempo/Proof.test-d.ts +13 -0
  113. package/src/tempo/Proof.test.ts +31 -0
  114. package/src/tempo/Proof.ts +13 -0
  115. package/src/tempo/client/Charge.ts +20 -6
  116. package/src/tempo/client/SessionManager.test.ts +4 -7
  117. package/src/tempo/index.ts +1 -0
  118. package/src/tempo/internal/fee-payer.test.ts +75 -1
  119. package/src/tempo/internal/fee-payer.ts +41 -3
  120. package/src/tempo/server/Charge.test.ts +309 -1
  121. package/src/tempo/server/Charge.ts +99 -1
  122. package/src/tempo/server/internal/html/main.ts +2 -2
  123. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -130,7 +130,7 @@ describe('createCredential', () => {
130
130
  })
131
131
 
132
132
  await expect(mppx.createCredential(response)).rejects.toThrow(
133
- 'No method found for "unknown.charge". Available: tempo.charge, tempo.session',
133
+ 'No method found for challenges: unknown.charge. Available: tempo.charge, tempo.session',
134
134
  )
135
135
  })
136
136
 
@@ -187,6 +187,135 @@ describe('createCredential', () => {
187
187
  expect(parsed.challenge.method).toBe('stripe')
188
188
  })
189
189
 
190
+ test('behavior: selects the preferred challenge from a multi-challenge response', async () => {
191
+ const stripeCharge = Method.from({
192
+ name: 'stripe',
193
+ intent: 'charge',
194
+ schema: {
195
+ credential: {
196
+ payload: Methods.charge.schema.credential.payload,
197
+ },
198
+ request: Methods.charge.schema.request,
199
+ },
200
+ })
201
+
202
+ const stripe = Method.toClient(stripeCharge, {
203
+ async createCredential({ challenge }) {
204
+ return Credential.serialize({
205
+ challenge,
206
+ payload: { signature: '0xstripe', type: 'transaction' },
207
+ })
208
+ },
209
+ })
210
+
211
+ const mppx = Mppx.create({
212
+ polyfill: false,
213
+ methods: [tempo({ account: accounts[1], getClient: () => client }), stripe],
214
+ paymentPreferences: ({ stripe }) => ({
215
+ [stripe.charge]: 0.5,
216
+ }),
217
+ })
218
+
219
+ const tempoChallenge = Challenge.fromMethod(Methods.charge, {
220
+ realm,
221
+ secretKey,
222
+ expires: new Date(Date.now() + 60_000).toISOString(),
223
+ request: {
224
+ amount: '1000',
225
+ currency: '0x1234567890123456789012345678901234567890',
226
+ decimals: 6,
227
+ recipient: '0x1234567890123456789012345678901234567890',
228
+ },
229
+ })
230
+ const stripeChallenge = Challenge.from({
231
+ id: 'stripe-challenge-id',
232
+ realm,
233
+ method: 'stripe',
234
+ intent: 'charge',
235
+ request: {
236
+ amount: '2000',
237
+ currency: '0xabcd',
238
+ recipient: '0xefgh',
239
+ },
240
+ })
241
+
242
+ const response = new Response(null, {
243
+ status: 402,
244
+ headers: {
245
+ 'WWW-Authenticate': `${Challenge.serialize(stripeChallenge)}, ${Challenge.serialize(tempoChallenge)}`,
246
+ },
247
+ })
248
+
249
+ const credential = await mppx.createCredential(response)
250
+ const parsed = Credential.deserialize(credential)
251
+
252
+ expect(parsed.challenge.method).toBe('tempo')
253
+ })
254
+
255
+ test('behavior: createCredential accepts a request-local Accept-Payment override', async () => {
256
+ const stripeCharge = Method.from({
257
+ name: 'stripe',
258
+ intent: 'charge',
259
+ schema: {
260
+ credential: {
261
+ payload: Methods.charge.schema.credential.payload,
262
+ },
263
+ request: Methods.charge.schema.request,
264
+ },
265
+ })
266
+
267
+ const stripe = Method.toClient(stripeCharge, {
268
+ async createCredential({ challenge }) {
269
+ return Credential.serialize({
270
+ challenge,
271
+ payload: { signature: '0xstripe', type: 'transaction' },
272
+ })
273
+ },
274
+ })
275
+
276
+ const mppx = Mppx.create({
277
+ polyfill: false,
278
+ methods: [tempo({ account: accounts[1], getClient: () => client }), stripe],
279
+ })
280
+
281
+ const tempoChallenge = Challenge.fromMethod(Methods.charge, {
282
+ realm,
283
+ secretKey,
284
+ expires: new Date(Date.now() + 60_000).toISOString(),
285
+ request: {
286
+ amount: '1000',
287
+ currency: '0x1234567890123456789012345678901234567890',
288
+ decimals: 6,
289
+ recipient: '0x1234567890123456789012345678901234567890',
290
+ },
291
+ })
292
+ const stripeChallenge = Challenge.from({
293
+ id: 'stripe-challenge-id',
294
+ realm,
295
+ method: 'stripe',
296
+ intent: 'charge',
297
+ request: {
298
+ amount: '2000',
299
+ currency: '0xabcd',
300
+ recipient: '0xefgh',
301
+ },
302
+ })
303
+
304
+ const response = new Response(null, {
305
+ status: 402,
306
+ headers: {
307
+ 'WWW-Authenticate': `${Challenge.serialize(stripeChallenge)}, ${Challenge.serialize(tempoChallenge)}`,
308
+ },
309
+ })
310
+
311
+ const credential = await mppx.createCredential(response, undefined, {
312
+ acceptPayment: 'stripe/charge, tempo/charge;q=0.1',
313
+ })
314
+ const parsed = Credential.deserialize(credential)
315
+
316
+ expect(parsed.challenge.method).toBe('stripe')
317
+ })
318
+
190
319
  test('behavior: passes context to createCredential', async () => {
191
320
  const mppx = Mppx.create({
192
321
  polyfill: false,
@@ -1,4 +1,5 @@
1
1
  import type * as Challenge from '../Challenge.js'
2
+ import * as AcceptPayment from '../internal/AcceptPayment.js'
2
3
  import type * as Method from '../Method.js'
3
4
  import type * as z from '../zod.js'
4
5
  import * as Fetch from './internal/Fetch.js'
@@ -25,6 +26,7 @@ export type Mppx<
25
26
  createCredential: (
26
27
  response: Transport.ResponseOf<transport>,
27
28
  context?: AnyContextFor<FlattenMethods<methods>> | undefined,
29
+ options?: createCredential.Options | undefined,
28
30
  ) => Promise<string>
29
31
  }
30
32
 
@@ -61,11 +63,13 @@ export function create<
61
63
  const rawFetch = config.fetch ?? globalThis.fetch
62
64
 
63
65
  const methods = config.methods.flat() as unknown as FlattenMethods<methods>
66
+ const acceptPayment = AcceptPayment.resolve(methods, config.paymentPreferences)
64
67
 
65
68
  const resolvedOnChallenge = onChallenge as Fetch.from.Config<
66
69
  FlattenMethods<methods>
67
70
  >['onChallenge']
68
71
  const config_fetch = {
72
+ acceptPayment,
69
73
  ...(config.fetch && { fetch: config.fetch }),
70
74
  ...(resolvedOnChallenge && { onChallenge: resolvedOnChallenge }),
71
75
  methods,
@@ -78,15 +82,24 @@ export function create<
78
82
  rawFetch,
79
83
  methods,
80
84
  transport,
81
- async createCredential(response: Transport.ResponseOf<transport>, context?: unknown) {
82
- const challenge = transport.getChallenge(response as never) as Challenge.Challenge
85
+ async createCredential(
86
+ response: Transport.ResponseOf<transport>,
87
+ context?: unknown,
88
+ options?: createCredential.Options,
89
+ ) {
90
+ const challenges = transport.getChallenges
91
+ ? transport.getChallenges(response as never)
92
+ : [transport.getChallenge(response as never)]
93
+ const preferences = resolveChallengePreferences(acceptPayment.entries, options?.acceptPayment)
83
94
 
84
- const mi = methods.find((m) => m.name === challenge.method && m.intent === challenge.intent)
85
- if (!mi)
95
+ const selected = AcceptPayment.selectChallenge(challenges, methods, preferences)
96
+ if (!selected)
86
97
  throw new Error(
87
- `No method found for "${challenge.method}.${challenge.intent}". Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
98
+ `No method found for challenges: ${challenges.map((challenge) => `${challenge.method}.${challenge.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
88
99
  )
89
100
 
101
+ const { challenge, method: mi } = selected
102
+
90
103
  const parsedContext =
91
104
  mi.context && context !== undefined ? mi.context.parse(context) : undefined
92
105
 
@@ -99,6 +112,13 @@ export function create<
99
112
  }
100
113
  }
101
114
 
115
+ export declare namespace createCredential {
116
+ type Options = {
117
+ /** Request-local Accept-Payment override for manual rawFetch + createCredential flows. */
118
+ acceptPayment?: string | readonly AcceptPayment.Entry[] | undefined
119
+ }
120
+ }
121
+
102
122
  /**
103
123
  * Restores the original `fetch` after `create()` polyfilled it.
104
124
  *
@@ -133,6 +153,8 @@ export declare namespace create {
133
153
  },
134
154
  ) => Promise<string | undefined>)
135
155
  | undefined
156
+ /** Client-declared supported payment methods, keyed by typed `method/intent` strings. */
157
+ paymentPreferences?: AcceptPayment.Config<FlattenMethods<methods>> | undefined
136
158
  /** Array of methods to use. Accepts individual clients or tuples (e.g. from `tempo()`). */
137
159
  methods: methods
138
160
  /** Whether to polyfill `globalThis.fetch` with the payment-aware wrapper. @default true */
@@ -168,3 +190,11 @@ type FlattenMethods<methods extends Methods> = methods extends readonly [
168
190
  ? readonly [head, ...FlattenMethods<tail>]
169
191
  : never
170
192
  : readonly []
193
+
194
+ function resolveChallengePreferences(
195
+ fallback: readonly AcceptPayment.Entry[],
196
+ override?: string | readonly AcceptPayment.Entry[] | undefined,
197
+ ): readonly AcceptPayment.Entry[] {
198
+ if (!override) return fallback
199
+ return typeof override === 'string' ? AcceptPayment.parse(override) : override
200
+ }
@@ -57,20 +57,18 @@ describe('http', () => {
57
57
  },
58
58
  })
59
59
 
60
- expect(transport.getChallenge(response)).toMatchInlineSnapshot(`
61
- {
62
- "expires": "2025-01-01T00:00:00.000Z",
63
- "id": "0hnrySRDqWfttlDIJpuxV4mJsRJIS7d7RjnufuonJOE",
64
- "intent": "charge",
65
- "method": "tempo",
66
- "realm": "api.example.com",
67
- "request": {
68
- "amount": "1000",
69
- "currency": "0x20c0000000000000000000000000000000000001",
70
- "recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00",
71
- },
72
- }
73
- `)
60
+ expect(transport.getChallenge(response)).toMatchObject({
61
+ expires: '2025-01-01T00:00:00.000Z',
62
+ id: expect.any(String),
63
+ intent: 'charge',
64
+ method: 'tempo',
65
+ realm: 'api.example.com',
66
+ request: {
67
+ amount: '1000',
68
+ currency: '0x20c0000000000000000000000000000000000001',
69
+ recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
70
+ },
71
+ })
74
72
  })
75
73
 
76
74
  test('throws for non-402 response', () => {
@@ -81,6 +79,24 @@ describe('http', () => {
81
79
  })
82
80
  })
83
81
 
82
+ describe('getChallenges', () => {
83
+ test('returns all HTTP challenges', () => {
84
+ const transport = Transport.http()
85
+ const alternate = { ...challenge, id: 'alternate', method: 'stripe' as const }
86
+ const response = new Response(null, {
87
+ status: 402,
88
+ headers: {
89
+ 'WWW-Authenticate': `${Challenge.serialize(challenge)}, ${Challenge.serialize(alternate)}`,
90
+ },
91
+ })
92
+
93
+ expect(transport.getChallenges?.(response).map((entry) => entry.id)).toEqual([
94
+ challenge.id,
95
+ 'alternate',
96
+ ])
97
+ })
98
+ })
99
+
84
100
  describe('setCredential', () => {
85
101
  test('default', () => {
86
102
  const transport = Transport.http()
@@ -89,9 +105,7 @@ describe('http', () => {
89
105
  const result = transport.setCredential({}, serialized)
90
106
  const headers = result.headers as Headers
91
107
 
92
- expect(headers.get('Authorization')).toMatchInlineSnapshot(
93
- `"Payment eyJjaGFsbGVuZ2UiOnsiZXhwaXJlcyI6IjIwMjUtMDEtMDFUMDA6MDA6MDAuMDAwWiIsImlkIjoiMGhucnlTUkRxV2Z0dGxESUpwdXhWNG1Kc1JKSVM3ZDdSam51ZnVvbkpPRSIsImludGVudCI6ImNoYXJnZSIsIm1ldGhvZCI6InRlbXBvIiwicmVhbG0iOiJhcGkuZXhhbXBsZS5jb20iLCJyZXF1ZXN0IjoiZXlKaGJXOTFiblFpT2lJeE1EQXdJaXdpWTNWeWNtVnVZM2tpT2lJd2VESXdZekF3TURBd01EQXdNREF3TURBd01EQXdNREF3TURBd01EQXdNREF3TURBd01EQXdNREVpTENKeVpXTnBjR2xsYm5RaU9pSXdlRGMwTW1Rek5VTmpOall6TkVNd05UTXlPVEkxWVROaU9EUTBRbU01WlRjMU9UVm1PR1pGTURBaWZRIn0sInBheWxvYWQiOnsic2lnbmF0dXJlIjoiMHhhYmMxMjMiLCJ0eXBlIjoidHJhbnNhY3Rpb24ifX0"`,
94
- )
108
+ expect(headers.get('Authorization')).toBe(serialized)
95
109
  })
96
110
 
97
111
  test('preserves existing headers', () => {
@@ -178,20 +192,18 @@ describe('mcp', () => {
178
192
  },
179
193
  }
180
194
 
181
- expect(transport.getChallenge(response)).toMatchInlineSnapshot(`
182
- {
183
- "expires": "2025-01-01T00:00:00.000Z",
184
- "id": "0hnrySRDqWfttlDIJpuxV4mJsRJIS7d7RjnufuonJOE",
185
- "intent": "charge",
186
- "method": "tempo",
187
- "realm": "api.example.com",
188
- "request": {
189
- "amount": "1000",
190
- "currency": "0x20c0000000000000000000000000000000000001",
191
- "recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00",
192
- },
193
- }
194
- `)
195
+ expect(transport.getChallenge(response)).toMatchObject({
196
+ expires: '2025-01-01T00:00:00.000Z',
197
+ id: expect.any(String),
198
+ intent: 'charge',
199
+ method: 'tempo',
200
+ realm: 'api.example.com',
201
+ request: {
202
+ amount: '1000',
203
+ currency: '0x20c0000000000000000000000000000000000001',
204
+ recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
205
+ },
206
+ })
195
207
  })
196
208
 
197
209
  test('throws for success response', () => {
@@ -224,39 +236,60 @@ describe('mcp', () => {
224
236
  })
225
237
  })
226
238
 
239
+ describe('getChallenges', () => {
240
+ test('returns all MCP challenges', () => {
241
+ const transport = Transport.mcp()
242
+ const response: Mcp.Response = {
243
+ jsonrpc: '2.0',
244
+ id: 1,
245
+ error: {
246
+ code: Mcp.paymentRequiredCode,
247
+ message: 'Payment Required',
248
+ data: {
249
+ httpStatus: 402,
250
+ challenges: [challenge, { ...challenge, id: 'alternate', method: 'stripe' }],
251
+ },
252
+ },
253
+ }
254
+
255
+ expect(transport.getChallenges?.(response).map((entry) => entry.id)).toEqual([
256
+ challenge.id,
257
+ 'alternate',
258
+ ])
259
+ })
260
+ })
261
+
227
262
  describe('setCredential', () => {
228
263
  test('default', () => {
229
264
  const transport = Transport.mcp()
230
265
  const serialized = Credential.serialize(credential)
231
266
 
232
- expect(transport.setCredential(mcpRequest, serialized)).toMatchInlineSnapshot(`
233
- {
234
- "method": "tools/call",
235
- "params": {
236
- "_meta": {
237
- "org.paymentauth/credential": {
238
- "challenge": {
239
- "expires": "2025-01-01T00:00:00.000Z",
240
- "id": "0hnrySRDqWfttlDIJpuxV4mJsRJIS7d7RjnufuonJOE",
241
- "intent": "charge",
242
- "method": "tempo",
243
- "realm": "api.example.com",
244
- "request": {
245
- "amount": "1000",
246
- "currency": "0x20c0000000000000000000000000000000000001",
247
- "recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00",
248
- },
249
- },
250
- "payload": {
251
- "signature": "0xabc123",
252
- "type": "transaction",
267
+ expect(transport.setCredential(mcpRequest, serialized)).toMatchObject({
268
+ method: 'tools/call',
269
+ params: {
270
+ _meta: {
271
+ 'org.paymentauth/credential': {
272
+ challenge: {
273
+ expires: '2025-01-01T00:00:00.000Z',
274
+ id: expect.any(String),
275
+ intent: 'charge',
276
+ method: 'tempo',
277
+ realm: 'api.example.com',
278
+ request: {
279
+ amount: '1000',
280
+ currency: '0x20c0000000000000000000000000000000000001',
281
+ recipient: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00',
253
282
  },
254
283
  },
284
+ payload: {
285
+ signature: '0xabc123',
286
+ type: 'transaction',
287
+ },
255
288
  },
256
- "name": "test-tool",
257
289
  },
258
- }
259
- `)
290
+ name: 'test-tool',
291
+ },
292
+ })
260
293
  })
261
294
 
262
295
  test('preserves existing _meta', () => {
@@ -13,6 +13,8 @@ export type Transport<in out request = unknown, in out response = unknown> = {
13
13
  name: string
14
14
  /** Checks if a response indicates payment is required. */
15
15
  isPaymentRequired: (response: response) => boolean
16
+ /** Extracts all challenges from a payment-required response, when the transport supports multiple offers. */
17
+ getChallenges?: (response: response) => Challenge.Challenge[]
16
18
  /** Extracts the challenge from a payment-required response. */
17
19
  getChallenge: (response: response) => Challenge.Challenge
18
20
  /** Attaches a credential to a request. */
@@ -64,6 +66,10 @@ export function http() {
64
66
  return response.status === 402
65
67
  },
66
68
 
69
+ getChallenges(response) {
70
+ return Challenge.fromResponseList(response)
71
+ },
72
+
67
73
  getChallenge(response) {
68
74
  return Challenge.fromResponse(response)
69
75
  },
@@ -91,6 +97,13 @@ export function mcp() {
91
97
  return 'error' in response && response.error?.code === Mcp.paymentRequiredCode
92
98
  },
93
99
 
100
+ getChallenges(response) {
101
+ if (!('error' in response) || !response.error) throw new Error('Response is not an error.')
102
+ const challenges = response.error.data?.challenges
103
+ if (!challenges?.length) throw new Error('No challenge in error response.')
104
+ return challenges
105
+ },
106
+
94
107
  getChallenge(response) {
95
108
  if (!('error' in response) || !response.error) throw new Error('Response is not an error.')
96
109
  const challenge = response.error.data?.challenges[0]
@@ -22,6 +22,10 @@ function make402() {
22
22
  })
23
23
  }
24
24
 
25
+ function toHeaders(headers: unknown): Headers {
26
+ return new Headers((headers ?? {}) as HeadersInit)
27
+ }
28
+
25
29
  /** Returns a fetch wrapper and the init captured from the 402 retry call. */
26
30
  function setup() {
27
31
  const calls: (RequestInit | undefined)[] = []
@@ -38,7 +42,7 @@ function setup() {
38
42
  /** Headers sent on the retry (second) request. */
39
43
  retryHeaders: async (input: RequestInfo | URL, init?: RequestInit) => {
40
44
  await fetch(input, init)
41
- return (calls[1] as Record<string, unknown>)?.headers as Record<string, string>
45
+ return toHeaders((calls[1] as Record<string, unknown>)?.headers)
42
46
  },
43
47
  }
44
48
  }
@@ -49,9 +53,9 @@ describe('Fetch.from: browser header normalization', () => {
49
53
  const h = await retryHeaders('https://example.com', {
50
54
  headers: new Headers({ 'X-Custom': 'value', 'Content-Type': 'application/json' }),
51
55
  })
52
- expect(h['x-custom']).toBe('value')
53
- expect(h['content-type']).toBe('application/json')
54
- expect(h.Authorization).toBe('credential')
56
+ expect(h.get('x-custom')).toBe('value')
57
+ expect(h.get('content-type')).toBe('application/json')
58
+ expect(h.get('authorization')).toBe('credential')
55
59
  })
56
60
 
57
61
  test('preserves header tuples', async () => {
@@ -62,9 +66,9 @@ describe('Fetch.from: browser header normalization', () => {
62
66
  ['Accept', 'application/json'],
63
67
  ],
64
68
  })
65
- expect(h['X-Custom']).toBe('value')
66
- expect(h.Accept).toBe('application/json')
67
- expect(h.Authorization).toBe('credential')
69
+ expect(h.get('x-custom')).toBe('value')
70
+ expect(h.get('accept')).toBe('application/json')
71
+ expect(h.get('authorization')).toBe('credential')
68
72
  })
69
73
 
70
74
  test('replaces authorization case-insensitively', async () => {
@@ -72,22 +76,21 @@ describe('Fetch.from: browser header normalization', () => {
72
76
  const h = await retryHeaders('https://example.com', {
73
77
  headers: { authorization: 'Bearer stale', 'X-Custom': 'value' },
74
78
  })
75
- expect(h.authorization).toBeUndefined()
76
- expect(h.Authorization).toBe('credential')
77
- expect(h['X-Custom']).toBe('value')
79
+ expect(h.get('authorization')).toBe('credential')
80
+ expect(h.get('x-custom')).toBe('value')
78
81
  })
79
82
 
80
83
  test('preserves plain object headers', async () => {
81
84
  const { retryHeaders } = setup()
82
85
  const h = await retryHeaders('https://example.com', { headers: { 'X-Custom': 'val' } })
83
- expect(h['X-Custom']).toBe('val')
84
- expect(h.Authorization).toBe('credential')
86
+ expect(h.get('x-custom')).toBe('val')
87
+ expect(h.get('authorization')).toBe('credential')
85
88
  })
86
89
 
87
90
  test('adds Authorization when no headers provided', async () => {
88
91
  const { retryHeaders } = setup()
89
92
  const h = await retryHeaders('https://example.com')
90
- expect(h.Authorization).toBe('credential')
93
+ expect(h.get('authorization')).toBe('credential')
91
94
  })
92
95
  })
93
96