web3-tools-mcp 2.0.1 → 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.
Files changed (167) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +5 -0
  3. package/dist/anvil.d.ts +17 -19
  4. package/dist/anvil.d.ts.map +1 -1
  5. package/dist/anvil.js +124 -228
  6. package/dist/anvil.js.map +1 -1
  7. package/dist/chains.d.ts +9 -1
  8. package/dist/chains.d.ts.map +1 -1
  9. package/dist/chains.js +11 -2
  10. package/dist/chains.js.map +1 -1
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js.map +1 -1
  13. package/dist/context.d.ts +22 -0
  14. package/dist/context.d.ts.map +1 -0
  15. package/dist/context.js +24 -0
  16. package/dist/context.js.map +1 -0
  17. package/dist/hosted.d.ts +12 -0
  18. package/dist/hosted.d.ts.map +1 -0
  19. package/dist/hosted.js +12 -0
  20. package/dist/hosted.js.map +1 -0
  21. package/dist/http-server.d.ts +13 -0
  22. package/dist/http-server.d.ts.map +1 -1
  23. package/dist/http-server.js +13 -4
  24. package/dist/http-server.js.map +1 -1
  25. package/dist/index.js +19 -15
  26. package/dist/index.js.map +1 -1
  27. package/dist/lib.d.ts +24 -0
  28. package/dist/lib.d.ts.map +1 -0
  29. package/dist/lib.js +23 -0
  30. package/dist/lib.js.map +1 -0
  31. package/dist/log.d.ts +16 -0
  32. package/dist/log.d.ts.map +1 -0
  33. package/dist/log.js +20 -0
  34. package/dist/log.js.map +1 -0
  35. package/dist/mcp-server.d.ts +4 -0
  36. package/dist/mcp-server.d.ts.map +1 -0
  37. package/dist/mcp-server.js +43 -0
  38. package/dist/mcp-server.js.map +1 -0
  39. package/dist/oauth.d.ts +41 -6
  40. package/dist/oauth.d.ts.map +1 -1
  41. package/dist/oauth.js +75 -41
  42. package/dist/oauth.js.map +1 -1
  43. package/dist/preview.d.ts +7 -0
  44. package/dist/preview.d.ts.map +1 -1
  45. package/dist/preview.js +14 -2
  46. package/dist/preview.js.map +1 -1
  47. package/dist/signing-requests.d.ts +81 -0
  48. package/dist/signing-requests.d.ts.map +1 -0
  49. package/dist/signing-requests.js +138 -0
  50. package/dist/signing-requests.js.map +1 -0
  51. package/dist/tools/index.d.ts +27 -1
  52. package/dist/tools/index.d.ts.map +1 -1
  53. package/dist/tools/index.js +37 -15
  54. package/dist/tools/index.js.map +1 -1
  55. package/dist/tools/{advanced.d.ts → read/advanced.d.ts} +30 -26
  56. package/dist/tools/read/advanced.d.ts.map +1 -0
  57. package/dist/tools/{advanced.js → read/advanced.js} +10 -10
  58. package/dist/tools/read/advanced.js.map +1 -0
  59. package/dist/tools/{balance.d.ts → read/balance.d.ts} +8 -7
  60. package/dist/tools/read/balance.d.ts.map +1 -0
  61. package/dist/tools/{balance.js → read/balance.js} +2 -2
  62. package/dist/tools/read/balance.js.map +1 -0
  63. package/dist/tools/{contract-info.d.ts → read/contract-info.d.ts} +18 -15
  64. package/dist/tools/read/contract-info.d.ts.map +1 -0
  65. package/dist/tools/{contract-info.js → read/contract-info.js} +2 -2
  66. package/dist/tools/read/contract-info.js.map +1 -0
  67. package/dist/tools/{contract.d.ts → read/contract.d.ts} +14 -12
  68. package/dist/tools/read/contract.d.ts.map +1 -0
  69. package/dist/tools/{contract.js → read/contract.js} +2 -2
  70. package/dist/tools/read/contract.js.map +1 -0
  71. package/dist/tools/{ens.d.ts → read/ens.d.ts} +30 -25
  72. package/dist/tools/read/ens.d.ts.map +1 -0
  73. package/dist/tools/{ens.js → read/ens.js} +2 -2
  74. package/dist/tools/read/ens.js.map +1 -0
  75. package/dist/tools/read/gas.d.ts +171 -0
  76. package/dist/tools/read/gas.d.ts.map +1 -0
  77. package/dist/tools/read/gas.js +368 -0
  78. package/dist/tools/read/gas.js.map +1 -0
  79. package/dist/tools/{logs.d.ts → read/logs.d.ts} +6 -5
  80. package/dist/tools/read/logs.d.ts.map +1 -0
  81. package/dist/tools/{logs.js → read/logs.js} +2 -2
  82. package/dist/tools/read/logs.js.map +1 -0
  83. package/dist/tools/{signatures.d.ts → read/signatures.d.ts} +8 -4
  84. package/dist/tools/read/signatures.d.ts.map +1 -0
  85. package/dist/tools/{signatures.js → read/signatures.js} +1 -1
  86. package/dist/tools/read/signatures.js.map +1 -0
  87. package/dist/tools/read/signing.d.ts +27 -0
  88. package/dist/tools/read/signing.d.ts.map +1 -0
  89. package/dist/tools/read/signing.js +75 -0
  90. package/dist/tools/read/signing.js.map +1 -0
  91. package/dist/tools/sign/errors.d.ts +9 -0
  92. package/dist/tools/sign/errors.d.ts.map +1 -0
  93. package/dist/tools/sign/errors.js +29 -0
  94. package/dist/tools/sign/errors.js.map +1 -0
  95. package/dist/tools/{transactions.d.ts → sign/transactions.d.ts} +48 -62
  96. package/dist/tools/sign/transactions.d.ts.map +1 -0
  97. package/dist/tools/sign/transactions.js +316 -0
  98. package/dist/tools/sign/transactions.js.map +1 -0
  99. package/dist/tools/sign/wallet.d.ts +50 -0
  100. package/dist/tools/sign/wallet.d.ts.map +1 -0
  101. package/dist/tools/sign/wallet.js +180 -0
  102. package/dist/tools/sign/wallet.js.map +1 -0
  103. package/dist/utils.d.ts +5 -2
  104. package/dist/utils.d.ts.map +1 -1
  105. package/dist/utils.js +5 -2
  106. package/dist/utils.js.map +1 -1
  107. package/dist/wallet-client.d.ts +33 -4
  108. package/dist/wallet-client.d.ts.map +1 -1
  109. package/dist/wallet-client.js +90 -24
  110. package/dist/wallet-client.js.map +1 -1
  111. package/dist/walletconnect.d.ts +111 -13
  112. package/dist/walletconnect.d.ts.map +1 -1
  113. package/dist/walletconnect.js +248 -68
  114. package/dist/walletconnect.js.map +1 -1
  115. package/package.json +10 -4
  116. package/src/anvil.ts +150 -271
  117. package/src/chains.ts +14 -2
  118. package/src/client.ts +0 -1
  119. package/src/context.ts +27 -0
  120. package/src/hosted.ts +11 -0
  121. package/src/http-server.ts +30 -5
  122. package/src/index.ts +19 -16
  123. package/src/lib.ts +24 -0
  124. package/src/log.ts +25 -0
  125. package/src/mcp-server.ts +47 -0
  126. package/src/oauth.ts +98 -45
  127. package/src/preview.ts +21 -2
  128. package/src/signing-requests.ts +198 -0
  129. package/src/tools/index.ts +67 -16
  130. package/src/tools/{advanced.ts → read/advanced.ts} +13 -11
  131. package/src/tools/{balance.ts → read/balance.ts} +3 -3
  132. package/src/tools/{contract-info.ts → read/contract-info.ts} +3 -3
  133. package/src/tools/{contract.ts → read/contract.ts} +3 -3
  134. package/src/tools/{ens.ts → read/ens.ts} +3 -3
  135. package/src/tools/read/gas.ts +480 -0
  136. package/src/tools/{logs.ts → read/logs.ts} +3 -3
  137. package/src/tools/{signatures.ts → read/signatures.ts} +2 -2
  138. package/src/tools/read/signing.ts +86 -0
  139. package/src/tools/sign/errors.ts +28 -0
  140. package/src/tools/sign/transactions.ts +428 -0
  141. package/src/tools/sign/wallet.ts +216 -0
  142. package/src/utils.ts +5 -2
  143. package/src/wallet-client.ts +91 -22
  144. package/src/walletconnect.ts +286 -66
  145. package/dist/tools/advanced.d.ts.map +0 -1
  146. package/dist/tools/advanced.js.map +0 -1
  147. package/dist/tools/balance.d.ts.map +0 -1
  148. package/dist/tools/balance.js.map +0 -1
  149. package/dist/tools/contract-info.d.ts.map +0 -1
  150. package/dist/tools/contract-info.js.map +0 -1
  151. package/dist/tools/contract.d.ts.map +0 -1
  152. package/dist/tools/contract.js.map +0 -1
  153. package/dist/tools/ens.d.ts.map +0 -1
  154. package/dist/tools/ens.js.map +0 -1
  155. package/dist/tools/gas.d.ts +0 -99
  156. package/dist/tools/gas.d.ts.map +0 -1
  157. package/dist/tools/gas.js +0 -181
  158. package/dist/tools/gas.js.map +0 -1
  159. package/dist/tools/logs.d.ts.map +0 -1
  160. package/dist/tools/logs.js.map +0 -1
  161. package/dist/tools/signatures.d.ts.map +0 -1
  162. package/dist/tools/signatures.js.map +0 -1
  163. package/dist/tools/transactions.d.ts.map +0 -1
  164. package/dist/tools/transactions.js +0 -361
  165. package/dist/tools/transactions.js.map +0 -1
  166. package/src/tools/gas.ts +0 -219
  167. package/src/tools/transactions.ts +0 -452
