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
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 {
|
|
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
|
|
361
|
-
|
|
362
|
-
|
|
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 =
|
|
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
|
|
432
|
+
methodOpts,
|
|
397
433
|
})
|
|
398
434
|
tokenSymbol = pluginResult.tokenSymbol
|
|
399
435
|
tokenDecimals = pluginResult.tokenDecimals
|
package/src/cli/plugins/tempo.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 })],
|
package/src/client/Mppx.test.ts
CHANGED
|
@@ -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',
|
package/src/client/Mppx.ts
CHANGED
|
@@ -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
|
|
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]>()
|