@zeam-labs/x402-mcp-bridge 2.0.0

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +216 -0
  3. package/index.mjs +532 -0
  4. package/package.json +29 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ZEAM Labs, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,216 @@
1
+ # x402-mcp-bridge
2
+
3
+ `@zeam-labs/x402-mcp-bridge` on npm. Source: <https://github.com/zeam-labs/x402-mcp-bridge>.
4
+
5
+ Published from this source at the tagged version. npm's integrity hash is of that
6
+ build — verify it before you run anything against your key.
7
+
8
+ Put a wallet in front of a paid MCP server.
9
+
10
+ ## One command, no MCP client
11
+
12
+ A headless agent does not run a desktop MCP client. It runs a shell. So:
13
+
14
+ With your key already exported into the environment as `X402_PRIVATE_KEY`:
15
+
16
+ npx -y @zeam-labs/x402-mcp-bridge \
17
+ --call rpc '{"chain":"base","method":"eth_blockNumber","params":[]}'
18
+
19
+ npx -y @zeam-labs/x402-mcp-bridge --tools
20
+
21
+ That pays for the call and prints the answer. Nothing else to write. With no
22
+ arguments this is still an MCP stdio server, which is what an MCP client wants.
23
+
24
+ ## The line, and when this client drops it
25
+
26
+ The server holds an idle line for **5000ms** and bills for that time. This client
27
+ drops its own after **four tick intervals (1000ms)** of no use, so a pause costs
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
30
+ line to reopen during a slow session. `X402_LINE=off` pays per call instead.
31
+
32
+ ## Why you need it
33
+
34
+ A metered MCP endpoint takes payment inside the tool call's `params._meta` — a
35
+ signed x402 payload the **client** builds per call against accumulating channel
36
+ state. No stock MCP client does that, so adding a paid endpoint to your config
37
+ gets you `tools/list`, the free tools, and 402 on everything else, with no API
38
+ key to paste because there is no API key.
39
+
40
+ This is that client, wearing a stdio MCP server on the front. Your existing
41
+ client talks to this; this talks money upstream.
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "prism": {
47
+ "command": "npx",
48
+ "args": ["-y", "@zeam-labs/x402-mcp-bridge"],
49
+ "env": { "X402_PRIVATE_KEY": "0x..." }
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ Verify what you are about to run before you point a funded key at it; see
56
+ [below](#why-the-versions-are-pinned-exactly).
57
+
58
+ The key stays on your machine. It signs vouchers locally; it is never sent
59
+ anywhere. The bridge holds no funds — your deposit sits in the upstream's
60
+ settlement contract, withdrawable by your side of the channel alone: the
61
+ `payer`, or the `payerAuthorizer` you named when the channel was opened, which
62
+ for most clients is the same key but need not be.
63
+
64
+ That escrow is not the seller's. It is x402's own batch-settlement contract,
65
+ hardcoded in [`@x402/evm`](https://www.npmjs.com/package/@x402/evm) and published
66
+ to npm by Coinbase in `2.12.0` on 2026-05-13 — two days before the contract
67
+ existed on Base. No owner, no pause, no upgrade, no sweep.
68
+
69
+ ```
70
+ npm pack @x402/evm@2.12.0 && grep -rl 0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003 package/
71
+ ```
72
+
73
+ ## Permit2 assets need one approval first
74
+
75
+ USDT, DAI and WETH settle through Permit2, so the wallet must approve the Permit2
76
+ contract once before its first payment:
77
+
78
+ ```
79
+ approve(0x000000000022D473030F116dDEE9F6B43aC78BA3, amount) // on the token
80
+ ```
81
+
82
+ A bounded amount is enough. Without it the payment is refused with
83
+ `invalid_batch_settlement_evm_permit2_allowance_required`. USDC and EURC also
84
+ accept EIP-3009, which needs no approval at all.
85
+
86
+ ## Paying without asking first
87
+
88
+ The stock x402 flow sends every call **unpaid**, reads the 402 it comes back
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.
95
+
96
+ The terms are static and published, so this bridge reads them once from
97
+ `/.well-known/x402` at connect and attaches payment to its **first** request.
98
+ Measured against the same server: six calls issued six 402 challenges the old
99
+ way, and zero the new way.
100
+
101
+ A refused payment re-reads the terms and retries once, because quotes for
102
+ non-stable assets move with the oracle. Anything still failing falls back to the
103
+ old probe-then-pay path rather than dropping your call.
104
+
105
+ ## Holding a line
106
+
107
+ Upstream sells **time**, not calls, and there is a cheaper way to buy it than
108
+ paying per call.
109
+
110
+ 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.
112
+ 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.
118
+
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.
122
+
123
+ **This bridge drives a line for you.** `X402_LINE` controls it:
124
+
125
+ | value | |
126
+ |---|---|
127
+ | `auto` *(default)* | open a line on your first call, hold it while calls keep coming, let it lapse when they stop. A line is cheaper than per-call pricing exactly while work is flowing and more expensive while it is not, so this follows the work. |
128
+ | `on` | hold a line from startup and keep paying whether or not anyone calls. |
129
+ | `off` | per-call payment only. Works against any x402 endpoint. |
130
+
131
+ If the server refuses a call because the line is gone — an ordinary rotate or
132
+ idle close — the bridge **reopens the line and retries**, and only pays per call
133
+ if that fails too. That ordering matters: falling straight through to per-call
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
136
+ requests that burn the hourly ceiling and lock a funded wallet out of its own
137
+ channel. Measured before the fix, at 25-way concurrency: 3 of 12 calls served.
138
+ After: 200 of 200 across 8 line deaths.
139
+
140
+ **Cold starts.** A voucher signs a *cumulative* total, and that total is not on
141
+ the chain — the escrow knows your balance and what has been claimed, not what has
142
+ been metered. The only place it exists is the seller's 402. So on a cold start,
143
+ or whenever a payment is refused as stale, the bridge spends one probing round
144
+ trip to resync rather than handing you a failed call. Keep `X402_STATE_DIR` and
145
+ it happens once; lose it, or run the same key on a second machine, and it happens
146
+ again on the next call and then not after.
147
+
148
+ 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`.
153
+
154
+ ## Configuration
155
+
156
+ | variable | default | |
157
+ |---|---|---|
158
+ | `X402_PRIVATE_KEY` | — | **required.** Funds the channel and signs vouchers. |
159
+ | `X402_MCP_URL` | `https://mcp.zeamprism.com/mcp` | any x402-paid MCP endpoint |
160
+ | `X402_NETWORK` | `eip155:8453` | CAIP-2 |
161
+ | `X402_LINE` | `auto` | `auto`, `on` or `off` — see **Holding a line** above |
162
+ | `X402_ASSET` | first quoted | address or symbol, if you hold a specific token |
163
+ | `X402_RPC_URL` | the chain's own public RPC | chain reads. Point it at your own node — checking a seller's claims through the seller proves nothing. |
164
+ | `X402_STATE_DIR` | `~/.x402-mcp-bridge/<host>/<address>` | channel state |
165
+ | `X402_SALT` | scheme default | open a distinct channel. Any string; it is hashed to bytes32 |
166
+ | `X402_MAX_SPEND` | `10000000` (=$10) | ceiling on what **this run** may spend, in micro-USD. `0` removes it — see below |
167
+ | `X402_DEPOSIT_MULTIPLIER` | `400` | how much collateral a deposit puts in escrow, as a multiple of the quote. **The quote is 250 micro-USD, so the default deposit is 400 x 250 = 100,000 micro-USD = $0.10.** That is refundable collateral, not a charge — but it leaves your wallet the moment you open a channel, and no page said the number out loud until a cold buyer had to multiply two figures from two documents to find out what plugging in the config would cost it. Lower it if $0.10 is more than you want committed; the scheme refuses below 3x. |
168
+
169
+ ## It stops spending when you stop watching
170
+
171
+ This process holds your key and pays without asking, so two limits bound it.
172
+
173
+ **It dies with its parent.** `npx` is a wrapper, so a client killing its child
174
+ kills npx and not this. A stdio server's parent going away closes stdin, and that
175
+ is what this watches. `X402_LINE=auto` also lapses an unused line after four tick
176
+ intervals; `X402_LINE=on` holds one regardless, so use it deliberately.
177
+
178
+ **It will not spend past `X402_MAX_SPEND`** (default 10,000,000 µUSD ≈ $10, about
179
+ three hours of held line), counted from where the meter stood at startup. On
180
+ reaching it the line drops and further calls return
181
+ `x402_bridge_spend_cap_reached` with the numbers. `X402_MAX_SPEND=0` removes it.
182
+
183
+ ## Channel state matters
184
+
185
+ Keep `X402_STATE_DIR` on disk. A client that reconnects to an existing channel
186
+ with empty state pays a deposit it did not need, and can only recover if its
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.
190
+
191
+ Measured against `mcp.zeamprism.com`: fresh channel, first call 2.6s (one
192
+ on-chain deposit) then ~180ms per call. State deliberately wiped: healed and
193
+ served in 3.9s, then ~150ms.
194
+
195
+ ## What it does not do
196
+
197
+ It does not custody funds, meter you, or add a fee. It forwards `tools/list` and
198
+ `tools/call` unchanged and attaches payment. If the upstream is free, you do not
199
+ need this.
200
+
201
+ ## Why the versions are pinned exactly
202
+
203
+ Read this file — it is short on purpose — and you still cannot see what the
204
+ dependencies do, and the signing happens inside them. Floating them on `"*"` means
205
+ `npx -y` today and `npx -y` next month execute different code against your key. They are pinned to exact versions. Verify what you are about
206
+ to run:
207
+
208
+ npm view @zeam-labs/x402-mcp-bridge version dist.integrity
209
+ npm pack @zeam-labs/x402-mcp-bridge
210
+ less package/index.mjs
211
+
212
+ npm's integrity hash proves the bytes you fetched are the bytes that were
213
+ published — not that we are honest. The file is short on
214
+ purpose: read it.
215
+
216
+ MIT.
package/index.mjs ADDED
@@ -0,0 +1,532 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'
5
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
6
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
7
+ import { wrapMCPClientWithPayment, x402Client } from '@x402/mcp'
8
+ import { BatchSettlementEvmScheme } from '@x402/evm/batch-settlement/client'
9
+ import { FileClientChannelStorage } from '@x402/evm/batch-settlement/client/file-storage'
10
+ import { toClientEvmSigner } from '@x402/evm'
11
+ import { privateKeyToAccount } from 'viem/accounts'
12
+ import { createPublicClient, http, fallback, keccak256, toHex, getAddress } from 'viem'
13
+ import * as chains from 'viem/chains'
14
+ import { mkdirSync, readdirSync } from 'node:fs'
15
+ import { homedir } from 'node:os'
16
+ import { join } from 'node:path'
17
+ import { readFileSync } from 'node:fs'
18
+
19
+ const UPSTREAM =
20
+ process.env.X402_MCP_URL ?? process.env.X402_UPSTREAM ?? 'https://mcp.zeamprism.com/mcp'
21
+ const KEY = process.env.X402_PRIVATE_KEY ?? process.env.PRISM_PRIVATE_KEY
22
+ const NETWORK = process.env.X402_NETWORK ?? 'eip155:8453'
23
+ const WANT = (process.env.X402_ASSET ?? '').toLowerCase()
24
+ const NAME = process.env.X402_NAME ?? 'x402-bridge'
25
+ const VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version
26
+ const log = (...a) => console.error('[x402-bridge]', ...a)
27
+
28
+ const argv = process.argv.slice(2)
29
+ const flag = (name) => { const i = argv.indexOf(name); return i === -1 ? null : (argv[i + 1] ?? '') }
30
+ const has = (name) => argv.includes(name)
31
+
32
+ if (has('--help') || has('-h')) {
33
+ process.stdout.write([
34
+ 'x402-mcp-bridge — pay for MCP tools with a wallet.',
35
+ '',
36
+ ' With your key exported as X402_PRIVATE_KEY:',
37
+ '',
38
+ ' npx -y <this tarball> --call rpc \'{"chain":"base","method":"eth_blockNumber","params":[]}\'',
39
+ ' npx -y <this tarball> --tools',
40
+ '',
41
+ 'With no arguments it runs as an MCP stdio server, which is what an MCP client wants.',
42
+ '',
43
+ 'Env: X402_PRIVATE_KEY (required), X402_MCP_URL (X402_UPSTREAM also accepted),',
44
+ ' X402_MAX_SPEND (0 = no cap; base units of the paid asset if the server publishes no price),',
45
+ ' X402_DEPOSIT_MULTIPLIER (default 400 × the SELLER\'S quoted per-call amount; against',
46
+ ' zeamprism that is 400 × 250 = $0.10 of refundable collateral), X402_LINE=auto|on|off,',
47
+ ' X402_SALT.',
48
+ ' auto: buy per-call minimum holds until calls arrive faster than the',
49
+ ' server minimum hold, then hold a line while that lasts. A held line',
50
+ ' bills wall-clock whether you call or not, so holding a line for a',
51
+ ' sparse caller costs several times what the slices would have.',
52
+ '',
53
+ ].join('\n'))
54
+ process.exit(0)
55
+ }
56
+
57
+
58
+ if (!KEY || !/^0x[0-9a-fA-F]{64}$/.test(KEY)) {
59
+ log('set X402_PRIVATE_KEY to a 0x-prefixed 32-byte key. It stays on this machine;')
60
+ log('it signs payment vouchers locally and is never sent anywhere.')
61
+ process.exit(1)
62
+ }
63
+ const account = privateKeyToAccount(KEY)
64
+ const chainId = Number(String(NETWORK).split(':')[1])
65
+ const chain = Object.values(chains).find((c) => c?.id === chainId)
66
+ if (!chain) { log(`unknown network ${NETWORK}`); process.exit(1) }
67
+
68
+ const stateDir = process.env.X402_STATE_DIR ??
69
+ join(homedir(), '.x402-mcp-bridge', new URL(UPSTREAM).host, account.address.toLowerCase())
70
+ mkdirSync(stateDir, { recursive: true })
71
+
72
+ const readers = [
73
+ ...(process.env.X402_RPC_URL ? [process.env.X402_RPC_URL] : []),
74
+ ...(chain.rpcUrls?.default?.http ?? []),
75
+ new URL('/bootstrap', UPSTREAM).toString(),
76
+ ]
77
+ const pub = createPublicClient({ chain, transport: fallback(readers.map(u => http(u))) })
78
+ log(`chain reads: ${readers.map(u => new URL(u).host).join(' -> ')}` +
79
+ (process.env.X402_RPC_URL ? '' : ' (set X402_RPC_URL to put your own node first)'))
80
+
81
+ let chosenAccept = null
82
+ const selector = (_version, accepts) => {
83
+ if (WANT) {
84
+ const hit = accepts.find((a) => String(a.asset).toLowerCase() === WANT ||
85
+ String(a.extra?.name ?? '').toLowerCase() === WANT)
86
+ if (hit) { chosenAccept = hit; return hit }
87
+ log(`X402_ASSET=${WANT} is not among the ${accepts.length} quoted; falling back to the first`)
88
+ }
89
+ chosenAccept = accepts[0]
90
+ return accepts[0]
91
+ }
92
+
93
+ const storage = new FileClientChannelStorage({ directory: stateDir })
94
+ let channelId = null
95
+ const watchedStorage = {
96
+ get: (k) => storage.get(k),
97
+ 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
+ set: (k, ctx) => { channelId = k; noteBilled(ctx); return storage.set(k, ctx) },
101
+ }
102
+ // And across restarts: the scheme persists one file per channel.
103
+ try {
104
+ const f = readdirSync(join(stateDir, 'client')).find((n) => n.endsWith('.json'))
105
+ if (f) channelId = f.replace(/\.json$/, '')
106
+ } catch { /* first run, nothing to recover */ }
107
+
108
+ const depositPolicy = { depositMultiplier: Number(process.env.X402_DEPOSIT_MULTIPLIER ?? 400) }
109
+
110
+ const MAX_SPEND = Number(process.env.X402_MAX_SPEND ?? 10_000_000)
111
+ let capReached = false
112
+
113
+ let startedAt = null
114
+ let spentMicroUSD = 0
115
+
116
+ let quoteMicroUSD = null // set from the seller's own terms
117
+ let spendUnit = 'micro-USD' // what the numbers we report actually ARE
118
+
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
+ const quoteFromTerms = (j) => {
122
+ for (const c of [j?.rate?.deposit?.tickQuoteMicroUSD, j?.rate?.tickQuoteMicroUSD,
123
+ j?.rate?.microUSDPerCall, j?.quoteMicroUSD]) {
124
+ const n = Number(c)
125
+ if (Number.isFinite(n) && n > 0) return n
126
+ }
127
+ return null
128
+ }
129
+
130
+ 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
+ const quoted = Number(chosenAccept?.amount ?? 0)
134
+ if (quoteMicroUSD === null || !(quoted > 0)) return units
135
+ return units * (quoteMicroUSD / quoted)
136
+ }
137
+
138
+ const noteBilled = (ctx) => {
139
+ try {
140
+ const charged = Number(BigInt(ctx?.chargedCumulativeAmount ?? 0))
141
+ if (!Number.isFinite(charged)) return
142
+ if (startedAt === null) startedAt = charged
143
+ const spent = microUSDOf(charged - startedAt)
144
+ if (spent > spentMicroUSD) spentMicroUSD = spent
145
+ } catch { /* a record we cannot read is not a reason to stop paying */ }
146
+ if (!MAX_SPEND || capReached || spentMicroUSD < MAX_SPEND) return
147
+ capReached = true
148
+ log(`SPEND CAP REACHED — this run has spent ${spentMicroUSD} ${spendUnit} against a cap of ` +
149
+ `${MAX_SPEND}. Paying for nothing further. Raise or remove it with X402_MAX_SPEND.`)
150
+ dropLine('spend cap reached')
151
+ }
152
+
153
+ const saltOf = (raw) => {
154
+ const v = String(raw).trim()
155
+ if (/^0x[0-9a-fA-F]{64}$/.test(v)) return v
156
+ return keccak256(toHex(v))
157
+ }
158
+
159
+ const CARD_PAYER = process.env.X402_PAYER_ADDRESS
160
+ ? getAddress(process.env.X402_PAYER_ADDRESS) : null
161
+
162
+ const cardSigner = CARD_PAYER ? {
163
+ address: CARD_PAYER,
164
+ readContract: pub.readContract.bind(pub),
165
+ signTypedData: () => {
166
+ throw new Error(
167
+ 'this is a gift card and it cannot add funds: it holds a spending key, not ' +
168
+ `the key to ${CARD_PAYER}. The card is out of money — ask whoever funded it ` +
169
+ 'to top it up (agent-wallet fund) or issue a new one.')
170
+ },
171
+ } : null
172
+
173
+ if (CARD_PAYER) {
174
+ log(`gift card mode — spending ${CARD_PAYER}'s channel, authorized as ${account.address}`)
175
+ log('this key can spend the card and send it home; it cannot move the money anywhere else')
176
+ }
177
+
178
+ const payments = new x402Client(selector).register(NETWORK,
179
+ new BatchSettlementEvmScheme(toClientEvmSigner(cardSigner ?? account, pub), {
180
+ depositPolicy,
181
+ storage: watchedStorage,
182
+ ...(CARD_PAYER ? { payerAuthorizer: account.address, voucherSigner: toClientEvmSigner(account, pub) } : {}),
183
+ ...(process.env.X402_SALT ? { salt: saltOf(process.env.X402_SALT) } : {}),
184
+ }))
185
+
186
+ const upstream = wrapMCPClientWithPayment(
187
+ new Client({ name: NAME, version: VERSION }), payments, { autoPayment: true })
188
+ await upstream.connect(new StreamableHTTPClientTransport(new URL(UPSTREAM)))
189
+ log(`paying as ${account.address} -> ${UPSTREAM}`)
190
+
191
+ const termsURL = new URL('/.well-known/x402', UPSTREAM).toString()
192
+ let accepts = null
193
+ let tickAccepts = null
194
+ const loadTerms = async () => {
195
+ const r = await fetch(termsURL)
196
+ const j = await r.json()
197
+ if (!Array.isArray(j.accepts) || !j.accepts.length) throw new Error('no accepts in well-known')
198
+ accepts = { x402Version: j.x402Version ?? 1, accepts: j.accepts }
199
+ tickAccepts = Array.isArray(j.tickAccepts) && j.tickAccepts.length
200
+ ? { x402Version: j.x402Version ?? 1, accepts: j.tickAccepts }
201
+ : accepts // an upstream that does not sell time
202
+ const q = quoteFromTerms(j)
203
+ if (q !== null) {
204
+ if (q !== quoteMicroUSD) log(`quote: ${q} micro-USD per call, from the seller's own terms`)
205
+ quoteMicroUSD = q; spendUnit = 'micro-USD'
206
+ } else if (quoteMicroUSD === null) {
207
+ spendUnit = 'base units of the paid asset'
208
+ log(`quote: this server publishes no micro-USD price, so X402_MAX_SPEND is read as ` +
209
+ `BASE UNITS OF THE ASSET, not dollars. Guessing a price here is how a spend cap ` +
210
+ `silently stops capping.`)
211
+ }
212
+ return accepts
213
+ }
214
+ try { await loadTerms(); log(`terms cached from ${termsURL} — paying without probing`) }
215
+ catch (e) { log(`could not cache terms (${e.message}); falling back to probe-then-pay`) }
216
+
217
+ let paymentQueue = Promise.resolve()
218
+ const oneAtATime = (fn) => {
219
+ const run = paymentQueue.then(fn, fn)
220
+ paymentQueue = run.then(() => {}, () => {}) // a failure must not poison the queue
221
+ return run
222
+ }
223
+
224
+ const declarePaying = () => {
225
+ try { if (line.socket?.readyState === 1) line.socket.send(JSON.stringify({ op: 'paying' })) } catch { }
226
+ }
227
+
228
+ const payFirst = (name, args) => oneAtATime(() => { declarePaying(); return payNow(name, args) })
229
+
230
+ let coldStart = !channelId
231
+ if (coldStart) log('no local channel state — probing once to learn where this channel stands')
232
+
233
+ const payNow = async (name, args) => {
234
+ if (coldStart) {
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
+ return upstream.callTool(name, args)
239
+ }
240
+ const terms = name === 'tick' ? tickAccepts : accepts
241
+ if (!terms) return upstream.callTool(name, args)
242
+ for (const attempt of [1, 2]) {
243
+ try {
244
+ const payload = await payments.createPaymentPayload(terms)
245
+ const out = await upstream.callToolWithPayment(name, args, payload)
246
+ if (explainPermit2(out)) return out
247
+ if (refusedPayment(out)) {
248
+ log('payment refused as stale — dropping the local channel record and resyncing')
249
+ if (channelId) { try { await watchedStorage.delete(channelId) } catch { /* it will be rebuilt */ } }
250
+ return upstream.callTool(name, args)
251
+ }
252
+ return out
253
+ } catch (e) {
254
+ if (attempt === 2) { log(`pay-first failed twice (${e.message}); using probe path`); return upstream.callTool(name, args) }
255
+ try { await loadTerms() } catch { /* keep the old terms and let attempt 2 decide */ }
256
+ }
257
+ }
258
+ }
259
+
260
+ const PERMIT2 = '0x000000000022D473030F116dDEE9F6B43aC78BA3'
261
+ let approvalToldFor = null
262
+ const explainPermit2 = (out) => {
263
+ const body = String(out?.content?.[0]?.text ?? '')
264
+ if (!/permit2_allowance_required/.test(body)) return false
265
+ const token = chosenAccept?.asset
266
+ if (!token || approvalToldFor === token) return true
267
+ approvalToldFor = token
268
+ const per = Number(chosenAccept?.amount ?? 0)
269
+ const mult = Number(process.env.X402_DEPOSIT_MULTIPLIER ?? 400)
270
+ const suggested = per > 0 ? BigInt(Math.ceil(per * mult * 4)) : 0n
271
+ log(`${token} moves through Permit2 and your wallet has not approved it.`)
272
+ log(` send once, from your wallet: approve(${PERMIT2}, ${suggested || '<amount>'}) on ${token}`)
273
+ log(' a bounded amount is enough — this covers several deposits at your current multiplier.')
274
+ log(' it is your transaction and costs gas; nothing else in this flow does.')
275
+ return true
276
+ }
277
+
278
+ function refusedPayment(out) {
279
+ if (!out?.isError) return false
280
+ const body = String(out?.content?.[0]?.text ?? '')
281
+ return /x402Version/.test(body) && /"error"\s*:\s*"(invalid_|insufficient_|cumulative_)/.test(body)
282
+ }
283
+ log(`channel state in ${stateDir}`)
284
+
285
+ const LINE_MODE = (process.env.X402_LINE ?? 'auto').toLowerCase()
286
+
287
+ const AUTO_FAST_RUN = Number(process.env.X402_AUTO_FAST_RUN ?? 2)
288
+
289
+ const AUTO_SLOW_RUN = Number(process.env.X402_AUTO_SLOW_RUN ?? 4)
290
+
291
+ const line = { credential: null, socket: null, timer: null, tickMs: 250, lastUse: 0, opening: null }
292
+
293
+ // Rolling view of how fast the caller is actually going.
294
+ const rate = { lastCallAt: 0, fastRun: 0, slowRun: 0 }
295
+
296
+ function holdingIsCheaper() {
297
+ const now = Date.now()
298
+ const gap = rate.lastCallAt ? now - rate.lastCallAt : Infinity
299
+ 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
+ if (gap <= line.tickMs) { rate.fastRun += 1; rate.slowRun = 0 }
303
+ else { rate.slowRun += 1; rate.fastRun = 0 }
304
+
305
+ // Already holding? Keep holding until several gaps in a row say otherwise.
306
+ if (line.credential) return rate.slowRun < AUTO_SLOW_RUN
307
+ return rate.fastRun >= AUTO_FAST_RUN
308
+ }
309
+
310
+ const wsURL = () => {
311
+ const u = new URL(UPSTREAM)
312
+ u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'
313
+ u.pathname = '/pay'
314
+ u.search = ''
315
+ return u.toString()
316
+ }
317
+
318
+ const dropLine = (why) => {
319
+ if (line.timer) { clearInterval(line.timer); line.timer = null }
320
+ try { line.socket?.close() } catch { /* already gone */ }
321
+ if (line.credential) log(`line closed (${why})`)
322
+ line.socket = null
323
+ line.credential = null
324
+ }
325
+
326
+ let ticking = false
327
+ const tick = async () => {
328
+ if (capReached) return dropLine('spend cap reached')
329
+ if (!line.credential || ticking) return
330
+ ticking = true
331
+ try {
332
+ const out = await payFirst('tick', { line: line.credential })
333
+ const body = String(out?.content?.[0]?.text ?? '')
334
+ if (/"paid"\s*:\s*false/.test(body) || lineWasRefused(out)) {
335
+ dropLine('the line this tick paid for is gone')
336
+ }
337
+ } catch (e) { dropLine(`tick failed: ${e.message}`) }
338
+ finally { ticking = false }
339
+ }
340
+
341
+ const openLine = () => {
342
+ if (line.credential || line.opening) return line.opening
343
+ if (!channelId) return null // no channel yet; the first paid call makes one
344
+ line.opening = new Promise((resolve) => {
345
+ let socket
346
+ try { socket = new WebSocket(wsURL()) } catch (e) { log(`line: ${e.message}`); return resolve(null) }
347
+ const give_up = setTimeout(() => { try { socket.close() } catch {} ; resolve(null) }, 10_000)
348
+ socket.onmessage = (ev) => {
349
+ let m; try { m = JSON.parse(String(ev.data)) } catch { return }
350
+ if (m.op === 'opened') {
351
+ clearTimeout(give_up)
352
+ line.socket = socket
353
+ 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.
358
+ line.lastUse = Date.now()
359
+ log(`line open — ${m.microUSDPerMs ?? 1} micro-USD/ms, collateral buys ${m.buysMs ?? '?'}ms`)
360
+ const first = tick()
361
+ 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
+ if (LINE_MODE === 'auto' && Date.now() - line.lastUse > line.tickMs * 4) return dropLine('idle')
365
+ tick()
366
+ }, line.tickMs)
367
+ line.timer.unref?.()
368
+ first.then(() => resolve(line.credential))
369
+ } else if (m.op === 'closing' || m.error) {
370
+ log(`line: ${m.why ?? m.error}`)
371
+ clearTimeout(give_up)
372
+ dropLine(m.why ?? m.error)
373
+ resolve(null)
374
+ }
375
+ }
376
+ socket.onopen = () => socket.send(JSON.stringify({ op: 'open', channelId }))
377
+ socket.onclose = () => { clearTimeout(give_up); dropLine('socket closed'); resolve(null) }
378
+ socket.onerror = () => { /* onclose follows and does the work */ }
379
+ }).finally(() => { line.opening = null })
380
+ return line.opening
381
+ }
382
+
383
+ const server = new Server({ name: NAME, version: VERSION }, { capabilities: { tools: {} } })
384
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
385
+ const { tools } = await upstream.listTools()
386
+ return { tools }
387
+ })
388
+ const callOnLine = async (name, args) => {
389
+ if (capReached) {
390
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({
391
+ error: 'x402_bridge_spend_cap_reached',
392
+ spentMicroUSD, capMicroUSD: MAX_SPEND,
393
+ message: 'This bridge has spent its X402_MAX_SPEND ceiling and will not pay for more. ' +
394
+ 'Raise it, set X402_MAX_SPEND=0 to remove it, or restart the bridge.' }) }] }
395
+ }
396
+ line.lastUse = Date.now()
397
+ if (LINE_MODE === 'off') return payFirst(name, args)
398
+
399
+ if (LINE_MODE === 'auto') {
400
+ if (!holdingIsCheaper()) {
401
+ if (line.credential) dropLine('slower than the minimum hold — slices are cheaper')
402
+ return payFirst(name, args)
403
+ }
404
+ }
405
+
406
+ if (!channelId) return payFirst(name, args)
407
+
408
+ for (const attempt of [1, 2]) {
409
+ if (!line.credential) await openLine()
410
+ if (!line.credential) break
411
+ const out = await upstream.callTool(name, { ...args, line: line.credential })
412
+ if (!lineWasRefused(out)) return out
413
+ dropLine(reasonFrom(out) ?? 'refused by the server')
414
+ if (attempt === 2) {
415
+ log('the line was refused twice; paying for this call directly. If the reason above ' +
416
+ 'is collateral, raise X402_DEPOSIT_MULTIPLIER — the minimum of 3 buys under a second of line.')
417
+ }
418
+ }
419
+ return payFirst(name, args)
420
+ }
421
+
422
+ const LINE_IS_GONE = /"lineGone"\s*:\s*true|"(?:error|code)"\s*:\s*"(?:unknown_line|line_unpaid|line_closed)"/
423
+ const lineWasRefused = (out) => LINE_IS_GONE.test(String(out?.content?.[0]?.text ?? ''))
424
+
425
+ const reasonFrom = (out) => {
426
+ try {
427
+ const body = JSON.parse(String(out?.content?.[0]?.text ?? ''))
428
+ const why = body.why ?? body.message ?? body.error ?? body.code
429
+ return typeof why === 'string' && why ? why.slice(0, 160) : null
430
+ } catch { return null }
431
+ }
432
+
433
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
434
+ const out = await callOnLine(req.params.name, req.params.arguments ?? {})
435
+ return out
436
+ })
437
+
438
+ const stopPayingQuietly = async () => {
439
+ dropLine('one-shot call finished')
440
+ await new Promise(r => setTimeout(r, 50))
441
+ }
442
+
443
+ let leaving = false
444
+ const stopPaying = (why) => {
445
+ if (leaving) return
446
+ leaving = true
447
+ dropLine(why)
448
+ process.exit(0)
449
+ }
450
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => stopPaying('shutting down'))
451
+ for (const ev of ['end', 'close']) {
452
+ process.stdin.on(ev, () => stopPaying('stdin closed — whatever started us is gone'))
453
+ }
454
+ process.on('disconnect', () => stopPaying('parent disconnected'))
455
+
456
+ if (has('--tools')) {
457
+ const out = await upstream.listTools()
458
+ process.stdout.write(JSON.stringify(out.tools.map(t => t.name), null, 2) + '\n')
459
+ await stopPayingQuietly()
460
+ process.exit(0)
461
+ }
462
+
463
+ if (has('--call')) {
464
+ const tool = flag('--call')
465
+ if (!tool) { process.stderr.write('--call needs a tool name\n'); process.exit(2) }
466
+ // The JSON argument is optional: several tools take none.
467
+ const rawArgs = argv[argv.indexOf('--call') + 2]
468
+ let args = {}
469
+ if (rawArgs && !rawArgs.startsWith('--')) {
470
+ try { args = JSON.parse(rawArgs) }
471
+ catch (e) { process.stderr.write(`--call arguments must be JSON: ${e.message}\n`); process.exit(2) }
472
+ }
473
+ try {
474
+ const out = await callOnLine(tool, args)
475
+ const text = out?.content?.[0]?.text
476
+ process.stdout.write((typeof text === 'string' ? text : JSON.stringify(out)) + '\n')
477
+ await stopPayingQuietly()
478
+ process.exit(out?.isError ? 1 : 0)
479
+ } catch (e) {
480
+ process.stderr.write(`call failed: ${e.message}\n`)
481
+ await stopPayingQuietly()
482
+ process.exit(1)
483
+ }
484
+ }
485
+
486
+ if (has('--refund')) {
487
+ if (!channelId) { process.stderr.write('no channel to refund — nothing has been bought with this key and salt\n'); process.exit(2) }
488
+
489
+ // The channel is proved by signing for it, not by holding a session open. No
490
+ // line, no tick, no keep-warm: one message, one answer. Under a gift card the
491
+ // key here is the payerAuthorizer, which the server accepts for the same reason.
492
+ const issued = new Date().toISOString()
493
+ const message = `ZEAM Prism refund\nchannel: ${String(channelId).toLowerCase()}\nissued: ${issued}`
494
+ const signature = await account.signMessage({ message })
495
+
496
+ const answer = await new Promise((resolve) => {
497
+ let socket
498
+ try { socket = new WebSocket(wsURL()) } catch (e) { return resolve({ error: e.message }) }
499
+ const done = setTimeout(() => { try { socket.close() } catch {} ; resolve({ error: 'no answer in 60s' }) }, 60_000)
500
+ socket.onopen = () => socket.send(JSON.stringify({ op: 'refund', channelId, issued, signature }))
501
+ socket.onmessage = (ev) => {
502
+ let m; try { m = JSON.parse(String(ev.data)) } catch { return }
503
+ if (m.op === 'refunding') return log(m.note ?? 'refunding')
504
+ if (m.op === 'refunded' || m.op === 'refund_failed' || m.error) {
505
+ clearTimeout(done); try { socket.close() } catch {} ; resolve(m)
506
+ }
507
+ }
508
+ socket.onerror = (e) => { clearTimeout(done); resolve({ error: e?.message ?? 'socket error' }) }
509
+ })
510
+
511
+ process.stdout.write(JSON.stringify(answer, null, 2) + '\n')
512
+ if (answer.op !== 'refunded') {
513
+ process.stderr.write([
514
+ '',
515
+ 'The exit that always works needs nothing from us:',
516
+ ' initiateWithdraw(config, amount) then, after the delay, finalizeWithdraw(config)',
517
+ 'We also watch for that first call and return the collateral ourselves, at our gas,',
518
+ 'so you usually do not have to send the second transaction. We do NOT promise when:',
519
+ 'a third-party auditor measured 920 seconds on 2026-08-26, i.e. just after the delay',
520
+ 'elapsed. Plan against the delay. The escrow gates withdrawal to you alone, so your',
521
+ 'unspent collateral is safe either way.',
522
+ '',
523
+ ].join('\n'))
524
+ }
525
+ process.exit(answer.op === 'refunded' ? 0 : 1)
526
+ }
527
+
528
+ await server.connect(new StdioServerTransport())
529
+ if (LINE_MODE === 'on' && channelId) await openLine()
530
+ log(`bridge up on stdio — line mode ${LINE_MODE}${channelId ? '' : ' (channel opens on your first call)'}` +
531
+ ` | spend cap ${MAX_SPEND ? MAX_SPEND + ' ' + spendUnit : 'NONE (X402_MAX_SPEND=0)'}` +
532
+ ' | stops when stdin closes')
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@zeam-labs/x402-mcp-bridge",
3
+ "version": "2.0.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "x402-mcp-bridge": "index.mjs"
8
+ },
9
+ "main": "index.mjs",
10
+ "files": [
11
+ "index.mjs",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "license": "MIT",
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "1.30.0",
20
+ "@x402/mcp": "2.22.0",
21
+ "@x402/evm": "2.22.0",
22
+ "viem": "2.55.16"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/zeam-labs/x402-mcp-bridge"
27
+ },
28
+ "homepage": "https://mcp.zeamprism.com/llms.txt"
29
+ }