package/src/index.ts CHANGED
@@ -1,14 +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
- import { registerAllTools } from './tools/index.js'
9
+ import { attachLogServer } from './log.js'
10
+ import { createMcpServer } from './mcp-server.js'
10
11
  import { parseCommandLineArgs } from './utils.js'
11
- import { getWalletClient } from './wallet-client.js'
12
+ import { allWalletClients, configureWalletRelay, getWalletClient } from './wallet-client.js'
12
13
 
13
14
  // Parse configuration
14
15
  const config = parseCommandLineArgs()
@@ -41,12 +42,15 @@ ENVIRONMENT VARIABLES:
41
42
  WALLETCONNECT_PROJECT_ID Alternative to --walletconnect-project-id
42
43
  CUSTOM_RPC Alternative to --custom-rpc
43
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)
44
46
  MCP_TOKEN Bearer token required by the HTTP transport
45
47
  MCP_PUBLIC_URL Public URL of this server, advertised in OAuth metadata
46
48
  UPSTASH_REDIS_REST_URL Keep WalletConnect sessions in Redis, not on disk
47
49
  UPSTASH_REDIS_REST_TOKEN
48
50
  WALLET_SERVER_URL URL of a hosted wallet relay (omit to run one locally)
49
- WALLET_TOKEN Shared pairing token for the wallet relay
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
50
54
 
