web3-tools-mcp 1.6.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +328 -234
  3. package/dist/anvil.d.ts +1 -1
  4. package/dist/anvil.d.ts.map +1 -1
  5. package/dist/anvil.js +11 -9
  6. package/dist/anvil.js.map +1 -1
  7. package/dist/chain-meta.d.ts.map +1 -1
  8. package/dist/chain-meta.js +7 -3
  9. package/dist/chain-meta.js.map +1 -1
  10. package/dist/chains.d.ts +960 -0
  11. package/dist/chains.d.ts.map +1 -0
  12. package/dist/chains.js +108 -0
  13. package/dist/chains.js.map +1 -0
  14. package/dist/client.d.ts +10 -1717
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +33 -218
  17. package/dist/client.js.map +1 -1
  18. package/dist/http-server.d.ts +6 -0
  19. package/dist/http-server.d.ts.map +1 -1
  20. package/dist/http-server.js +55 -40
  21. package/dist/http-server.js.map +1 -1
  22. package/dist/index.js +50 -29
  23. package/dist/index.js.map +1 -1
  24. package/dist/oauth.d.ts +31 -0
  25. package/dist/oauth.d.ts.map +1 -0
  26. package/dist/oauth.js +227 -0
  27. package/dist/oauth.js.map +1 -0
  28. package/dist/preview.d.ts.map +1 -1
  29. package/dist/preview.js +2 -2
  30. package/dist/preview.js.map +1 -1
  31. package/dist/tools/advanced.d.ts +4 -4
  32. package/dist/tools/advanced.d.ts.map +1 -1
  33. package/dist/tools/advanced.js +228 -247
  34. package/dist/tools/advanced.js.map +1 -1
  35. package/dist/tools/balance.d.ts +1 -1
  36. package/dist/tools/balance.d.ts.map +1 -1
  37. package/dist/tools/balance.js +97 -103
  38. package/dist/tools/balance.js.map +1 -1
  39. package/dist/tools/contract-info.d.ts +3 -3
  40. package/dist/tools/contract-info.d.ts.map +1 -1
  41. package/dist/tools/contract-info.js +150 -161
  42. package/dist/tools/contract-info.js.map +1 -1
  43. package/dist/tools/contract.d.ts +4 -4
  44. package/dist/tools/contract.d.ts.map +1 -1
  45. package/dist/tools/contract.js +91 -110
  46. package/dist/tools/contract.js.map +1 -1
  47. package/dist/tools/ens.d.ts +6 -6
  48. package/dist/tools/ens.d.ts.map +1 -1
  49. package/dist/tools/ens.js +239 -297
  50. package/dist/tools/ens.js.map +1 -1
  51. package/dist/tools/gas.d.ts +4 -4
  52. package/dist/tools/gas.d.ts.map +1 -1
  53. package/dist/tools/gas.js +90 -138
  54. package/dist/tools/gas.js.map +1 -1
  55. package/dist/tools/index.d.ts +1 -1
  56. package/dist/tools/index.d.ts.map +1 -1
  57. package/dist/tools/logs.d.ts +1 -1
  58. package/dist/tools/logs.d.ts.map +1 -1
  59. package/dist/tools/logs.js +36 -37
  60. package/dist/tools/logs.js.map +1 -1
  61. package/dist/tools/signatures.d.ts.map +1 -1
  62. package/dist/tools/signatures.js +67 -89
  63. package/dist/tools/signatures.js.map +1 -1
  64. package/dist/tools/transactions.d.ts +35 -5
  65. package/dist/tools/transactions.d.ts.map +1 -1
  66. package/dist/tools/transactions.js +148 -74
  67. package/dist/tools/transactions.js.map +1 -1
  68. package/dist/types.d.ts +1 -1
  69. package/dist/types.d.ts.map +1 -1
  70. package/dist/utils.d.ts +1 -1
  71. package/dist/utils.d.ts.map +1 -1
  72. package/dist/utils.js +35 -98
  73. package/dist/utils.js.map +1 -1
  74. package/dist/wallet-client.d.ts +11 -1
  75. package/dist/wallet-client.d.ts.map +1 -1
  76. package/dist/wallet-client.js +27 -9
  77. package/dist/wallet-client.js.map +1 -1
  78. package/dist/walletconnect.d.ts +22 -1
  79. package/dist/walletconnect.d.ts.map +1 -1
  80. package/dist/walletconnect.js +101 -8
  81. package/dist/walletconnect.js.map +1 -1
  82. package/package.json +17 -9
  83. package/src/anvil.ts +15 -18
  84. package/src/chain-meta.ts +8 -4
  85. package/src/chains.ts +145 -0
  86. package/src/client.ts +39 -254
  87. package/src/http-server.ts +67 -44
  88. package/src/index.ts +77 -53
  89. package/src/oauth.ts +270 -0
  90. package/src/preview.ts +3 -12
  91. package/src/tools/advanced.ts +195 -213
  92. package/src/tools/balance.ts +103 -108
  93. package/src/tools/contract-info.ts +163 -184
  94. package/src/tools/contract.ts +123 -144
  95. package/src/tools/ens.ts +290 -345
  96. package/src/tools/gas.ts +122 -170
  97. package/src/tools/index.ts +2 -2
  98. package/src/tools/logs.ts +45 -43
  99. package/src/tools/signatures.ts +68 -86
  100. package/src/tools/transactions.ts +194 -87
  101. package/src/types.ts +1 -15
  102. package/src/utils.ts +52 -110
  103. package/src/wallet-client.ts +31 -9
  104. package/src/walletconnect.ts +119 -7
