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.
Files changed (92) hide show
  1. package/CHANGELOG.md +16 -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 +125 -14
  13. package/dist/cli/plugins/tempo.js.map +1 -1
  14. package/dist/cli/utils.d.ts +2 -2
  15. package/dist/cli/utils.d.ts.map +1 -1
  16. package/dist/cli/utils.js +7 -1
  17. package/dist/cli/utils.js.map +1 -1
  18. package/dist/client/Mppx.d.ts +2 -0
  19. package/dist/client/Mppx.d.ts.map +1 -1
  20. package/dist/client/Mppx.js +2 -1
  21. package/dist/client/Mppx.js.map +1 -1
  22. package/dist/client/Transport.d.ts.map +1 -1
  23. package/dist/client/Transport.js +11 -12
  24. package/dist/client/Transport.js.map +1 -1
  25. package/dist/client/internal/Fetch.d.ts +2 -0
  26. package/dist/client/internal/Fetch.d.ts.map +1 -1
  27. package/dist/client/internal/Fetch.js +55 -50
  28. package/dist/client/internal/Fetch.js.map +1 -1
  29. package/dist/client/internal/protocols/Mcp.d.ts +6 -0
  30. package/dist/client/internal/protocols/Mcp.d.ts.map +1 -1
  31. package/dist/client/internal/protocols/Mcp.js +28 -10
  32. package/dist/client/internal/protocols/Mcp.js.map +1 -1
  33. package/dist/client/internal/protocols/X402.d.ts.map +1 -1
  34. package/dist/client/internal/protocols/X402.js +18 -12
  35. package/dist/client/internal/protocols/X402.js.map +1 -1
  36. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  37. package/dist/stripe/server/internal/html.gen.js +1 -1
  38. package/dist/tempo/client/Charge.d.ts.map +1 -1
  39. package/dist/tempo/client/Charge.js +7 -1
  40. package/dist/tempo/client/Charge.js.map +1 -1
  41. package/dist/tempo/internal/auto-swap.d.ts.map +1 -1
  42. package/dist/tempo/internal/auto-swap.js +15 -5
  43. package/dist/tempo/internal/auto-swap.js.map +1 -1
  44. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  45. package/dist/tempo/internal/fee-payer.js +57 -1
  46. package/dist/tempo/internal/fee-payer.js.map +1 -1
  47. package/dist/tempo/internal/fee-token.js +1 -1
  48. package/dist/tempo/internal/fee-token.js.map +1 -1
  49. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  50. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  51. package/dist/tempo/server/internal/html.gen.js +1 -1
  52. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  53. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  54. package/dist/tempo/session/client/SessionManager.js +47 -5
  55. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  56. package/dist/x402/Header.d.ts +10 -1
  57. package/dist/x402/Header.d.ts.map +1 -1
  58. package/dist/x402/Header.js +27 -1
  59. package/dist/x402/Header.js.map +1 -1
  60. package/package.json +4 -4
  61. package/src/Errors.test.ts +28 -0
  62. package/src/Errors.ts +19 -0
  63. package/src/cli/cli.test.ts +194 -0
  64. package/src/cli/cli.ts +43 -7
  65. package/src/cli/plugins/tempo.ts +161 -17
  66. package/src/cli/utils.ts +14 -2
  67. package/src/client/Mppx.test-d.ts +9 -0
  68. package/src/client/Mppx.test.ts +41 -0
  69. package/src/client/Mppx.ts +4 -0
  70. package/src/client/Transport.test.ts +124 -0
  71. package/src/client/Transport.ts +11 -9
  72. package/src/client/internal/Fetch.test-d.ts +9 -0
  73. package/src/client/internal/Fetch.test.ts +186 -4
  74. package/src/client/internal/Fetch.ts +85 -76
  75. package/src/client/internal/protocols/Mcp.ts +39 -15
  76. package/src/client/internal/protocols/X402.ts +9 -5
  77. package/src/mcp/client/McpClient.integration.test.ts +12 -1
  78. package/src/server/Mppx.test.ts +3 -0
  79. package/src/stripe/server/internal/html.gen.ts +1 -1
  80. package/src/tempo/client/Charge.test.ts +56 -0
  81. package/src/tempo/client/Charge.ts +20 -2
  82. package/src/tempo/internal/auto-swap.test.ts +68 -1
  83. package/src/tempo/internal/auto-swap.ts +38 -5
  84. package/src/tempo/internal/fee-payer.test.ts +106 -0
  85. package/src/tempo/internal/fee-payer.ts +71 -1
  86. package/src/tempo/internal/fee-token.ts +1 -1
  87. package/src/tempo/server/Charge.test.ts +31 -16
  88. package/src/tempo/server/internal/html/package.json +2 -2
  89. package/src/tempo/server/internal/html.gen.ts +1 -1
  90. package/src/tempo/session/client/SessionManager.test.ts +57 -4
  91. package/src/tempo/session/client/SessionManager.ts +50 -6
  92. package/src/x402/Header.ts +45 -0
