web3-tools-mcp 2.0.2 → 3.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/CHANGELOG.md +97 -0
- package/README.md +5 -0
- package/dist/anvil.d.ts +17 -19
- package/dist/anvil.d.ts.map +1 -1
- package/dist/anvil.js +124 -228
- package/dist/anvil.js.map +1 -1
- package/dist/chains.d.ts +9 -1
- package/dist/chains.d.ts.map +1 -1
- package/dist/chains.js +11 -2
- package/dist/chains.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/context.d.ts +22 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +24 -0
- package/dist/context.js.map +1 -0
- package/dist/hosted.d.ts +12 -0
- package/dist/hosted.d.ts.map +1 -0
- package/dist/hosted.js +12 -0
- package/dist/hosted.js.map +1 -0
- package/dist/http-server.d.ts +13 -0
- package/dist/http-server.d.ts.map +1 -1
- package/dist/http-server.js +13 -4
- package/dist/http-server.js.map +1 -1
- package/dist/index.js +15 -11
- package/dist/index.js.map +1 -1
- package/dist/lib.d.ts +24 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +23 -0
- package/dist/lib.js.map +1 -0
- package/dist/mcp-server.d.ts +4 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +43 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/oauth.d.ts +41 -6
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +75 -41
- package/dist/oauth.js.map +1 -1
- package/dist/preview.d.ts +7 -0
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +14 -2
- package/dist/preview.js.map +1 -1
- package/dist/signing-requests.d.ts +81 -0
- package/dist/signing-requests.d.ts.map +1 -0
- package/dist/signing-requests.js +138 -0
- package/dist/signing-requests.js.map +1 -0
- package/dist/tools/index.d.ts +27 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +37 -15
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/{advanced.d.ts → read/advanced.d.ts} +30 -26
- package/dist/tools/read/advanced.d.ts.map +1 -0
- package/dist/tools/{advanced.js → read/advanced.js} +10 -10
- package/dist/tools/read/advanced.js.map +1 -0
- package/dist/tools/{balance.d.ts → read/balance.d.ts} +8 -7
- package/dist/tools/read/balance.d.ts.map +1 -0
- package/dist/tools/{balance.js → read/balance.js} +2 -2
- package/dist/tools/read/balance.js.map +1 -0
- package/dist/tools/{contract-info.d.ts → read/contract-info.d.ts} +18 -15
- package/dist/tools/read/contract-info.d.ts.map +1 -0
- package/dist/tools/{contract-info.js → read/contract-info.js} +2 -2
- package/dist/tools/read/contract-info.js.map +1 -0
- package/dist/tools/{contract.d.ts → read/contract.d.ts} +14 -12
- package/dist/tools/read/contract.d.ts.map +1 -0
- package/dist/tools/{contract.js → read/contract.js} +2 -2
- package/dist/tools/read/contract.js.map +1 -0
- package/dist/tools/{ens.d.ts → read/ens.d.ts} +30 -25
- package/dist/tools/read/ens.d.ts.map +1 -0
- package/dist/tools/{ens.js → read/ens.js} +2 -2
- package/dist/tools/read/ens.js.map +1 -0
- package/dist/tools/read/gas.d.ts +171 -0
- package/dist/tools/read/gas.d.ts.map +1 -0
- package/dist/tools/read/gas.js +368 -0
- package/dist/tools/read/gas.js.map +1 -0
- package/dist/tools/{logs.d.ts → read/logs.d.ts} +6 -5
- package/dist/tools/read/logs.d.ts.map +1 -0
- package/dist/tools/{logs.js → read/logs.js} +2 -2
- package/dist/tools/read/logs.js.map +1 -0
- package/dist/tools/{signatures.d.ts → read/signatures.d.ts} +8 -4
- package/dist/tools/read/signatures.d.ts.map +1 -0
- package/dist/tools/{signatures.js → read/signatures.js} +1 -1
- package/dist/tools/read/signatures.js.map +1 -0
- package/dist/tools/read/signing.d.ts +27 -0
- package/dist/tools/read/signing.d.ts.map +1 -0
- package/dist/tools/read/signing.js +75 -0
- package/dist/tools/read/signing.js.map +1 -0
- package/dist/tools/sign/errors.d.ts +9 -0
- package/dist/tools/sign/errors.d.ts.map +1 -0
- package/dist/tools/sign/errors.js +29 -0
- package/dist/tools/sign/errors.js.map +1 -0
- package/dist/tools/{transactions.d.ts → sign/transactions.d.ts} +48 -62
- package/dist/tools/sign/transactions.d.ts.map +1 -0
- package/dist/tools/sign/transactions.js +316 -0
- package/dist/tools/sign/transactions.js.map +1 -0
- package/dist/tools/sign/wallet.d.ts +50 -0
- package/dist/tools/sign/wallet.d.ts.map +1 -0
- package/dist/tools/sign/wallet.js +180 -0
- package/dist/tools/sign/wallet.js.map +1 -0
- package/dist/utils.d.ts +5 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +5 -2
- package/dist/utils.js.map +1 -1
- package/dist/wallet-client.d.ts +33 -4
- package/dist/wallet-client.d.ts.map +1 -1
- package/dist/wallet-client.js +90 -24
- package/dist/wallet-client.js.map +1 -1
- package/dist/walletconnect.d.ts +100 -13
- package/dist/walletconnect.d.ts.map +1 -1
- package/dist/walletconnect.js +215 -50
- package/dist/walletconnect.js.map +1 -1
- package/package.json +10 -4
- package/src/anvil.ts +150 -271
- package/src/chains.ts +14 -2
- package/src/client.ts +0 -1
- package/src/context.ts +27 -0
- package/src/hosted.ts +11 -0
- package/src/http-server.ts +30 -5
- package/src/index.ts +15 -12
- package/src/lib.ts +24 -0
- package/src/mcp-server.ts +47 -0
- package/src/oauth.ts +98 -45
- package/src/preview.ts +21 -2
- package/src/signing-requests.ts +198 -0
- package/src/tools/index.ts +67 -16
- package/src/tools/{advanced.ts → read/advanced.ts} +13 -11
- package/src/tools/{balance.ts → read/balance.ts} +3 -3
- package/src/tools/{contract-info.ts → read/contract-info.ts} +3 -3
- package/src/tools/{contract.ts → read/contract.ts} +3 -3
- package/src/tools/{ens.ts → read/ens.ts} +3 -3
- package/src/tools/read/gas.ts +480 -0
- package/src/tools/{logs.ts → read/logs.ts} +3 -3
- package/src/tools/{signatures.ts → read/signatures.ts} +2 -2
- package/src/tools/read/signing.ts +86 -0
- package/src/tools/sign/errors.ts +28 -0
- package/src/tools/sign/transactions.ts +428 -0
- package/src/tools/sign/wallet.ts +216 -0
- package/src/utils.ts +5 -2
- package/src/wallet-client.ts +91 -22
- package/src/walletconnect.ts +250 -48
- package/dist/tools/advanced.d.ts.map +0 -1
- package/dist/tools/advanced.js.map +0 -1
- package/dist/tools/balance.d.ts.map +0 -1
- package/dist/tools/balance.js.map +0 -1
- package/dist/tools/contract-info.d.ts.map +0 -1
- package/dist/tools/contract-info.js.map +0 -1
- package/dist/tools/contract.d.ts.map +0 -1
- package/dist/tools/contract.js.map +0 -1
- package/dist/tools/ens.d.ts.map +0 -1
- package/dist/tools/ens.js.map +0 -1
- package/dist/tools/gas.d.ts +0 -99
- package/dist/tools/gas.d.ts.map +0 -1
- package/dist/tools/gas.js +0 -181
- package/dist/tools/gas.js.map +0 -1
- package/dist/tools/logs.d.ts.map +0 -1
- package/dist/tools/logs.js.map +0 -1
- package/dist/tools/signatures.d.ts.map +0 -1
- package/dist/tools/signatures.js.map +0 -1
- package/dist/tools/transactions.d.ts.map +0 -1
- package/dist/tools/transactions.js +0 -365
- package/dist/tools/transactions.js.map +0 -1
- package/src/tools/gas.ts +0 -219
- package/src/tools/transactions.ts +0 -456
package/src/index.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
4
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
5
4
|
import { WalletRelay } from 'web3-wallet-relay'
|
|
6
5
|
import packageJson from '../package.json' with { type: 'json' }
|
|
7
6
|
import { initializeClientManager, SUPPORTED_CHAINS } from './client.js'
|
|
7
|
+
import { DEFAULT_IDENTITY } from './context.js'
|
|
8
8
|
import { startHttpServer } from './http-server.js'
|
|
9
9
|
import { attachLogServer } from './log.js'
|
|
10
|
-
import {
|
|
10
|
+
import { createMcpServer } from './mcp-server.js'
|
|
11
11
|
import { parseCommandLineArgs } from './utils.js'
|
|
12
|
-
import { getWalletClient } from './wallet-client.js'
|
|
12
|
+
import { allWalletClients, configureWalletRelay, getWalletClient } from './wallet-client.js'
|
|
13
13
|
|
|
14
14
|
// Parse configuration
|
|
15
15
|
const config = parseCommandLineArgs()
|
|
@@ -42,12 +42,15 @@ ENVIRONMENT VARIABLES:
|
|
|
42
42
|
WALLETCONNECT_PROJECT_ID Alternative to --walletconnect-project-id
|
|
43
43
|
CUSTOM_RPC Alternative to --custom-rpc
|
|
44
44
|
MCP_HTTP_PORT Serve MCP over HTTP on this port instead of stdio
|
|
45
|
+
MCP_HOSTED Apply the hosted guards without the CLI (library use)
|
|
45
46
|
MCP_TOKEN Bearer token required by the HTTP transport
|
|
46
47
|
MCP_PUBLIC_URL Public URL of this server, advertised in OAuth metadata
|
|
47
48
|
UPSTASH_REDIS_REST_URL Keep WalletConnect sessions in Redis, not on disk
|
|
48
49
|
UPSTASH_REDIS_REST_TOKEN
|
|
49
50
|
WALLET_SERVER_URL URL of a hosted wallet relay (omit to run one locally)
|
|
50
|
-
WALLET_TOKEN
|
|
51
|
+
WALLET_TOKEN Secret the wallet relay signs its room tokens with
|
|
52
|
+
ANVIL_RPC_URL Node exposing debug_traceCall (default: 127.0.0.1:8545)
|
|
53
|
+
ANVIL_ALLOW_RESET Let tracing re-fork that node, clearing its state
|
|
51
54
|
|
|
52
55
|
SUPPORTED CHAINS:
|
|
53
56
|
${SUPPORTED_CHAINS.join(', ')}
|
|
@@ -92,12 +95,6 @@ if (config.customRpcUrls) {
|
|
|
92
95
|
// Initialize client manager
|
|
93
96
|
initializeClientManager(config)
|
|
94
97
|
|
|
95
|
-
function createMcpServer() {
|
|
96
|
-
const server = new McpServer({ name: 'web3-tools-mcp', version: packageJson.version }, { capabilities: { logging: {} } })
|
|
97
|
-
registerAllTools(server)
|
|
98
|
-
return server
|
|
99
|
-
}
|
|
100
|
-
|
|
101
98
|
const httpMode = Boolean(process.env.MCP_HTTP_PORT)
|
|
102
99
|
const httpPort = Number(process.env.MCP_HTTP_PORT)
|
|
103
100
|
|
|
@@ -114,7 +111,13 @@ const hostedRelay = httpMode
|
|
|
114
111
|
? new WalletRelay({ token: process.env.WALLET_TOKEN, publicUrl: process.env.MCP_PUBLIC_URL })
|
|
115
112
|
: undefined
|
|
116
113
|
|
|
117
|
-
|
|
114
|
+
// Hosted, the relay is this process's own, on the platform's port. Set before any client
|
|
115
|
+
// is made, so every identity's client reaches that one rather than starting its own.
|
|
116
|
+
if (hostedRelay) configureWalletRelay({ port: httpPort, token: hostedRelay.token })
|
|
117
|
+
|
|
118
|
+
// Outside any tool call, so this is the shared identity — the only one a self-hosted
|
|
119
|
+
// server ever has, and the one a hosted server uses before anybody has authenticated.
|
|
120
|
+
const wallet = getWalletClient(DEFAULT_IDENTITY)
|
|
118
121
|
|
|
119
122
|
// Hosted, the relay is attached to the HTTP server, so connect only once that is listening.
|
|
120
123
|
const walletReady = httpMode
|
|
@@ -131,7 +134,7 @@ async function shutdown(reason: string) {
|
|
|
131
134
|
if (shuttingDown) return
|
|
132
135
|
shuttingDown = true
|
|
133
136
|
console.error(`[MCP] Shutting down (${reason})`)
|
|
134
|
-
await
|
|
137
|
+
await Promise.all(allWalletClients().map((client) => client.stop().catch(() => {})))
|
|
135
138
|
process.exit(0)
|
|
136
139
|
}
|
|
137
140
|
|
package/src/lib.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this package offers a host that serves it to other people.
|
|
3
|
+
*
|
|
4
|
+
* The CLI at `index.ts` is a program: importing it parses argv, starts a relay and runs a
|
|
5
|
+
* server. This is the library half — nothing here has an effect until it is called, so a
|
|
6
|
+
* deployment can assemble its own server with its own way of deciding who may do what.
|
|
7
|
+
*
|
|
8
|
+
* The split in what each side owns is deliberate. This package knows how to talk to chains
|
|
9
|
+
* and wallets and how to keep one person's signing away from another's; it holds no opinion
|
|
10
|
+
* about who anybody is, what they are allowed, or what it costs. Those are policy, and they
|
|
11
|
+
* belong to whatever is doing the serving.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export { initializeClientManager, SUPPORTED_CHAINS } from './client.js'
|
|
15
|
+
export { DEFAULT_IDENTITY, identityFrom } from './context.js'
|
|
16
|
+
export { type HttpServerOptions, startHttpServer } from './http-server.js'
|
|
17
|
+
export { attachLogServer, log } from './log.js'
|
|
18
|
+
export { createMcpServer } from './mcp-server.js'
|
|
19
|
+
export { type Login, OAuthProvider, SingleUserOAuthProvider, tokenLogin } from './oauth.js'
|
|
20
|
+
export { registerAllTools, type ToolGroup, type ToolMiddleware, toolGroup } from './tools/index.js'
|
|
21
|
+
export type { ChainName, Config, ToolResult } from './types.js'
|
|
22
|
+
export { parseCommandLineArgs } from './utils.js'
|
|
23
|
+
export { allWalletClients, configureWalletRelay, getWalletClient, WalletClient } from './wallet-client.js'
|
|
24
|
+
export { getPhoneSigner, PhoneSigner, qrSvg } from './walletconnect.js'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
import packageJson from '../package.json' with { type: 'json' }
|
|
3
|
+
import { registerAllTools, type ToolMiddleware } from './tools/index.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A server with every tool registered, ready to be connected to a transport.
|
|
7
|
+
*
|
|
8
|
+
* Exists so a host does not have to construct one itself. Reaching for the MCP SDK from
|
|
9
|
+
* outside would mean a second copy of it, and two copies of the same class are two
|
|
10
|
+
* unrelated types as far as a compiler is concerned — the error arrives at the seam rather
|
|
11
|
+
* than where the mistake was made.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* What a client shows for this server, when it has somewhere to fetch it from.
|
|
15
|
+
*
|
|
16
|
+
* Only when a public URL is known: the icon has to be same-origin with the MCP endpoint and
|
|
17
|
+
* fetchable without credentials, which is true of the signing page's own static files and
|
|
18
|
+
* not true of anything a stdio server could offer. A client that cannot resolve it falls
|
|
19
|
+
* back to a letter, which is what it already does.
|
|
20
|
+
*/
|
|
21
|
+
function identity() {
|
|
22
|
+
const publicUrl = process.env.MCP_PUBLIC_URL?.replace(/\/$/, '')
|
|
23
|
+
if (!publicUrl) return {}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
websiteUrl: publicUrl,
|
|
27
|
+
icons: [
|
|
28
|
+
{ src: `${publicUrl}/icon.png`, mimeType: 'image/png', sizes: ['512x512'] },
|
|
29
|
+
{ src: `${publicUrl}/icon.svg`, mimeType: 'image/svg+xml', sizes: ['any'] }
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createMcpServer(middleware?: ToolMiddleware): McpServer {
|
|
35
|
+
const server = new McpServer(
|
|
36
|
+
{
|
|
37
|
+
name: 'web3-tools-mcp',
|
|
38
|
+
version: packageJson.version,
|
|
39
|
+
title: 'Web3 Tools',
|
|
40
|
+
description: packageJson.description,
|
|
41
|
+
...identity()
|
|
42
|
+
},
|
|
43
|
+
{ capabilities: { logging: {} } }
|
|
44
|
+
)
|
|
45
|
+
registerAllTools(server, middleware)
|
|
46
|
+
return server
|
|
47
|
+
}
|
package/src/oauth.ts
CHANGED
|
@@ -29,12 +29,14 @@ interface StoredCode {
|
|
|
29
29
|
redirectUri: string
|
|
30
30
|
resource?: string
|
|
31
31
|
scopes: string[]
|
|
32
|
+
address?: string
|
|
32
33
|
expiresAt: number
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
interface StoredToken {
|
|
36
37
|
clientId: string
|
|
37
38
|
scopes: string[]
|
|
39
|
+
address?: string
|
|
38
40
|
expiresAt: number
|
|
39
41
|
}
|
|
40
42
|
|
|
@@ -92,18 +94,28 @@ function constantTimeEquals(a: string, b: string): boolean {
|
|
|
92
94
|
return left.length === right.length && timingSafeEqual(left, right)
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
/**
|
|
98
|
+
* How a deployment decides who is at the authorize screen.
|
|
99
|
+
*
|
|
100
|
+
* Deliberately free of any type from the MCP SDK, a chain library, or this package: an
|
|
101
|
+
* implementation renders a page and reads back a form, and everything about *how* somebody
|
|
102
|
+
* proves who they are — a shared token, a wallet signature, a company SSO — stays outside.
|
|
103
|
+
* Returning an address makes the issued token that person's; returning none authenticates
|
|
104
|
+
* the deployment rather than anybody in particular.
|
|
105
|
+
*/
|
|
106
|
+
export interface Login {
|
|
107
|
+
/** The page served at GET /authorize. `hidden` must be placed inside its form. */
|
|
108
|
+
page(hidden: string, error?: string): string | Promise<string>
|
|
109
|
+
/** Reads the posted form. An `error` re-renders the page; an `address` names the person. */
|
|
110
|
+
identify(form: Record<string, string>): Promise<{ address?: string; error?: string }>
|
|
111
|
+
/**
|
|
112
|
+
* Form fields this login owns. They are kept out of the hidden inputs replayed into the
|
|
113
|
+
* page, so a credential can never be echoed back into the HTML.
|
|
114
|
+
*/
|
|
115
|
+
owns?: string[]
|
|
116
|
+
}
|
|
101
117
|
|
|
102
|
-
|
|
103
|
-
<meta charset="utf-8">
|
|
104
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
105
|
-
<title>Connect to web3-tools-mcp</title>
|
|
106
|
-
<style>
|
|
118
|
+
const PAGE_STYLE = `
|
|
107
119
|
:root { color-scheme: light dark; }
|
|
108
120
|
body { font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 26rem;
|
|
109
121
|
margin: 12vh auto; padding: 0 1.25rem; }
|
|
@@ -113,22 +125,45 @@ function loginPage(params: { error?: string; fields: Record<string, string> }):
|
|
|
113
125
|
box-sizing: border-box; }
|
|
114
126
|
input { border: 1px solid #ccd; font-family: ui-monospace, monospace; }
|
|
115
127
|
button { margin-top: .75rem; border: 0; background: #3856d6; color: #fff; font-weight: 600; }
|
|
116
|
-
.error { color: #b3251d; font-weight: 600; }
|
|
128
|
+
.error { color: #b3251d; font-weight: 600; }`
|
|
129
|
+
|
|
130
|
+
/** The default: one shared credential, so a self-hoster needs no identity provider. */
|
|
131
|
+
export function tokenLogin(accessToken: string): Login {
|
|
132
|
+
return {
|
|
133
|
+
owns: ['token'],
|
|
134
|
+
page: (hidden, error) => `<!doctype html>
|
|
135
|
+
<meta charset="utf-8">
|
|
136
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
137
|
+
<title>Connect to web3-tools-mcp</title>
|
|
138
|
+
<style>${PAGE_STYLE}
|
|
117
139
|
</style>
|
|
118
140
|
<h1>Connect to web3-tools-mcp</h1>
|
|
119
141
|
<p>Paste this server's access token to authorise the client.</p>
|
|
120
|
-
${
|
|
142
|
+
${error ? `<p class="error">${error}</p>` : ''}
|
|
121
143
|
<form method="post">
|
|
122
144
|
${hidden}
|
|
123
145
|
<input name="token" type="password" placeholder="MCP_TOKEN" autocomplete="off" autofocus>
|
|
124
146
|
<button type="submit">Authorise</button>
|
|
125
|
-
</form
|
|
147
|
+
</form>`,
|
|
148
|
+
async identify(form) {
|
|
149
|
+
if (!constantTimeEquals(form.token ?? '', accessToken)) return { error: 'That token was not accepted.' }
|
|
150
|
+
// No address: this authenticates the deployment, not a person.
|
|
151
|
+
return {}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
126
154
|
}
|
|
127
155
|
|
|
128
|
-
export class
|
|
156
|
+
export class OAuthProvider implements OAuthServerProvider {
|
|
129
157
|
private store = new Store()
|
|
130
158
|
|
|
131
|
-
|
|
159
|
+
/**
|
|
160
|
+
* `staticToken` is accepted verbatim in an Authorization header, for clients that can send
|
|
161
|
+
* one and skip the flow entirely. It names no person, so it carries no identity.
|
|
162
|
+
*/
|
|
163
|
+
constructor(
|
|
164
|
+
private login: Login,
|
|
165
|
+
private staticToken?: string
|
|
166
|
+
) {}
|
|
132
167
|
|
|
133
168
|
get clientsStore(): OAuthRegisteredClientsStore {
|
|
134
169
|
const store = this.store
|
|
@@ -151,34 +186,35 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
|
|
|
151
186
|
}
|
|
152
187
|
|
|
153
188
|
/**
|
|
154
|
-
* GET renders the
|
|
155
|
-
* both here keeps the whole login inside the SDK's authorize
|
|
189
|
+
* GET renders the login; POST hands the form to it and, if it names somebody, redirects
|
|
190
|
+
* back with a code. Handling both here keeps the whole login inside the SDK's authorize
|
|
191
|
+
* route, which is the only place the client will follow a redirect from.
|
|
156
192
|
*/
|
|
157
193
|
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
|
158
194
|
const request = res.req
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (!constantTimeEquals(submitted, this.accessToken)) {
|
|
195
|
+
const form = { ...(request.query as Record<string, string>), ...(request.body as Record<string, string>) }
|
|
196
|
+
|
|
197
|
+
// The SDK's authorize handler reads its parameters from the body on POST, so the form
|
|
198
|
+
// has to carry the whole authorization request back — minus whatever the login owns,
|
|
199
|
+
// which would mean echoing a credential into the HTML.
|
|
200
|
+
const owned = new Set(this.login.owns ?? [])
|
|
201
|
+
const hidden = Object.entries(form)
|
|
202
|
+
.filter(([name, value]) => !owned.has(name) && typeof value === 'string')
|
|
203
|
+
.map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
|
|
204
|
+
.join('\n ')
|
|
205
|
+
|
|
206
|
+
const render = async (status: number, error?: string) => {
|
|
175
207
|
res
|
|
176
|
-
.status(
|
|
208
|
+
.status(status)
|
|
177
209
|
.setHeader('content-type', 'text/html; charset=utf-8')
|
|
178
|
-
.send(
|
|
179
|
-
return
|
|
210
|
+
.send(await this.login.page(hidden, error ? escapeHtml(error) : undefined))
|
|
180
211
|
}
|
|
181
212
|
|
|
213
|
+
if (request.method !== 'POST') return render(200)
|
|
214
|
+
|
|
215
|
+
const identified = await this.login.identify(form)
|
|
216
|
+
if (identified.error) return render(401, identified.error)
|
|
217
|
+
|
|
182
218
|
const code = randomBytes(24).toString('hex')
|
|
183
219
|
await this.store.set<StoredCode>(`code:${code}`, {
|
|
184
220
|
clientId: client.client_id,
|
|
@@ -186,6 +222,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
|
|
|
186
222
|
redirectUri: params.redirectUri,
|
|
187
223
|
resource: params.resource?.href,
|
|
188
224
|
scopes: params.scopes ?? [],
|
|
225
|
+
address: identified.address,
|
|
189
226
|
expiresAt: Date.now() + CODE_TTL
|
|
190
227
|
})
|
|
191
228
|
|
|
@@ -208,7 +245,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
|
|
|
208
245
|
|
|
209
246
|
// One use only: a replayed code must not mint a second token.
|
|
210
247
|
await this.store.remove(`code:${authorizationCode}`)
|
|
211
|
-
return this.issue(client.client_id, stored.scopes)
|
|
248
|
+
return this.issue(client.client_id, stored.scopes, stored.address)
|
|
212
249
|
}
|
|
213
250
|
|
|
214
251
|
async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens> {
|
|
@@ -217,16 +254,17 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
|
|
|
217
254
|
if (stored.clientId !== client.client_id) throw new Error('Refresh token was issued to a different client')
|
|
218
255
|
|
|
219
256
|
await this.store.remove(`refresh:${refreshToken}`)
|
|
220
|
-
|
|
257
|
+
// The refreshed token stays the same person's: a refresh proves possession, not identity.
|
|
258
|
+
return this.issue(client.client_id, scopes ?? stored.scopes, stored.address)
|
|
221
259
|
}
|
|
222
260
|
|
|
223
|
-
private async issue(clientId: string, scopes: string[]): Promise<OAuthTokens> {
|
|
261
|
+
private async issue(clientId: string, scopes: string[], address?: string): Promise<OAuthTokens> {
|
|
224
262
|
const accessToken = randomBytes(32).toString('hex')
|
|
225
263
|
const refreshToken = randomBytes(32).toString('hex')
|
|
226
264
|
const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_TTL
|
|
227
265
|
|
|
228
|
-
await this.store.set<StoredToken>(`token:${accessToken}`, { clientId, scopes, expiresAt })
|
|
229
|
-
await this.store.set<StoredToken>(`refresh:${refreshToken}`, { clientId, scopes, expiresAt })
|
|
266
|
+
await this.store.set<StoredToken>(`token:${accessToken}`, { clientId, scopes, address, expiresAt })
|
|
267
|
+
await this.store.set<StoredToken>(`refresh:${refreshToken}`, { clientId, scopes, address, expiresAt })
|
|
230
268
|
|
|
231
269
|
return {
|
|
232
270
|
access_token: accessToken,
|
|
@@ -237,9 +275,9 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
|
|
|
237
275
|
}
|
|
238
276
|
}
|
|
239
277
|
|
|
240
|
-
/** Accepts an issued token, or
|
|
278
|
+
/** Accepts an issued token, or the static one so a client that can send a header still works. */
|
|
241
279
|
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
|
242
|
-
if (constantTimeEquals(token, this.
|
|
280
|
+
if (this.staticToken && constantTimeEquals(token, this.staticToken)) {
|
|
243
281
|
// requireBearerAuth refuses a token with no expiry, and this one never expires.
|
|
244
282
|
return { token, clientId: 'static', scopes: [], expiresAt: Math.floor(Date.now() / 1000) + TOKEN_TTL }
|
|
245
283
|
}
|
|
@@ -253,7 +291,15 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
|
|
|
253
291
|
throw new InvalidTokenError('Access token has expired')
|
|
254
292
|
}
|
|
255
293
|
|
|
256
|
-
|
|
294
|
+
// `extra.address` is where the tools read the caller's identity from; a token that names
|
|
295
|
+
// nobody leaves it unset, and everything falls back to the shared identity.
|
|
296
|
+
return {
|
|
297
|
+
token,
|
|
298
|
+
clientId: stored.clientId,
|
|
299
|
+
scopes: stored.scopes,
|
|
300
|
+
expiresAt: stored.expiresAt,
|
|
301
|
+
...(stored.address ? { extra: { address: stored.address } } : {})
|
|
302
|
+
}
|
|
257
303
|
}
|
|
258
304
|
|
|
259
305
|
async revokeToken(_client: OAuthClientInformationFull, request: { token: string }): Promise<void> {
|
|
@@ -262,6 +308,13 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
|
|
|
262
308
|
}
|
|
263
309
|
}
|
|
264
310
|
|
|
311
|
+
/** One shared credential, which is all a self-hosted server needs. */
|
|
312
|
+
export class SingleUserOAuthProvider extends OAuthProvider {
|
|
313
|
+
constructor(accessToken: string) {
|
|
314
|
+
super(tokenLogin(accessToken), accessToken)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
265
318
|
/** PKCE helper, used by the tests to drive the flow the way a real client would. */
|
|
266
319
|
export function pkce(): { verifier: string; challenge: string } {
|
|
267
320
|
const verifier = randomBytes(32).toString('base64url')
|
package/src/preview.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface AssetChange {
|
|
|
23
23
|
to: string
|
|
24
24
|
amount: string
|
|
25
25
|
humanAmount?: string
|
|
26
|
+
/** Set for an ERC-721 transfer, which moves this one token rather than an amount. */
|
|
27
|
+
tokenId?: string
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface TxPreview {
|
|
@@ -162,13 +164,30 @@ function topicToAddress(topic: string): string {
|
|
|
162
164
|
return `0x${topic.slice(-40)}`
|
|
163
165
|
}
|
|
164
166
|
|
|
165
|
-
async function enrichTransfers(
|
|
167
|
+
export async function enrichTransfers(
|
|
168
|
+
chain: ChainName,
|
|
169
|
+
logs: readonly { address: string; topics: readonly string[]; data: string }[]
|
|
170
|
+
) {
|
|
166
171
|
const transfers = logs.filter((log) => log.topics[0]?.toLowerCase() === TRANSFER_TOPIC && log.topics.length >= 3)
|
|
167
172
|
|
|
168
173
|
return Promise.all(
|
|
169
174
|
transfers.map(async (log): Promise<AssetChange> => {
|
|
170
|
-
const amount = log.data && log.data !== '0x' ? BigInt(log.data).toString() : '0'
|
|
171
175
|
const meta = await tokenMeta(chain, log.address).catch(() => ({ symbol: undefined, decimals: undefined }))
|
|
176
|
+
|
|
177
|
+
// ERC-721 shares ERC-20's Transfer topic but indexes the token id as a fourth topic,
|
|
178
|
+
// leaving the data empty — read as ERC-20, a mint looks like a transfer of nothing.
|
|
179
|
+
if (log.topics.length === 4) {
|
|
180
|
+
return {
|
|
181
|
+
token: log.address,
|
|
182
|
+
symbol: meta.symbol,
|
|
183
|
+
from: topicToAddress(log.topics[1]!),
|
|
184
|
+
to: topicToAddress(log.topics[2]!),
|
|
185
|
+
amount: '1',
|
|
186
|
+
tokenId: BigInt(log.topics[3]!).toString()
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const amount = log.data && log.data !== '0x' ? BigInt(log.data).toString() : '0'
|
|
172
191
|
return {
|
|
173
192
|
token: log.address,
|
|
174
193
|
symbol: meta.symbol,
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { log } from './log.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A signature that outlives the tool call that asked for it.
|
|
6
|
+
*
|
|
7
|
+
* Approving happens on a human's schedule. A browser signer is told to come — the tab opens
|
|
8
|
+
* and the badge flashes — but a phone wallet is only as loud as the wallet itself, and some
|
|
9
|
+
* send no notification at all. So the wait routinely outlives whatever timeout the MCP
|
|
10
|
+
* client applies, and the agent is left holding a tool call that never returns while the
|
|
11
|
+
* request is still perfectly alive in the wallet.
|
|
12
|
+
*
|
|
13
|
+
* Each request gets an id and outlives its call. Settling quickly still returns inline, so
|
|
14
|
+
* the common case — a browser tab that pops up and is approved in seconds — is unchanged.
|
|
15
|
+
* Past that the call hands back the id and the agent collects the result later.
|
|
16
|
+
*
|
|
17
|
+
* Held in memory on purpose: a request is only meaningful while the socket or WalletConnect
|
|
18
|
+
* session that carries it is alive, and both die with the process anyway.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How long to wait on a signer that has been told to come — the browser page, whose tab
|
|
23
|
+
* opens and whose title flashes. Comfortably under any MCP client's tool timeout.
|
|
24
|
+
*
|
|
25
|
+
* A signer that cannot raise the alarm does not use this at all: it calls `waiting()` the
|
|
26
|
+
* moment the request is with the wallet, and the call returns then. Waiting on a phone that
|
|
27
|
+
* never buzzed is dead time either way.
|
|
28
|
+
*/
|
|
29
|
+
export const GRACE_MS = 25_000
|
|
30
|
+
|
|
31
|
+
/** How long a finished result stays collectable. */
|
|
32
|
+
const RESULT_TTL = 15 * 60_000
|
|
33
|
+
|
|
34
|
+
export type SigningState = 'pending' | 'done' | 'failed'
|
|
35
|
+
|
|
36
|
+
export interface SigningRequest {
|
|
37
|
+
id: string
|
|
38
|
+
identity: string
|
|
39
|
+
/** What is being asked for, in a line, so a later check can say what it was. */
|
|
40
|
+
summary: string
|
|
41
|
+
signer: 'browser' | 'phone'
|
|
42
|
+
/** Waiting on a person, or on the chain. They deserve different things said about them. */
|
|
43
|
+
stage: 'approval' | 'mining'
|
|
44
|
+
/** Known once approved, so a request can be followed on an explorer before it settles. */
|
|
45
|
+
txHash?: string
|
|
46
|
+
startedAt: number
|
|
47
|
+
state: SigningState
|
|
48
|
+
settledAt?: number
|
|
49
|
+
result?: unknown
|
|
50
|
+
error?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** How a signing job says where it has got to. */
|
|
54
|
+
export interface SigningProgress {
|
|
55
|
+
/**
|
|
56
|
+
* The request is with the wallet and only a human is left. Everything that fails quickly —
|
|
57
|
+
* no session, an unapproved chain, a reverted simulation — has already thrown.
|
|
58
|
+
*/
|
|
59
|
+
waiting: () => void
|
|
60
|
+
/** Approved and broadcast. Nothing is settled until it is mined. */
|
|
61
|
+
mining: (txHash: string) => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Tracked extends SigningRequest {
|
|
65
|
+
/** Already has a catch attached, so awaiting it again can never be unhandled. */
|
|
66
|
+
settled: Promise<void>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const requests = new Map<string, Tracked>()
|
|
70
|
+
|
|
71
|
+
function sweep(): void {
|
|
72
|
+
const now = Date.now()
|
|
73
|
+
for (const [id, request] of requests) {
|
|
74
|
+
if (request.settledAt && now - request.settledAt > RESULT_TTL) requests.delete(id)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function view(request: Tracked): SigningRequest {
|
|
79
|
+
const { settled: _settled, ...rest } = request
|
|
80
|
+
return rest
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resolves when the request settles, or when the grace runs out — whichever comes first. */
|
|
84
|
+
function race(request: Tracked, graceMs: number, handoff?: Promise<void>): Promise<void> {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const timer = setTimeout(resolve, graceMs)
|
|
87
|
+
// A request nobody is waiting for must not keep a stdio server alive after its client
|
|
88
|
+
// has gone; shutdown is what ends it.
|
|
89
|
+
timer.unref?.()
|
|
90
|
+
|
|
91
|
+
const done = () => {
|
|
92
|
+
clearTimeout(timer)
|
|
93
|
+
resolve()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
request.settled.then(done)
|
|
97
|
+
handoff?.then(done)
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run a signing request, waiting only as long as a tool call reasonably can.
|
|
103
|
+
*
|
|
104
|
+
* The work starts here either way — returning `done: false` means nobody has approved yet,
|
|
105
|
+
* never that nothing was asked.
|
|
106
|
+
*/
|
|
107
|
+
export async function signingCall<T>(
|
|
108
|
+
context: { identity: string; summary: string; signer: 'browser' | 'phone'; graceMs?: number },
|
|
109
|
+
run: (progress: SigningProgress) => Promise<T>
|
|
110
|
+
): Promise<{ done: true; result: T } | { done: false; request: SigningRequest }> {
|
|
111
|
+
sweep()
|
|
112
|
+
|
|
113
|
+
const id = randomBytes(16).toString('hex')
|
|
114
|
+
|
|
115
|
+
// Ends the wait: until it is called, a failure is a real error the caller should see
|
|
116
|
+
// rather than something to be collected later.
|
|
117
|
+
let handedOff: () => void
|
|
118
|
+
const handoff = new Promise<void>((resolve) => {
|
|
119
|
+
handedOff = resolve
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Built before `run` starts, because a signer is free to report progress synchronously.
|
|
123
|
+
const request: Tracked = {
|
|
124
|
+
id,
|
|
125
|
+
identity: context.identity,
|
|
126
|
+
summary: context.summary,
|
|
127
|
+
signer: context.signer,
|
|
128
|
+
stage: 'approval',
|
|
129
|
+
startedAt: Date.now(),
|
|
130
|
+
state: 'pending',
|
|
131
|
+
settled: Promise.resolve()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Broadcasting does not end the wait. On a fast chain the receipt is seconds away, and a
|
|
135
|
+
// caller still inside its grace would rather have the mined result than a request id.
|
|
136
|
+
const started = run({
|
|
137
|
+
waiting: () => handedOff(),
|
|
138
|
+
mining: (txHash) => {
|
|
139
|
+
request.stage = 'mining'
|
|
140
|
+
request.txHash = txHash
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
request.settled = started.then(
|
|
145
|
+
(result) => {
|
|
146
|
+
request.state = 'done'
|
|
147
|
+
request.result = result
|
|
148
|
+
request.settledAt = Date.now()
|
|
149
|
+
},
|
|
150
|
+
(error: unknown) => {
|
|
151
|
+
request.state = 'failed'
|
|
152
|
+
request.error = error instanceof Error ? error.message : String(error)
|
|
153
|
+
request.settledAt = Date.now()
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
requests.set(id, request)
|
|
158
|
+
await race(request, context.graceMs ?? GRACE_MS, handoff)
|
|
159
|
+
|
|
160
|
+
if (request.state === 'done') {
|
|
161
|
+
requests.delete(id)
|
|
162
|
+
return { done: true, result: request.result as T }
|
|
163
|
+
}
|
|
164
|
+
if (request.state === 'failed') {
|
|
165
|
+
requests.delete(id)
|
|
166
|
+
throw new Error(request.error)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
log('info', 'Signing', `${context.summary} is still waiting for approval — handed back as ${id.slice(0, 8)}`)
|
|
170
|
+
return { done: false, request: view(request) }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Collect a request handed back earlier, waiting out another grace period if it is still
|
|
175
|
+
* pending — so an agent that checks in a loop paces itself rather than spinning.
|
|
176
|
+
*
|
|
177
|
+
* Scoped to the caller: an id is unguessable, but on a server carrying several people that
|
|
178
|
+
* is not the reason one of them cannot read another's signature.
|
|
179
|
+
*/
|
|
180
|
+
export async function collectSigningRequest(id: string, identity: string, graceMs = GRACE_MS): Promise<SigningRequest> {
|
|
181
|
+
sweep()
|
|
182
|
+
|
|
183
|
+
const request = requests.get(id)
|
|
184
|
+
if (!request || request.identity !== identity) {
|
|
185
|
+
throw new Error(`No signing request ${id} is waiting. It may have been collected already, or it expired.`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (request.state === 'pending') await race(request, graceMs)
|
|
189
|
+
if (request.state !== 'pending') requests.delete(id)
|
|
190
|
+
|
|
191
|
+
return view(request)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Everything this identity still has in front of a wallet. */
|
|
195
|
+
export function pendingSigningRequests(identity: string): SigningRequest[] {
|
|
196
|
+
sweep()
|
|
197
|
+
return [...requests.values()].filter((request) => request.identity === identity && request.state === 'pending').map(view)
|
|
198
|
+
}
|