package/src/index.ts CHANGED
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import packageJson from "../package.json" with { type: "json" };
4
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
- import { initializeClientManager } from "./client.js";
7
- import { registerAllTools } from "./tools/index.js";
8
- import { parseCommandLineArgs } from "./utils.js";
9
- import { getWalletClient } from "./wallet-client.js";
10
- import { startHttpServer } from "./http-server.js";
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
5
+ import { WalletRelay } from 'web3-wallet-relay'
6
+ import packageJson from '../package.json' with { type: 'json' }
7
+ import { initializeClientManager, SUPPORTED_CHAINS } from './client.js'
8
+ import { startHttpServer } from './http-server.js'
9
+ import { registerAllTools } from './tools/index.js'
10
+ import { parseCommandLineArgs } from './utils.js'
11
+ import { getWalletClient } from './wallet-client.js'
11
12
 
12
13
  // Parse configuration
13
- const config = parseCommandLineArgs();
14
+ const config = parseCommandLineArgs()
14
15
 
15
16
  // Show help if requested
16
17
  if (config.showHelp) {
@@ -38,15 +39,17 @@ ENVIRONMENT VARIABLES:
38
39
  INFURA_API_KEY Alternative to --infura-api-key
39
40
  HYPERSYNC_API_KEY Alternative to --hypersync-api-key
40
41
  WALLETCONNECT_PROJECT_ID Alternative to --walletconnect-project-id
42
+ CUSTOM_RPC Alternative to --custom-rpc
41
43
  MCP_HTTP_PORT Serve MCP over HTTP on this port instead of stdio
42
44
  MCP_TOKEN Bearer token required by the HTTP transport
45
+ MCP_PUBLIC_URL Public URL of this server, advertised in OAuth metadata
43
46
  UPSTASH_REDIS_REST_URL Keep WalletConnect sessions in Redis, not on disk
44
47
  UPSTASH_REDIS_REST_TOKEN
45
48
  WALLET_SERVER_URL URL of a hosted wallet relay (omit to run one locally)
46
49
  WALLET_TOKEN Shared pairing token for the wallet relay
47
50
 
48
51
  SUPPORTED CHAINS:
49
- mainnet, arbitrum, avalanche, base, bnb, gnosis, sonic, optimism, polygon, zksync, linea, unichain, localhost
52
+ ${SUPPORTED_CHAINS.join(', ')}
50
53
 
51
54
  EXAMPLES:
52
55
  # Use with npx (recommended)
@@ -64,87 +67,108 @@ DOCUMENTATION:
64
67
  Issues: ${packageJson.bugs.url}
65
68
 
66
69
  For MCP client configuration, see the README.md file.
67
- `);
68
- process.exit(0);
70
+ `)
71
+ process.exit(0)
69
72
  }
70
73
 
71
74
  // Log configuration info
72
75
  if (config.etherscanApiKey) {
73
- console.error("[MCP] Etherscan API key configured");
76
+ console.error('[MCP] Etherscan API key configured')
74
77
  }
75
78
  if (config.alchemyApiKey) {
76
- console.error("[MCP] Alchemy API key configured");
79
+ console.error('[MCP] Alchemy API key configured')
77
80
  }
