mppx 0.5.11 → 0.5.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +41 -16
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/cli/config.d.ts +6 -4
  6. package/dist/cli/config.d.ts.map +1 -1
  7. package/dist/cli/config.js.map +1 -1
  8. package/dist/cli/internal.d.ts +8 -0
  9. package/dist/cli/internal.d.ts.map +1 -1
  10. package/dist/cli/internal.js +33 -3
  11. package/dist/cli/internal.js.map +1 -1
  12. package/dist/cli/plugins/plugin.d.ts +2 -0
  13. package/dist/cli/plugins/plugin.d.ts.map +1 -1
  14. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  15. package/dist/cli/plugins/stripe.js +3 -0
  16. package/dist/cli/plugins/stripe.js.map +1 -1
  17. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  18. package/dist/cli/plugins/tempo.js +3 -0
  19. package/dist/cli/plugins/tempo.js.map +1 -1
  20. package/dist/client/Mppx.d.ts +10 -1
  21. package/dist/client/Mppx.d.ts.map +1 -1
  22. package/dist/client/Mppx.js +17 -5
  23. package/dist/client/Mppx.js.map +1 -1
  24. package/dist/client/Transport.d.ts +2 -0
  25. package/dist/client/Transport.d.ts.map +1 -1
  26. package/dist/client/Transport.js +11 -0
  27. package/dist/client/Transport.js.map +1 -1
  28. package/dist/client/internal/Fetch.d.ts +3 -0
  29. package/dist/client/internal/Fetch.d.ts.map +1 -1
  30. package/dist/client/internal/Fetch.js +65 -19
  31. package/dist/client/internal/Fetch.js.map +1 -1
  32. package/dist/internal/AcceptPayment.d.ts +72 -0
  33. package/dist/internal/AcceptPayment.d.ts.map +1 -0
  34. package/dist/internal/AcceptPayment.js +185 -0
  35. package/dist/internal/AcceptPayment.js.map +1 -0
  36. package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
  37. package/dist/mcp-sdk/client/McpClient.js +8 -4
  38. package/dist/mcp-sdk/client/McpClient.js.map +1 -1
  39. package/dist/server/Mppx.d.ts +1 -1
  40. package/dist/server/Mppx.d.ts.map +1 -1
  41. package/dist/server/Mppx.js +33 -24
  42. package/dist/server/Mppx.js.map +1 -1
  43. package/dist/server/internal/html/config.d.ts.map +1 -1
  44. package/dist/server/internal/html/config.js +8 -1
  45. package/dist/server/internal/html/config.js.map +1 -1
  46. package/dist/stripe/internal/constants.d.ts +8 -0
  47. package/dist/stripe/internal/constants.d.ts.map +1 -0
  48. package/dist/stripe/internal/constants.js +8 -0
  49. package/dist/stripe/internal/constants.js.map +1 -0
  50. package/dist/stripe/server/Charge.d.ts.map +1 -1
  51. package/dist/stripe/server/Charge.js +23 -5
  52. package/dist/stripe/server/Charge.js.map +1 -1
  53. package/dist/tempo/Methods.d.ts +8 -0
  54. package/dist/tempo/Methods.d.ts.map +1 -1
  55. package/dist/tempo/Methods.js +6 -2
  56. package/dist/tempo/Methods.js.map +1 -1
  57. package/dist/tempo/Proof.d.ts +12 -0
  58. package/dist/tempo/Proof.d.ts.map +1 -0
  59. package/dist/tempo/Proof.js +10 -0
  60. package/dist/tempo/Proof.js.map +1 -0
  61. package/dist/tempo/client/Charge.d.ts +11 -1
  62. package/dist/tempo/client/Charge.d.ts.map +1 -1
  63. package/dist/tempo/client/Charge.js +14 -2
  64. package/dist/tempo/client/Charge.js.map +1 -1
  65. package/dist/tempo/client/Methods.d.ts +6 -0
  66. package/dist/tempo/client/Methods.d.ts.map +1 -1
  67. package/dist/tempo/index.d.ts +1 -0
  68. package/dist/tempo/index.d.ts.map +1 -1
  69. package/dist/tempo/index.js +1 -0
  70. package/dist/tempo/index.js.map +1 -1
  71. package/dist/tempo/internal/fee-payer.d.ts +8 -0
  72. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  73. package/dist/tempo/internal/fee-payer.js +29 -3
  74. package/dist/tempo/internal/fee-payer.js.map +1 -1
  75. package/dist/tempo/server/Charge.d.ts +17 -0
  76. package/dist/tempo/server/Charge.d.ts.map +1 -1
  77. package/dist/tempo/server/Charge.js +69 -4
  78. package/dist/tempo/server/Charge.js.map +1 -1
  79. package/dist/tempo/server/Methods.d.ts +6 -0
  80. package/dist/tempo/server/Methods.d.ts.map +1 -1
  81. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  82. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  83. package/dist/tempo/server/internal/html.gen.js +1 -1
  84. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  85. package/package.json +2 -2
  86. package/src/cli/cli.test.ts +278 -0
  87. package/src/cli/cli.ts +47 -16
  88. package/src/cli/config.ts +10 -4
  89. package/src/cli/internal.ts +59 -3
  90. package/src/cli/plugins/plugin.ts +3 -0
  91. package/src/cli/plugins/stripe.ts +3 -0
  92. package/src/cli/plugins/tempo.ts +3 -0
  93. package/src/client/Mppx.test-d.ts +33 -0
  94. package/src/client/Mppx.test.ts +130 -1
  95. package/src/client/Mppx.ts +35 -5
  96. package/src/client/Transport.test.ts +88 -55
  97. package/src/client/Transport.ts +13 -0
  98. package/src/client/internal/Fetch.browser.test.ts +16 -13
  99. package/src/client/internal/Fetch.test.ts +307 -10
  100. package/src/client/internal/Fetch.ts +85 -19
  101. package/src/internal/AcceptPayment.test.ts +211 -0
  102. package/src/internal/AcceptPayment.ts +304 -0
  103. package/src/mcp-sdk/client/McpClient.ts +11 -5
  104. package/src/server/Mppx.test.ts +141 -44
  105. package/src/server/Mppx.ts +43 -23
  106. package/src/server/Transport.test.ts +20 -0
  107. package/src/server/internal/html/config.ts +9 -1
  108. package/src/stripe/internal/constants.ts +7 -0
  109. package/src/stripe/server/Charge.ts +22 -4
  110. package/src/tempo/Methods.test.ts +25 -0
  111. package/src/tempo/Methods.ts +30 -22
  112. package/src/tempo/Proof.test-d.ts +13 -0
  113. package/src/tempo/Proof.test.ts +31 -0
  114. package/src/tempo/Proof.ts +13 -0
  115. package/src/tempo/client/Charge.ts +20 -6
  116. package/src/tempo/client/SessionManager.test.ts +4 -7
  117. package/src/tempo/index.ts +1 -0
  118. package/src/tempo/internal/fee-payer.test.ts +75 -1
  119. package/src/tempo/internal/fee-payer.ts +41 -3
  120. package/src/tempo/server/Charge.test.ts +309 -1
  121. package/src/tempo/server/Charge.ts +99 -1
  122. package/src/tempo/server/internal/html/main.ts +2 -2
  123. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -0,0 +1,211 @@
