@zeam-labs/x402-mcp-bridge 2.0.3 → 2.0.4
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/README.md +17 -20
- package/index.mjs +15 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -87,11 +87,9 @@ accept EIP-3009, which needs no approval at all.
|
|
|
87
87
|
|
|
88
88
|
The stock x402 flow sends every call **unpaid**, reads the 402 it comes back
|
|
89
89
|
with, and then sends the same call again carrying payment. Two network round
|
|
90
|
-
trips for one call. At a 130ms round trip that is 260ms instead of 130ms
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
those it will answer, which is how a funded wallet gets locked out of a channel
|
|
94
|
-
it has money in.
|
|
90
|
+
trips for one call. At a 130ms round trip that is 260ms instead of 130ms. Those
|
|
91
|
+
probes are also unpaid calls, and a server may cap how many of those it will
|
|
92
|
+
answer, which is how a funded wallet gets locked out of a channel it has money in.
|
|
95
93
|
|
|
96
94
|
The terms are static and published, so this bridge reads them once from
|
|
97
95
|
`/.well-known/x402` at connect and attaches payment to its **first** request.
|
|
@@ -104,21 +102,21 @@ old probe-then-pay path rather than dropping your call.
|
|
|
104
102
|
|
|
105
103
|
## Holding a line
|
|
106
104
|
|
|
107
|
-
|
|
108
|
-
|
|
105
|
+
A server may sell **time** rather than calls, with a cheaper path than paying
|
|
106
|
+
per call. The pricing is the server's — read it in its published terms — and this
|
|
107
|
+
bridge drives it for you:
|
|
109
108
|
|
|
110
109
|
1. Deposit once — your first paid call does it for you.
|
|
111
|
-
2. Open a line on the endpoint's `/pay` websocket.
|
|
110
|
+
2. Open a line on the endpoint's `/pay` websocket. If the server challenges,
|
|
111
|
+
the bridge signs the challenge with your key to prove the channel is yours,
|
|
112
|
+
and gets back a credential.
|
|
112
113
|
3. Call the `tick` tool on a steady cadence, passing `{line: "<credential>"}`.
|
|
113
|
-
That is an ordinary paid call and it
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
no signature per call, nothing to serialize, and as many calls in flight at
|
|
117
|
-
once as you like.
|
|
114
|
+
That is an ordinary paid call and it pays the server for more time.
|
|
115
|
+
4. Every other call carries only `{line: "<credential>"}` and no payment, and as
|
|
116
|
+
many can be in flight at once as you like.
|
|
118
117
|
|
|
119
|
-
Stop ticking and the line
|
|
120
|
-
|
|
121
|
-
then stops existing.
|
|
118
|
+
Stop ticking and the line lapses. What the server charges, when a line lapses,
|
|
119
|
+
and whether unused time is kept are the server's to state, not this bridge's.
|
|
122
120
|
|
|
123
121
|
**This bridge drives a line for you.** `X402_LINE` controls it:
|
|
124
122
|
|
|
@@ -146,10 +144,9 @@ it happens once; lose it, or run the same key on a second machine, and it happen
|
|
|
146
144
|
again on the next call and then not after.
|
|
147
145
|
|
|
148
146
|
Two things worth knowing if you write your own client. Pay a tick against
|
|
149
|
-
`tickAccepts` from `/.well-known/x402
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
`channel_busy`.
|
|
147
|
+
`tickAccepts` from `/.well-known/x402`, not `accepts`. And never send two ticks at
|
|
148
|
+
once: a voucher signs a cumulative total, so a channel carries one payment at a
|
|
149
|
+
time and an overlapping tick is refused as `channel_busy`.
|
|
153
150
|
|
|
154
151
|
## Configuration
|
|
155
152
|
|
package/index.mjs
CHANGED
|
@@ -54,7 +54,6 @@ if (has('--help') || has('-h')) {
|
|
|
54
54
|
process.exit(0)
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
58
57
|
if (!KEY || !/^0x[0-9a-fA-F]{64}$/.test(KEY)) {
|
|
59
58
|
log('set X402_PRIVATE_KEY to a 0x-prefixed 32-byte key. It stays on this machine;')
|
|
60
59
|
log('it signs payment vouchers locally and is never sent anywhere.')
|
|
@@ -69,8 +68,6 @@ const stateDir = process.env.X402_STATE_DIR ??
|
|
|
69
68
|
join(homedir(), '.x402-mcp-bridge', new URL(UPSTREAM).host, account.address.toLowerCase())
|
|
70
69
|
mkdirSync(stateDir, { recursive: true })
|
|
71
70
|
|
|
72
|
-
// The seller's free scoped RPC, last. It reaches their node without a wallet,
|
|
73
|
-
// which is what makes it usable before you have paid them anything.
|
|
74
71
|
const readers = [
|
|
75
72
|
...(process.env.X402_RPC_URL ? [process.env.X402_RPC_URL] : []),
|
|
76
73
|
...(chain.rpcUrls?.default?.http ?? []),
|
|
@@ -97,11 +94,8 @@ let channelId = null
|
|
|
97
94
|
const watchedStorage = {
|
|
98
95
|
get: (k) => storage.get(k),
|
|
99
96
|
delete: (k) => storage.delete(k),
|
|
100
|
-
// Every write carries the seller's running total for this channel, so this is
|
|
101
|
-
// also where we learn what we have been billed -- see noteBilled below.
|
|
102
97
|
set: (k, ctx) => { channelId = k; noteBilled(ctx); return storage.set(k, ctx) },
|
|
103
98
|
}
|
|
104
|
-
// And across restarts: the scheme persists one file per channel.
|
|
105
99
|
try {
|
|
106
100
|
const f = readdirSync(join(stateDir, 'client')).find((n) => n.endsWith('.json'))
|
|
107
101
|
if (f) channelId = f.replace(/\.json$/, '')
|
|
@@ -118,8 +112,6 @@ let spentMicroUSD = 0
|
|
|
118
112
|
let quoteMicroUSD = null // set from the seller's own terms
|
|
119
113
|
let spendUnit = 'micro-USD' // what the numbers we report actually ARE
|
|
120
114
|
|
|
121
|
-
// Liberal in where it looks, strict about giving up. A server that does not say
|
|
122
|
-
// what a call costs in USD gets NO GUESS.
|
|
123
115
|
const quoteFromTerms = (j) => {
|
|
124
116
|
for (const c of [j?.rate?.deposit?.tickQuoteMicroUSD, j?.rate?.tickQuoteMicroUSD,
|
|
125
117
|
j?.rate?.microUSDPerCall, j?.quoteMicroUSD]) {
|
|
@@ -130,8 +122,6 @@ const quoteFromTerms = (j) => {
|
|
|
130
122
|
}
|
|
131
123
|
|
|
132
124
|
const microUSDOf = (units) => {
|
|
133
|
-
// The entry the selector actually chose. Not accepts[0] -- that is USDC, and
|
|
134
|
-
// converting WETH units by a USDC quote is the same units bug in a new hat.
|
|
135
125
|
const quoted = Number(chosenAccept?.amount ?? 0)
|
|
136
126
|
if (quoteMicroUSD === null || !(quoted > 0)) return units
|
|
137
127
|
return units * (quoteMicroUSD / quoted)
|
|
@@ -193,8 +183,6 @@ log(`paying as ${account.address} -> ${UPSTREAM}`)
|
|
|
193
183
|
const termsURL = new URL('/.well-known/x402', UPSTREAM).toString()
|
|
194
184
|
let accepts = null
|
|
195
185
|
let tickAccepts = null
|
|
196
|
-
// The seller's line facts: how often to tick, and what a millisecond costs.
|
|
197
|
-
// They belong to the service, not to one line, so they come from the manifest.
|
|
198
186
|
let lineFacts = { tickMs: 250, microUSDPerMs: null }
|
|
199
187
|
const loadTerms = async () => {
|
|
200
188
|
const r = await fetch(termsURL)
|
|
@@ -245,8 +233,6 @@ if (coldStart) log('no local channel state — probing once to learn where this
|
|
|
245
233
|
const payNow = async (name, args) => {
|
|
246
234
|
if (coldStart) {
|
|
247
235
|
coldStart = false
|
|
248
|
-
// autoPayment handles the 402 and pays the retry, and the 402 is what
|
|
249
|
-
// carries the channel state the client is missing.
|
|
250
236
|
return upstream.callTool(name, args)
|
|
251
237
|
}
|
|
252
238
|
const terms = name === 'tick' ? tickAccepts : accepts
|
|
@@ -302,19 +288,15 @@ const AUTO_SLOW_RUN = Number(process.env.X402_AUTO_SLOW_RUN ?? 4)
|
|
|
302
288
|
|
|
303
289
|
const line = { credential: null, socket: null, timer: null, tickMs: 250, lastUse: 0, opening: null }
|
|
304
290
|
|
|
305
|
-
// Rolling view of how fast the caller is actually going.
|
|
306
291
|
const rate = { lastCallAt: 0, fastRun: 0, slowRun: 0 }
|
|
307
292
|
|
|
308
293
|
function holdingIsCheaper() {
|
|
309
294
|
const now = Date.now()
|
|
310
295
|
const gap = rate.lastCallAt ? now - rate.lastCallAt : Infinity
|
|
311
296
|
rate.lastCallAt = now
|
|
312
|
-
// <=, not <: at exactly one call per hold window the two cost the same, and
|
|
313
|
-
// holding avoids a signature and a settlement per call.
|
|
314
297
|
if (gap <= line.tickMs) { rate.fastRun += 1; rate.slowRun = 0 }
|
|
315
298
|
else { rate.slowRun += 1; rate.fastRun = 0 }
|
|
316
299
|
|
|
317
|
-
// Already holding? Keep holding until several gaps in a row say otherwise.
|
|
318
300
|
if (line.credential) return rate.slowRun < AUTO_SLOW_RUN
|
|
319
301
|
return rate.fastRun >= AUTO_FAST_RUN
|
|
320
302
|
}
|
|
@@ -357,23 +339,32 @@ const openLine = () => {
|
|
|
357
339
|
let socket
|
|
358
340
|
try { socket = new WebSocket(wsURL()) } catch (e) { log(`line: ${e.message}`); return resolve(null) }
|
|
359
341
|
const give_up = setTimeout(() => { try { socket.close() } catch {} ; resolve(null) }, 10_000)
|
|
360
|
-
socket.onmessage = (ev) => {
|
|
342
|
+
socket.onmessage = async (ev) => {
|
|
361
343
|
let m; try { m = JSON.parse(String(ev.data)) } catch { return }
|
|
344
|
+
if (m.op === 'challenge') {
|
|
345
|
+
try {
|
|
346
|
+
const signature = await account.signMessage({ message: m.message })
|
|
347
|
+
socket.send(JSON.stringify({ op: 'prove', signature }))
|
|
348
|
+
} catch (e) {
|
|
349
|
+
clearTimeout(give_up); log(`line: cannot sign open challenge — ${e.message}`)
|
|
350
|
+
try { socket.close() } catch {}; resolve(null)
|
|
351
|
+
}
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
if (m.op === 'open_failed') {
|
|
355
|
+
clearTimeout(give_up); log(`line: open refused — ${m.error ?? m.why}`)
|
|
356
|
+
try { socket.close() } catch {}; resolve(null); return
|
|
357
|
+
}
|
|
362
358
|
if (m.op === 'opened') {
|
|
363
359
|
clearTimeout(give_up)
|
|
364
360
|
line.socket = socket
|
|
365
361
|
line.credential = m.credential
|
|
366
362
|
line.tickMs = lineFacts.tickMs
|
|
367
|
-
// A line that has just opened has not been idle. lastUse starts at 0, so
|
|
368
|
-
// without this the first timer fire sees an age of Date.now() and drops
|
|
369
|
-
// the line before anything can use it.
|
|
370
363
|
line.lastUse = Date.now()
|
|
371
364
|
log(`line open — ${lineFacts.microUSDPerMs ?? '?'} micro-USD/ms, ` +
|
|
372
365
|
`collateral buys ${m.buysMs ?? '?'}ms`)
|
|
373
366
|
const first = tick()
|
|
374
367
|
line.timer = setInterval(() => {
|
|
375
|
-
// Stop paying for a line nobody is using. The server closes an unused
|
|
376
|
-
// line on its own; this is us not paying for the window before it does.
|
|
377
368
|
if (LINE_MODE === 'auto' && Date.now() - line.lastUse > line.tickMs * 4) return dropLine('idle')
|
|
378
369
|
tick()
|
|
379
370
|
}, line.tickMs)
|
|
@@ -407,15 +398,6 @@ const callOnLine = async (name, args) => {
|
|
|
407
398
|
'Raise it, set X402_MAX_SPEND=0 to remove it, or restart the bridge.' }) }] }
|
|
408
399
|
}
|
|
409
400
|
line.lastUse = Date.now()
|
|
410
|
-
// TICK IS THE PAYMENT. The catalog publishes it, so a client can call it, and
|
|
411
|
-
// it went down the line path like any other tool -- sent with a credential and
|
|
412
|
-
// no payment, which is the one thing it cannot be. The server answered 402,
|
|
413
|
-
// and the retry dropped a working line. It also has to share the queue with
|
|
414
|
-
// our own ticker, or the two race and the channel refuses the loser as busy.
|
|
415
|
-
// A TICK NEEDS A LINE TO PAY FOR. Routing it straight to payFirst kept it off
|
|
416
|
-
// the line path, which is also the only thing that opens one -- so a client
|
|
417
|
-
// that funded and then only ticked never got a line and every tick was
|
|
418
|
-
// refused as matching none.
|
|
419
401
|
if (name === 'tick') {
|
|
420
402
|
if (!line.credential && channelId) await openLine()
|
|
421
403
|
return payFirst('tick', line.credential ? { line: line.credential } : args)
|
|
@@ -489,7 +471,6 @@ if (has('--tools')) {
|
|
|
489
471
|
if (has('--call')) {
|
|
490
472
|
const tool = flag('--call')
|
|
491
473
|
if (!tool) { process.stderr.write('--call needs a tool name\n'); process.exit(2) }
|
|
492
|
-
// The JSON argument is optional: several tools take none.
|
|
493
474
|
const rawArgs = argv[argv.indexOf('--call') + 2]
|
|
494
475
|
let args = {}
|
|
495
476
|
if (rawArgs && !rawArgs.startsWith('--')) {
|
|
@@ -512,9 +493,6 @@ if (has('--call')) {
|
|
|
512
493
|
if (has('--refund')) {
|
|
513
494
|
if (!channelId) { process.stderr.write('no channel to refund — nothing has been bought with this key and salt\n'); process.exit(2) }
|
|
514
495
|
|
|
515
|
-
// The channel is proved by signing for it, not by holding a session open. No
|
|
516
|
-
// line, no tick, no keep-warm: one message, one answer. Under a gift card the
|
|
517
|
-
// key here is the payerAuthorizer, which the server accepts for the same reason.
|
|
518
496
|
const issued = new Date().toISOString()
|
|
519
497
|
const message = `ZEAM Prism refund\nchannel: ${String(channelId).toLowerCase()}\nissued: ${issued}`
|
|
520
498
|
const signature = await account.signMessage({ message })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeam-labs/x402-mcp-bridge",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.4",
|
|
4
4
|
"description": "Put a wallet in front of any x402-paid MCP server, and hold a metered line on the ones that sell time. Stock MCP clients cannot construct x402 payments; this one does, and proxies your existing client through it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|