web3-tools-mcp 1.5.0 → 1.6.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/README.md +90 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -2
- package/dist/client.js.map +1 -1
- package/dist/http-server.d.ts +12 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +74 -0
- package/dist/http-server.js.map +1 -0
- package/dist/index.js +37 -13
- package/dist/index.js.map +1 -1
- package/dist/kv-storage.d.ts +23 -0
- package/dist/kv-storage.d.ts.map +1 -0
- package/dist/kv-storage.js +69 -0
- package/dist/kv-storage.js.map +1 -0
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +12 -2
- package/dist/preview.js.map +1 -1
- package/dist/tools/transactions.d.ts +14 -0
- package/dist/tools/transactions.d.ts.map +1 -1
- package/dist/tools/transactions.js +128 -18
- package/dist/tools/transactions.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +2 -7
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +1 -0
- package/dist/utils.js.map +1 -1
- package/dist/walletconnect.d.ts +31 -0
- package/dist/walletconnect.d.ts.map +1 -0
- package/dist/walletconnect.js +128 -0
- package/dist/walletconnect.js.map +1 -0
- package/package.json +7 -3
- package/src/client.ts +4 -2
- package/src/http-server.ts +94 -0
- package/src/index.ts +38 -13
- package/src/kv-storage.ts +83 -0
- package/src/preview.ts +12 -2
- package/src/tools/transactions.ts +146 -20
- package/src/types.ts +1 -0
- package/src/utils.ts +4 -17
- package/src/walletconnect.ts +157 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
3
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
4
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Serve the MCP over HTTP instead of stdio, so the server can live somewhere other than the
|
|
8
|
+
* machine running the agent. Only worth doing alongside WalletConnect: a hosted server has
|
|
9
|
+
* no browser to open, so a phone wallet is the only way it can have anything signed.
|
|
10
|
+
*
|
|
11
|
+
* Stateless — a server per request — because every tool here is a one-shot call and the
|
|
12
|
+
* signing state lives in the WalletConnect session, not in the transport.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DEFAULT_PORT = 3457
|
|
16
|
+
|
|
17
|
+
function unauthorized(res: ServerResponse) {
|
|
18
|
+
res.writeHead(401, { 'content-type': 'application/json', 'www-authenticate': 'Bearer' })
|
|
19
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'Unauthorized' } }))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Constant-time compare, so the token cannot be guessed a character at a time. */
|
|
23
|
+
function tokenMatches(presented: string, expected: string): boolean {
|
|
24
|
+
const a = Buffer.from(presented)
|
|
25
|
+
const b = Buffer.from(expected)
|
|
26
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface HttpServerOptions {
|
|
30
|
+
port?: number
|
|
31
|
+
host?: string
|
|
32
|
+
token?: string
|
|
33
|
+
createMcpServer: () => McpServer
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function startHttpServer(options: HttpServerOptions): Promise<{ url: string; port: number }> {
|
|
37
|
+
const port = options.port ?? Number(process.env.MCP_HTTP_PORT) ?? DEFAULT_PORT
|
|
38
|
+
const host = options.host ?? process.env.HOST ?? (process.env.MCP_HTTP_HOST ? process.env.MCP_HTTP_HOST : '0.0.0.0')
|
|
39
|
+
const token = options.token ?? process.env.MCP_TOKEN
|
|
40
|
+
|
|
41
|
+
// A reachable MCP server with a paired phone can push signing prompts at that phone. It
|
|
42
|
+
// never runs without a token.
|
|
43
|
+
if (!token) {
|
|
44
|
+
throw new Error('MCP_TOKEN is required to serve MCP over HTTP — anyone reaching the URL could request signatures')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
48
|
+
if (req.url === '/health') {
|
|
49
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
50
|
+
res.end(JSON.stringify({ status: 'ok' }))
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!req.url?.startsWith('/mcp')) {
|
|
55
|
+
res.writeHead(404).end()
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const presented = req.headers.authorization?.replace(/^Bearer /i, '') ?? ''
|
|
60
|
+
if (!tokenMatches(presented, token)) {
|
|
61
|
+
unauthorized(res)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const mcp = options.createMcpServer()
|
|
66
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true })
|
|
67
|
+
|
|
68
|
+
res.on('close', () => {
|
|
69
|
+
transport.close().catch(() => {})
|
|
70
|
+
mcp.close().catch(() => {})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
await mcp.connect(transport)
|
|
75
|
+
await transport.handleRequest(req, res)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error('[MCP] Request failed:', error)
|
|
78
|
+
if (!res.headersSent) {
|
|
79
|
+
res.writeHead(500, { 'content-type': 'application/json' })
|
|
80
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' } }))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
await new Promise<void>((resolve, reject) => {
|
|
86
|
+
server.once('error', reject)
|
|
87
|
+
server.listen(port, host, () => {
|
|
88
|
+
server.removeListener('error', reject)
|
|
89
|
+
resolve()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
return { url: `http://${host}:${port}/mcp`, port }
|
|
94
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { initializeClientManager } from "./client.js";
|
|
|
7
7
|
import { registerAllTools } from "./tools/index.js";
|
|
8
8
|
import { parseCommandLineArgs } from "./utils.js";
|
|
9
9
|
import { getWalletClient } from "./wallet-client.js";
|
|
10
|
+
import { startHttpServer } from "./http-server.js";
|
|
10
11
|
|
|
11
12
|
// Parse configuration
|
|
12
13
|
const config = parseCommandLineArgs();
|
|
@@ -26,6 +27,8 @@ OPTIONS:
|
|
|
26
27
|
--alchemy-api-key <key> Alchemy API key (for enhanced RPC)
|
|
27
28
|
--infura-api-key <key> Infura API key (for alternative RPC)
|
|
28
29
|
--hypersync-api-key <key> Hypersync API key (for fast event queries)
|
|
30
|
+
--walletconnect-project-id <id>
|
|
31
|
+
WalletConnect project id, to sign from a phone
|
|
29
32
|
--custom-rpc <json> Custom RPC URLs as JSON object
|
|
30
33
|
Example: '{"mainnet":"https://...", "base":"https://..."}'
|
|
31
34
|
|
|
@@ -34,6 +37,11 @@ ENVIRONMENT VARIABLES:
|
|
|
34
37
|
ALCHEMY_API_KEY Alternative to --alchemy-api-key
|
|
35
38
|
INFURA_API_KEY Alternative to --infura-api-key
|
|
36
39
|
HYPERSYNC_API_KEY Alternative to --hypersync-api-key
|
|
40
|
+
WALLETCONNECT_PROJECT_ID Alternative to --walletconnect-project-id
|
|
41
|
+
MCP_HTTP_PORT Serve MCP over HTTP on this port instead of stdio
|
|
42
|
+
MCP_TOKEN Bearer token required by the HTTP transport
|
|
43
|
+
UPSTASH_REDIS_REST_URL Keep WalletConnect sessions in Redis, not on disk
|
|
44
|
+
UPSTASH_REDIS_REST_TOKEN
|
|
37
45
|
WALLET_SERVER_URL URL of a hosted wallet relay (omit to run one locally)
|
|
38
46
|
WALLET_TOKEN Shared pairing token for the wallet relay
|
|
39
47
|
|
|
@@ -70,6 +78,9 @@ if (config.alchemyApiKey) {
|
|
|
70
78
|
if (config.infuraApiKey) {
|
|
71
79
|
console.error("[MCP] Infura API key configured");
|
|
72
80
|
}
|
|
81
|
+
if (config.walletConnectProjectId) {
|
|
82
|
+
console.error("[MCP] WalletConnect project id configured — phone signing available");
|
|
83
|
+
}
|
|
73
84
|
if (config.customRpcUrls) {
|
|
74
85
|
console.error("[MCP] Custom RPC URLs:", Object.keys(config.customRpcUrls));
|
|
75
86
|
}
|
|
@@ -77,21 +88,26 @@ if (config.customRpcUrls) {
|
|
|
77
88
|
// Initialize client manager
|
|
78
89
|
initializeClientManager(config);
|
|
79
90
|
|
|
80
|
-
|
|
81
|
-
const server = new McpServer({
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
});
|
|
91
|
+
function createMcpServer() {
|
|
92
|
+
const server = new McpServer({
|
|
93
|
+
name: "web3-tools-mcp",
|
|
94
|
+
version: packageJson.version,
|
|
95
|
+
});
|
|
96
|
+
registerAllTools(server);
|
|
97
|
+
return server;
|
|
98
|
+
}
|
|
85
99
|
|
|
86
|
-
//
|
|
87
|
-
|
|
100
|
+
// Serving over HTTP means there is no browser on this machine to open, so the wallet relay
|
|
101
|
+
// is skipped and signing goes to a paired phone over WalletConnect.
|
|
102
|
+
const httpMode = Boolean(process.env.MCP_HTTP_PORT);
|
|
88
103
|
|
|
89
|
-
// Connect to the wallet relay (hosted when WALLET_SERVER_URL is set, embedded otherwise)
|
|
90
104
|
const wallet = getWalletClient();
|
|
91
|
-
const walletReady =
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
105
|
+
const walletReady = httpMode
|
|
106
|
+
? Promise.resolve()
|
|
107
|
+
: wallet.connect().catch((error) => {
|
|
108
|
+
console.error("[MCP] Wallet relay unavailable:", error.message);
|
|
109
|
+
console.error("[MCP] Transaction signing features will not be available");
|
|
110
|
+
});
|
|
95
111
|
|
|
96
112
|
// The wallet relay's listening socket keeps the event loop alive, so this process would
|
|
97
113
|
// outlive the client that spawned it and go on holding its port. Leave when the client does.
|
|
@@ -111,8 +127,17 @@ process.on("SIGINT", () => shutdown("SIGINT"));
|
|
|
111
127
|
|
|
112
128
|
// Start server
|
|
113
129
|
async function main() {
|
|
130
|
+
if (httpMode) {
|
|
131
|
+
const { url } = await startHttpServer({ createMcpServer });
|
|
132
|
+
console.error(`Web3 Tools MCP Server listening on ${url}`);
|
|
133
|
+
if (!config.walletConnectProjectId) {
|
|
134
|
+
console.error("[MCP] No WalletConnect project id — a hosted server has no way to sign transactions");
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
114
139
|
const transport = new StdioServerTransport();
|
|
115
|
-
await
|
|
140
|
+
await createMcpServer().connect(transport);
|
|
116
141
|
console.error("Web3 Tools MCP Server running on stdio");
|
|
117
142
|
// The relay may still be picking a free port, and its URL carries the pairing token.
|
|
118
143
|
await walletReady;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WalletConnect session storage backed by Upstash Redis.
|
|
3
|
+
*
|
|
4
|
+
* The default store is a file, which is fine on a laptop and useless on a free-tier host:
|
|
5
|
+
* the filesystem is ephemeral, so every sleep/wake would lose the pairing and ask for the
|
|
6
|
+
* QR again. Everything lives in one Redis hash, which is exactly the shape WalletConnect's
|
|
7
|
+
* key-value interface expects.
|
|
8
|
+
*
|
|
9
|
+
* Uses the REST API over plain fetch — no Redis client, no TCP, works anywhere.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const HASH_KEY = 'web3-tools-mcp:walletconnect'
|
|
13
|
+
|
|
14
|
+
export interface KeyValueStorage {
|
|
15
|
+
getKeys(): Promise<string[]>
|
|
16
|
+
getEntries<T = unknown>(): Promise<[string, T][]>
|
|
17
|
+
getItem<T = unknown>(key: string): Promise<T | undefined>
|
|
18
|
+
setItem<T = unknown>(key: string, value: T): Promise<void>
|
|
19
|
+
removeItem(key: string): Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class RedisKeyValueStorage implements KeyValueStorage {
|
|
23
|
+
constructor(
|
|
24
|
+
private url: string,
|
|
25
|
+
private token: string
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
/** Upstash takes a command as a JSON array and answers `{ result }`. */
|
|
29
|
+
private async command<T>(...parts: (string | number)[]): Promise<T> {
|
|
30
|
+
const response = await fetch(this.url, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { authorization: `Bearer ${this.token}`, 'content-type': 'application/json' },
|
|
33
|
+
body: JSON.stringify(parts)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`Upstash request failed (${response.status}): ${await response.text()}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return ((await response.json()) as { result: T }).result
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getKeys(): Promise<string[]> {
|
|
44
|
+
return (await this.command<string[]>('HKEYS', HASH_KEY)) ?? []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async getEntries<T = unknown>(): Promise<[string, T][]> {
|
|
48
|
+
// HGETALL comes back as a flat [field, value, field, value, …] array.
|
|
49
|
+
const flat = (await this.command<string[]>('HGETALL', HASH_KEY)) ?? []
|
|
50
|
+
const entries: [string, T][] = []
|
|
51
|
+
for (let i = 0; i < flat.length; i += 2) {
|
|
52
|
+
const key = flat[i]
|
|
53
|
+
const value = flat[i + 1]
|
|
54
|
+
if (key === undefined || value === undefined) continue
|
|
55
|
+
entries.push([key, JSON.parse(value) as T])
|
|
56
|
+
}
|
|
57
|
+
return entries
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async getItem<T = unknown>(key: string): Promise<T | undefined> {
|
|
61
|
+
const value = await this.command<string | null>('HGET', HASH_KEY, key)
|
|
62
|
+
return value == null ? undefined : (JSON.parse(value) as T)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async setItem<T = unknown>(key: string, value: T): Promise<void> {
|
|
66
|
+
await this.command('HSET', HASH_KEY, key, JSON.stringify(value))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async removeItem(key: string): Promise<void> {
|
|
70
|
+
await this.command('HDEL', HASH_KEY, key)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Redis-backed storage when Upstash is configured, otherwise nothing — the caller falls
|
|
76
|
+
* back to WalletConnect's own file store.
|
|
77
|
+
*/
|
|
78
|
+
export function getKeyValueStorage(): KeyValueStorage | undefined {
|
|
79
|
+
const url = process.env.UPSTASH_REDIS_REST_URL
|
|
80
|
+
const token = process.env.UPSTASH_REDIS_REST_TOKEN
|
|
81
|
+
if (!url || !token) return undefined
|
|
82
|
+
return new RedisKeyValueStorage(url, token)
|
|
83
|
+
}
|
package/src/preview.ts
CHANGED
|
@@ -244,8 +244,18 @@ export async function buildTxPreview(chain: ChainName, tx: RawTx, from?: string)
|
|
|
244
244
|
const value = tx.value ? BigInt(tx.value).toString() : undefined
|
|
245
245
|
|
|
246
246
|
const [decoded, simulation, toLabel] = await Promise.all([
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
// A preview that cannot be built must not block the transaction, but swallowing the
|
|
248
|
+
// reason makes "no details shown" impossible to diagnose.
|
|
249
|
+
decodeCalldata(chain, tx).catch((error) => {
|
|
250
|
+
console.error('[Preview] Could not decode calldata:', error instanceof Error ? error.message : error)
|
|
251
|
+
return undefined
|
|
252
|
+
}),
|
|
253
|
+
from
|
|
254
|
+
? simulate(chain, tx, from as Address).catch((error) => {
|
|
255
|
+
console.error('[Preview] Could not simulate:', error instanceof Error ? error.message : error)
|
|
256
|
+
return undefined
|
|
257
|
+
})
|
|
258
|
+
: Promise.resolve(undefined),
|
|
249
259
|
// The registry's protocol name beats anything on-chain: "Aave", not a proxy's class name.
|
|
250
260
|
Promise.resolve(lookupContract(chain, tx.to))
|
|
251
261
|
.then((entry) => (entry ? protocolLabel(entry.protocol) : addressLabel(chain, tx.to)))
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { getWalletClient } from '../wallet-client.js'
|
|
3
3
|
import { getClientManager, SUPPORTED_CHAINS } from '../client.js'
|
|
4
|
-
import { encodeFunctionData, isAddress, parseAbiItem, parseUnits, type AbiFunction, type Address } from 'viem'
|
|
4
|
+
import { encodeFunctionData, isAddress, parseAbiItem, parseUnits, toHex, type AbiFunction, type Address } from 'viem'
|
|
5
5
|
import { randomBytes } from 'node:crypto'
|
|
6
6
|
import type { ChainName } from '../types.js'
|
|
7
|
-
import { buildTxPreview, type RawTx } from '../preview.js'
|
|
7
|
+
import { buildTxPreview, type RawTx, type TxPreview } from '../preview.js'
|
|
8
|
+
import { getPhoneSigner } from '../walletconnect.js'
|
|
8
9
|
import { createTool, formatResponse } from '../utils.js'
|
|
10
|
+
import qrcode from 'qrcode-terminal'
|
|
11
|
+
|
|
12
|
+
/** Render a pairing URI as an ASCII QR, so it can be scanned straight from the chat. */
|
|
13
|
+
function qrFor(uri: string): Promise<string> {
|
|
14
|
+
return new Promise((resolve) => qrcode.generate(uri, { small: true }, resolve))
|
|
15
|
+
}
|
|
9
16
|
|
|
10
17
|
function generateRequestId(): string {
|
|
11
18
|
return randomBytes(16).toString('hex')
|
|
@@ -21,10 +28,28 @@ function explorerTxUrl(chain: ChainName, txHash: unknown): string {
|
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
/**
|
|
24
|
-
* Send a transaction for signing
|
|
25
|
-
*
|
|
31
|
+
* Send a transaction for signing.
|
|
32
|
+
*
|
|
33
|
+
* A paired phone wins over the browser page: it was set up deliberately and is the only
|
|
34
|
+
* option when there is no browser to open. Either way the transaction is decoded and
|
|
35
|
+
* simulated first — the phone wallet shows its own summary, so the preview is returned to
|
|
36
|
+
* the caller as well, where it can be read before the prompt is approved.
|
|
26
37
|
*/
|
|
27
38
|
async function requestSignature(chain: ChainName, tx: RawTx & { data?: string }) {
|
|
39
|
+
const phone = getPhoneSigner()
|
|
40
|
+
|
|
41
|
+
if (await phone?.isPaired()) {
|
|
42
|
+
const session = await phone!.session()
|
|
43
|
+
const from = session?.accounts[0]
|
|
44
|
+
const preview = await buildTxPreview(chain, tx, from)
|
|
45
|
+
|
|
46
|
+
const txHash = await phone!.request(chain, 'eth_sendTransaction', [
|
|
47
|
+
{ from, to: tx.to, value: tx.value ?? '0x0', ...(tx.data && { data: tx.data }) }
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
return { txHash, preview, signedWith: 'phone' as const }
|
|
51
|
+
}
|
|
52
|
+
|
|
28
53
|
const wallet = getWalletClient()
|
|
29
54
|
await wallet.waitForSigner()
|
|
30
55
|
|
|
@@ -38,14 +63,42 @@ async function requestSignature(chain: ChainName, tx: RawTx & { data?: string })
|
|
|
38
63
|
data: { to: tx.to, value: tx.value ?? '0x0', ...(tx.data && { data: tx.data }) },
|
|
39
64
|
preview
|
|
40
65
|
}),
|
|
41
|
-
preview
|
|
66
|
+
preview,
|
|
67
|
+
signedWith: 'browser' as const
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What the caller should see about a transaction before it is approved. */
|
|
72
|
+
function previewSummary(preview: TxPreview) {
|
|
73
|
+
return {
|
|
74
|
+
action: preview.decoded?.intent ?? preview.decoded?.functionName,
|
|
75
|
+
protocol: preview.decoded?.protocol ?? preview.toLabel,
|
|
76
|
+
details: preview.decoded?.fields.map((f) => `${f.name}: ${f.value}${f.warning ? ` (${f.warning})` : ''}`),
|
|
77
|
+
simulation: preview.simulation
|
|
42
78
|
}
|
|
43
79
|
}
|
|
44
80
|
|
|
81
|
+
/**
|
|
82
|
+
* WalletConnect rejects with a plain `{ code, message }` rather than an Error, which
|
|
83
|
+
* String() renders as "[object Object]" — the reason a wallet gave for refusing is the
|
|
84
|
+
* whole point of the message, so dig it out.
|
|
85
|
+
*/
|
|
86
|
+
function describeError(error: unknown): string {
|
|
87
|
+
if (error instanceof Error) return error.message
|
|
88
|
+
if (error && typeof error === 'object') {
|
|
89
|
+
const { message, code, reason } = error as { message?: string; code?: number; reason?: string }
|
|
90
|
+
const text = message ?? reason
|
|
91
|
+
if (text) return code === undefined ? text : `${text} (code ${code})`
|
|
92
|
+
return JSON.stringify(error)
|
|
93
|
+
}
|
|
94
|
+
return String(error)
|
|
95
|
+
}
|
|
96
|
+
|
|
45
97
|
function failure(error: unknown, message: string) {
|
|
98
|
+
console.error('[Transaction]', message, '—', describeError(error))
|
|
46
99
|
return formatResponse({
|
|
47
100
|
success: false,
|
|
48
|
-
error:
|
|
101
|
+
error: describeError(error),
|
|
49
102
|
message
|
|
50
103
|
})
|
|
51
104
|
}
|
|
@@ -65,7 +118,7 @@ export default {
|
|
|
65
118
|
const to = requireAddress('recipient address', args.to)
|
|
66
119
|
const value = `0x${parseUnits(args.amount, 18).toString(16)}`
|
|
67
120
|
|
|
68
|
-
const { txHash, preview } = await requestSignature(args.chain as ChainName, { to, value, data: args.data })
|
|
121
|
+
const { txHash, preview, signedWith } = await requestSignature(args.chain as ChainName, { to, value, data: args.data })
|
|
69
122
|
|
|
70
123
|
return formatResponse({
|
|
71
124
|
success: true,
|
|
@@ -73,7 +126,8 @@ export default {
|
|
|
73
126
|
transactionHash: txHash,
|
|
74
127
|
to,
|
|
75
128
|
amount: args.amount,
|
|
76
|
-
|
|
129
|
+
signedWith,
|
|
130
|
+
preview: previewSummary(preview),
|
|
77
131
|
explorerUrl: explorerTxUrl(args.chain as ChainName, txHash)
|
|
78
132
|
})
|
|
79
133
|
} catch (error) {
|
|
@@ -102,7 +156,7 @@ export default {
|
|
|
102
156
|
args: [to, parseUnits(args.amount, args.decimals ?? 18)]
|
|
103
157
|
})
|
|
104
158
|
|
|
105
|
-
const { txHash, preview } = await requestSignature(args.chain as ChainName, { to: tokenAddress, data, value: '0x0' })
|
|
159
|
+
const { txHash, preview, signedWith } = await requestSignature(args.chain as ChainName, { to: tokenAddress, data, value: '0x0' })
|
|
106
160
|
|
|
107
161
|
return formatResponse({
|
|
108
162
|
success: true,
|
|
@@ -111,7 +165,8 @@ export default {
|
|
|
111
165
|
tokenAddress,
|
|
112
166
|
to,
|
|
113
167
|
amount: args.amount,
|
|
114
|
-
|
|
168
|
+
signedWith,
|
|
169
|
+
preview: previewSummary(preview),
|
|
115
170
|
explorerUrl: explorerTxUrl(args.chain as ChainName, txHash)
|
|
116
171
|
})
|
|
117
172
|
} catch (error) {
|
|
@@ -145,7 +200,7 @@ export default {
|
|
|
145
200
|
})
|
|
146
201
|
const value = args.value ? `0x${parseUnits(args.value, 18).toString(16)}` : '0x0'
|
|
147
202
|
|
|
148
|
-
const { txHash, preview } = await requestSignature(args.chain as ChainName, { to: contractAddress, data, value })
|
|
203
|
+
const { txHash, preview, signedWith } = await requestSignature(args.chain as ChainName, { to: contractAddress, data, value })
|
|
149
204
|
|
|
150
205
|
return formatResponse({
|
|
151
206
|
success: true,
|
|
@@ -153,7 +208,8 @@ export default {
|
|
|
153
208
|
transactionHash: txHash,
|
|
154
209
|
contractAddress,
|
|
155
210
|
functionName: abiItem.name,
|
|
156
|
-
|
|
211
|
+
signedWith,
|
|
212
|
+
preview: previewSummary(preview),
|
|
157
213
|
explorerUrl: explorerTxUrl(args.chain as ChainName, txHash)
|
|
158
214
|
})
|
|
159
215
|
} catch (error) {
|
|
@@ -164,23 +220,30 @@ export default {
|
|
|
164
220
|
|
|
165
221
|
sign_message: createTool(
|
|
166
222
|
'Sign Message',
|
|
167
|
-
'Sign a message with the connected wallet
|
|
223
|
+
'Sign a message with the connected wallet — a paired phone if there is one, otherwise the browser wallet.',
|
|
168
224
|
z.object({
|
|
169
225
|
message: z.string().describe('Message to sign')
|
|
170
226
|
}),
|
|
171
227
|
async (args) => {
|
|
172
228
|
try {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
229
|
+
// Same precedence as transactions: a hosted server has no browser to fall back on.
|
|
230
|
+
const phone = getPhoneSigner()
|
|
231
|
+
const session = await phone?.session()
|
|
232
|
+
|
|
233
|
+
const signature = session
|
|
234
|
+
? await phone!.request('mainnet', 'personal_sign', [toHex(args.message), session.accounts[0]])
|
|
235
|
+
: await getWalletClient().request({
|
|
236
|
+
id: generateRequestId(),
|
|
237
|
+
type: 'sign_message',
|
|
238
|
+
chain: 'any',
|
|
239
|
+
data: { message: args.message }
|
|
240
|
+
})
|
|
179
241
|
|
|
180
242
|
return formatResponse({
|
|
181
243
|
success: true,
|
|
182
244
|
message: args.message,
|
|
183
245
|
signature,
|
|
246
|
+
signedWith: session ? 'phone' : 'browser',
|
|
184
247
|
signatureType: 'personal_sign'
|
|
185
248
|
})
|
|
186
249
|
} catch (error) {
|
|
@@ -189,13 +252,74 @@ export default {
|
|
|
189
252
|
}
|
|
190
253
|
),
|
|
191
254
|
|
|
255
|
+
pair_phone_wallet: createTool(
|
|
256
|
+
'Pair Phone Wallet',
|
|
257
|
+
'Pair a phone wallet over WalletConnect, so transactions are signed on the phone instead of in a browser. Returns a QR code to scan. Requires a WalletConnect project id.',
|
|
258
|
+
z.object({
|
|
259
|
+
chains: z
|
|
260
|
+
.array(z.enum(SUPPORTED_CHAINS))
|
|
261
|
+
.optional()
|
|
262
|
+
.describe('Chains to request access to (defaults to every supported network)')
|
|
263
|
+
}),
|
|
264
|
+
async (args) => {
|
|
265
|
+
const phone = getPhoneSigner()
|
|
266
|
+
if (!phone) {
|
|
267
|
+
return formatResponse({
|
|
268
|
+
success: false,
|
|
269
|
+
message:
|
|
270
|
+
'No WalletConnect project id configured. Get a free one at https://dashboard.reown.com and pass --walletconnect-project-id or set WALLETCONNECT_PROJECT_ID.'
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const existing = await phone.session()
|
|
276
|
+
if (existing) {
|
|
277
|
+
return formatResponse({
|
|
278
|
+
alreadyPaired: true,
|
|
279
|
+
address: existing.accounts[0],
|
|
280
|
+
wallet: existing.peer,
|
|
281
|
+
message: 'A phone wallet is already paired. Transactions will be sent to it.'
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const chains = (args.chains ?? SUPPORTED_CHAINS.filter((c) => c !== 'localhost')) as ChainName[]
|
|
286
|
+
const uri = await phone.pair(chains)
|
|
287
|
+
|
|
288
|
+
return formatResponse({
|
|
289
|
+
qr: await qrFor(uri),
|
|
290
|
+
uri,
|
|
291
|
+
message:
|
|
292
|
+
'Scan this QR with your phone wallet (MetaMask, Rabby, Trust…), or open the uri on the phone. ' +
|
|
293
|
+
'Approve the session, then run wallet_status to confirm.'
|
|
294
|
+
})
|
|
295
|
+
} catch (error) {
|
|
296
|
+
return failure(error, 'Could not start WalletConnect pairing')
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
),
|
|
300
|
+
|
|
192
301
|
wallet_status: createTool(
|
|
193
302
|
'Wallet Status',
|
|
194
303
|
'Check if a wallet is connected to the browser interface',
|
|
195
304
|
z.object({}),
|
|
196
305
|
async () => {
|
|
197
|
-
|
|
306
|
+
// A paired phone is the signer of record, so look there first. Connecting to the
|
|
307
|
+
// browser relay before checking would start one on a hosted server, which has no
|
|
308
|
+
// browser to open it in.
|
|
309
|
+
const phone = getPhoneSigner()
|
|
310
|
+
const phoneSession = await phone?.session().catch(() => undefined)
|
|
311
|
+
if (phoneSession) {
|
|
312
|
+
return formatResponse({
|
|
313
|
+
connected: true,
|
|
314
|
+
signer: 'phone',
|
|
315
|
+
address: phoneSession.accounts[0],
|
|
316
|
+
wallet: phoneSession.peer,
|
|
317
|
+
chains: phoneSession.chains,
|
|
318
|
+
message: 'A phone wallet is paired over WalletConnect. Transactions are sent there for signing.'
|
|
319
|
+
})
|
|
320
|
+
}
|
|
198
321
|
|
|
322
|
+
const wallet = getWalletClient()
|
|
199
323
|
try {
|
|
200
324
|
await wallet.connect()
|
|
201
325
|
} catch (error) {
|
|
@@ -206,6 +330,8 @@ export default {
|
|
|
206
330
|
|
|
207
331
|
return formatResponse({
|
|
208
332
|
connected: wallet.isConnected(),
|
|
333
|
+
signer: 'browser',
|
|
334
|
+
phonePairing: phone ? 'Not paired — run pair_phone_wallet to sign from a phone.' : undefined,
|
|
209
335
|
address: wallet.getAddress(),
|
|
210
336
|
walletUrl: wallet.getUrl(),
|
|
211
337
|
openTab: wallet.getPageUrl(),
|
package/src/types.ts
CHANGED
package/src/utils.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AbiParameter } from 'viem'
|
|
2
|
-
import type { ToolResult } from './types.js'
|
|
2
|
+
import type { Config, ToolResult } from './types.js'
|
|
3
3
|
import { z } from 'zod'
|
|
4
4
|
|
|
5
5
|
// Create tool helper
|
|
@@ -264,22 +264,8 @@ export function formatResponse(data: unknown): ToolResult {
|
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
// Parse command line arguments
|
|
267
|
-
export function parseCommandLineArgs(): {
|
|
268
|
-
|
|
269
|
-
alchemyApiKey?: string
|
|
270
|
-
infuraApiKey?: string
|
|
271
|
-
customRpcUrls?: Record<string, string>
|
|
272
|
-
hypersyncApiKey?: string
|
|
273
|
-
showHelp?: boolean
|
|
274
|
-
} {
|
|
275
|
-
const config: {
|
|
276
|
-
etherscanApiKey?: string
|
|
277
|
-
alchemyApiKey?: string
|
|
278
|
-
infuraApiKey?: string
|
|
279
|
-
customRpcUrls?: Record<string, string>
|
|
280
|
-
hypersyncApiKey?: string
|
|
281
|
-
showHelp?: boolean
|
|
282
|
-
} = {}
|
|
267
|
+
export function parseCommandLineArgs(): Config & { showHelp?: boolean } {
|
|
268
|
+
const config: Config & { showHelp?: boolean } = {}
|
|
283
269
|
|
|
284
270
|
function getArgValue(argName: string): string | undefined {
|
|
285
271
|
const args = process.argv
|
|
@@ -302,6 +288,7 @@ export function parseCommandLineArgs(): {
|
|
|
302
288
|
config.alchemyApiKey = process.env.ALCHEMY_API_KEY || getArgValue('--alchemy-api-key')
|
|
303
289
|
config.infuraApiKey = process.env.INFURA_API_KEY || getArgValue('--infura-api-key')
|
|
304
290
|
config.hypersyncApiKey = process.env.HYPERSYNC_API_KEY || getArgValue('--hypersync-api-key')
|
|
291
|
+
config.walletConnectProjectId = process.env.WALLETCONNECT_PROJECT_ID || getArgValue('--walletconnect-project-id')
|
|
305
292
|
|
|
306
293
|
// Parse custom RPC URLs
|
|
307
294
|
const customRpcs = getArgValue('--custom-rpc')
|