78
81
  if (config.infuraApiKey) {
79
- console.error("[MCP] Infura API key configured");
82
+ console.error('[MCP] Infura API key configured')
80
83
  }
81
84
  if (config.walletConnectProjectId) {
82
- console.error("[MCP] WalletConnect project id configured — phone signing available");
85
+ console.error('[MCP] WalletConnect project id configured — phone signing available')
83
86
  }
84
87
  if (config.customRpcUrls) {
85
- console.error("[MCP] Custom RPC URLs:", Object.keys(config.customRpcUrls));
88
+ console.error('[MCP] Custom RPC URLs:', Object.keys(config.customRpcUrls))
86
89
  }
87
90
 
88
91
  // Initialize client manager
89
- initializeClientManager(config);
92
+ initializeClientManager(config)
90
93
 
91
94
  function createMcpServer() {
92
95
  const server = new McpServer({
93
- name: "web3-tools-mcp",
94
- version: packageJson.version,
95
- });
96
- registerAllTools(server);
97
- return server;
96
+ name: 'web3-tools-mcp',
97
+ version: packageJson.version
98
+ })
99
+ registerAllTools(server)
100
+ return server
98
101
  }
99
102
 
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);
103
-
104
- const wallet = getWalletClient();
103
+ const httpMode = Boolean(process.env.MCP_HTTP_PORT)
104
+ const httpPort = Number(process.env.MCP_HTTP_PORT)
105
+
106
+ /**
107
+ * Hosted, the signing page rides on the same port as /mcp, so a browser wallet works from
108
+ * a deployment too — the platform gives one port and both fit on it.
109
+ *
110
+ * Its token is minted per boot rather than persisted. A hosted relay is reachable from
111
+ * anywhere, and the pairing URL is fetched through wallet_status, which already needs
112
+ * MCP_TOKEN — so nobody types this token, and a short-lived one costs nothing. Locally the
113
+ * token stays on disk, because several editor sessions have to find the same relay.
114
+ */
115
+ const hostedRelay = httpMode
116
+ ? new WalletRelay({ token: process.env.WALLET_TOKEN, publicUrl: process.env.MCP_PUBLIC_URL })
117
+ : undefined
118
+
119
+ const wallet = getWalletClient(hostedRelay ? { port: httpPort, token: hostedRelay.token } : undefined)
120
+
121
+ // Hosted, the relay is attached to the HTTP server, so connect only once that is listening.
105
122
  const walletReady = httpMode
106
123
  ? Promise.resolve()
107
124
  : 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
- });
125
+ console.error('[MCP] Wallet relay unavailable:', error.message)
126
+ console.error('[MCP] Transaction signing features will not be available')
127
+ })
111
128
 
112
129
  // The wallet relay's listening socket keeps the event loop alive, so this process would
113
130
  // outlive the client that spawned it and go on holding its port. Leave when the client does.
114
- let shuttingDown = false;
131
+ let shuttingDown = false
115
132
  async function shutdown(reason: string) {
116
- if (shuttingDown) return;
117
- shuttingDown = true;
118
- console.error(`[MCP] Shutting down (${reason})`);
119
- await wallet.stop().catch(() => {});
120
- process.exit(0);
133
+ if (shuttingDown) return
134
+ shuttingDown = true
135
+ console.error(`[MCP] Shutting down (${reason})`)
136
+ await wallet.stop().catch(() => {})
137
+ process.exit(0)
121
138
  }
122
139
 
123
- process.stdin.on("end", () => shutdown("client disconnected"));
124
- process.stdin.on("close", () => shutdown("client disconnected"));
125
- process.on("SIGTERM", () => shutdown("SIGTERM"));
126
- process.on("SIGINT", () => shutdown("SIGINT"));
140
+ process.stdin.on('end', () => shutdown('client disconnected'))
141
+ process.stdin.on('close', () => shutdown('client disconnected'))
142
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
143
+ process.on('SIGINT', () => shutdown('SIGINT'))
127
144
 
128
145
  // Start server
