@zeam-labs/x402-mcp-bridge 2.0.2 → 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 +21 -24
- package/index.mjs +31 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ arguments this is still an MCP stdio server, which is what an MCP client wants.
|
|
|
26
26
|
The server holds an idle line for **5000ms** and bills for that time. This client
|
|
27
27
|
drops its own after **four tick intervals (1000ms)** of no use, so a pause costs
|
|
28
28
|
you a reopen rather than four seconds of billing. So `closesAfterIdleMs: 5000` in
|
|
29
|
-
`services.json` is the server's ceiling, not this client's
|
|
29
|
+
`services.json` is the server's ceiling, not this client's behavior — expect a
|
|
30
30
|
line to reopen during a slow session. `X402_LINE=off` pays per call instead.
|
|
31
31
|
|
|
32
32
|
## Why you need it
|
|
@@ -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 serialise, 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
|
|
|
@@ -132,7 +130,7 @@ If the server refuses a call because the line is gone — an ordinary rotate or
|
|
|
132
130
|
idle close — the bridge **reopens the line and retries**, and only pays per call
|
|
133
131
|
if that fails too. That ordering matters: falling straight through to per-call
|
|
134
132
|
payment turns one closed line into a signed payment per in-flight call,
|
|
135
|
-
|
|
133
|
+
serialized behind one channel, and when those run out of road they become unpaid
|
|
136
134
|
requests that burn the hourly ceiling and lock a funded wallet out of its own
|
|
137
135
|
channel. Measured before the fix, at 25-way concurrency: 3 of 12 calls served.
|
|
138
136
|
After: 200 of 200 across 8 line deaths.
|
|
@@ -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
|
|
|
@@ -185,8 +182,8 @@ reaching it the line drops and further calls return
|
|
|
185
182
|
Keep `X402_STATE_DIR` on disk. A client that reconnects to an existing channel
|
|
186
183
|
with empty state pays a deposit it did not need, and can only recover if its
|
|
187
184
|
signer can **read the chain** — so this bridge always gives the signer a reader.
|
|
188
|
-
By default that reader is the upstream's own free `/
|
|
189
|
-
|
|
185
|
+
By default that reader is the upstream's own free `/verify` surface, which means
|
|
186
|
+
recovery costs nothing and needs no RPC of your own.
|
|
190
187
|
|
|
191
188
|
Measured against `mcp.zeamprism.com`: fresh channel, first call 2.6s (one
|
|
192
189
|
on-chain deposit) then ~180ms per call. State deliberately wiped: healed and
|
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.')
|
|
@@ -72,7 +71,7 @@ mkdirSync(stateDir, { recursive: true })
|
|
|
72
71
|
const readers = [
|
|
73
72
|
...(process.env.X402_RPC_URL ? [process.env.X402_RPC_URL] : []),
|
|
74
73
|
...(chain.rpcUrls?.default?.http ?? []),
|
|
75
|
-
new URL('/
|
|
74
|
+
new URL('/verify', UPSTREAM).toString(),
|
|
76
75
|
]
|
|
77
76
|
const pub = createPublicClient({ chain, transport: fallback(readers.map(u => http(u))) })
|
|
78
77
|
log(`chain reads: ${readers.map(u => new URL(u).host).join(' -> ')}` +
|
|
@@ -95,11 +94,8 @@ let channelId = null
|
|
|
95
94
|
const watchedStorage = {
|
|
96
95
|
get: (k) => storage.get(k),
|
|
97
96
|
delete: (k) => storage.delete(k),
|
|
98
|
-
// Every write carries the seller's running total for this channel, so this is
|
|
99
|
-
// also where we learn what we have been billed -- see noteBilled below.
|
|
100
97
|
set: (k, ctx) => { channelId = k; noteBilled(ctx); return storage.set(k, ctx) },
|
|
101
98
|
}
|
|
102
|
-
// And across restarts: the scheme persists one file per channel.
|
|
103
99
|
try {
|
|
104
100
|
const f = readdirSync(join(stateDir, 'client')).find((n) => n.endsWith('.json'))
|
|
105
101
|
if (f) channelId = f.replace(/\.json$/, '')
|
|
@@ -116,8 +112,6 @@ let spentMicroUSD = 0
|
|
|
116
112
|
let quoteMicroUSD = null // set from the seller's own terms
|
|
117
113
|
let spendUnit = 'micro-USD' // what the numbers we report actually ARE
|
|
118
114
|
|
|
119
|
-
// Liberal in where it looks, strict about giving up. A server that does not say
|
|
120
|
-
// what a call costs in USD gets NO GUESS.
|
|
121
115
|
const quoteFromTerms = (j) => {
|
|
122
116
|
for (const c of [j?.rate?.deposit?.tickQuoteMicroUSD, j?.rate?.tickQuoteMicroUSD,
|
|
123
117
|
j?.rate?.microUSDPerCall, j?.quoteMicroUSD]) {
|
|
@@ -128,8 +122,6 @@ const quoteFromTerms = (j) => {
|
|
|
128
122
|
}
|
|
129
123
|
|
|
130
124
|
const microUSDOf = (units) => {
|
|
131
|
-
// The entry the selector actually chose. Not accepts[0] -- that is USDC, and
|
|
132
|
-
// converting WETH units by a USDC quote is the same units bug in a new hat.
|
|
133
125
|
const quoted = Number(chosenAccept?.amount ?? 0)
|
|
134
126
|
if (quoteMicroUSD === null || !(quoted > 0)) return units
|
|
135
127
|
return units * (quoteMicroUSD / quoted)
|
|
@@ -191,6 +183,7 @@ log(`paying as ${account.address} -> ${UPSTREAM}`)
|
|
|
191
183
|
const termsURL = new URL('/.well-known/x402', UPSTREAM).toString()
|
|
192
184
|
let accepts = null
|
|
193
185
|
let tickAccepts = null
|
|
186
|
+
let lineFacts = { tickMs: 250, microUSDPerMs: null }
|
|
194
187
|
const loadTerms = async () => {
|
|
195
188
|
const r = await fetch(termsURL)
|
|
196
189
|
const j = await r.json()
|
|
@@ -199,6 +192,13 @@ const loadTerms = async () => {
|
|
|
199
192
|
tickAccepts = Array.isArray(j.tickAccepts) && j.tickAccepts.length
|
|
200
193
|
? { x402Version: j.x402Version ?? 1, accepts: j.tickAccepts }
|
|
201
194
|
: accepts // an upstream that does not sell time
|
|
195
|
+
const ln = j.limits?.line ?? j.payment?.limits?.line
|
|
196
|
+
lineFacts = {
|
|
197
|
+
tickMs: Number(ln?.tickMs) > 0 ? Number(ln.tickMs) : lineFacts.tickMs,
|
|
198
|
+
microUSDPerMs: Number(j.rate?.microUSDPerMillisecond) > 0
|
|
199
|
+
? Number(j.rate.microUSDPerMillisecond) : lineFacts.microUSDPerMs,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
202
|
const q = quoteFromTerms(j)
|
|
203
203
|
if (q !== null) {
|
|
204
204
|
if (q !== quoteMicroUSD) log(`quote: ${q} micro-USD per call, from the seller's own terms`)
|
|
@@ -233,8 +233,6 @@ if (coldStart) log('no local channel state — probing once to learn where this
|
|
|
233
233
|
const payNow = async (name, args) => {
|
|
234
234
|
if (coldStart) {
|
|
235
235
|
coldStart = false
|
|
236
|
-
// autoPayment handles the 402 and pays the retry, and the 402 is what
|
|
237
|
-
// carries the channel state the client is missing.
|
|
238
236
|
return upstream.callTool(name, args)
|
|
239
237
|
}
|
|
240
238
|
const terms = name === 'tick' ? tickAccepts : accepts
|
|
@@ -290,19 +288,15 @@ const AUTO_SLOW_RUN = Number(process.env.X402_AUTO_SLOW_RUN ?? 4)
|
|
|
290
288
|
|
|
291
289
|
const line = { credential: null, socket: null, timer: null, tickMs: 250, lastUse: 0, opening: null }
|
|
292
290
|
|
|
293
|
-
// Rolling view of how fast the caller is actually going.
|
|
294
291
|
const rate = { lastCallAt: 0, fastRun: 0, slowRun: 0 }
|
|
295
292
|
|
|
296
293
|
function holdingIsCheaper() {
|
|
297
294
|
const now = Date.now()
|
|
298
295
|
const gap = rate.lastCallAt ? now - rate.lastCallAt : Infinity
|
|
299
296
|
rate.lastCallAt = now
|
|
300
|
-
// <=, not <: at exactly one call per hold window the two cost the same, and
|
|
301
|
-
// holding avoids a signature and a settlement per call.
|
|
302
297
|
if (gap <= line.tickMs) { rate.fastRun += 1; rate.slowRun = 0 }
|
|
303
298
|
else { rate.slowRun += 1; rate.fastRun = 0 }
|
|
304
299
|
|
|
305
|
-
// Already holding? Keep holding until several gaps in a row say otherwise.
|
|
306
300
|
if (line.credential) return rate.slowRun < AUTO_SLOW_RUN
|
|
307
301
|
return rate.fastRun >= AUTO_FAST_RUN
|
|
308
302
|
}
|
|
@@ -345,22 +339,32 @@ const openLine = () => {
|
|
|
345
339
|
let socket
|
|
346
340
|
try { socket = new WebSocket(wsURL()) } catch (e) { log(`line: ${e.message}`); return resolve(null) }
|
|
347
341
|
const give_up = setTimeout(() => { try { socket.close() } catch {} ; resolve(null) }, 10_000)
|
|
348
|
-
socket.onmessage = (ev) => {
|
|
342
|
+
socket.onmessage = async (ev) => {
|
|
349
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
|
+
}
|
|
350
358
|
if (m.op === 'opened') {
|
|
351
359
|
clearTimeout(give_up)
|
|
352
360
|
line.socket = socket
|
|
353
361
|
line.credential = m.credential
|
|
354
|
-
line.tickMs =
|
|
355
|
-
// A line that has just opened has not been idle. lastUse starts at 0, so
|
|
356
|
-
// without this the first timer fire sees an age of Date.now() and drops
|
|
357
|
-
// the line before anything can use it.
|
|
362
|
+
line.tickMs = lineFacts.tickMs
|
|
358
363
|
line.lastUse = Date.now()
|
|
359
|
-
log(`line open — ${
|
|
364
|
+
log(`line open — ${lineFacts.microUSDPerMs ?? '?'} micro-USD/ms, ` +
|
|
365
|
+
`collateral buys ${m.buysMs ?? '?'}ms`)
|
|
360
366
|
const first = tick()
|
|
361
367
|
line.timer = setInterval(() => {
|
|
362
|
-
// Stop paying for a line nobody is using. The server closes an unused
|
|
363
|
-
// line on its own; this is us not paying for the window before it does.
|
|
364
368
|
if (LINE_MODE === 'auto' && Date.now() - line.lastUse > line.tickMs * 4) return dropLine('idle')
|
|
365
369
|
tick()
|
|
366
370
|
}, line.tickMs)
|
|
@@ -394,12 +398,10 @@ const callOnLine = async (name, args) => {
|
|
|
394
398
|
'Raise it, set X402_MAX_SPEND=0 to remove it, or restart the bridge.' }) }] }
|
|
395
399
|
}
|
|
396
400
|
line.lastUse = Date.now()
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
// our own ticker, or the two race and the channel refuses the loser as busy.
|
|
402
|
-
if (name === 'tick') return payFirst('tick', line.credential ? { line: line.credential } : args)
|
|
401
|
+
if (name === 'tick') {
|
|
402
|
+
if (!line.credential && channelId) await openLine()
|
|
403
|
+
return payFirst('tick', line.credential ? { line: line.credential } : args)
|
|
404
|
+
}
|
|
403
405
|
if (LINE_MODE === 'off') return payFirst(name, args)
|
|
404
406
|
|
|
405
407
|
if (LINE_MODE === 'auto') {
|
|
@@ -469,7 +471,6 @@ if (has('--tools')) {
|
|
|
469
471
|
if (has('--call')) {
|
|
470
472
|
const tool = flag('--call')
|
|
471
473
|
if (!tool) { process.stderr.write('--call needs a tool name\n'); process.exit(2) }
|
|
472
|
-
// The JSON argument is optional: several tools take none.
|
|
473
474
|
const rawArgs = argv[argv.indexOf('--call') + 2]
|
|
474
475
|
let args = {}
|
|
475
476
|
if (rawArgs && !rawArgs.startsWith('--')) {
|
|
@@ -492,9 +493,6 @@ if (has('--call')) {
|
|
|
492
493
|
if (has('--refund')) {
|
|
493
494
|
if (!channelId) { process.stderr.write('no channel to refund — nothing has been bought with this key and salt\n'); process.exit(2) }
|
|
494
495
|
|
|
495
|
-
// The channel is proved by signing for it, not by holding a session open. No
|
|
496
|
-
// line, no tick, no keep-warm: one message, one answer. Under a gift card the
|
|
497
|
-
// key here is the payerAuthorizer, which the server accepts for the same reason.
|
|
498
496
|
const issued = new Date().toISOString()
|
|
499
497
|
const message = `ZEAM Prism refund\nchannel: ${String(channelId).toLowerCase()}\nissued: ${issued}`
|
|
500
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": {
|