@zeam-labs/x402-mcp-bridge 2.0.2 → 2.0.3

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 +5 -5
  2. package/index.mjs +24 -4
  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
@@ -113,7 +113,7 @@ paying per call.
113
113
  That is an ordinary paid call and it buys the milliseconds since your previous
114
114
  tick.
115
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
116
+ no signature per call, nothing to serialize, and as many calls in flight at
117
117
  once as you like.
118
118
 
119
119
  Stop ticking and the line closes. An open line bills the whole time it is open,
@@ -132,7 +132,7 @@ If the server refuses a call because the line is gone — an ordinary rotate or
132
132
  idle close — the bridge **reopens the line and retries**, and only pays per call
133
133
  if that fails too. That ordering matters: falling straight through to per-call
134
134
  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
135
+ serialized behind one channel, and when those run out of road they become unpaid
136
136
  requests that burn the hourly ceiling and lock a funded wallet out of its own
137
137
  channel. Measured before the fix, at 25-way concurrency: 3 of 12 calls served.
138
138
  After: 200 of 200 across 8 line deaths.
@@ -185,8 +185,8 @@ reaching it the line drops and further calls return
185
185
  Keep `X402_STATE_DIR` on disk. A client that reconnects to an existing channel
186
186
  with empty state pays a deposit it did not need, and can only recover if its
187
187
  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.
188
+ By default that reader is the upstream's own free `/verify` surface, which means
189
+ recovery costs nothing and needs no RPC of your own.
190
190
 
191
191
  Measured against `mcp.zeamprism.com`: fresh channel, first call 2.6s (one
192
192
  on-chain deposit) then ~180ms per call. State deliberately wiped: healed and
package/index.mjs CHANGED
@@ -69,10 +69,12 @@ const stateDir = process.env.X402_STATE_DIR ??
69
69
  join(homedir(), '.x402-mcp-bridge', new URL(UPSTREAM).host, account.address.toLowerCase())
70
70
  mkdirSync(stateDir, { recursive: true })
71
71
 
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.
72
74
  const readers = [
73
75
  ...(process.env.X402_RPC_URL ? [process.env.X402_RPC_URL] : []),
74
76
  ...(chain.rpcUrls?.default?.http ?? []),
75
- new URL('/bootstrap', UPSTREAM).toString(),
77
+ new URL('/verify', UPSTREAM).toString(),
76
78
  ]
77
79
  const pub = createPublicClient({ chain, transport: fallback(readers.map(u => http(u))) })
78
80
  log(`chain reads: ${readers.map(u => new URL(u).host).join(' -> ')}` +
@@ -191,6 +193,9 @@ log(`paying as ${account.address} -> ${UPSTREAM}`)
191
193
  const termsURL = new URL('/.well-known/x402', UPSTREAM).toString()
192
194
  let accepts = null
193
195
  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
+ let lineFacts = { tickMs: 250, microUSDPerMs: null }
194
199
  const loadTerms = async () => {
195
200
  const r = await fetch(termsURL)
196
201
  const j = await r.json()
@@ -199,6 +204,13 @@ const loadTerms = async () => {
199
204
  tickAccepts = Array.isArray(j.tickAccepts) && j.tickAccepts.length
200
205
  ? { x402Version: j.x402Version ?? 1, accepts: j.tickAccepts }
201
206
  : accepts // an upstream that does not sell time
207
+ const ln = j.limits?.line ?? j.payment?.limits?.line
208
+ lineFacts = {
209
+ tickMs: Number(ln?.tickMs) > 0 ? Number(ln.tickMs) : lineFacts.tickMs,
210
+ microUSDPerMs: Number(j.rate?.microUSDPerMillisecond) > 0
211
+ ? Number(j.rate.microUSDPerMillisecond) : lineFacts.microUSDPerMs,
212
+ }
213
+
202
214
  const q = quoteFromTerms(j)
203
215
  if (q !== null) {
204
216
  if (q !== quoteMicroUSD) log(`quote: ${q} micro-USD per call, from the seller's own terms`)
@@ -351,12 +363,13 @@ const openLine = () => {
351
363
  clearTimeout(give_up)
352
364
  line.socket = socket
353
365
  line.credential = m.credential
354
- line.tickMs = Number(m.tickMs) || 250
366
+ line.tickMs = lineFacts.tickMs
355
367
  // A line that has just opened has not been idle. lastUse starts at 0, so
356
368
  // without this the first timer fire sees an age of Date.now() and drops
357
369
  // the line before anything can use it.
358
370
  line.lastUse = Date.now()
359
- log(`line open — ${m.microUSDPerMs ?? 1} micro-USD/ms, collateral buys ${m.buysMs ?? '?'}ms`)
371
+ log(`line open — ${lineFacts.microUSDPerMs ?? '?'} micro-USD/ms, ` +
372
+ `collateral buys ${m.buysMs ?? '?'}ms`)
360
373
  const first = tick()
361
374
  line.timer = setInterval(() => {
362
375
  // Stop paying for a line nobody is using. The server closes an unused
@@ -399,7 +412,14 @@ const callOnLine = async (name, args) => {
399
412
  // no payment, which is the one thing it cannot be. The server answered 402,
400
413
  // and the retry dropped a working line. It also has to share the queue with
401
414
  // 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)
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
+ if (name === 'tick') {
420
+ if (!line.credential && channelId) await openLine()
421
+ return payFirst('tick', line.credential ? { line: line.credential } : args)
422
+ }
403
423
  if (LINE_MODE === 'off') return payFirst(name, args)
404
424
 
405
425
  if (LINE_MODE === 'auto') {
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.3",
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": {