129
146
  async function main() {
130
147
  if (httpMode) {
131
- const { url } = await startHttpServer({ createMcpServer });
132
- console.error(`Web3 Tools MCP Server listening on ${url}`);
148
+ const { url, walletUrl } = await startHttpServer({ createMcpServer, walletRelay: hostedRelay })
149
+ console.error(`Web3 Tools MCP Server listening on ${url}`)
150
+
151
+ // Join the relay now that it is listening, so browser signing is available here too.
152
+ await wallet.connect().catch((error) => {
153
+ console.error('[MCP] Could not join the wallet relay:', error.message)
154
+ })
155
+ if (walletUrl) console.error(`Wallet interface available at ${walletUrl}`)
156
+
133
157
  if (!config.walletConnectProjectId) {
134
- console.error("[MCP] No WalletConnect project id — a hosted server has no way to sign transactions");
158
+ console.error('[MCP] No WalletConnect project id — phone signing is unavailable on this server')
135
159
  }
136
- return;
160
+ return
137
161
  }
138
162
 
139
- const transport = new StdioServerTransport();
140
- await createMcpServer().connect(transport);
141
- console.error("Web3 Tools MCP Server running on stdio");
163
+ const transport = new StdioServerTransport()
164
+ await createMcpServer().connect(transport)
165
+ console.error('Web3 Tools MCP Server running on stdio')
142
166
  // The relay may still be picking a free port, and its URL carries the pairing token.
143
- await walletReady;
144
- console.error(`Wallet interface available at ${wallet.getUrl()}`);
167
+ await walletReady
168
+ console.error(`Wallet interface available at ${wallet.getUrl()}`)
145
169
  }
146
170
 