51
55
  SUPPORTED CHAINS:
52
56
  ${SUPPORTED_CHAINS.join(', ')}
@@ -91,15 +95,6 @@ if (config.customRpcUrls) {
91
95
  // Initialize client manager
92
96
  initializeClientManager(config)
93
97
 
94
- function createMcpServer() {
95
- const server = new McpServer({
96
- name: 'web3-tools-mcp',
97
- version: packageJson.version
98
- })
99
- registerAllTools(server)
100
- return server
101
- }
102
-
103
98
  const httpMode = Boolean(process.env.MCP_HTTP_PORT)
104
99
  const httpPort = Number(process.env.MCP_HTTP_PORT)
105
100
 
@@ -116,7 +111,13 @@ const hostedRelay = httpMode
116
111
  ? new WalletRelay({ token: process.env.WALLET_TOKEN, publicUrl: process.env.MCP_PUBLIC_URL })
117
112
  : undefined
118
113
 
119
- const wallet = getWalletClient(hostedRelay ? { port: httpPort, token: hostedRelay.token } : undefined)
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)
120
121
 
121
122
  // Hosted, the relay is attached to the HTTP server, so connect only once that is listening.
122
123
  const walletReady = httpMode
@@ -133,7 +134,7 @@ async function shutdown(reason: string) {
133
134
  if (shuttingDown) return
134
135
  shuttingDown = true
135
136
  console.error(`[MCP] Shutting down (${reason})`)
136
- await wallet.stop().catch(() => {})
137
+ await Promise.all(allWalletClients().map((client) => client.stop().catch(() => {})))
137
138
  process.exit(0)
138
139
  }
