clanker-wallet 0.5.0 → 0.6.1
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 +29 -0
- package/SKILL.md +31 -0
- package/bin/cli.js +56 -5
- package/dist/client.d.ts +4 -0
- package/dist/client.js +37 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp.d.ts +176 -0
- package/dist/mcp.js +381 -0
- package/dist/types.d.ts +8 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -154,6 +154,35 @@ Identity and pairing are persisted in `~/.clanker-wallet/`:
|
|
|
154
154
|
| Sepolia (testnet) | 11155111 |
|
|
155
155
|
| Base Sepolia (testnet) | 84532 |
|
|
156
156
|
|
|
157
|
+
## MCP (assistants)
|
|
158
|
+
|
|
159
|
+
`clanker-wallet mcp` runs an MCP server over stdio, so an assistant (Claude Desktop, Claude Code, Cursor, or another MCP host that runs local commands) can ask for transactions as tool calls. The human approves every request on their own device; the server holds no wallet keys. It uses the CLI's identity and pairings in `~/.clanker-wallet/`, so pair first (`clanker-wallet pair '<JSON>'`, once per chain family). In a sandbox the server needs the same relay access and `~/.clanker-wallet/` writes as the CLI.
|
|
160
|
+
|
|
161
|
+
Claude Code:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
claude mcp add clanker-wallet -- npx -y clanker-wallet mcp
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Claude Desktop (`claude_desktop_config.json`), Cursor (`~/.cursor/mcp.json`), and any other MCP host that runs local stdio servers (command `npx`, args `-y clanker-wallet mcp`):
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"mcpServers": {
|
|
172
|
+
"clanker-wallet": { "command": "npx", "args": ["-y", "clanker-wallet", "mcp"] }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
| Tool | Arguments | Returns |
|
|
178
|
+
|------|-----------|---------|
|
|
179
|
+
| `request_evm_tx` | `chain_id`, `to`, `value` (wei, integer string), `data` (default `0x`), `reason`, optional `urgency` / `expected_outcome` | `{"request_id":"req_...","status":"pending","expires_at":"..."}` |
|
|
180
|
+
| `request_transfer` | `family` (`bitcoin`), `network` (`mainnet`, `testnet`, `signet`), `to`, `amount` (sats, integer string), `reason`, optional `urgency` / `expected_outcome` | as above |
|
|
181
|
+
| `get_request` | `request_id`, optional `wait_seconds` (at most 50) | `status`: `pending`, `approved` (with `tx_hash`), `rejected`, `failed` (with `error`), `unknown` (with `error`) or `expired` |
|
|
182
|
+
| `list_pairings` | none | `agent_id`, `fingerprint`, and the paired families (`evm`, `bitcoin`) with network, wallet and mode |
|
|
183
|
+
|
|
184
|
+
Requests are asynchronous: a request tool returns once the relay has the request, then the assistant calls `get_request` (with `wait_seconds` to wait for an answer) until the status is no longer `pending`. Invalid input or a missing pairing is a tool error, and nothing is sent. Pending requests live in the server process: after 1 h the server stops waiting and reports `expired`, and a restarted server no longer knows them (`get_request` then reports an unknown `request_id`). In both cases the human may still approve the request in their wallet, so ask them before sending it again. `unknown` means the request was delivered but the server lost its relay connection: the human may still approve it, so ask them before sending it again. Chia is not offered over MCP yet.
|
|
185
|
+
|
|
157
186
|
---
|
|
158
187
|
|
|
159
188
|
## Library Usage (Advanced)
|
package/SKILL.md
CHANGED
|
@@ -191,6 +191,37 @@ clanker-wallet whoami
|
|
|
191
191
|
# → {"agent_id":"agent_abc123","public_key":"ab12cd34ef567890...","fingerprint":"ab12 cd34 ef56 7890"}
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
+
## MCP (assistants)
|
|
195
|
+
|
|
196
|
+
`clanker-wallet mcp` runs an MCP server over stdio, so an assistant (Claude Desktop, Claude Code, Cursor, or another MCP host that runs local commands) can ask for transactions as tool calls. The human approves every request on their own device; the server holds no wallet keys. It uses the CLI's identity and pairings in `~/.clanker-wallet/`, so pair first (`clanker-wallet pair '<JSON>'`, once per chain family). In a sandbox the server needs the same relay access and `~/.clanker-wallet/` writes as the CLI.
|
|
197
|
+
|
|
198
|
+
If your host supports MCP, the human can add the server instead of you running `tx` / `send`:
|
|
199
|
+
|
|
200
|
+
Claude Code:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
claude mcp add clanker-wallet -- npx -y clanker-wallet mcp
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Claude Desktop (`claude_desktop_config.json`), Cursor (`~/.cursor/mcp.json`), and any other MCP host that runs local stdio servers (command `npx`, args `-y clanker-wallet mcp`):
|
|
207
|
+
|
|
208
|
+
```json
|
|
209
|
+
{
|
|
210
|
+
"mcpServers": {
|
|
211
|
+
"clanker-wallet": { "command": "npx", "args": ["-y", "clanker-wallet", "mcp"] }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
| Tool | Arguments | Returns |
|
|
217
|
+
|------|-----------|---------|
|
|
218
|
+
| `request_evm_tx` | `chain_id`, `to`, `value` (wei, integer string), `data` (default `0x`), `reason`, optional `urgency` / `expected_outcome` | `{"request_id":"req_...","status":"pending","expires_at":"..."}` |
|
|
219
|
+
| `request_transfer` | `family` (`bitcoin`), `network` (`mainnet`, `testnet`, `signet`), `to`, `amount` (sats, integer string), `reason`, optional `urgency` / `expected_outcome` | as above |
|
|
220
|
+
| `get_request` | `request_id`, optional `wait_seconds` (at most 50) | `status`: `pending`, `approved` (with `tx_hash`), `rejected`, `failed` (with `error`), `unknown` (with `error`) or `expired` |
|
|
221
|
+
| `list_pairings` | none | `agent_id`, `fingerprint`, and the paired families (`evm`, `bitcoin`) with network, wallet and mode |
|
|
222
|
+
|
|
223
|
+
Flow: call `list_pairings` to see which chains you can use, call `request_evm_tx` or `request_transfer` (same rules as `tx` / `send`: wei and sats as integer strings, real addresses), tell the human a request is waiting (see "Transaction notifications"), then call `get_request` with `wait_seconds` until the status is no longer `pending`. Invalid input or a missing pairing comes back as a tool error and nothing is sent. After 1 h the server stops waiting and reports `expired`, and a restarted server no longer knows its earlier requests (`get_request` then reports an unknown `request_id`). In both cases the human may still approve the request in their wallet, so ask them before sending it again. `unknown` means the request was delivered but the server lost its relay connection: the human may still approve it, so ask them before sending it again. Chia is not offered over MCP yet.
|
|
224
|
+
|
|
194
225
|
## Troubleshooting
|
|
195
226
|
|
|
196
227
|
- **exit code 5 / "invalid --flag: ..."** — fix the named flag; nothing was sent. Usually a placeholder `--to`, a `--value` in ETH instead of wei, or `--data` that isn't even-length hex
|
package/bin/cli.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { ClankerWallet, InvalidTxError, validateTransfer, validateTx } from '../dist/index.js'
|
|
3
|
+
import { createMcpServer, MAX_WAIT_SECONDS, REQUEST_STATUSES } from '../dist/mcp.js'
|
|
3
4
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'fs'
|
|
4
5
|
import { join } from 'path'
|
|
5
6
|
import { homedir } from 'os'
|
|
7
|
+
import { createInterface } from 'readline'
|
|
6
8
|
|
|
7
9
|
// ── constants ────────────────────────────────────────────────────────
|
|
8
10
|
const STATE_DIR = join(homedir(), '.clanker-wallet')
|
|
@@ -167,9 +169,8 @@ function contextFrom(flags) {
|
|
|
167
169
|
return Object.keys(context).length > 0 ? context : undefined
|
|
168
170
|
}
|
|
169
171
|
|
|
170
|
-
/** a ClankerWallet paired from a saved pairing */
|
|
171
|
-
function pairedWallet(pairing) {
|
|
172
|
-
const wallet = getWallet()
|
|
172
|
+
/** a ClankerWallet (the saved identity by default) paired from a saved pairing */
|
|
173
|
+
function pairedWallet(pairing, wallet = getWallet()) {
|
|
173
174
|
wallet.pair(JSON.stringify({
|
|
174
175
|
version: 1,
|
|
175
176
|
pubkey: pairing.pubkey,
|
|
@@ -308,9 +309,55 @@ function cmdWhoami() {
|
|
|
308
309
|
})
|
|
309
310
|
}
|
|
310
311
|
|
|
312
|
+
function packageVersion() {
|
|
313
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
314
|
+
}
|
|
315
|
+
|
|
311
316
|
function cmdVersion() {
|
|
312
|
-
|
|
313
|
-
|
|
317
|
+
process.stdout.write(packageVersion() + '\n')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function showMcpHelp() {
|
|
321
|
+
process.stderr.write(`clanker-wallet mcp — MCP server over stdio for assistants (Claude Desktop, Claude Code, Cursor, ...)
|
|
322
|
+
|
|
323
|
+
uses this agent's identity and pairings (~/.clanker-wallet/); pair first with: clanker-wallet pair '<JSON>'
|
|
324
|
+
every request waits for the human to approve it on their own device.
|
|
325
|
+
|
|
326
|
+
tools:
|
|
327
|
+
request_evm_tx ask for an evm transaction (value in wei, integer string) -> { request_id }
|
|
328
|
+
request_transfer ask for a bitcoin send (amount in sats, integer string) -> { request_id }
|
|
329
|
+
get_request ${REQUEST_STATUSES.join(' | ')}; wait_seconds up to ${MAX_WAIT_SECONDS}
|
|
330
|
+
list_pairings paired chain families, networks, wallets, and the agent fingerprint
|
|
331
|
+
|
|
332
|
+
claude code: claude mcp add clanker-wallet -- npx -y clanker-wallet mcp
|
|
333
|
+
other hosts: {"mcpServers":{"clanker-wallet":{"command":"npx","args":["-y","clanker-wallet","mcp"]}}}
|
|
334
|
+
|
|
335
|
+
requests are kept in memory: after 1 h the server stops waiting (expired), and a restart forgets pending ones.
|
|
336
|
+
expired, unknown (relay lost after delivery) or forgotten: the human may still approve it, so ask before sending it again.
|
|
337
|
+
docs: https://clanker-wallet.xyz/SKILL.md
|
|
338
|
+
`)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** serve MCP on stdin/stdout until stdin ends; stdout carries only protocol messages */
|
|
342
|
+
async function cmdMcp(flags) {
|
|
343
|
+
if (flags.help) return showMcpHelp()
|
|
344
|
+
|
|
345
|
+
const identity = getWallet()
|
|
346
|
+
const server = createMcpServer({
|
|
347
|
+
version: packageVersion(),
|
|
348
|
+
identity: { agent_id: identity.agentId, fingerprint: fingerprint(hex(identity.publicKey)) },
|
|
349
|
+
loadPairing,
|
|
350
|
+
// the identity loaded at startup, which list_pairings shows: re-reading identity.json per request could switch agents mid-run
|
|
351
|
+
wallet: (pairing) => pairedWallet(pairing, new ClankerWallet({ secretKey: identity.secretKey })),
|
|
352
|
+
})
|
|
353
|
+
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity })
|
|
354
|
+
// each line is answered when it settles, so a get_request that waits never holds up the others
|
|
355
|
+
lines.on('line', (line) => {
|
|
356
|
+
server.handleLine(line).then((reply) => { if (reply) process.stdout.write(reply + '\n') })
|
|
357
|
+
})
|
|
358
|
+
await new Promise((resolve) => lines.on('close', resolve))
|
|
359
|
+
// the host is gone: drop pending requests (their relay sockets would keep the process alive)
|
|
360
|
+
process.exit(EXIT.OK)
|
|
314
361
|
}
|
|
315
362
|
|
|
316
363
|
function showHelp() {
|
|
@@ -322,6 +369,7 @@ commands:
|
|
|
322
369
|
send send btc or xch; the human's wallet builds it (blocks until approved/rejected)
|
|
323
370
|
status check pairing status (one pairing per chain family: evm, bitcoin, chia)
|
|
324
371
|
whoami show agent identity
|
|
372
|
+
mcp run an MCP server over stdio for assistants (Claude Desktop/Code, Cursor, ...); mcp --help
|
|
325
373
|
|
|
326
374
|
tx flags:
|
|
327
375
|
--chain <id> chain ID (required) — 1, 8453, 84532 (base sepolia), etc.
|
|
@@ -383,6 +431,9 @@ switch (cmd) {
|
|
|
383
431
|
case 'whoami':
|
|
384
432
|
cmdWhoami()
|
|
385
433
|
break
|
|
434
|
+
case 'mcp':
|
|
435
|
+
await cmdMcp(flags)
|
|
436
|
+
break
|
|
386
437
|
case '--version':
|
|
387
438
|
case '-v':
|
|
388
439
|
cmdVersion()
|
package/dist/client.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { TypedEmitter } from './events.js';
|
|
2
2
|
import type { PairingData, RequestTransferOptions, RequestTxOptions } from './types.js';
|
|
3
|
+
/** the relay answered with an error frame (rate limited, message too large, ...): it refused the request instead of passing it on */
|
|
4
|
+
export declare class RelayError extends Error {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
3
7
|
export interface ClankerWalletOptions {
|
|
4
8
|
/** agent display name (informational) */
|
|
5
9
|
agentName?: string;
|
package/dist/client.js
CHANGED
|
@@ -12,6 +12,13 @@ function proxyHint() {
|
|
|
12
12
|
? '; HTTPS_PROXY is set but node ignores it, rerun with NODE_USE_ENV_PROXY=1 (node 24+)'
|
|
13
13
|
: '';
|
|
14
14
|
}
|
|
15
|
+
/** the relay answered with an error frame (rate limited, message too large, ...): it refused the request instead of passing it on */
|
|
16
|
+
export class RelayError extends Error {
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'RelayError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
15
22
|
export class ClankerWallet extends TypedEmitter {
|
|
16
23
|
publicKey;
|
|
17
24
|
secretKey;
|
|
@@ -92,7 +99,7 @@ export class ClankerWallet extends TypedEmitter {
|
|
|
92
99
|
const simulate = mode === 'managed'
|
|
93
100
|
? { from: humanWallet || '', to: transaction.to, value: transaction.value, data: transaction.data, chainId: chain_id }
|
|
94
101
|
: undefined;
|
|
95
|
-
return this.send(txRequest, options
|
|
102
|
+
return this.send(txRequest, options, simulate);
|
|
96
103
|
}
|
|
97
104
|
/**
|
|
98
105
|
* send a bitcoin / chia transfer to the paired human and wait for their response.
|
|
@@ -122,34 +129,56 @@ export class ClankerWallet extends TypedEmitter {
|
|
|
122
129
|
};
|
|
123
130
|
request.signature = signMessage(JSON.stringify(request), this.signingSecretKey);
|
|
124
131
|
// the relay simulator is evm-only: no simulate object for transfers, even on a managed pairing
|
|
125
|
-
return this.send(request, options
|
|
132
|
+
return this.send(request, options);
|
|
126
133
|
}
|
|
127
134
|
/** encrypt a signed request to the paired human, send it through the relay, and wait for the answer */
|
|
128
|
-
send(request,
|
|
135
|
+
send(request, options, simulate) {
|
|
129
136
|
const { humanPubkey, humanPubkeyHex, relayUrl } = this.pairing;
|
|
130
137
|
const requestId = request.request_id;
|
|
138
|
+
const timeoutMs = options.timeout_ms ?? DEFAULT_TIMEOUT_MS;
|
|
139
|
+
const { join_timeout_ms: joinTimeoutMs, signal } = options;
|
|
131
140
|
return new Promise((resolve, reject) => {
|
|
132
141
|
let ws;
|
|
133
142
|
let timer;
|
|
143
|
+
let joinTimer;
|
|
134
144
|
let settled = false;
|
|
145
|
+
const onAbort = () => {
|
|
146
|
+
if (!settled)
|
|
147
|
+
fail(`request ${requestId} cancelled`);
|
|
148
|
+
};
|
|
135
149
|
const cleanup = () => {
|
|
136
150
|
settled = true;
|
|
137
151
|
clearTimeout(timer);
|
|
152
|
+
clearTimeout(joinTimer);
|
|
153
|
+
signal?.removeEventListener('abort', onAbort);
|
|
138
154
|
if (ws && ws.readyState <= WebSocket.OPEN) {
|
|
139
155
|
ws.close();
|
|
140
156
|
}
|
|
141
157
|
};
|
|
142
|
-
const fail = (
|
|
158
|
+
const fail = (error) => {
|
|
159
|
+
const err = typeof error === 'string' ? new Error(error) : error;
|
|
143
160
|
cleanup();
|
|
144
|
-
this.emit('error', { message });
|
|
145
|
-
reject(
|
|
161
|
+
this.emit('error', { message: err.message });
|
|
162
|
+
reject(err);
|
|
146
163
|
};
|
|
164
|
+
if (signal?.aborted) {
|
|
165
|
+
onAbort();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
signal?.addEventListener('abort', onAbort);
|
|
147
169
|
timer = setTimeout(() => {
|
|
148
170
|
if (!settled) {
|
|
149
171
|
fail(`request ${requestId} timed out after ${timeoutMs}ms`);
|
|
150
172
|
}
|
|
151
173
|
}, timeoutMs);
|
|
152
174
|
this.emit('connecting', { relay: relayUrl });
|
|
175
|
+
// the request goes out only once joined, so failing before then guarantees nothing was sent
|
|
176
|
+
if (joinTimeoutMs !== undefined) {
|
|
177
|
+
joinTimer = setTimeout(() => {
|
|
178
|
+
if (!settled)
|
|
179
|
+
fail(`relay ${relayUrl} did not answer join within ${joinTimeoutMs}ms; request ${requestId} was not sent`);
|
|
180
|
+
}, joinTimeoutMs);
|
|
181
|
+
}
|
|
153
182
|
try {
|
|
154
183
|
ws = new WebSocket(relayUrl);
|
|
155
184
|
}
|
|
@@ -183,6 +212,7 @@ export class ClankerWallet extends TypedEmitter {
|
|
|
183
212
|
return;
|
|
184
213
|
}
|
|
185
214
|
if (msg.type === 'joined') {
|
|
215
|
+
clearTimeout(joinTimer);
|
|
186
216
|
this.emit('connected', { room: humanPubkeyHex });
|
|
187
217
|
const ciphertext = encryptMessage(JSON.stringify(request), humanPubkey, this.secretKey);
|
|
188
218
|
const envelope = {
|
|
@@ -238,7 +268,7 @@ export class ClankerWallet extends TypedEmitter {
|
|
|
238
268
|
return;
|
|
239
269
|
}
|
|
240
270
|
if (msg.type === 'error') {
|
|
241
|
-
fail(`relay error: ${msg.message}`);
|
|
271
|
+
fail(new RelayError(`relay error: ${msg.message}`));
|
|
242
272
|
}
|
|
243
273
|
});
|
|
244
274
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { ClankerWallet } from './client.js';
|
|
1
|
+
export { ClankerWallet, RelayError } from './client.js';
|
|
2
2
|
export type { ClankerWalletOptions } from './client.js';
|
|
3
3
|
export { base64ToBytes, bytesToBase64, decryptMessage, encryptMessage, fromHex, generateKeypair, keypairFromSecret, signMessage, signingKeypairFromSeed, toHex, verifySignature } from './crypto.js';
|
|
4
4
|
export { TypedEmitter } from './events.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { ClankerWallet } from './client.js';
|
|
1
|
+
export { ClankerWallet, RelayError } from './client.js';
|
|
2
2
|
export { base64ToBytes, bytesToBase64, decryptMessage, encryptMessage, fromHex, generateKeypair, keypairFromSecret, signMessage, signingKeypairFromSeed, toHex, verifySignature } from './crypto.js';
|
|
3
3
|
export { TypedEmitter } from './events.js';
|
|
4
4
|
export { InvalidTxError, isChecksumAddress, validateTransfer, validateTx } from './validate.js';
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { type ClankerWallet } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* MCP server for `clanker-wallet mcp`: newline-delimited JSON-RPC 2.0 over stdio, hand-written so
|
|
4
|
+
* tweetnacl stays the only runtime dependency. the cli owns stdin/stdout and the identity and pairing
|
|
5
|
+
* files; this module answers one line at a time and keeps each request's state in memory.
|
|
6
|
+
*/
|
|
7
|
+
/** newest first; a client asking for another version gets the newest. not 2025-03-26: it requires receiving batches, which we refuse */
|
|
8
|
+
export declare const SUPPORTED_PROTOCOL_VERSIONS: string[];
|
|
9
|
+
/** get_request waits at most this long per call; assistants time tool calls out after about a minute */
|
|
10
|
+
export declare const MAX_WAIT_SECONDS = 50;
|
|
11
|
+
/** a pairing as the cli saves it in ~/.clanker-wallet/pairings/<family>.json */
|
|
12
|
+
export interface SavedPairing {
|
|
13
|
+
pubkey: string;
|
|
14
|
+
relay: string;
|
|
15
|
+
wallet: string | null;
|
|
16
|
+
mode: 'encrypted' | 'managed' | null;
|
|
17
|
+
chain_family?: string;
|
|
18
|
+
chain_network?: string;
|
|
19
|
+
}
|
|
20
|
+
/** the families the tools request on; chia waits until the web app shows it again */
|
|
21
|
+
type RequestFamily = 'evm' | 'bitcoin';
|
|
22
|
+
export interface McpServerOptions {
|
|
23
|
+
/** the package version, reported in initialize */
|
|
24
|
+
version: string;
|
|
25
|
+
identity: {
|
|
26
|
+
agent_id: string;
|
|
27
|
+
fingerprint: string;
|
|
28
|
+
};
|
|
29
|
+
/** the saved pairing for a family, or null when that family isn't paired */
|
|
30
|
+
loadPairing: (family: RequestFamily) => SavedPairing | null;
|
|
31
|
+
/** a new ClankerWallet with the agent identity, paired from `pairing` */
|
|
32
|
+
wallet: (pairing: SavedPairing) => ClankerWallet;
|
|
33
|
+
/** how long a request waits for the human (default 1 h, the relay's buffer) */
|
|
34
|
+
requestTtlMs?: number;
|
|
35
|
+
/** how long a request waits for the relay to confirm the join before failing unsent (default 20 s) */
|
|
36
|
+
joinTimeoutMs?: number;
|
|
37
|
+
}
|
|
38
|
+
export declare const MCP_TOOLS: ({
|
|
39
|
+
name: string;
|
|
40
|
+
description: string;
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: string;
|
|
43
|
+
properties: {
|
|
44
|
+
reason: {
|
|
45
|
+
type: string;
|
|
46
|
+
description: string;
|
|
47
|
+
};
|
|
48
|
+
urgency: {
|
|
49
|
+
type: string;
|
|
50
|
+
enum: string[];
|
|
51
|
+
description: string;
|
|
52
|
+
};
|
|
53
|
+
expected_outcome: {
|
|
54
|
+
type: string;
|
|
55
|
+
description: string;
|
|
56
|
+
};
|
|
57
|
+
chain_id: {
|
|
58
|
+
type: string;
|
|
59
|
+
description: string;
|
|
60
|
+
};
|
|
61
|
+
to: {
|
|
62
|
+
type: string;
|
|
63
|
+
description: string;
|
|
64
|
+
};
|
|
65
|
+
value: {
|
|
66
|
+
type: string;
|
|
67
|
+
description: string;
|
|
68
|
+
};
|
|
69
|
+
data: {
|
|
70
|
+
type: string;
|
|
71
|
+
description: string;
|
|
72
|
+
};
|
|
73
|
+
request_id?: undefined;
|
|
74
|
+
wait_seconds?: undefined;
|
|
75
|
+
};
|
|
76
|
+
required: string[];
|
|
77
|
+
};
|
|
78
|
+
annotations: {
|
|
79
|
+
readOnlyHint: boolean;
|
|
80
|
+
destructiveHint: boolean;
|
|
81
|
+
idempotentHint: boolean;
|
|
82
|
+
openWorldHint: boolean;
|
|
83
|
+
};
|
|
84
|
+
} | {
|
|
85
|
+
name: string;
|
|
86
|
+
description: string;
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: string;
|
|
89
|
+
properties: {
|
|
90
|
+
reason: {
|
|
91
|
+
type: string;
|
|
92
|
+
description: string;
|
|
93
|
+
};
|
|
94
|
+
urgency: {
|
|
95
|
+
type: string;
|
|
96
|
+
enum: string[];
|
|
97
|
+
description: string;
|
|
98
|
+
};
|
|
99
|
+
expected_outcome: {
|
|
100
|
+
type: string;
|
|
101
|
+
description: string;
|
|
102
|
+
};
|
|
103
|
+
family: {
|
|
104
|
+
type: string;
|
|
105
|
+
enum: string[];
|
|
106
|
+
description: string;
|
|
107
|
+
};
|
|
108
|
+
network: {
|
|
109
|
+
type: string;
|
|
110
|
+
enum: string[];
|
|
111
|
+
description: string;
|
|
112
|
+
};
|
|
113
|
+
to: {
|
|
114
|
+
type: string;
|
|
115
|
+
description: string;
|
|
116
|
+
};
|
|
117
|
+
amount: {
|
|
118
|
+
type: string;
|
|
119
|
+
description: string;
|
|
120
|
+
};
|
|
121
|
+
request_id?: undefined;
|
|
122
|
+
wait_seconds?: undefined;
|
|
123
|
+
};
|
|
124
|
+
required: string[];
|
|
125
|
+
};
|
|
126
|
+
annotations: {
|
|
127
|
+
readOnlyHint: boolean;
|
|
128
|
+
destructiveHint: boolean;
|
|
129
|
+
idempotentHint: boolean;
|
|
130
|
+
openWorldHint: boolean;
|
|
131
|
+
};
|
|
132
|
+
} | {
|
|
133
|
+
name: string;
|
|
134
|
+
description: string;
|
|
135
|
+
inputSchema: {
|
|
136
|
+
type: string;
|
|
137
|
+
properties: {
|
|
138
|
+
request_id: {
|
|
139
|
+
type: string;
|
|
140
|
+
description: string;
|
|
141
|
+
};
|
|
142
|
+
wait_seconds: {
|
|
143
|
+
type: string;
|
|
144
|
+
minimum: number;
|
|
145
|
+
maximum: number;
|
|
146
|
+
description: string;
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
required: string[];
|
|
150
|
+
};
|
|
151
|
+
annotations: {
|
|
152
|
+
readOnlyHint: boolean;
|
|
153
|
+
openWorldHint: boolean;
|
|
154
|
+
};
|
|
155
|
+
} | {
|
|
156
|
+
name: string;
|
|
157
|
+
description: string;
|
|
158
|
+
inputSchema: {
|
|
159
|
+
type: string;
|
|
160
|
+
properties: {
|
|
161
|
+
request_id?: undefined;
|
|
162
|
+
wait_seconds?: undefined;
|
|
163
|
+
};
|
|
164
|
+
required?: undefined;
|
|
165
|
+
};
|
|
166
|
+
annotations: {
|
|
167
|
+
readOnlyHint: boolean;
|
|
168
|
+
openWorldHint: boolean;
|
|
169
|
+
};
|
|
170
|
+
})[];
|
|
171
|
+
/** what get_request reports, in the order the docs list them */
|
|
172
|
+
export declare const REQUEST_STATUSES: readonly ["pending", "approved", "rejected", "failed", "unknown", "expired"];
|
|
173
|
+
export declare function createMcpServer(options: McpServerOptions): {
|
|
174
|
+
handleLine: (line: string) => Promise<string | null>;
|
|
175
|
+
};
|
|
176
|
+
export {};
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { RelayError } from './client.js';
|
|
2
|
+
import { InvalidTxError, validateTransfer, validateTx } from './validate.js';
|
|
3
|
+
/**
|
|
4
|
+
* MCP server for `clanker-wallet mcp`: newline-delimited JSON-RPC 2.0 over stdio, hand-written so
|
|
5
|
+
* tweetnacl stays the only runtime dependency. the cli owns stdin/stdout and the identity and pairing
|
|
6
|
+
* files; this module answers one line at a time and keeps each request's state in memory.
|
|
7
|
+
*/
|
|
8
|
+
/** newest first; a client asking for another version gets the newest. not 2025-03-26: it requires receiving batches, which we refuse */
|
|
9
|
+
export const SUPPORTED_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2024-11-05'];
|
|
10
|
+
/** get_request waits at most this long per call; assistants time tool calls out after about a minute */
|
|
11
|
+
export const MAX_WAIT_SECONDS = 50;
|
|
12
|
+
/** the relay buffers room messages for 1 h, so a later answer can't reach this process */
|
|
13
|
+
const DEFAULT_REQUEST_TTL_MS = 3_600_000;
|
|
14
|
+
/** a relay that hasn't confirmed the join by then fails the request with nothing sent, inside the hosts' 60 s tool-call timeout */
|
|
15
|
+
const DEFAULT_JOIN_TIMEOUT_MS = 20_000;
|
|
16
|
+
const REQUEST_FAMILIES = ['evm', 'bitcoin'];
|
|
17
|
+
const URGENCIES = ['low', 'medium', 'high'];
|
|
18
|
+
const CONTEXT_PROPERTIES = {
|
|
19
|
+
reason: { type: 'string', description: 'why you want this, in plain words; shown to the human on the review card' },
|
|
20
|
+
urgency: { type: 'string', enum: URGENCIES, description: 'priority hint shown to the human' },
|
|
21
|
+
expected_outcome: { type: 'string', description: 'what will happen once it is sent' },
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* MCP tool annotations: assistants (ChatGPT, dots, Claude) may skip their confirmation prompt for read-only tools.
|
|
25
|
+
* a request moves nothing until the human approves it on their device, so it is not destructive, but each call
|
|
26
|
+
* sends a new request to the human's wallet.
|
|
27
|
+
*/
|
|
28
|
+
const REQUEST_HINTS = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
|
|
29
|
+
const READ_HINTS = { readOnlyHint: true, openWorldHint: false };
|
|
30
|
+
export const MCP_TOOLS = [
|
|
31
|
+
{
|
|
32
|
+
name: 'request_evm_tx',
|
|
33
|
+
description: 'Ask the human to send an EVM transaction (ETH or a contract call) from their own wallet. ' +
|
|
34
|
+
'Nothing is signed here: the human approves every request on their own device, or rejects it. ' +
|
|
35
|
+
'Returns { request_id } once the request is delivered; then call get_request for the outcome. ' +
|
|
36
|
+
'value is an integer string in wei (1 ETH = "1000000000000000000"), no decimals.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
chain_id: { type: 'integer', description: 'EVM chain id: 1 Ethereum, 8453 Base, 84532 Base Sepolia, 10 Optimism, 42161 Arbitrum, 137 Polygon' },
|
|
41
|
+
to: { type: 'string', description: 'recipient or contract: 0x followed by 40 hex characters (mixed case must be EIP-55 checksummed)' },
|
|
42
|
+
value: { type: 'string', description: 'integer string in wei; "0" for a contract call that sends no ETH' },
|
|
43
|
+
data: { type: 'string', description: 'hex calldata, default "0x" (a plain transfer)' },
|
|
44
|
+
...CONTEXT_PROPERTIES,
|
|
45
|
+
},
|
|
46
|
+
required: ['chain_id', 'to', 'value', 'reason'],
|
|
47
|
+
},
|
|
48
|
+
annotations: REQUEST_HINTS,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'request_transfer',
|
|
52
|
+
description: 'Ask the human to send bitcoin from their own wallet, which builds, signs and broadcasts it. ' +
|
|
53
|
+
'Nothing is signed here: the human approves every request on their own device, or rejects it. ' +
|
|
54
|
+
'Returns { request_id } once the request is delivered; then call get_request for the outcome. ' +
|
|
55
|
+
'amount is an integer string in sats (1 BTC = "100000000"), no decimals; the wallet sets the fee.',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
properties: {
|
|
59
|
+
family: { type: 'string', enum: ['bitcoin'], description: 'chain family' },
|
|
60
|
+
network: { type: 'string', enum: ['mainnet', 'testnet', 'signet'], description: 'must match the paired wallet (see list_pairings)' },
|
|
61
|
+
to: { type: 'string', description: 'segwit address on that network: bc1... on mainnet, tb1... on testnet and signet' },
|
|
62
|
+
amount: { type: 'string', description: 'integer string in sats' },
|
|
63
|
+
...CONTEXT_PROPERTIES,
|
|
64
|
+
},
|
|
65
|
+
required: ['family', 'network', 'to', 'amount', 'reason'],
|
|
66
|
+
},
|
|
67
|
+
annotations: REQUEST_HINTS,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'get_request',
|
|
71
|
+
description: 'Get the outcome of a request: pending, approved (with tx_hash), rejected, failed (with error), unknown (with error) or expired. ' +
|
|
72
|
+
`The human may take minutes to answer: pass wait_seconds (at most ${MAX_WAIT_SECONDS}) to wait for it, and call again while it is pending. ` +
|
|
73
|
+
'unknown means the request was delivered but this server lost its relay connection: the human may still approve it, so ask them before sending it again. ' +
|
|
74
|
+
'expired means this server stopped waiting, 1 hour after sending; after a restart it no longer knows earlier request ids. ' +
|
|
75
|
+
'In both cases the human may still approve it in their wallet, so ask them before sending it again.',
|
|
76
|
+
inputSchema: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
request_id: { type: 'string', description: 'from request_evm_tx or request_transfer' },
|
|
80
|
+
wait_seconds: { type: 'number', minimum: 0, maximum: MAX_WAIT_SECONDS, description: 'wait up to this long for an answer (default 0)' },
|
|
81
|
+
},
|
|
82
|
+
required: ['request_id'],
|
|
83
|
+
},
|
|
84
|
+
annotations: READ_HINTS,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'list_pairings',
|
|
88
|
+
description: 'List the wallets you can request on (chain family, network, wallet address, mode) and your agent fingerprint, ' +
|
|
89
|
+
'which the human compares in the web app before trusting you.',
|
|
90
|
+
inputSchema: { type: 'object', properties: {} },
|
|
91
|
+
annotations: READ_HINTS,
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
const INSTRUCTIONS = 'clanker-wallet sends transaction requests to a human, who approves or rejects each one in their own wallet; ' +
|
|
95
|
+
'you never hold keys. Call list_pairings to see which chains you can use, request_evm_tx or request_transfer to ask, ' +
|
|
96
|
+
'and get_request (with wait_seconds) until the request is no longer pending.';
|
|
97
|
+
/** what get_request reports, in the order the docs list them */
|
|
98
|
+
export const REQUEST_STATUSES = ['pending', 'approved', 'rejected', 'failed', 'unknown', 'expired'];
|
|
99
|
+
const STILL_APPROVABLE = 'the request was delivered, so the human may still approve it in their wallet: ask them before sending it again';
|
|
100
|
+
/** a JSON-RPC error answered to the client */
|
|
101
|
+
class RpcError extends Error {
|
|
102
|
+
code;
|
|
103
|
+
constructor(code, message) {
|
|
104
|
+
super(message);
|
|
105
|
+
this.code = code;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const PARSE_ERROR = -32700;
|
|
109
|
+
const INVALID_REQUEST = -32600;
|
|
110
|
+
const METHOD_NOT_FOUND = -32601;
|
|
111
|
+
const INVALID_PARAMS = -32602;
|
|
112
|
+
const INTERNAL_ERROR = -32603;
|
|
113
|
+
/** a tool's input was refused; reported as a tool result with isError, nothing sent */
|
|
114
|
+
class ToolInputError extends Error {
|
|
115
|
+
}
|
|
116
|
+
const toolResult = (body) => ({ content: [{ type: 'text', text: JSON.stringify(body) }] });
|
|
117
|
+
const toolError = (message) => ({ content: [{ type: 'text', text: message }], isError: true });
|
|
118
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
119
|
+
/** reason (required, non-blank), urgency and expected_outcome from tool arguments */
|
|
120
|
+
function contextFrom(args) {
|
|
121
|
+
const { reason, urgency, expected_outcome } = args;
|
|
122
|
+
if (typeof reason !== 'string' || !reason.trim())
|
|
123
|
+
throw new ToolInputError('invalid reason: say why, in plain words; the human sees it');
|
|
124
|
+
const context = { reason };
|
|
125
|
+
if (urgency !== undefined) {
|
|
126
|
+
if (typeof urgency !== 'string' || !URGENCIES.includes(urgency))
|
|
127
|
+
throw new ToolInputError(`invalid urgency: must be ${URGENCIES.join(', ')}`);
|
|
128
|
+
context.urgency = urgency;
|
|
129
|
+
}
|
|
130
|
+
if (expected_outcome !== undefined) {
|
|
131
|
+
if (typeof expected_outcome !== 'string')
|
|
132
|
+
throw new ToolInputError('invalid expected_outcome: must be a string');
|
|
133
|
+
context.expected_outcome = expected_outcome;
|
|
134
|
+
}
|
|
135
|
+
return context;
|
|
136
|
+
}
|
|
137
|
+
const NOT_PAIRED = {
|
|
138
|
+
evm: 'not paired with an evm wallet: ask the human to open https://clanker-wallet.xyz, copy the pairing JSON, and run: clanker-wallet pair \'<JSON>\'',
|
|
139
|
+
bitcoin: 'not paired with a bitcoin wallet: ask the human to open the bitcoin tab at https://clanker-wallet.xyz, copy its pairing JSON, and run: clanker-wallet pair \'<JSON>\'',
|
|
140
|
+
};
|
|
141
|
+
export function createMcpServer(options) {
|
|
142
|
+
const ttl = options.requestTtlMs ?? DEFAULT_REQUEST_TTL_MS;
|
|
143
|
+
const joinTimeout = options.joinTimeoutMs ?? DEFAULT_JOIN_TIMEOUT_MS;
|
|
144
|
+
const records = new Map();
|
|
145
|
+
/** tools/call requests still being answered, by JSON-RPC id, for notifications/cancelled */
|
|
146
|
+
const inFlight = new Map();
|
|
147
|
+
/**
|
|
148
|
+
* start a request on the family's pairing and answer once the relay has it: { request_id } after
|
|
149
|
+
* the wallet sends it, or a tool error when it fails first (bad input, unreachable relay, join timeout, cancelled)
|
|
150
|
+
*/
|
|
151
|
+
function submit(family, cancelled, send) {
|
|
152
|
+
const pairing = options.loadPairing(family);
|
|
153
|
+
if (!pairing)
|
|
154
|
+
return Promise.resolve(toolError(NOT_PAIRED[family]));
|
|
155
|
+
const wallet = options.wallet(pairing);
|
|
156
|
+
const expiresAt = Date.now() + ttl;
|
|
157
|
+
// a cancel stops the wallet only before it sends: once delivered, the human can still approve, so keep waiting
|
|
158
|
+
const unsent = new AbortController();
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
let record;
|
|
161
|
+
let answered;
|
|
162
|
+
// this timer decides expiry, not a Date.now() check: timers can fire a millisecond before the clock reaches their
|
|
163
|
+
// target. it is set before the wallet's own timer of the same ttl, and node runs equal delays in the order they
|
|
164
|
+
// were set, so the request is expired before the wallet's timeout rejection arrives
|
|
165
|
+
const expiry = setTimeout(() => record?.settle('expired'), ttl);
|
|
166
|
+
cancelled.addEventListener('abort', () => { if (!record)
|
|
167
|
+
unsent.abort(); });
|
|
168
|
+
wallet.on('request_sent', ({ request_id }) => {
|
|
169
|
+
record = newRecord(request_id, expiresAt);
|
|
170
|
+
records.set(request_id, record);
|
|
171
|
+
record.settled.then(() => clearTimeout(expiry));
|
|
172
|
+
resolve(toolResult({ request_id, status: 'pending', expires_at: new Date(expiresAt).toISOString() }));
|
|
173
|
+
});
|
|
174
|
+
wallet.on('response', (response) => { answered = response; });
|
|
175
|
+
send(wallet, { timeout_ms: ttl, join_timeout_ms: joinTimeout, signal: unsent.signal }).then((txHash) => record.settle('approved', { tx_hash: txHash }), (err) => {
|
|
176
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
177
|
+
if (!record) {
|
|
178
|
+
clearTimeout(expiry);
|
|
179
|
+
return resolve(toolError(message));
|
|
180
|
+
}
|
|
181
|
+
if (answered?.status === 'rejected')
|
|
182
|
+
record.settle('rejected');
|
|
183
|
+
else if (answered)
|
|
184
|
+
record.settle('failed', { error: message });
|
|
185
|
+
// the relay refused the request (rate limit, size) and dropped it: nothing reached the human
|
|
186
|
+
else if (err instanceof RelayError)
|
|
187
|
+
record.settle('failed', { error: message });
|
|
188
|
+
// delivered, then the relay connection dropped: the relay still holds the request for the human
|
|
189
|
+
// (after expiry the record is already settled and this changes nothing)
|
|
190
|
+
else
|
|
191
|
+
record.settle('unknown', { error: `${message}; ${STILL_APPROVABLE}` });
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
async function requestEvmTx(args, cancelled) {
|
|
196
|
+
const context = contextFrom(args);
|
|
197
|
+
const { chain_id, transaction } = validateTx(args.chain_id, { to: args.to, value: args.value, data: args.data ?? '0x' });
|
|
198
|
+
return submit('evm', cancelled, (wallet, limits) => wallet.requestTx({ chain_id, transaction, context, ...limits }));
|
|
199
|
+
}
|
|
200
|
+
async function requestTransfer(args, cancelled) {
|
|
201
|
+
if (args.family !== 'bitcoin')
|
|
202
|
+
throw new ToolInputError(`invalid family: must be "bitcoin", got ${JSON.stringify(args.family)}`);
|
|
203
|
+
const context = contextFrom(args);
|
|
204
|
+
let request;
|
|
205
|
+
try {
|
|
206
|
+
request = validateTransfer('bitcoin', args.network, { asset: 'btc', to: args.to, amount: args.amount });
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
// the tool calls the network field `network`
|
|
210
|
+
if (err instanceof InvalidTxError && err.field === 'chain_network')
|
|
211
|
+
throw new ToolInputError(`invalid network: ${err.reason}`);
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
const { chain_network, transfer } = request;
|
|
215
|
+
return submit('bitcoin', cancelled, async (wallet, limits) => {
|
|
216
|
+
try {
|
|
217
|
+
return await wallet.requestTransfer({ chain_network, transfer, context, ...limits });
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
// the pairing is for another network
|
|
221
|
+
if (err instanceof InvalidTxError && err.field === 'chain_network')
|
|
222
|
+
throw new Error(`invalid network: ${err.reason}`);
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async function getRequest(args, cancelled) {
|
|
228
|
+
const { request_id, wait_seconds = 0 } = args;
|
|
229
|
+
if (typeof request_id !== 'string')
|
|
230
|
+
throw new ToolInputError('invalid request_id: must be the string request_evm_tx or request_transfer returned');
|
|
231
|
+
if (typeof wait_seconds !== 'number' || !(wait_seconds >= 0)) {
|
|
232
|
+
throw new ToolInputError(`invalid wait_seconds: must be a number from 0 to ${MAX_WAIT_SECONDS}`);
|
|
233
|
+
}
|
|
234
|
+
const record = records.get(request_id);
|
|
235
|
+
if (!record) {
|
|
236
|
+
throw new ToolInputError(`unknown request_id ${JSON.stringify(request_id)}: this server did not send it, or sent it before it restarted and stopped waiting. ` +
|
|
237
|
+
'if it was sent before a restart, the human may still approve it in their wallet: ask them before sending it again');
|
|
238
|
+
}
|
|
239
|
+
const waitMs = Math.min(wait_seconds, MAX_WAIT_SECONDS) * 1000;
|
|
240
|
+
if (record.status === 'pending' && waitMs > 0) {
|
|
241
|
+
let timer;
|
|
242
|
+
const waits = [record.settled, new Promise((r) => cancelled.addEventListener('abort', r, { once: true }))];
|
|
243
|
+
// a wait that reaches past expiry ends when the request's expiry timer settles it
|
|
244
|
+
if (waitMs < record.expiresAt - Date.now())
|
|
245
|
+
waits.push(new Promise((r) => { timer = setTimeout(r, waitMs); }));
|
|
246
|
+
await Promise.race(waits);
|
|
247
|
+
clearTimeout(timer);
|
|
248
|
+
}
|
|
249
|
+
// a fallback for an expiry timer that runs late (a busy process): it only marks expired once the clock has passed expires_at
|
|
250
|
+
if (record.status === 'pending' && Date.now() >= record.expiresAt)
|
|
251
|
+
record.settle('expired');
|
|
252
|
+
const { status, tx_hash, error } = record;
|
|
253
|
+
if (status === 'pending')
|
|
254
|
+
return toolResult({ request_id, status, expires_at: new Date(record.expiresAt).toISOString() });
|
|
255
|
+
return toolResult({ request_id, status, ...(tx_hash !== undefined && { tx_hash }), ...(error !== undefined && { error }) });
|
|
256
|
+
}
|
|
257
|
+
function listPairings() {
|
|
258
|
+
const pairings = REQUEST_FAMILIES.flatMap((family) => {
|
|
259
|
+
const pairing = options.loadPairing(family);
|
|
260
|
+
return pairing ? [{ family, network: pairing.chain_network ?? null, wallet: pairing.wallet ?? null, mode: pairing.mode ?? null }] : [];
|
|
261
|
+
});
|
|
262
|
+
return toolResult({ ...options.identity, pairings });
|
|
263
|
+
}
|
|
264
|
+
const TOOL_HANDLERS = {
|
|
265
|
+
request_evm_tx: requestEvmTx,
|
|
266
|
+
request_transfer: requestTransfer,
|
|
267
|
+
get_request: getRequest,
|
|
268
|
+
list_pairings: listPairings,
|
|
269
|
+
};
|
|
270
|
+
async function callTool(params, cancelled) {
|
|
271
|
+
if (!isObject(params) || typeof params.name !== 'string')
|
|
272
|
+
throw new RpcError(INVALID_PARAMS, 'tools/call needs a tool name');
|
|
273
|
+
const handler = TOOL_HANDLERS[params.name];
|
|
274
|
+
if (!handler)
|
|
275
|
+
throw new RpcError(INVALID_PARAMS, `unknown tool: ${params.name}`);
|
|
276
|
+
const args = params.arguments ?? {};
|
|
277
|
+
if (!isObject(args))
|
|
278
|
+
throw new RpcError(INVALID_PARAMS, 'tool arguments must be an object');
|
|
279
|
+
try {
|
|
280
|
+
return await handler(args, cancelled);
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
if (err instanceof ToolInputError || err instanceof InvalidTxError)
|
|
284
|
+
return toolError(err.message);
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function initialize(params) {
|
|
289
|
+
const requested = isObject(params) ? params.protocolVersion : undefined;
|
|
290
|
+
const protocolVersion = typeof requested === 'string' && SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : SUPPORTED_PROTOCOL_VERSIONS[0];
|
|
291
|
+
return {
|
|
292
|
+
protocolVersion,
|
|
293
|
+
capabilities: { tools: {} },
|
|
294
|
+
serverInfo: { name: 'clanker-wallet', version: options.version },
|
|
295
|
+
instructions: INSTRUCTIONS,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
async function dispatch(method, params, cancelled) {
|
|
299
|
+
switch (method) {
|
|
300
|
+
case 'initialize': return initialize(params);
|
|
301
|
+
case 'ping': return {};
|
|
302
|
+
case 'tools/list': return { tools: MCP_TOOLS };
|
|
303
|
+
case 'tools/call': return callTool(params, cancelled);
|
|
304
|
+
default: throw new RpcError(METHOD_NOT_FOUND, `method not found: ${method}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/** the reply to one input line, or null when none is due (blank line, notification, a response from the client) */
|
|
308
|
+
async function handleLine(line) {
|
|
309
|
+
if (!line.trim())
|
|
310
|
+
return null;
|
|
311
|
+
let msg;
|
|
312
|
+
try {
|
|
313
|
+
msg = JSON.parse(line);
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return reply(null, { error: { code: PARSE_ERROR, message: 'parse error: each line must be one JSON-RPC message' } });
|
|
317
|
+
}
|
|
318
|
+
if (Array.isArray(msg))
|
|
319
|
+
return reply(null, { error: { code: INVALID_REQUEST, message: 'batches are not supported' } });
|
|
320
|
+
if (!isObject(msg))
|
|
321
|
+
return reply(null, { error: { code: INVALID_REQUEST, message: 'invalid request' } });
|
|
322
|
+
const hasId = 'id' in msg;
|
|
323
|
+
const id = typeof msg.id === 'string' || typeof msg.id === 'number' ? msg.id : null;
|
|
324
|
+
if (hasId && id === null)
|
|
325
|
+
return reply(null, { error: { code: INVALID_REQUEST, message: 'invalid request: id must be a string or number' } });
|
|
326
|
+
if (typeof msg.method !== 'string') {
|
|
327
|
+
// a response to us: we send no requests, so there is nothing to match it with
|
|
328
|
+
if ('result' in msg || 'error' in msg)
|
|
329
|
+
return null;
|
|
330
|
+
return reply(id, { error: { code: INVALID_REQUEST, message: 'invalid request: no method' } });
|
|
331
|
+
}
|
|
332
|
+
if (msg.jsonrpc !== '2.0')
|
|
333
|
+
return reply(id, { error: { code: INVALID_REQUEST, message: 'invalid request: jsonrpc must be "2.0"' } });
|
|
334
|
+
// notifications need no answer; only cancelled changes anything here
|
|
335
|
+
if (!hasId) {
|
|
336
|
+
if (msg.method === 'notifications/cancelled' && isObject(msg.params))
|
|
337
|
+
inFlight.get(msg.params.requestId)?.abort();
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
const controller = new AbortController();
|
|
341
|
+
inFlight.set(id, controller);
|
|
342
|
+
let answer;
|
|
343
|
+
try {
|
|
344
|
+
answer = reply(id, { result: await dispatch(msg.method, msg.params, controller.signal) });
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
if (err instanceof RpcError)
|
|
348
|
+
answer = reply(id, { error: { code: err.code, message: err.message } });
|
|
349
|
+
else
|
|
350
|
+
answer = reply(id, { error: { code: INTERNAL_ERROR, message: err instanceof Error ? err.message : String(err) } });
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
if (inFlight.get(id) === controller)
|
|
354
|
+
inFlight.delete(id);
|
|
355
|
+
}
|
|
356
|
+
// the client gave up on this call: it expects no response
|
|
357
|
+
return controller.signal.aborted ? null : answer;
|
|
358
|
+
}
|
|
359
|
+
return { handleLine };
|
|
360
|
+
}
|
|
361
|
+
function reply(id, body) {
|
|
362
|
+
return JSON.stringify({ jsonrpc: '2.0', id, ...body });
|
|
363
|
+
}
|
|
364
|
+
function newRecord(request_id, expiresAt) {
|
|
365
|
+
let resolveSettled;
|
|
366
|
+
const record = {
|
|
367
|
+
request_id,
|
|
368
|
+
status: 'pending',
|
|
369
|
+
expiresAt,
|
|
370
|
+
settled: new Promise((r) => { resolveSettled = r; }),
|
|
371
|
+
settle: (status, detail = {}) => {
|
|
372
|
+
if (record.status !== 'pending')
|
|
373
|
+
return;
|
|
374
|
+
record.status = status;
|
|
375
|
+
record.tx_hash = detail.tx_hash;
|
|
376
|
+
record.error = detail.error;
|
|
377
|
+
resolveSettled();
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
return record;
|
|
381
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -61,6 +61,10 @@ export interface RequestTxOptions {
|
|
|
61
61
|
context?: TxContext;
|
|
62
62
|
/** timeout in ms (default: 3_600_000 = 1hr) */
|
|
63
63
|
timeout_ms?: number;
|
|
64
|
+
/** fail when the relay hasn't confirmed joining the room this long after connecting (default: only timeout_ms applies); nothing was sent then */
|
|
65
|
+
join_timeout_ms?: number;
|
|
66
|
+
/** aborting before the relay confirms the join fails the request with nothing sent; after that it only stops waiting */
|
|
67
|
+
signal?: AbortSignal;
|
|
64
68
|
}
|
|
65
69
|
/** wallet families a transfer can go to */
|
|
66
70
|
export type TransferFamily = 'bitcoin' | 'chia';
|
|
@@ -101,4 +105,8 @@ export interface RequestTransferOptions {
|
|
|
101
105
|
context?: TxContext;
|
|
102
106
|
/** timeout in ms (default: 3_600_000 = 1hr) */
|
|
103
107
|
timeout_ms?: number;
|
|
108
|
+
/** fail when the relay hasn't confirmed joining the room this long after connecting (default: only timeout_ms applies); nothing was sent then */
|
|
109
|
+
join_timeout_ms?: number;
|
|
110
|
+
/** aborting before the relay confirms the join fails the request with nothing sent; after that it only stops waiting */
|
|
111
|
+
signal?: AbortSignal;
|
|
104
112
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clanker-wallet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"tweetnacl": "^1.0.3"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
62
63
|
"@types/bun": "^1.3.8",
|
|
63
64
|
"typescript": "^5.4.0",
|
|
64
65
|
"vitest": "^2.1.0"
|