package/src/cli/cli.ts CHANGED
@@ -16,7 +16,11 @@ import { validate as validateDiscovery } from '../discovery/Validate.js'
16
16
  import { createDefaultStore, createKeychain, resolveAccountName } from './account.js'
17
17
  import { loadConfig, resolveAcceptPayment, selectChallenge } from './internal.js'
18
18
  import type { Plugin } from './plugins/plugin.js'
19
- import { readTempoKeystore, resolveTempoAccount } from './plugins/tempo.js'
19
+ import {
20
+ orderTempoChargeChallengesByBalance,
21
+ readTempoKeystore,
22
+ resolveTempoAccount,
23
+ } from './plugins/tempo.js'
20
24
  import {
21
25
  chainName,
22
26
  confirm,
@@ -151,6 +155,18 @@ function formatPayment(payment: unknown): string {
151
155
  .join(' ')
152
156
  }
153
157
 
158
+ function filterChallengesByCurrency(
159
+ challenges: readonly Challenge.Challenge[],
160
+ currency: string | undefined,
161
+ ): Challenge.Challenge[] {
162
+ if (!currency) return [...challenges]
163
+ const normalized = currency.toLowerCase()
164
+ return challenges.filter((challenge) => {
165
+ const offered = challenge.request.currency
166
+ return typeof offered === 'string' && offered.toLowerCase() === normalized
167
+ })
168
+ }
169
+
154
170
  async function fetchServicesRegistry(): Promise<ServiceRegistryService[]> {
155
171
  const url = process.env.MPPX_SERVICES_URL ?? servicesRegistryUrl
156
172
  const response = await globalThis.fetch(url)
@@ -198,6 +214,7 @@ const cli = Cli.create('mppx', {
198
214
  autoSwap: z.boolean().optional().describe('Auto-swap source tokens into payment currency'),
199
215
  config: z.string().optional().describe('Path to config file'),
200
216
  confirm: z.boolean().optional().default(false).describe('Show confirmation prompts'),
217
+ currency: z.string().optional().describe('Payment currency/token address to select'),
201
218
  data: z.string().optional().describe('Send request body (implies POST unless -X is set)'),
202
219
  fail: z.boolean().optional().describe('Fail silently on HTTP errors (exit 22)'),
203
220
  header: z.array(z.string()).optional().describe('Add header (repeatable)'),
@@ -357,12 +374,31 @@ const cli = Cli.create('mppx', {
357
374
  return
358
375
  }
359
376
 
360
- const selected = selectChallenge(
361
- Challenge.fromResponseList(challengeResponse),
362
- loaded?.config,
363
- )
377
+ const methodOpts = parseMethodOpts(c.options.methodOpt)
378
+ const offeredChallenges = Challenge.fromResponseList(challengeResponse)
379
+ const currencyChallenges = filterChallengesByCurrency(offeredChallenges, c.options.currency)
380
+ if (c.options.currency && currencyChallenges.length === 0) {
381
+ const offers = offeredChallenges
382
+ .map((challenge) => challenge.request.currency)
383
+ .filter((currency): currency is string => typeof currency === 'string')
384
+ .join(', ')
385
+ return c.error({
386
+ code: 'UNSUPPORTED_CURRENCY',
387
+ message: `Server did not offer payment currency ${c.options.currency}.${offers ? ` Offered currencies: ${offers}.` : ''}`,
388
+ exitCode: 2,
389
+ })
390
+ }
391
+ const challenges = await orderTempoChargeChallengesByBalance(currencyChallenges, {
392
+ options: {
393
+ account: c.options.account,
394
+ network: c.options.network,
395
+ rpcUrl: c.options.rpcUrl,
396
+ },
397
+ })
398
+
399
+ const selected = selectChallenge(challenges, loaded?.config)
364
400
  if (!selected) {
365
- const offers = Challenge.fromResponseList(challengeResponse)
401
+ const offers = challenges
366
402
  .map((challenge) => `${challenge.method}/${challenge.intent}`)
367
403
  .join(', ')
368
404
  return c.error({
@@ -393,7 +429,7 @@ const cli = Cli.create('mppx', {
393
429
  rpcUrl: c.options.rpcUrl,
394
430
  slippage: c.options.slippage,
395
431
  },
396
- methodOpts: parseMethodOpts(c.options.methodOpt),
432
+ methodOpts,
397
433
  })
398
434
  tokenSymbol = pluginResult.tokenSymbol
399
435
  tokenDecimals = pluginResult.tokenDecimals
@@ -9,7 +9,9 @@ import { Base64 } from 'ox'
9
9
  import type { Address } from 'viem'
10
10
  import { createClient, http } from 'viem'
11
11
  import { privateKeyToAccount } from 'viem/accounts'
12
+ import { Actions } from 'viem/tempo'
12
13
 
14
+ import type * as Challenge from '../../Challenge.js'
13
15
  import { normalizeHeaders } from '../../client/internal/Fetch.js'
14
16
  import * as Constants from '../../Constants.js'
15
17
  import * as Credential from '../../Credential.js'
@@ -30,10 +32,88 @@ import {
30
32
  pc,
31
33
  resolveChain,
32
34
  resolveRpcUrl,
35
+ type Network,
33
36
  } from '../utils.js'
34
37
  import { createPlugin, type Plugin } from './plugin.js'
35
38
 
36
39
  const packageJson = createRequire(import.meta.url)('../../../package.json') as { name: string }
40
+ const booleanOption = z.union([
41
+ z.boolean(),
42
+ z.literal('true').transform(() => true),
43
+ z.literal('false').transform(() => false),
44
+ ])
45
+ const tempoOptionSchema = z.object({
46
+ autoSwap: z.optional(booleanOption),
47
+ channel: z.optional(z.coerce.string()),
48
+ deposit: z.optional(z.union([z.string(), z.number()])),
49
+ payWith: z.optional(z.string()),
50
+ slippage: z.optional(z.coerce.number()),
51
+ tokenIn: z.optional(z.string()),
52
+ })
53
+ const tempoOptionKeys = [
54
+ 'autoSwap',
55
+ 'channel',
56
+ 'deposit',
57
+ 'payWith',
58
+ 'slippage',
59
+ 'tokenIn',
60
+ ] as const
61
+ const tempoChargeChallengeScoreOrder = [
62
+ 'payable',
63
+ 'unknownBalance',
64
+ 'insufficientBalance',
65
+ 'unsupported',
66
+ ] as const
67
+
68
+ type TempoChargeChallengeScore = (typeof tempoChargeChallengeScoreOrder)[number]
69
+
70
+ /**
71
+ * Orders Tempo charge challenges by whether the configured wallet can satisfy them.
72
+ *
73
+ * Direct balances are preferred first. Unsupported or unpayable challenges keep
74
+ * their original relative order.
75
+ */
76
+ export async function orderTempoChargeChallengesByBalance(
77
+ challenges: readonly Challenge.Challenge[],
78
+ ctx: {
79
+ options: {
80
+ account?: string | undefined
81
+ network?: Network | undefined
82
+ rpcUrl?: string | undefined
83
+ }
84
+ },
85
+ ): Promise<Challenge.Challenge[]> {
86
+ const tempoPositions: number[] = []
87
+ const tempoChallenges: Challenge.Challenge[] = []
88
+ for (const [index, challenge] of challenges.entries()) {
89
+ if (!isTempoChargeChallenge(challenge)) continue
90
+ tempoPositions.push(index)
91
+ tempoChallenges.push(challenge)
92
+ }
93
+ if (tempoChallenges.length < 2) return [...challenges]
94
+
95
+ const probe = await resolveBalanceProbe(ctx).catch(() => undefined)
96
+ if (!probe) return [...challenges]
97
+
98
+ const scored = await Promise.all(
99
+ tempoChallenges.map(async (challenge, index) => ({
100
+ challenge,
101
+ index,
102
+ score: await scoreTempoChargeChallenge(challenge, probe),
103
+ })),
104
+ )
105
+ scored.sort(
106
+ (left, right) =>
107
+ tempoChargeChallengeScoreOrder.indexOf(left.score) -
108
+ tempoChargeChallengeScoreOrder.indexOf(right.score) || left.index - right.index,
109
+ )
110
+
111
+ const ordered = [...challenges]
112
+ for (const [index, position] of tempoPositions.entries()) {
113
+ ordered[position] = scored[index]!.challenge
114
+ }
115
+ return ordered
116
+ }
37
117
 
38
118
  export function tempo() {
39
119
  let _session:
@@ -60,23 +140,7 @@ export function tempo() {
60
140
  const accountName = resolveAccountName(options.account)
61
141
  const challengeRequest = challenge.request as Record<string, unknown>
62
142
  const currency = challengeRequest.currency as string | undefined
63
- const booleanOption = z.union([
64
- z.boolean(),
65
- z.literal('true').transform(() => true),
66
- z.literal('false').transform(() => false),
67
- ])
68
- const tempoOpts = parseOptions(
69
- z.object({
70
- autoSwap: z.optional(booleanOption),
71
- channel: z.optional(z.coerce.string()),
72
- deposit: z.optional(z.union([z.string(), z.number()])),
73
- payWith: z.optional(z.string()),
74
- slippage: z.optional(z.coerce.number()),
75
- tokenIn: z.optional(z.string()),
76
- }),
77
- methodOpts,
78
- ['autoSwap', 'channel', 'deposit', 'payWith', 'slippage', 'tokenIn'],
79
- )
143
+ const tempoOpts = parseTempoOptions(methodOpts)
80
144
  const autoSwap = resolveAutoSwap({
81
145
  autoSwap: tempoOpts.autoSwap ?? options.autoSwap,
82
146
  payWith: tempoOpts.payWith ?? options.payWith,
@@ -746,6 +810,86 @@ function detectTerminalBg(
746
810
 
747
811
  // --- Account helpers ---
748
812
 
813
+ type BalanceProbe = {
814
+ account: Address
815
+ client: ReturnType<typeof createClient>
816
+ }
817
+
818
+ function parseTempoOptions(rawOptions: Record<string, string>) {
819
+ return parseOptions(tempoOptionSchema, rawOptions, tempoOptionKeys)
820
+ }
821
+
822
+ function isTempoChargeChallenge(challenge: Challenge.Challenge): boolean {
823
+ return challenge.method === 'tempo' && challenge.intent === 'charge'
824
+ }
825
+
826
+ async function resolveBalanceProbe(ctx: {
827
+ options: {
828
+ account?: string | undefined
829
+ network?: Network | undefined
830
+ rpcUrl?: string | undefined
831
+ }
832
+ }): Promise<BalanceProbe | undefined> {
833
+ const accountName = resolveAccountName(ctx.options.account)
834
+ const privateKey =
835
+ process.env.MPPX_PRIVATE_KEY?.trim() ||
836
+ (isTempoAccount(accountName) ? undefined : await createKeychain(accountName).get())
837
+
838
+ let account: Address | undefined
839
+ if (privateKey) {
840
+ account = privateKeyToAccount(privateKey as `0x${string}`).address
841
+ } else if (isTempoAccount(accountName) && hasTempoCliSync()) {
842
+ account = resolveTempoAccount(accountName)?.wallet_address as Address | undefined
843
+ } else {
844
+ const fallback = fallbackFromTempo()
845
+ if (fallback) {
846
+ const fallbackKey = await createKeychain(fallback).get()
847
+ if (fallbackKey) account = privateKeyToAccount(fallbackKey as `0x${string}`).address
848
+ }
849
+ }
850
+ if (!account) return undefined
851
+
852
+ const rpcUrl = resolveRpcUrl(ctx.options.rpcUrl, { network: ctx.options.network })
853
+ const client = createClient({
854
+ chain: await resolveChain({ network: ctx.options.network, rpcUrl }),
855
+ transport: http(rpcUrl),
856
+ })
857
+
858
+ return {
859
+ account,
860
+ client,
861
+ }
862
+ }
863
+
864
+ async function scoreTempoChargeChallenge(
865
+ challenge: Challenge.Challenge,
866
+ probe: BalanceProbe,
867
+ ): Promise<TempoChargeChallengeScore> {
868
+ const request = challenge.request as Record<string, unknown>
869
+ const currency = request.currency
870
+ const amount = request.amount
871
+ if (typeof currency !== 'string' || typeof amount !== 'string') return 'unsupported'
872
+
873
+ const requiredChainId = (request.methodDetails as { chainId?: number } | undefined)?.chainId
874
+ if (requiredChainId && probe.client.chain?.id && requiredChainId !== probe.client.chain.id) {
875
+ return 'unsupported'
876
+ }
877
+
878
+ const amountRaw = BigInt(amount)
879
+ if (amountRaw === 0n) return 'payable'
880
+
881
+ const balance = await Actions.token
882
+ .getBalance(probe.client as never, {
883
+ account: probe.account,
884
+ token: currency as Address,
885
+ })
886
+ .then((value) => value.amount)
887
+ .catch(() => undefined)
888
+ if (balance === undefined) return 'unknownBalance'
889
+ if (balance >= amountRaw) return 'payable'
890
+ return 'insufficientBalance'
891
+ }
892
+
749
893
  function parseOptions<const schema extends z.ZodType>(
750
894
  schema: schema,
751
895
  rawOptions: unknown,
package/src/cli/utils.ts CHANGED
@@ -287,6 +287,10 @@ export const testnetTokens = [
287
287
  '0x20c0000000000000000000000000000000000003',
288
288
  ] as const
289
289
 
290
+ function toBaseUnitAmount(value: bigint | { amount: bigint }) {
291
+ return typeof value === 'bigint' ? value : value.amount
292
+ }
293
+
290
294
  export function isTestnet(chain: Chain) {
291
295
  return chain.id !== tempoMainnet.id
292
296
  }
@@ -295,10 +299,18 @@ export async function fetchTokenInfo(
295
299
  client: ReturnType<typeof createClient>,
296
300
  token: Address,
297
301
  account: Address,
298
- ) {
302
+ ): Promise<{
303
+ balance: bigint
304
+ decimals: number
305
+ symbol: string
306
+ token: Address
307
+ }> {
299
308
  const { Actions } = await import('viem/tempo')
300
309
  const [balance, metadata] = await Promise.all([
301
- Actions.token.getBalance(client, { account, token }).catch(() => 0n),
310
+ Actions.token
311
+ .getBalance(client, { account, token })
312
+ .then(toBaseUnitAmount)
313
+ .catch(() => 0n),
302
314
  Actions.token.getMetadata(client, { token }).catch(() => ({ symbol: token as string })),
303
315
  ])
304
316
  const knownSymbols: Record<string, string> = {
@@ -62,6 +62,15 @@ describe('create.Config', () => {
62
62
  expectTypeOf<Config>().toHaveProperty('methods')
63
63
  })
64
64
 
65
+ test('accepts payment retry cap', () => {
66
+ const mppx = Mppx.create({
67
+ maxPaymentRetries: 1,
68
+ methods: [charge()],
69
+ })
70
+
71
+ expectTypeOf(mppx.fetch).toBeFunction()
72
+ })
73
+
65
74
  test('paymentPreferences callback exposes typed method keys', () => {
66
75
  const mppx = Mppx.create({
67
76
  methods: [tempo({ account: {} as Account })],
@@ -41,6 +41,47 @@ describe('Mppx.create', () => {
41
41
  expect(mppx.transport.name).toBe('mcp')
42
42
  })
43
43
 
44
+ test('behavior: passes maxPaymentRetries to fetch wrapper', async () => {
45
+ const testMethod = Method.toClient(
46
+ Method.from({
47
+ name: 'test',
48
+ intent: 'test',
49
+ schema: Methods.charge.schema,
50
+ }),
51
+ {
52
+ async createCredential() {
53
+ return 'credential'
54
+ },
55
+ },
56
+ )
57
+ const challenge = Challenge.from({
58
+ id: 'retry-cap',
59
+ realm,
60
+ method: 'test',
61
+ intent: 'test',
62
+ request: { amount: '1' },
63
+ })
64
+ let callCount = 0
65
+ const fetch = vi.fn(async () => {
66
+ callCount++
67
+ return new Response(null, {
68
+ status: 402,
69
+ headers: { 'WWW-Authenticate': Challenge.serialize(challenge) },
70
+ })
71
+ })
72
+ const mppx = Mppx.create({
73
+ fetch: fetch as typeof globalThis.fetch,
74
+ maxPaymentRetries: 1,
75
+ methods: [testMethod],
76
+ polyfill: false,
77
+ })
78
+
79
+ const response = await mppx.fetch('https://example.com/api')
80
+
81
+ expect(response.status).toBe(402)
82
+ expect(callCount).toBe(2)
83
+ })
84
+
44
85
  test('behavior: with multiple methods', () => {
45
86
  const stripeCharge = Method.from({
46
87
  name: 'stripe',
@@ -100,6 +100,7 @@ export function create<
100
100
  >,
101
101
  >(config: create.Config<methods, transport>): Mppx<methods, transport> {
102
102
  const {
103
+ maxPaymentRetries,
103
104
  onChallenge,
104
105
  orderChallenges,
105
106
  polyfill = true,
@@ -122,6 +123,7 @@ export function create<
122
123
  acceptPaymentPolicy,
123
124
  ...(config.fetch && { fetch: config.fetch }),
124
125
  eventDispatcher: events,
126
+ ...(maxPaymentRetries !== undefined && { maxPaymentRetries }),
125
127
  ...(resolvedOnChallenge && { onChallenge: resolvedOnChallenge }),
126
128
  ...(orderChallenges && { orderChallenges }),
127
129
  methods,
@@ -298,6 +300,8 @@ export declare namespace create {
298
300
  },
299
301
  ) => Promise<string | undefined>)
300
302
  | undefined
303
+ /** Maximum number of payment challenge retries after the initial response. @default 3 */
304
+ maxPaymentRetries?: Fetch.from.Config['maxPaymentRetries'] | undefined
301
305
  /** Filters and sorts supported challenges before credential creation. */
302
306
  orderChallenges?: AcceptPayment.OrderChallenges<FlattenMethods<methods>> | undefined
303
307
  /** Client-declared supported payment methods, keyed by typed `method/intent` strings. */
@@ -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
 
@@ -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 'error' in response && response.error?.code === Mcp.paymentRequiredCode
164
+ return !!paymentRequiredData(response)
159
165
  },
160
166
 
161
167
  getChallenges(response) {
162
- if (!('error' in response) || !response.error) throw new Error('Response is not an error.')
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
- if (!('error' in response) || !response.error) throw new Error('Response is not an error.')
170
- const challenge = response.error.data?.challenges[0]
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]>()