147
171
  main().catch((error) => {
148
- console.error("Fatal error:", error);
149
- process.exit(1);
150
- });
172
+ console.error('Fatal error:', error)
173
+ process.exit(1)
174
+ })
package/src/oauth.ts ADDED
@@ -0,0 +1,270 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'
6
+ import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'
7
+ import type { AuthorizationParams, OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider.js'
8
+ import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'
9
+ import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'
10
+ import type { Response } from 'express'
11
+ import { getKeyValueStorage, type KeyValueStorage } from './kv-storage.js'
12
+
13
+ /**
14
+ * OAuth for clients that will not send a static bearer token — claude.ai connectors, and
15
+ * so anything driving them from a browser or phone, only speak the OAuth flow the MCP spec
16
+ * defines.
17
+ *
18
+ * There are no user accounts here: the single credential is MCP_TOKEN, the same one this
19
+ * server already requires. The authorize step is a one-field page that asks for it, so a
20
+ * self-hoster needs no identity provider and no extra configuration.
21
+ */
22
+
23
+ const CODE_TTL = 10 * 60 * 1000
24
+ const TOKEN_TTL = 30 * 24 * 60 * 60
25
+
26
+ interface StoredCode {
27
+ clientId: string
28
+ codeChallenge: string
29
+ redirectUri: string
30
+ resource?: string
31
+ scopes: string[]
32
+ expiresAt: number
33
+ }
34
+
35
+ interface StoredToken {
36
+ clientId: string
37
+ scopes: string[]
38
+ expiresAt: number
39
+ }
40
+
41
+ /** Redis where configured, a JSON file otherwise — issued tokens must outlive a restart. */
42
+ class Store {
43
+ private redis: KeyValueStorage | undefined
44
+ private file: string
45
+ private cache: Record<string, unknown> | undefined
46
+
47
+ constructor() {
48
+ this.redis = getKeyValueStorage()
49
+ const dir = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'web3-tools-mcp')
50
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
51
+ this.file = join(dir, 'oauth.json')
52
+ }
53
+
54
+ private read(): Record<string, unknown> {
55
+ if (!this.cache) {
56
+ try {
57
+ this.cache = JSON.parse(readFileSync(this.file, 'utf8'))
58
+ } catch {
59
+ this.cache = {}
60
+ }
61
+ }
62
+ return this.cache as Record<string, unknown>
63
+ }
64
+
65
+ async get<T>(key: string): Promise<T | undefined> {
66
+ if (this.redis) return this.redis.getItem<T>(`oauth:${key}`)
67
+ return this.read()[key] as T | undefined
68
+ }
69
+
70
+ async set<T>(key: string, value: T): Promise<void> {
71
+ if (this.redis) return this.redis.setItem(`oauth:${key}`, value)
72
+ const data = this.read()
73
+ data[key] = value
74
+ writeFileSync(this.file, JSON.stringify(data), { mode: 0o600 })
75
+ }
76
+
77
+ async remove(key: string): Promise<void> {
78
+ if (this.redis) return this.redis.removeItem(`oauth:${key}`)
79
+ const data = this.read()
80
+ delete data[key]
81
+ writeFileSync(this.file, JSON.stringify(data), { mode: 0o600 })
82
+ }
83
+ }
84
+
85
+ function escapeHtml(value: string): string {
86
+ return value.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] as string)
87
+ }
88
+
89
+ function constantTimeEquals(a: string, b: string): boolean {
90
+ const left = Buffer.from(a)
91
+ const right = Buffer.from(b)
92
+ return left.length === right.length && timingSafeEqual(left, right)
93
+ }
94
+
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 ')
101
+
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>
107
+ :root { color-scheme: light dark; }
108
+ body { font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 26rem;
109
+ margin: 12vh auto; padding: 0 1.25rem; }
110
+ h1 { font-size: 1.15rem; margin-bottom: .25rem; }
111
+ p { color: #667; margin-top: 0; }
112
+ input, button { font: inherit; width: 100%; padding: .7rem .8rem; border-radius: .5rem;
113
+ box-sizing: border-box; }
114
+ input { border: 1px solid #ccd; font-family: ui-monospace, monospace; }
115
+ button { margin-top: .75rem; border: 0; background: #3856d6; color: #fff; font-weight: 600; }
116
+ .error { color: #b3251d; font-weight: 600; }
117
+ </style>
118
+ <h1>Connect to web3-tools-mcp</h1>
119
+ <p>Paste this server's access token to authorise the client.</p>
120
+ ${params.error ? `<p class="error">${params.error}</p>` : ''}
121
+ <form method="post">
122
+ ${hidden}
123
+ <input name="token" type="password" placeholder="MCP_TOKEN" autocomplete="off" autofocus>
124
+ <button type="submit">Authorise</button>
125
+ </form>`
126
+ }
127
+
128
+ export class SingleUserOAuthProvider implements OAuthServerProvider {
129
+ private store = new Store()
130
+
131
+ constructor(private accessToken: string) {}
132
+
133
+ get clientsStore(): OAuthRegisteredClientsStore {
134
+ const store = this.store
135
+ return {
136
+ async getClient(clientId) {
137
+ return store.get<OAuthClientInformationFull>(`client:${clientId}`)
138
+ },
139
+ // Registration is open because MCP clients register themselves before anyone can
140
+ // log in; holding MCP_TOKEN is what actually grants access.
141
+ async registerClient(client) {
142
+ const registered: OAuthClientInformationFull = {
143
+ ...client,
144
+ client_id: randomBytes(16).toString('hex'),
145
+ client_id_issued_at: Math.floor(Date.now() / 1000)
146
+ }
147
+ await store.set(`client:${registered.client_id}`, registered)
148
+ return registered
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
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.
156
+ */
157
+ async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
158
+ 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)) {
175
+ res
176
+ .status(401)
177
+ .setHeader('content-type', 'text/html; charset=utf-8')
178
+ .send(loginPage({ fields, error: 'That token was not accepted.' }))
179
+ return
180
+ }
181
+
182
+ const code = randomBytes(24).toString('hex')
183
+ await this.store.set<StoredCode>(`code:${code}`, {
184
+ clientId: client.client_id,
185
+ codeChallenge: params.codeChallenge,
186
+ redirectUri: params.redirectUri,
187
+ resource: params.resource?.href,
188
+ scopes: params.scopes ?? [],
189
+ expiresAt: Date.now() + CODE_TTL
190
+ })
191
+
192
+ const redirect = new URL(params.redirectUri)
193
+ redirect.searchParams.set('code', code)
194
+ if (params.state) redirect.searchParams.set('state', params.state)
195
+ res.redirect(redirect.href)
196
+ }
197
+
198
+ async challengeForAuthorizationCode(_client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
199
+ const stored = await this.store.get<StoredCode>(`code:${authorizationCode}`)
200
+ if (!stored || stored.expiresAt < Date.now()) throw new Error('Authorization code is invalid or expired')
201
+ return stored.codeChallenge
202
+ }
203
+
204
+ async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<OAuthTokens> {
205
+ const stored = await this.store.get<StoredCode>(`code:${authorizationCode}`)
206
+ if (!stored || stored.expiresAt < Date.now()) throw new Error('Authorization code is invalid or expired')
207
+ if (stored.clientId !== client.client_id) throw new Error('Authorization code was issued to a different client')
208
+
209
+ // One use only: a replayed code must not mint a second token.
210
+ await this.store.remove(`code:${authorizationCode}`)
211
+ return this.issue(client.client_id, stored.scopes)
212
+ }
213
+
214
+ async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens> {
215
+ const stored = await this.store.get<StoredToken>(`refresh:${refreshToken}`)
216
+ if (!stored) throw new Error('Refresh token is invalid')
217
+ if (stored.clientId !== client.client_id) throw new Error('Refresh token was issued to a different client')
218
+
219
+ await this.store.remove(`refresh:${refreshToken}`)
220
+ return this.issue(client.client_id, scopes ?? stored.scopes)
221
+ }
222
+
223
+ private async issue(clientId: string, scopes: string[]): Promise<OAuthTokens> {
224
+ const accessToken = randomBytes(32).toString('hex')
225
+ const refreshToken = randomBytes(32).toString('hex')
226
+ const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_TTL
227
+
228
+ await this.store.set<StoredToken>(`token:${accessToken}`, { clientId, scopes, expiresAt })
229
+ await this.store.set<StoredToken>(`refresh:${refreshToken}`, { clientId, scopes, expiresAt })
230
+
231
+ return {
232
+ access_token: accessToken,
233
+ token_type: 'Bearer',
234
+ expires_in: TOKEN_TTL,
235
+ refresh_token: refreshToken,
236
+ scope: scopes.join(' ')
237
+ }
238
+ }
239
+
240
+ /** Accepts an issued token, or MCP_TOKEN itself so a client that can send a header still works. */
241
+ async verifyAccessToken(token: string): Promise<AuthInfo> {
242
+ if (constantTimeEquals(token, this.accessToken)) {
243
+ // requireBearerAuth refuses a token with no expiry, and this one never expires.
244
+ return { token, clientId: 'static', scopes: [], expiresAt: Math.floor(Date.now() / 1000) + TOKEN_TTL }
245
+ }
246
+
247
+ // Must be InvalidTokenError specifically: the SDK turns anything else into a 500, and
248
+ // a client needs the 401 to know it should authenticate again.
249
+ const stored = await this.store.get<StoredToken>(`token:${token}`)
250
+ if (!stored) throw new InvalidTokenError('Invalid access token')
251
+ if (stored.expiresAt * 1000 < Date.now()) {
252
+ await this.store.remove(`token:${token}`)
253
+ throw new InvalidTokenError('Access token has expired')
254
+ }
255
+
256
+ return { token, clientId: stored.clientId, scopes: stored.scopes, expiresAt: stored.expiresAt }
257
+ }
258
+
259
+ async revokeToken(_client: OAuthClientInformationFull, request: { token: string }): Promise<void> {
260
+ await this.store.remove(`token:${request.token}`)
261
+ await this.store.remove(`refresh:${request.token}`)
262
+ }
263
+ }
264
+
265
+ /** PKCE helper, used by the tests to drive the flow the way a real client would. */
266
+ export function pkce(): { verifier: string; challenge: string } {
267
+ const verifier = randomBytes(32).toString('base64url')
268
+ const challenge = createHash('sha256').update(verifier).digest('base64url')
269
+ return { verifier, challenge }
270
+ }
package/src/preview.ts CHANGED
@@ -1,17 +1,8 @@
1
- import {
2
- type Abi,
3
- type AbiFunction,
4
- type Address,
5
- decodeFunctionData,
6
- formatEther,
7
- formatUnits,
8
- parseAbiItem,
9
- toFunctionSelector
10
- } from 'viem'
1
+ import { type AbiFunction, type Address, decodeFunctionData, formatEther, formatUnits, toFunctionSelector } from 'viem'
11
2
  import { simulateBlocks } from 'viem/actions'
12
- import { getClientManager } from './client.js'
13
3
  import { addressLabel, loadAbi, tokenMeta, UNLIMITED_THRESHOLD } from './chain-meta.js'
14
4
  import { lookupContract, protocolLabel, resolveClearSigning } from './clear-signing.js'
5
+ import { getClientManager } from './client.js'
15
6
  import type { ChainName } from './types.js'
16
7
 
17
8
  export interface PreviewField {
@@ -266,7 +257,7 @@ export async function buildTxPreview(chain: ChainName, tx: RawTx, from?: string)
266
257
  chain,
267
258
  to: tx.to,
268
259
  toLabel,
269
- explorer: `https://${getClientManager().getEtherscanDomain(chain)}`,
260
+ explorer: getClientManager().explorerUrl(chain),
270
261
  value,
271
262
  valueFormatted: value && value !== '0' ? formatEther(BigInt(value)) : undefined,
272
263
  decoded,