@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.
Files changed (3) hide show
  1. package/README.md +21 -24
  2. package/index.mjs +31 -33
  3. 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 behaviour — expect a
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 — and on
91
- a meter that bills time on the line, the buyer pays for a handshake nobody
92
- needed. Those probes are also unpaid calls, and a server may cap how many of
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
- Upstream sells **time**, not calls, and there is a cheaper way to buy it than
108
- paying per call.
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. It hands back a credential.
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 buys the milliseconds since your previous
114
- tick.
115
- 4. Every other call carries only `{line: "<credential>"}` and costs **nothing** —
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 closes. An open line bills the whole time it is open,
120
- so a line you forget costs at most one idle-close window past your last tick and
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
- serialised behind one channel, and when those run out of road they become unpaid
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` same figure as a call, and the ceiling on
150
- one tick. And never send two ticks at once: a voucher signs a cumulative total, so
151
- a channel carries one payment at a time and an overlapping tick is refused as
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 `/bootstrap` surface, which
189
- means recovery costs nothing and needs no RPC of your own.
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('/bootstrap', UPSTREAM).toString(),
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 = Number(m.tickMs) || 250
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 — ${m.microUSDPerMs ?? 1} micro-USD/ms, collateral buys ${m.buysMs ?? '?'}ms`)
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
- // TICK IS THE PAYMENT. The catalog publishes it, so a client can call it, and
398
- // it went down the line path like any other tool -- sent with a credential and
399
- // no payment, which is the one thing it cannot be. The server answered 402,
400
- // and the retry dropped a working line. It also has to share the queue with
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.2",
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": {