mppx 0.8.1 → 0.8.3
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 +16 -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/plugins/tempo.d.ts +15 -0
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +125 -14
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/cli/utils.d.ts +2 -2
- package/dist/cli/utils.d.ts.map +1 -1
- package/dist/cli/utils.js +7 -1
- package/dist/cli/utils.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/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +7 -1
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/internal/auto-swap.d.ts.map +1 -1
- package/dist/tempo/internal/auto-swap.js +15 -5
- package/dist/tempo/internal/auto-swap.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +57 -1
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/internal/fee-token.js +1 -1
- package/dist/tempo/internal/fee-token.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/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 +4 -4
- 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/plugins/tempo.ts +161 -17
- package/src/cli/utils.ts +14 -2
- 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/mcp/client/McpClient.integration.test.ts +12 -1
- package/src/server/Mppx.test.ts +3 -0
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/client/Charge.test.ts +56 -0
- package/src/tempo/client/Charge.ts +20 -2
- package/src/tempo/internal/auto-swap.test.ts +68 -1
- package/src/tempo/internal/auto-swap.ts +38 -5
- package/src/tempo/internal/fee-payer.test.ts +106 -0
- package/src/tempo/internal/fee-payer.ts +71 -1
- package/src/tempo/internal/fee-token.ts +1 -1
- package/src/tempo/server/Charge.test.ts +31 -16
- 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/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="
|
|
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
|
})
|
|
@@ -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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
'
|
|
281
|
-
|
|
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
|
|
267
|
+
response,
|
|
288
268
|
}),
|
|
289
269
|
)
|
|
290
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
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
|
-
|
|
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.
|
|
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.
|
|
23
|
-
|
|
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)
|
|
@@ -33,6 +33,17 @@ const isLocalnet = tempoNetwork === 'localnet'
|
|
|
33
33
|
|
|
34
34
|
let escrowContract: Address
|
|
35
35
|
|
|
36
|
+
// TODO: Remove once the minimum viem version is >=2.54.0, which uses client-first Tempo call builders.
|
|
37
|
+
function tokenGetBalanceCall(parameters: { account: Address; token: Address }) {
|
|
38
|
+
const call = Actions.token.getBalance.call as unknown as {
|
|
39
|
+
length: number
|
|
40
|
+
(parameters: { account: Address; token: Address }): unknown
|
|
41
|
+
(client: unknown, parameters: { account: Address; token: Address }): unknown
|
|
42
|
+
}
|
|
43
|
+
if (call.length >= 2) return call(testClient, parameters)
|
|
44
|
+
return call(parameters)
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
beforeAll(async () => {
|
|
37
48
|
escrowContract = await deployEscrow()
|
|
38
49
|
await fundAccount({ address: accounts[4].address, token: Addresses.pathUsd })
|
|
@@ -621,7 +632,7 @@ async function getPaymentRequiredError(
|
|
|
621
632
|
async function getTokenBalance(account: Address): Promise<bigint> {
|
|
622
633
|
return readContract(
|
|
623
634
|
testClient,
|
|
624
|
-
|
|
635
|
+
tokenGetBalanceCall({ account, token: asset }) as never,
|
|
625
636
|
) as Promise<bigint>
|
|
626
637
|
}
|
|
627
638
|
|
package/src/server/Mppx.test.ts
CHANGED
|
@@ -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",
|