1
+ import { describe, expect, test } from 'vp/test'
2
+
3
+ import * as AcceptPayment from './AcceptPayment.js'
4
+
5
+ function stripIndex(entry: AcceptPayment.Entry) {
6
+ const { index: _index, ...rest } = entry
7
+ return rest
8
+ }
9
+
10
+ describe('AcceptPayment', () => {
11
+ test('resolve builds a typed header from methods and overrides', () => {
12
+ const resolved = AcceptPayment.resolve(
13
+ [
14
+ { name: 'tempo', intent: 'charge' },
15
+ { name: 'tempo', intent: 'session' },
16
+ { name: 'stripe', intent: 'charge' },
17
+ ] as const,
18
+ ({ tempo, stripe }) => ({
19
+ [stripe.charge]: 0.5,
20
+ [tempo.session]: 0,
21
+ }),
22
+ )
23
+
24
+ expect(resolved.header).toBe('tempo/charge, tempo/session;q=0, stripe/charge;q=0.5')
25
+ expect(resolved.keys.tempo.charge).toBe('tempo/charge')
26
+ expect(resolved.keys.tempo.session).toBe('tempo/session')
27
+ expect(resolved.keys.stripe.charge).toBe('stripe/charge')
28
+ })
29
+
30
+ test('parse supports q-values and wildcards', () => {
31
+ expect(
32
+ AcceptPayment.parse('tempo/*, stripe/charge;q=0.5, */session;q=0').map(stripIndex),
33
+ ).toEqual([
34
+ { intent: '*', method: 'tempo', q: 1 },
35
+ { intent: 'charge', method: 'stripe', q: 0.5 },
36
+ { intent: 'session', method: '*', q: 0 },
37
+ ])
38
+ })
39
+
40
+ test('parse raw header vectors into normalized entries', () => {
41
+ const vectors = [
42
+ {
43
+ header: 'tempo/charge',
44
+ entries: [{ intent: 'charge', method: 'tempo', q: 1 }],
45
+ normalized: 'tempo/charge',
46
+ },
47
+ {
48
+ header: ' stripe/charge ; q = 0.25 , */session ; q=0 ',
49
+ entries: [
50
+ { intent: 'charge', method: 'stripe', q: 0.25 },
51
+ { intent: 'session', method: '*', q: 0 },
52
+ ],
53
+ normalized: 'stripe/charge;q=0.25, */session;q=0',
54
+ },
55
+ {
56
+ header: 'tempo/*;q=1, tempo/charge;q=0, stripe/*;q=0.5',
57
+ entries: [
58
+ { intent: '*', method: 'tempo', q: 1 },
59
+ { intent: 'charge', method: 'tempo', q: 0 },
60
+ { intent: '*', method: 'stripe', q: 0.5 },
61
+ ],
62
+ normalized: 'tempo/*, tempo/charge;q=0, stripe/*;q=0.5',
63
+ },
64
+ ] as const
65
+
66
+ for (const { entries, header, normalized } of vectors) {
67
+ const parsed = AcceptPayment.parse(header)
68
+ expect(parsed.map(stripIndex)).toEqual(entries)
69
+ expect(AcceptPayment.serialize(parsed)).toBe(normalized)
70
+ }
71
+ })
72
+
73
+ test('parse rejects empty and malformed headers', () => {
74
+ expect(() => AcceptPayment.parse('')).toThrow('Accept-Payment header is empty.')
75
+ expect(() => AcceptPayment.parse('tempo')).toThrow('Invalid Accept-Payment entry: tempo')
76
+ expect(() => AcceptPayment.parse('Tempo/charge')).toThrow(
77
+ 'Invalid Accept-Payment method: Tempo',
78
+ )
79
+ expect(() => AcceptPayment.parse('tempo/charge;q')).toThrow(
80
+ 'Invalid Accept-Payment parameter: q',
81
+ )
82
+ expect(() => AcceptPayment.parse('tempo/charge;q=1.001')).toThrow('Expected an HTTP qvalue')
83
+ })
84
+
85
+ test('rank prefers higher q then preserves offer order for ties', () => {
86
+ const offers = [
87
+ { method: 'tempo', intent: 'charge' },
88
+ { method: 'stripe', intent: 'charge' },
89
+ { method: 'tempo', intent: 'session' },
90
+ ]
91
+ const preferences = AcceptPayment.parse(
92
+ 'stripe/charge;q=0.5, tempo/charge;q=0.9, tempo/session;q=0.9',
93
+ )
94
+
95
+ expect(AcceptPayment.rank(offers, preferences)).toEqual([
96
+ { method: 'tempo', intent: 'charge' },
97
+ { method: 'tempo', intent: 'session' },
98
+ { method: 'stripe', intent: 'charge' },
99
+ ])
100
+ })
101
+
102
+ test('rank prefers more specific wildcard matches', () => {
103
+ const preferences = AcceptPayment.parse('tempo/*;q=0.5, tempo/session;q=0.5, */*;q=0.1')
104
+
105
+ expect(AcceptPayment.rank([{ method: 'tempo', intent: 'session' }], preferences)).toEqual([
106
+ { method: 'tempo', intent: 'session' },
107
+ ])
108
+ })
109
+
110
+ test('rank applies the most specific match before q-value filtering', () => {
111
+ const preferences = AcceptPayment.parse('tempo/*;q=1, tempo/charge;q=0, stripe/*;q=0.5')
112
+
113
+ expect(
114
+ AcceptPayment.rank(
115
+ [
116
+ { method: 'tempo', intent: 'charge' },
117
+ { method: 'stripe', intent: 'charge' },
118
+ ],
119
+ preferences,
120
+ ),
121
+ ).toEqual([{ method: 'stripe', intent: 'charge' }])
122
+ })
123
+
124
+ test('rank excludes offers matched only by q=0 preferences', () => {
125
+ const preferences = AcceptPayment.parse('tempo/charge;q=0, stripe/*;q=0.1')
126
+
127
+ expect(
128
+ AcceptPayment.rank(
129
+ [
130
+ { method: 'tempo', intent: 'charge' },
131
+ { method: 'stripe', intent: 'session' },
132
+ ],
133
+ preferences,
134
+ ),
135
+ ).toEqual([{ method: 'stripe', intent: 'session' }])
136
+ })
137
+
138
+ test('selectChallenge returns the best supported offer', () => {
139
+ const selected = AcceptPayment.selectChallenge(
140
+ [
141
+ { id: '1', intent: 'charge', method: 'stripe', realm: 'test', request: {} },
142
+ { id: '2', intent: 'session', method: 'tempo', realm: 'test', request: {} },
143
+ ],
144
+ [
145
+ { name: 'tempo', intent: 'session' },
146
+ { name: 'stripe', intent: 'charge' },
147
+ ] as const,
148
+ AcceptPayment.parse('stripe/charge;q=0.5, tempo/session;q=0.9'),
149
+ )
150
+
151
+ expect(selected?.challenge.id).toBe('2')
152
+ expect(selected?.method).toEqual({ name: 'tempo', intent: 'session' })
153
+ })
154
+
155
+ test('selectChallenge honors a specific opt-out over a broader wildcard', () => {
156
+ const selected = AcceptPayment.selectChallenge(
157
+ [
158
+ { id: '1', intent: 'charge', method: 'tempo', realm: 'test', request: {} },
159
+ { id: '2', intent: 'charge', method: 'stripe', realm: 'test', request: {} },
160
+ ],
161
+ [
162
+ { name: 'tempo', intent: 'charge' },
163
+ { name: 'stripe', intent: 'charge' },
164
+ ] as const,
165
+ AcceptPayment.parse('tempo/*;q=1, tempo/charge;q=0, stripe/*;q=0.5'),
166
+ )
167
+
168
+ expect(selected?.challenge.id).toBe('2')
169
+ expect(selected?.method).toEqual({ name: 'stripe', intent: 'charge' })
170
+ })
171
+
172
+ test('selectChallenge returns undefined when supported offers are disabled', () => {
173
+ const selected = AcceptPayment.selectChallenge(
174
+ [
175
+ { id: '1', intent: 'charge', method: 'tempo', realm: 'test', request: {} },
176
+ { id: '2', intent: 'charge', method: 'stripe', realm: 'test', request: {} },
177
+ ],
178
+ [{ name: 'tempo', intent: 'charge' }] as const,
179
+ AcceptPayment.parse('tempo/charge;q=0, stripe/charge'),
180
+ )
181
+
182
+ expect(selected).toBeUndefined()
183
+ })
184
+
185
+ test('throws for unknown payment preference keys', () => {
186
+ expect(() =>
187
+ AcceptPayment.resolve(
188
+ [{ name: 'tempo', intent: 'charge' }] as const,
189
+ {
190
+ 'stripe/charge': 1,
191
+ } as never,
192
+ ),
193
+ ).toThrow('Unknown payment preference "stripe/charge"')
194
+ })
195
+
196
+ test('throws for invalid configured q-values', () => {
197
+ expect(() =>
198
+ AcceptPayment.resolve([{ name: 'tempo', intent: 'charge' }] as const, {
199
+ 'tempo/charge': 0.3333,
200
+ }),
201
+ ).toThrow('Expected at most 3 decimal places')
202
+ })
203
+
204
+ test('throws for non-finite configured q-values', () => {
205
+ expect(() =>
206
+ AcceptPayment.resolve([{ name: 'tempo', intent: 'charge' }] as const, {
207
+ 'tempo/charge': Number.POSITIVE_INFINITY,
208
+ }),
209
+ ).toThrow('Expected a finite number')
210
+ })
211
+ })
@@ -0,0 +1,304 @@
1
+ import type * as Challenge from '../Challenge.js'
2
+
3
+ type MethodLike = {
4
+ intent: string
5
+ name: string
6
+ }
7
+
8
+ /** Typed `method/intent` key for a configured payment capability. */
9
+ export type Key<methods extends readonly MethodLike[]> = methods[number] extends infer mi
10
+ ? mi extends { name: infer name extends string; intent: infer intent extends string }
11
+ ? `${name}/${intent}`
12
+ : never
13
+ : never
14
+
15
+ /** Method keys grouped by method name for ergonomic config callbacks. */
16
+ export type KeyTree<methods extends readonly MethodLike[]> = {
17
+ [name in methods[number]['name']]: {
18
+ [mi in Extract<
19
+ methods[number],
20
+ { name: name }
21
+ > as mi['intent']]: `${mi['name']}/${mi['intent']}`
22
+ }
23
+ }
24
+
25
+ /** Per-capability q-values keyed by typed `method/intent` strings. */
26
+ export type Definition<methods extends readonly MethodLike[]> = Partial<
27
+ Record<Key<methods>, number>
28
+ >
29
+
30
+ /**
31
+ * Accept-Payment configuration.
32
+ *
33
+ * Callers may provide a plain definition map or a callback that receives a
34
+ * typed key tree for authoring preferences without string literals.
35
+ */
36
+ export type Config<methods extends readonly MethodLike[]> =
37
+ | Definition<methods>
38
+ | ((keys: KeyTree<methods>) => Definition<methods>)
39
+
40
+ /** Parsed Accept-Payment entry with its original declaration order. */
41
+ export type Entry = {
42
+ intent: string | '*'
43
+ method: string | '*'
44
+ q: number
45
+ index: number
46
+ }
47
+
48
+ /** Resolved negotiation data derived from client methods and config. */
49
+ export type Resolved<methods extends readonly MethodLike[]> = {
50
+ definition: Definition<methods>
51
+ entries: Entry[]
52
+ header: string
53
+ keys: KeyTree<methods>
54
+ }
55
+
56
+ type Match = Entry & { specificity: number }
57
+
58
+ /** Builds the typed key tree used by callback-style preference config. */
59
+ export function buildKeys<const methods extends readonly MethodLike[]>(
60
+ methods: methods,
61
+ ): KeyTree<methods> {
62
+ const keys: Record<string, Record<string, string>> = {}
63
+
64
+ for (const method of methods) {
65
+ const group = (keys[method.name] ??= {})
66
+ group[method.intent] = keyOf(method)
67
+ }
68
+
69
+ return keys as KeyTree<methods>
70
+ }
71
+
72
+ /** Resolves configured payment preferences into a header string and parsed entries. */
73
+ export function resolve<const methods extends readonly MethodLike[]>(
74
+ methods: methods,
75
+ config?: Config<methods>,
76
+ ): Resolved<methods> {
77
+ const keys = buildKeys(methods)
78
+ const definition = resolveDefinition(methods, keys, config)
79
+ const entries = methods.map((method, index) => ({
80
+ intent: method.intent,
81
+ method: method.name,
82
+ q: definition[keyOf(method) as Key<methods>] ?? 1,
83
+ index,
84
+ }))
85
+
86
+ return {
87
+ definition,
88
+ entries,
89
+ header: serialize(entries),
90
+ keys,
91
+ }
92
+ }
93
+
94
+ /** Parses an `Accept-Payment` header into normalized preference entries. */
95
+ export function parse(header: string): Entry[] {
96
+ const parts = header
97
+ .split(/\s*,\s*/)
98
+ .map((part) => part.trim())
99
+ .filter(Boolean)
100
+
101
+ if (parts.length === 0) throw new Error('Accept-Payment header is empty.')
102
+
103
+ return parts.map((part, index) => parseEntry(part, index))
104
+ }
105
+
106
+ /** Serializes preference entries to an `Accept-Payment` header value. */
107
+ export function serialize(entries: readonly Omit<Entry, 'index'>[] | readonly Entry[]): string {
108
+ return entries
109
+ .map(({ method, intent, q }) => {
110
+ const value = `${method}/${intent}`
111
+ return q === 1 ? value : `${value};q=${formatQ(q)}`
112
+ })
113
+ .join(', ')
114
+ }
115
+
116
+ /**
117
+ * Orders offered payment methods by the best matching client preference.
118
+ *
119
+ * More specific matches win before comparing q-values, so an explicit opt-out
120
+ * like `tempo/charge;q=0` overrides a broader wildcard such as `tempo/*;q=1`.
121
+ */
122
+ export function rank<const offer extends { intent: string; method: string }>(
123
+ offers: readonly offer[],
124
+ preferences: readonly Entry[],
125
+ ): offer[] {
126
+ return offers
127
+ .map((offer, index) => {
128
+ const match = bestMatch(offer, preferences)
129
+ return match && match.q > 0 ? { match, offer, index } : undefined
130
+ })
131
+ .filter((candidate): candidate is NonNullable<typeof candidate> => Boolean(candidate))
132
+ .sort((left, right) => right.match.q - left.match.q || left.index - right.index)
133
+ .map(({ offer }) => offer)
134
+ }
135
+
136
+ /** Selects the best supported challenge from a set of server offers. */
137
+ export function selectChallenge<const methods extends readonly MethodLike[]>(
138
+ challenges: readonly Challenge.Challenge[],
139
+ methods: methods,
140
+ preferences: readonly Entry[],
141
+ ):
142
+ | {
143
+ challenge: Challenge.Challenge
144
+ method: methods[number]
145
+ }
146
+ | undefined {
147
+ const methodByKey = new Map<string, methods[number]>()
148
+ for (const method of methods) {
149
+ const key = keyOf(method)
150
+ if (!methodByKey.has(key)) methodByKey.set(key, method)
151
+ }
152
+
153
+ const ranked = rank(
154
+ challenges.filter((challenge) => methodByKey.has(keyOf(challenge))),
155
+ preferences,
156
+ )
157
+ const challenge = ranked[0]
158
+ if (!challenge) return undefined
159
+
160
+ return {
161
+ challenge,
162
+ method: methodByKey.get(keyOf(challenge))!,
163
+ }
164
+ }
165
+
166
+ /** Returns the canonical `method/intent` key for a method or challenge-like value. */
167
+ export function keyOf(value: { intent: string; method?: string; name?: string }): string {
168
+ const method = value.method ?? value.name
169
+ if (!method) throw new Error('Missing payment method name.')
170
+ return `${method}/${value.intent}`
171
+ }
172
+
173
+ function bestMatch(
174
+ offer: { intent: string; method: string },
175
+ preferences: readonly Entry[],
176
+ ): Match | undefined {
177
+ let best: Match | undefined
178
+
179
+ for (const preference of preferences) {
180
+ if (!matches(offer, preference)) continue
181
+
182
+ const candidate = { ...preference, specificity: specificity(preference) }
183
+ if (
184
+ !best ||
185
+ candidate.specificity > best.specificity ||
186
+ (candidate.specificity === best.specificity && candidate.q > best.q) ||
187
+ (candidate.specificity === best.specificity &&
188
+ candidate.q === best.q &&
189
+ candidate.index < best.index)
190
+ ) {
191
+ best = candidate
192
+ }
193
+ }
194
+
195
+ return best
196
+ }
197
+
198
+ function matches(
199
+ offer: { intent: string; method: string },
200
+ preference: Pick<Entry, 'intent' | 'method'>,
201
+ ): boolean {
202
+ return (
203
+ (preference.method === '*' || preference.method === offer.method) &&
204
+ (preference.intent === '*' || preference.intent === offer.intent)
205
+ )
206
+ }
207
+
208
+ function specificity(preference: Pick<Entry, 'intent' | 'method'>): number {
209
+ return Number(preference.method !== '*') + Number(preference.intent !== '*')
210
+ }
211
+
212
+ function parseEntry(part: string, index: number): Entry {
213
+ const match =
214
+ /^(?<method>[^/;\s]+|\*)\s*\/\s*(?<intent>[^/;\s]+|\*)(?<params>(?:\s*;\s*.+)?)$/u.exec(part)
215
+ const method = match?.groups?.method
216
+ const intent = match?.groups?.intent
217
+
218
+ if (!method || !intent) {
219
+ throw new Error(`Invalid Accept-Payment entry: ${part}`)
220
+ }
221
+
222
+ assertToken(method, 'method')
223
+ assertToken(intent, 'intent')
224
+
225
+ let q = 1
226
+ for (const param of splitParameters(match.groups?.params)) {
227
+ if (!param) continue
228
+
229
+ const parameterMatch = /^(?<name>[A-Za-z0-9_-]+)\s*=\s*(?<value>\S+)$/u.exec(param)
230
+ const name = parameterMatch?.groups?.name
231
+ const rawValue = parameterMatch?.groups?.value
232
+
233
+ if (!name || !rawValue) {
234
+ throw new Error(`Invalid Accept-Payment parameter: ${param}`)
235
+ }
236
+ if (name !== 'q') continue
237
+ q = parseHeaderQ(rawValue, `Accept-Payment entry "${part}"`)
238
+ }
239
+
240
+ return { intent, method, q, index }
241
+ }
242
+
243
+ function splitParameters(value?: string): string[] {
244
+ return value ? value.split(/\s*;\s*/).filter(Boolean) : []
245
+ }
246
+
247
+ function resolveDefinition<const methods extends readonly MethodLike[]>(
248
+ methods: methods,
249
+ keys: KeyTree<methods>,
250
+ config?: Config<methods>,
251
+ ): Definition<methods> {
252
+ if (!config) return {} as Definition<methods>
253
+
254
+ const raw = typeof config === 'function' ? config(keys) : config
255
+ const allowed = new Set(methods.map((method) => keyOf(method)))
256
+ const normalized: Record<string, number> = {}
257
+
258
+ for (const [key, value] of Object.entries(raw ?? {})) {
259
+ if (!allowed.has(key)) {
260
+ throw new Error(`Unknown payment preference "${key}". Available: ${[...allowed].join(', ')}`)
261
+ }
262
+ normalized[key] = parseQ(value, `payment preference "${key}"`)
263
+ }
264
+
265
+ return normalized as Definition<methods>
266
+ }
267
+
268
+ function parseQ(value: unknown, context: string): number {
269
+ if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) {
270
+ throw new Error(`Invalid q-value for ${context}. Expected a finite number.`)
271
+ }
272
+ return assertQ(value, context)
273
+ }
274
+
275
+ function parseHeaderQ(value: string, context: string): number {
276
+ if (!/^0(?:\.\d{0,3})?$|^1(?:\.0{0,3})?$/.test(value)) {
277
+ throw new Error(`Invalid q-value for ${context}. Expected an HTTP qvalue.`)
278
+ }
279
+ return assertQ(Number(value), context)
280
+ }
281
+
282
+ function assertQ(value: number, context: string): number {
283
+ if (value < 0 || value > 1) {
284
+ throw new Error(`Invalid q-value for ${context}. Expected a value between 0 and 1.`)
285
+ }
286
+ const rounded = Math.round(value * 1000)
287
+ if (Math.abs(value * 1000 - rounded) > 1e-9) {
288
+ throw new Error(`Invalid q-value for ${context}. Expected at most 3 decimal places.`)
289
+ }
290
+ return rounded / 1000
291
+ }
292
+
293
+ function formatQ(value: number): string {
294
+ return value
295
+ .toFixed(3)
296
+ .replace(/\.0+$/, '')
297
+ .replace(/(\.\d*?)0+$/, '$1')
298
+ }
299
+
300
+ function assertToken(value: string, label: string): void {
301
+ if (value !== '*' && !/^[a-z0-9-]+$/.test(value)) {
302
+ throw new Error(`Invalid Accept-Payment ${label}: ${value}`)
303
+ }
304
+ }
@@ -3,6 +3,7 @@ import type { McpError } from '@modelcontextprotocol/sdk/types.js'
3
3
 
