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.
- package/CHANGELOG.md +16 -0
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +41 -16
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/config.d.ts +6 -4
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/internal.d.ts +8 -0
- package/dist/cli/internal.d.ts.map +1 -1
- package/dist/cli/internal.js +33 -3
- package/dist/cli/internal.js.map +1 -1
- package/dist/cli/plugins/plugin.d.ts +2 -0
- package/dist/cli/plugins/plugin.d.ts.map +1 -1
- package/dist/cli/plugins/stripe.d.ts.map +1 -1
- package/dist/cli/plugins/stripe.js +3 -0
- package/dist/cli/plugins/stripe.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +3 -0
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/client/Mppx.d.ts +10 -1
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +17 -5
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/Transport.d.ts +2 -0
- package/dist/client/Transport.d.ts.map +1 -1
- package/dist/client/Transport.js +11 -0
- package/dist/client/Transport.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +3 -0
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +65 -19
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/internal/AcceptPayment.d.ts +72 -0
- package/dist/internal/AcceptPayment.d.ts.map +1 -0
- package/dist/internal/AcceptPayment.js +185 -0
- package/dist/internal/AcceptPayment.js.map +1 -0
- package/dist/mcp-sdk/client/McpClient.d.ts.map +1 -1
- package/dist/mcp-sdk/client/McpClient.js +8 -4
- package/dist/mcp-sdk/client/McpClient.js.map +1 -1
- package/dist/server/Mppx.d.ts +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +33 -24
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/internal/html/config.d.ts.map +1 -1
- package/dist/server/internal/html/config.js +8 -1
- package/dist/server/internal/html/config.js.map +1 -1
- package/dist/stripe/internal/constants.d.ts +8 -0
- package/dist/stripe/internal/constants.d.ts.map +1 -0
- package/dist/stripe/internal/constants.js +8 -0
- package/dist/stripe/internal/constants.js.map +1 -0
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +23 -5
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/tempo/Methods.d.ts +8 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +6 -2
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/Proof.d.ts +12 -0
- package/dist/tempo/Proof.d.ts.map +1 -0
- package/dist/tempo/Proof.js +10 -0
- package/dist/tempo/Proof.js.map +1 -0
- package/dist/tempo/client/Charge.d.ts +11 -1
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +14 -2
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +6 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/index.d.ts +1 -0
- package/dist/tempo/index.d.ts.map +1 -1
- package/dist/tempo/index.js +1 -0
- package/dist/tempo/index.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +8 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +29 -3
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +17 -0
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +69 -4
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +6 -0
- package/dist/tempo/server/Methods.d.ts.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/package.json +2 -2
- package/src/cli/cli.test.ts +278 -0
- package/src/cli/cli.ts +47 -16
- package/src/cli/config.ts +10 -4
- package/src/cli/internal.ts +59 -3
- package/src/cli/plugins/plugin.ts +3 -0
- package/src/cli/plugins/stripe.ts +3 -0
- package/src/cli/plugins/tempo.ts +3 -0
- package/src/client/Mppx.test-d.ts +33 -0
- package/src/client/Mppx.test.ts +130 -1
- package/src/client/Mppx.ts +35 -5
- package/src/client/Transport.test.ts +88 -55
- package/src/client/Transport.ts +13 -0
- package/src/client/internal/Fetch.browser.test.ts +16 -13
- package/src/client/internal/Fetch.test.ts +307 -10
- package/src/client/internal/Fetch.ts +85 -19
- package/src/internal/AcceptPayment.test.ts +211 -0
- package/src/internal/AcceptPayment.ts +304 -0
- package/src/mcp-sdk/client/McpClient.ts +11 -5
- package/src/server/Mppx.test.ts +141 -44
- package/src/server/Mppx.ts +43 -23
- package/src/server/Transport.test.ts +20 -0
- package/src/server/internal/html/config.ts +9 -1
- package/src/stripe/internal/constants.ts +7 -0
- package/src/stripe/server/Charge.ts +22 -4
- package/src/tempo/Methods.test.ts +25 -0
- package/src/tempo/Methods.ts +30 -22
- package/src/tempo/Proof.test-d.ts +13 -0
- package/src/tempo/Proof.test.ts +31 -0
- package/src/tempo/Proof.ts +13 -0
- package/src/tempo/client/Charge.ts +20 -6
- package/src/tempo/client/SessionManager.test.ts +4 -7
- package/src/tempo/index.ts +1 -0
- package/src/tempo/internal/fee-payer.test.ts +75 -1
- package/src/tempo/internal/fee-payer.ts +41 -3
- package/src/tempo/server/Charge.test.ts +309 -1
- package/src/tempo/server/Charge.ts +99 -1
- package/src/tempo/server/internal/html/main.ts +2 -2
- 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
|
-
|
|
80
|
-
|
|
81
|
-
methods
|
|
81
|
+
const selected = AcceptPayment.selectChallenge(
|
|
82
|
+
challenges,
|
|
83
|
+
methods,
|
|
84
|
+
paymentPreferences.entries,
|
|
82
85
|
)
|
|
83
|
-
if (!
|
|
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, {
|
|
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(
|