mppx 0.8.0 → 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.
- package/CHANGELOG.md +18 -0
- package/dist/Errors.d.ts +13 -0
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +12 -0
- package/dist/Errors.js.map +1 -1
- package/dist/cli/cli.d.ts +2 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +35 -4
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/internal.d.ts +1 -0
- package/dist/cli/internal.d.ts.map +1 -1
- package/dist/cli/internal.js +1 -15
- package/dist/cli/internal.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts +15 -0
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +124 -14
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/client/Mppx.d.ts +2 -0
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +2 -1
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/Transport.d.ts.map +1 -1
- package/dist/client/Transport.js +11 -12
- package/dist/client/Transport.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +2 -0
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +55 -50
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/client/internal/protocols/Mcp.d.ts +6 -0
- package/dist/client/internal/protocols/Mcp.d.ts.map +1 -1
- package/dist/client/internal/protocols/Mcp.js +28 -10
- package/dist/client/internal/protocols/Mcp.js.map +1 -1
- package/dist/client/internal/protocols/X402.d.ts.map +1 -1
- package/dist/client/internal/protocols/X402.js +18 -12
- package/dist/client/internal/protocols/X402.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +17 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +105 -25
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +4 -0
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/session/client/SessionManager.js +47 -5
- package/dist/tempo/session/client/SessionManager.js.map +1 -1
- package/dist/tempo/session/server/CredentialVerification.d.ts +6 -0
- package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
- package/dist/tempo/session/server/CredentialVerification.js +13 -0
- package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
- package/dist/x402/Header.d.ts +10 -1
- package/dist/x402/Header.d.ts.map +1 -1
- package/dist/x402/Header.js +27 -1
- package/dist/x402/Header.js.map +1 -1
- package/package.json +3 -3
- package/src/Errors.test.ts +28 -0
- package/src/Errors.ts +19 -0
- package/src/cli/cli.test.ts +194 -0
- package/src/cli/cli.ts +43 -7
- package/src/cli/config.test.ts +13 -7
- package/src/cli/internal.ts +1 -16
- package/src/cli/plugins/tempo.ts +160 -17
- package/src/client/Mppx.test-d.ts +9 -0
- package/src/client/Mppx.test.ts +41 -0
- package/src/client/Mppx.ts +4 -0
- package/src/client/Transport.test.ts +124 -0
- package/src/client/Transport.ts +11 -9
- package/src/client/internal/Fetch.test-d.ts +9 -0
- package/src/client/internal/Fetch.test.ts +186 -4
- package/src/client/internal/Fetch.ts +85 -76
- package/src/client/internal/protocols/Mcp.ts +39 -15
- package/src/client/internal/protocols/X402.ts +9 -5
- package/src/server/Mppx.test.ts +3 -0
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/internal/fee-payer.test.ts +130 -0
- package/src/tempo/internal/fee-payer.ts +157 -36
- package/src/tempo/server/Charge.ts +4 -0
- package/src/tempo/server/internal/html/package.json +2 -2
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/session/client/SessionManager.test.ts +57 -4
- package/src/tempo/session/client/SessionManager.ts +50 -6
- package/src/tempo/session/server/CredentialVerification.test.ts +36 -0
- package/src/tempo/session/server/CredentialVerification.ts +18 -0
- package/src/x402/Header.ts +45 -0
|
@@ -2,6 +2,7 @@ import { Challenge, Credential, Mcp } from 'mppx'
|
|
|
2
2
|
import { Transport } from 'mppx/client'
|
|
3
3
|
import { Methods } from 'mppx/tempo'
|
|
4
4
|
import { Header as x402_Header, Types as x402_Types, type PaymentRequired } from 'mppx/x402'
|
|
5
|
+
import { Base64 } from 'ox'
|
|
5
6
|
import { describe, expect, test } from 'vp/test'
|
|
6
7
|
|
|
7
8
|
import * as x402_ChallengeBrand from '../x402/internal/ChallengeBrand.js'
|
|
@@ -43,6 +44,25 @@ const x402PaymentRequired = {
|
|
|
43
44
|
x402Version: 2,
|
|
44
45
|
} satisfies PaymentRequired
|
|
45
46
|
|
|
47
|
+
const unsupportedSolanaAccept = {
|
|
48
|
+
amount: '10000',
|
|
49
|
+
asset: 'USDC',
|
|
50
|
+
maxTimeoutSeconds: 60,
|
|
51
|
+
network: 'solana:mainnet',
|
|
52
|
+
payTo: '9xQeWvG816bUx9EPjHmaT23yvVM2ZWdYqPxfowV5n2kg',
|
|
53
|
+
scheme: 'exact',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const unsupportedSchemeAccept = {
|
|
57
|
+
...x402PaymentRequired.accepts[0]!,
|
|
58
|
+
scheme: 'upto',
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Strict x402 encoding rejects unsupported accepts before the transport can filter them.
|
|
62
|
+
function encodeRawPaymentRequiredHeader(value: unknown): string {
|
|
63
|
+
return Base64.fromString(JSON.stringify(value))
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
describe('http', () => {
|
|
47
67
|
describe('isPaymentRequired', () => {
|
|
48
68
|
test.each([
|
|
@@ -140,6 +160,28 @@ describe('http', () => {
|
|
|
140
160
|
}),
|
|
141
161
|
name: 'Payment auth and x402 challenges when both are present',
|
|
142
162
|
},
|
|
163
|
+
{
|
|
164
|
+
expectedIds: [`${x402_Types.syntheticChallengeIdPrefix}0`],
|
|
165
|
+
expectedMethods: [x402_Types.paymentMethod],
|
|
166
|
+
headers: () => ({
|
|
167
|
+
'PAYMENT-REQUIRED': encodeRawPaymentRequiredHeader({
|
|
168
|
+
...x402PaymentRequired,
|
|
169
|
+
accepts: [x402PaymentRequired.accepts[0]!, unsupportedSolanaAccept],
|
|
170
|
+
}),
|
|
171
|
+
}),
|
|
172
|
+
name: 'x402 challenges filtered from mixed EVM and Solana accepts',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
expectedIds: [`${x402_Types.syntheticChallengeIdPrefix}0`],
|
|
176
|
+
expectedMethods: [x402_Types.paymentMethod],
|
|
177
|
+
headers: () => ({
|
|
178
|
+
'PAYMENT-REQUIRED': encodeRawPaymentRequiredHeader({
|
|
179
|
+
...x402PaymentRequired,
|
|
180
|
+
accepts: [x402PaymentRequired.accepts[0]!, unsupportedSchemeAccept],
|
|
181
|
+
}),
|
|
182
|
+
}),
|
|
183
|
+
name: 'x402 challenges filtered from mixed exact and non-exact accepts',
|
|
184
|
+
},
|
|
143
185
|
])('returns $name', async ({ expectedIds, expectedMethods, headers }) => {
|
|
144
186
|
const transport = Transport.http()
|
|
145
187
|
const response = new Response(null, {
|
|
@@ -151,6 +193,42 @@ describe('http', () => {
|
|
|
151
193
|
expect(challenges.map((entry) => entry.id)).toEqual(expectedIds)
|
|
152
194
|
expect(challenges.map((entry) => entry.method)).toEqual(expectedMethods)
|
|
153
195
|
})
|
|
196
|
+
|
|
197
|
+
test('returns Payment auth challenges when x402 accepts are unsupported', async () => {
|
|
198
|
+
const transport = Transport.http()
|
|
199
|
+
const response = new Response(null, {
|
|
200
|
+
status: 402,
|
|
201
|
+
headers: {
|
|
202
|
+
'PAYMENT-REQUIRED': encodeRawPaymentRequiredHeader({
|
|
203
|
+
...x402PaymentRequired,
|
|
204
|
+
accepts: [unsupportedSolanaAccept],
|
|
205
|
+
}),
|
|
206
|
+
'WWW-Authenticate': Challenge.serialize(challenge),
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const challenges = (await transport.getChallenges?.(response)) ?? []
|
|
211
|
+
|
|
212
|
+
expect(challenges.map((entry) => entry.id)).toEqual([challenge.id])
|
|
213
|
+
expect(challenges.map((entry) => entry.method)).toEqual(['tempo'])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('returns no challenges when only unsupported x402 accepts are present', async () => {
|
|
217
|
+
const transport = Transport.http()
|
|
218
|
+
const response = new Response(null, {
|
|
219
|
+
status: 402,
|
|
220
|
+
headers: {
|
|
221
|
+
'PAYMENT-REQUIRED': encodeRawPaymentRequiredHeader({
|
|
222
|
+
...x402PaymentRequired,
|
|
223
|
+
accepts: [unsupportedSolanaAccept],
|
|
224
|
+
}),
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const challenges = (await transport.getChallenges?.(response)) ?? []
|
|
229
|
+
expect(challenges).toEqual([])
|
|
230
|
+
expect(() => transport.getChallenge(response)).toThrow('No challenge in response.')
|
|
231
|
+
})
|
|
154
232
|
})
|
|
155
233
|
|
|
156
234
|
describe('setCredential', () => {
|
|
@@ -322,8 +400,24 @@ describe('http (MCP-over-HTTP)', () => {
|
|
|
322
400
|
data: { challenges: [challenge] },
|
|
323
401
|
},
|
|
324
402
|
}
|
|
403
|
+
const resultMessage = {
|
|
404
|
+
jsonrpc: '2.0',
|
|
405
|
+
id: 1,
|
|
406
|
+
result: {
|
|
407
|
+
content: [{ type: 'text', text: 'Payment Required' }],
|
|
408
|
+
isError: true,
|
|
409
|
+
_meta: {
|
|
410
|
+
[Mcp.paymentRequiredMetaKey]: {
|
|
411
|
+
httpStatus: 402,
|
|
412
|
+
challenges: [challenge],
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
}
|
|
325
417
|
const jsonBody = () =>
|
|
326
418
|
new Response(JSON.stringify(errorMessage), { headers: { 'content-type': 'application/json' } })
|
|
419
|
+
const jsonResultBody = () =>
|
|
420
|
+
new Response(JSON.stringify(resultMessage), { headers: { 'content-type': 'application/json' } })
|
|
327
421
|
const sseBody = () =>
|
|
328
422
|
new Response(`event: message\ndata: ${JSON.stringify(errorMessage)}\n\n`, {
|
|
329
423
|
headers: { 'content-type': 'text/event-stream' },
|
|
@@ -332,6 +426,9 @@ describe('http (MCP-over-HTTP)', () => {
|
|
|
332
426
|
test('detects -32042 in a JSON body (JSON-RPC request)', async () => {
|
|
333
427
|
expect(await Transport.http().isPaymentRequired(jsonBody(), jsonRpcRequest)).toBe(true)
|
|
334
428
|
})
|
|
429
|
+
test('detects payment-required metadata in a JSON-RPC result', async () => {
|
|
430
|
+
expect(await Transport.http().isPaymentRequired(jsonResultBody(), jsonRpcRequest)).toBe(true)
|
|
431
|
+
})
|
|
335
432
|
test('ignores a JSON-RPC response for a different request id', async () => {
|
|
336
433
|
const response = new Response(JSON.stringify({ ...errorMessage, id: 2 }), {
|
|
337
434
|
headers: { 'content-type': 'application/json' },
|
|
@@ -403,6 +500,10 @@ describe('http (MCP-over-HTTP)', () => {
|
|
|
403
500
|
const challenges = await Transport.http().getChallenges!(sseBody(), jsonRpcRequest)
|
|
404
501
|
expect(challenges.map((entry) => entry.id)).toEqual([challenge.id])
|
|
405
502
|
})
|
|
503
|
+
test('getChallenges extracts an MCP result metadata challenge', async () => {
|
|
504
|
+
const challenges = await Transport.http().getChallenges!(jsonResultBody(), jsonRpcRequest)
|
|
505
|
+
expect(challenges.map((entry) => entry.id)).toEqual([challenge.id])
|
|
506
|
+
})
|
|
406
507
|
test('setCredential routes the MCP challenge into the JSON-RPC _meta', async () => {
|
|
407
508
|
const transport = Transport.http()
|
|
408
509
|
const [mcpChallenge] = await transport.getChallenges!(sseBody(), jsonRpcRequest)
|
|
@@ -473,6 +574,20 @@ describe('mcp', () => {
|
|
|
473
574
|
},
|
|
474
575
|
},
|
|
475
576
|
}
|
|
577
|
+
const mcpResult: Mcp.Response = {
|
|
578
|
+
jsonrpc: '2.0',
|
|
579
|
+
id: 1,
|
|
580
|
+
result: {
|
|
581
|
+
content: [],
|
|
582
|
+
isError: true,
|
|
583
|
+
_meta: {
|
|
584
|
+
[Mcp.paymentRequiredMetaKey]: {
|
|
585
|
+
httpStatus: 402,
|
|
586
|
+
challenges: [challenge],
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
}
|
|
476
591
|
|
|
477
592
|
test('extracts payment-required challenges from JSON-RPC errors', async () => {
|
|
478
593
|
const transport = Transport.mcp()
|
|
@@ -483,6 +598,15 @@ describe('mcp', () => {
|
|
|
483
598
|
])
|
|
484
599
|
})
|
|
485
600
|
|
|
601
|
+
test('extracts payment-required challenges from tool result metadata', async () => {
|
|
602
|
+
const transport = Transport.mcp()
|
|
603
|
+
|
|
604
|
+
expect(await transport.isPaymentRequired(mcpResult)).toBe(true)
|
|
605
|
+
expect((await transport.getChallenges?.(mcpResult))?.map((entry) => entry.id)).toEqual([
|
|
606
|
+
challenge.id,
|
|
607
|
+
])
|
|
608
|
+
})
|
|
609
|
+
|
|
486
610
|
test('sets credentials in JSON-RPC _meta', () => {
|
|
487
611
|
const result = Transport.mcp().setCredential(mcpRequest, Credential.serialize(credential))
|
|
488
612
|
|
package/src/client/Transport.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as Challenge from '../Challenge.js'
|
|
2
2
|
import * as Credential from '../Credential.js'
|
|
3
3
|
import * as Mcp from '../Mcp.js'
|
|
4
|
-
import { mcp as mcpProtocol } from './internal/protocols/Mcp.js'
|
|
4
|
+
import { mcp as mcpProtocol, paymentRequiredData } from './internal/protocols/Mcp.js'
|
|
5
5
|
import { mpp as mppProtocol } from './internal/protocols/Mpp.js'
|
|
6
6
|
import type { Protocol } from './internal/protocols/Protocol.js'
|
|
7
7
|
import { paymentRequiredStatus } from './internal/protocols/Shared.js'
|
|
@@ -144,6 +144,12 @@ export function http(): Transport<RequestInit, Response> {
|
|
|
144
144
|
})
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
function mcpPaymentRequiredChallenges(response: Mcp.Response) {
|
|
148
|
+
const data = paymentRequiredData(response)
|
|
149
|
+
if (!data) throw new Error('No challenge in response.')
|
|
150
|
+
return data.challenges
|
|
151
|
+
}
|
|
152
|
+
|
|
147
153
|
/**
|
|
148
154
|
* MCP protocol transport for direct JSON-RPC objects.
|
|
149
155
|
*
|
|
@@ -155,20 +161,16 @@ export function mcp() {
|
|
|
155
161
|
name: 'mcp',
|
|
156
162
|
|
|
157
163
|
isPaymentRequired(response) {
|
|
158
|
-
return
|
|
164
|
+
return !!paymentRequiredData(response)
|
|
159
165
|
},
|
|
160
166
|
|
|
161
167
|
getChallenges(response) {
|
|
162
|
-
|
|
163
|
-
const challenges = response.error.data?.challenges
|
|
164
|
-
if (!challenges?.length) throw new Error('No challenge in error response.')
|
|
165
|
-
return challenges
|
|
168
|
+
return mcpPaymentRequiredChallenges(response)
|
|
166
169
|
},
|
|
167
170
|
|
|
168
171
|
getChallenge(response) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (!challenge) throw new Error('No challenge in error response.')
|
|
172
|
+
const challenge = mcpPaymentRequiredChallenges(response)[0]
|
|
173
|
+
if (!challenge) throw new Error('No challenge in response.')
|
|
172
174
|
return challenge
|
|
173
175
|
},
|
|
174
176
|
|
|
@@ -57,6 +57,15 @@ describe('Fetch.from', () => {
|
|
|
57
57
|
})
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
+
test('behavior: accepts payment retry cap', () => {
|
|
61
|
+
const fetch = Fetch.from({
|
|
62
|
+
maxPaymentRetries: 1,
|
|
63
|
+
methods: [charge()],
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expectTypeOf(fetch).toBeFunction()
|
|
67
|
+
})
|
|
68
|
+
|
|
60
69
|
test('behavior: events infer payload types from methods', () => {
|
|
61
70
|
const method = charge()
|
|
62
71
|
const dispatcher = Fetch.createEventDispatcher<[typeof method]>()
|
|
@@ -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="
|
|
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('
|
|
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
|
})
|