4
4
  import type * as Challenge from '../../Challenge.js'
5
5
  import * as Credential from '../../Credential.js'
6
+ import * as AcceptPayment from '../../internal/AcceptPayment.js'
6
7
  import * as core_Mcp from '../../Mcp.js'
7
8
  import type * as Method from '../../Method.js'
8
9
  import type * as z from '../../zod.js'
@@ -51,6 +52,7 @@ export function wrap<
51
52
  const methods extends readonly Method.AnyClient[],
52
53
  >(client: client, config: wrap.Config<methods>): wrap.McpClient<client, methods> {
53
54
  const { methods } = config
55
+ const paymentPreferences = AcceptPayment.resolve(methods)
54
56
 
55
57
  return {
56
58
  ...client,
@@ -76,11 +78,12 @@ export function wrap<
76
78
  const challenges = (error.data as { challenges?: Challenge.Challenge[] })?.challenges
77
79
  if (!challenges?.length) throw error
78
80
 
79
- // Select first challenge that matches an installed method intent
80
- const challenge = challenges.find((c) =>
81
- methods.some((m) => m.name === c.method && m.intent === c.intent),
81
+ const selected = AcceptPayment.selectChallenge(
82
+ challenges,
83
+ methods,
84
+ paymentPreferences.entries,
82
85
  )
83
- if (!challenge) {
86
+ if (!selected) {
84
87
  const available = challenges.map((c) => `${c.method}.${c.intent}`).join(', ')
85
88
  const installed = methods.map((m) => `${m.name}.${m.intent}`).join(', ')
86
89
  throw new Error(
@@ -89,7 +92,10 @@ export function wrap<
89
92
  )
90
93
  }
91
94
 
92
- const credential = await createCredential(challenge, { context, methods })
95
+ const credential = await createCredential(selected.challenge, {
96
+ context,
97
+ methods,
98
+ })
93
99
  const parsed = Credential.deserialize(credential)
94
100
 
95
101
  const retryResult = await client.callTool(