@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.
Files changed (3) hide show
  1. package/README.md +17 -20
  2. package/index.mjs +15 -37
  3. 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 — 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 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 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
 
@@ -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
 
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",
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": {