139
140
 
@@ -161,7 +162,9 @@ async function main() {
161
162
  }
162
163
 
163
164
  const transport = new StdioServerTransport()
164
- await createMcpServer().connect(transport)
165
+ const server = createMcpServer()
166
+ attachLogServer(server)
167
+ await server.connect(transport)
165
168
  console.error('Web3 Tools MCP Server running on stdio')
166
169
  // The relay may still be picking a free port, and its URL carries the pairing token.
167
170
  await walletReady
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'
package/src/log.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
+
3
+ type Level = 'debug' | 'info' | 'warning' | 'error'
4
+
5
+ let server: McpServer | undefined
6
+
7
+ /**
8
+ * Forward logs to the MCP client as well as stderr. Only stdio attaches: it has one server
9
+ * for the life of the process, while HTTP builds one per request and answers in plain JSON,
10
+ * with no stream to push a notification down.
11
+ */
12
+ export function attachLogServer(mcp: McpServer): void {
13
+ server = mcp
14
+ }
15
+
16
+ /**
17
+ * Always stderr, so a host's log capture sees it; stdout is the JSON-RPC stream in stdio
18
+ * mode and must carry nothing else. Also sent to the client as `notifications/message`,
19
+ * because some clients keep a server's messages but not its stderr.
20
+ */
21
+ export function log(level: Level, logger: string, message: string): void {
22
+ console.error(`[${logger}] ${message}`)
23
+ // Before the client has initialised there is nothing to send to; losing those is fine.
24
+ server?.sendLoggingMessage({ level, logger, data: message }).catch(() => {})
25
+ }
@@ -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
- function loginPage(params: { error?: string; fields: Record<string, string> }): string {
96
- // The SDK's authorize handler reads its parameters from the body on POST, so the form
97
- // has to carry the whole authorization request back, not just the token.
98
- const hidden = Object.entries(params.fields)
99
- .map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
100
- .join('\n ')
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
- return `<!doctype html>
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
- ${params.error ? `<p class="error">${params.error}</p>` : ''}
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 SingleUserOAuthProvider implements OAuthServerProvider {
156
+ export class OAuthProvider implements OAuthServerProvider {
129
157
  private store = new Store()
130
158
 
131
- constructor(private accessToken: string) {}
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 token prompt; POST checks it and redirects back with a code. Handling
155
- * both here keeps the whole login inside the SDK's authorize route.
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 submitted = (request.body as { token?: string } | undefined)?.token ?? ''
160
-
161
- // Everything except the token itself is replayed into the form, so the handler sees a
162
- // complete authorization request when the form posts back.
163
- const fields = Object.fromEntries(
164
- Object.entries({ ...(request.query as Record<string, string>), ...(request.body as Record<string, string>) }).filter(
165
- ([name, value]) => name !== 'token' && typeof value === 'string'
166
- )
167
- ) as Record<string, string>
168
-
169
- if (request.method !== 'POST') {
170
- res.setHeader('content-type', 'text/html; charset=utf-8').send(loginPage({ fields }))
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(401)
208
+ .status(status)
177
209
  .setHeader('content-type', 'text/html; charset=utf-8')
178
- .send(loginPage({ fields, error: 'That token was not accepted.' }))
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
- return this.issue(client.client_id, scopes ?? stored.scopes)
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 MCP_TOKEN itself so a client that can send a header still works. */
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.accessToken)) {
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
- return { token, clientId: stored.clientId, scopes: stored.scopes, expiresAt: stored.expiresAt }
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(chain: ChainName, logs: readonly { address: string; topics: readonly string[]; data: string }[]) {
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
+ }