@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.
- package/README.md +5 -5
- package/index.mjs +24 -4
- 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
|
|
@@ -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
|
|
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
|
-
|
|
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 `/
|
|
189
|
-
|
|
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('/
|
|
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 =
|
|
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 — ${
|
|
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
|
-
|
|
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.
|
|
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": {
|