@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.
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/index.mjs +532 -0
- 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
|
+
}
|