web3-tools-mcp 2.0.2 → 3.1.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 (180) hide show
  1. package/CHANGELOG.md +182 -1
  2. package/README.md +76 -47
  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/chain-meta.d.ts.map +1 -1
  8. package/dist/chain-meta.js +19 -8
  9. package/dist/chain-meta.js.map +1 -1
  10. package/dist/chains.d.ts +9 -1
  11. package/dist/chains.d.ts.map +1 -1
  12. package/dist/chains.js +11 -2
  13. package/dist/chains.js.map +1 -1
  14. package/dist/clear-signing.d.ts.map +1 -1
  15. package/dist/clear-signing.js +17 -6
  16. package/dist/clear-signing.js.map +1 -1
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js.map +1 -1
  19. package/dist/context.d.ts +22 -0
  20. package/dist/context.d.ts.map +1 -0
  21. package/dist/context.js +24 -0
  22. package/dist/context.js.map +1 -0
  23. package/dist/hosted.d.ts +12 -0
  24. package/dist/hosted.d.ts.map +1 -0
  25. package/dist/hosted.js +12 -0
  26. package/dist/hosted.js.map +1 -0
  27. package/dist/http-server.d.ts +13 -0
  28. package/dist/http-server.d.ts.map +1 -1
  29. package/dist/http-server.js +22 -7
  30. package/dist/http-server.js.map +1 -1
  31. package/dist/index.js +20 -12
  32. package/dist/index.js.map +1 -1
  33. package/dist/kv-storage.d.ts +2 -1
  34. package/dist/kv-storage.d.ts.map +1 -1
  35. package/dist/kv-storage.js +5 -2
  36. package/dist/kv-storage.js.map +1 -1
  37. package/dist/lib.d.ts +24 -0
  38. package/dist/lib.d.ts.map +1 -0
  39. package/dist/lib.js +23 -0
  40. package/dist/lib.js.map +1 -0
  41. package/dist/mcp-server.d.ts +4 -0
  42. package/dist/mcp-server.d.ts.map +1 -0
  43. package/dist/mcp-server.js +43 -0
  44. package/dist/mcp-server.js.map +1 -0
  45. package/dist/oauth.d.ts +57 -8
  46. package/dist/oauth.d.ts.map +1 -1
  47. package/dist/oauth.js +133 -63
  48. package/dist/oauth.js.map +1 -1
  49. package/dist/preview.d.ts +7 -0
  50. package/dist/preview.d.ts.map +1 -1
  51. package/dist/preview.js +21 -7
  52. package/dist/preview.js.map +1 -1
  53. package/dist/signing-requests.d.ts +81 -0
  54. package/dist/signing-requests.d.ts.map +1 -0
  55. package/dist/signing-requests.js +138 -0
  56. package/dist/signing-requests.js.map +1 -0
  57. package/dist/tools/index.d.ts +27 -1
  58. package/dist/tools/index.d.ts.map +1 -1
  59. package/dist/tools/index.js +50 -15
  60. package/dist/tools/index.js.map +1 -1
  61. package/dist/tools/{advanced.d.ts → read/advanced.d.ts} +39 -30
  62. package/dist/tools/read/advanced.d.ts.map +1 -0
  63. package/dist/tools/{advanced.js → read/advanced.js} +53 -41
  64. package/dist/tools/read/advanced.js.map +1 -0
  65. package/dist/tools/{balance.d.ts → read/balance.d.ts} +8 -7
  66. package/dist/tools/read/balance.d.ts.map +1 -0
  67. package/dist/tools/{balance.js → read/balance.js} +18 -33
  68. package/dist/tools/read/balance.js.map +1 -0
  69. package/dist/tools/{contract-info.d.ts → read/contract-info.d.ts} +20 -15
  70. package/dist/tools/read/contract-info.d.ts.map +1 -0
  71. package/dist/tools/{contract-info.js → read/contract-info.js} +54 -123
  72. package/dist/tools/read/contract-info.js.map +1 -0
  73. package/dist/tools/{contract.d.ts → read/contract.d.ts} +14 -12
  74. package/dist/tools/read/contract.d.ts.map +1 -0
  75. package/dist/tools/{contract.js → read/contract.js} +3 -2
  76. package/dist/tools/read/contract.js.map +1 -0
  77. package/dist/tools/read/ens.d.ts +104 -0
  78. package/dist/tools/read/ens.d.ts.map +1 -0
  79. package/dist/tools/{ens.js → read/ens.js} +25 -33
  80. package/dist/tools/read/ens.js.map +1 -0
  81. package/dist/tools/read/gas.d.ts +171 -0
  82. package/dist/tools/read/gas.d.ts.map +1 -0
  83. package/dist/tools/read/gas.js +333 -0
  84. package/dist/tools/read/gas.js.map +1 -0
  85. package/dist/tools/read/logs.d.ts +101 -0
  86. package/dist/tools/read/logs.d.ts.map +1 -0
  87. package/dist/tools/read/logs.js +211 -0
  88. package/dist/tools/read/logs.js.map +1 -0
  89. package/dist/tools/{signatures.d.ts → read/signatures.d.ts} +8 -4
  90. package/dist/tools/read/signatures.d.ts.map +1 -0
  91. package/dist/tools/{signatures.js → read/signatures.js} +4 -3
  92. package/dist/tools/read/signatures.js.map +1 -0
  93. package/dist/tools/read/signing.d.ts +27 -0
  94. package/dist/tools/read/signing.d.ts.map +1 -0
  95. package/dist/tools/read/signing.js +81 -0
  96. package/dist/tools/read/signing.js.map +1 -0
  97. package/dist/tools/sign/errors.d.ts +9 -0
  98. package/dist/tools/sign/errors.d.ts.map +1 -0
  99. package/dist/tools/sign/errors.js +29 -0
  100. package/dist/tools/sign/errors.js.map +1 -0
  101. package/dist/tools/sign/transactions.d.ts +141 -0
  102. package/dist/tools/sign/transactions.d.ts.map +1 -0
  103. package/dist/tools/sign/transactions.js +361 -0
  104. package/dist/tools/sign/transactions.js.map +1 -0
  105. package/dist/tools/sign/wallet.d.ts +50 -0
  106. package/dist/tools/sign/wallet.d.ts.map +1 -0
  107. package/dist/tools/sign/wallet.js +180 -0
  108. package/dist/tools/sign/wallet.js.map +1 -0
  109. package/dist/utils.d.ts +23 -2
  110. package/dist/utils.d.ts.map +1 -1
  111. package/dist/utils.js +48 -3
  112. package/dist/utils.js.map +1 -1
  113. package/dist/wallet-client.d.ts +34 -4
  114. package/dist/wallet-client.d.ts.map +1 -1
  115. package/dist/wallet-client.js +106 -27
  116. package/dist/wallet-client.js.map +1 -1
  117. package/dist/walletconnect.d.ts +100 -13
  118. package/dist/walletconnect.d.ts.map +1 -1
  119. package/dist/walletconnect.js +215 -50
  120. package/dist/walletconnect.js.map +1 -1
  121. package/package.json +12 -9
  122. package/src/anvil.ts +150 -271
  123. package/src/chain-meta.ts +16 -7
  124. package/src/chains.ts +14 -2
  125. package/src/clear-signing.ts +15 -9
  126. package/src/client.ts +0 -1
  127. package/src/context.ts +27 -0
  128. package/src/hosted.ts +11 -0
  129. package/src/http-server.ts +38 -8
  130. package/src/index.ts +20 -13
  131. package/src/kv-storage.ts +6 -3
  132. package/src/lib.ts +24 -0
  133. package/src/mcp-server.ts +47 -0
  134. package/src/oauth.ts +181 -69
  135. package/src/preview.ts +28 -7
  136. package/src/signing-requests.ts +198 -0
  137. package/src/tools/index.ts +78 -16
  138. package/src/tools/{advanced.ts → read/advanced.ts} +74 -43
  139. package/src/tools/{balance.ts → read/balance.ts} +20 -35
  140. package/src/tools/{contract-info.ts → read/contract-info.ts} +66 -146
  141. package/src/tools/{contract.ts → read/contract.ts} +4 -3
  142. package/src/tools/{ens.ts → read/ens.ts} +27 -34
  143. package/src/tools/read/gas.ts +443 -0
  144. package/src/tools/read/logs.ts +281 -0
  145. package/src/tools/{signatures.ts → read/signatures.ts} +5 -5
  146. package/src/tools/read/signing.ts +92 -0
  147. package/src/tools/sign/errors.ts +28 -0
  148. package/src/tools/sign/transactions.ts +479 -0
  149. package/src/tools/sign/wallet.ts +216 -0
  150. package/src/utils.ts +52 -3
  151. package/src/wallet-client.ts +107 -24
  152. package/src/walletconnect.ts +250 -48
  153. package/dist/tools/advanced.d.ts.map +0 -1
  154. package/dist/tools/advanced.js.map +0 -1
  155. package/dist/tools/balance.d.ts.map +0 -1
  156. package/dist/tools/balance.js.map +0 -1
  157. package/dist/tools/contract-info.d.ts.map +0 -1
  158. package/dist/tools/contract-info.js.map +0 -1
  159. package/dist/tools/contract.d.ts.map +0 -1
  160. package/dist/tools/contract.js.map +0 -1
  161. package/dist/tools/ens.d.ts +0 -119
  162. package/dist/tools/ens.d.ts.map +0 -1
  163. package/dist/tools/ens.js.map +0 -1
  164. package/dist/tools/gas.d.ts +0 -99
  165. package/dist/tools/gas.d.ts.map +0 -1
  166. package/dist/tools/gas.js +0 -181
  167. package/dist/tools/gas.js.map +0 -1
  168. package/dist/tools/logs.d.ts +0 -39
  169. package/dist/tools/logs.d.ts.map +0 -1
  170. package/dist/tools/logs.js +0 -230
  171. package/dist/tools/logs.js.map +0 -1
  172. package/dist/tools/signatures.d.ts.map +0 -1
  173. package/dist/tools/signatures.js.map +0 -1
  174. package/dist/tools/transactions.d.ts +0 -155
  175. package/dist/tools/transactions.d.ts.map +0 -1
  176. package/dist/tools/transactions.js +0 -365
  177. package/dist/tools/transactions.js.map +0 -1
  178. package/src/tools/gas.ts +0 -219
  179. package/src/tools/logs.ts +0 -293
  180. package/src/tools/transactions.ts +0 -456
package/src/oauth.ts CHANGED
@@ -3,7 +3,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
3
  import { homedir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'
6
- import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'
6
+ import { InvalidGrantError, InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js'
7
7
  import type { AuthorizationParams, OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider.js'
8
8
  import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'
9
9
  import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'
@@ -29,15 +29,26 @@ interface StoredCode {
29
29
  redirectUri: string
30
30
  resource?: string
31
31
  scopes: string[]
32
+ address?: string
33
+ credential?: string
32
34
  expiresAt: number
33
35
  }
34
36
 
35
37
  interface StoredToken {
36
38
  clientId: string
37
39
  scopes: string[]
40
+ address?: string
41
+ credential?: string
38
42
  expiresAt: number
39
43
  }
40
44
 
45
+ /** Codes expire in milliseconds, tokens in seconds — the unit OAuth reports them in. */
46
+ function expired(key: string, value: unknown): boolean {
47
+ const expiresAt = (value as { expiresAt?: number } | undefined)?.expiresAt
48
+ if (expiresAt === undefined) return false
49
+ return (key.startsWith('code:') ? expiresAt : expiresAt * 1000) < Date.now()
50
+ }
51
+
41
52
  /** Redis where configured, a JSON file otherwise — issued tokens must outlive a restart. */
42
53
  class Store {
43
54
  private redis: KeyValueStorage | undefined
@@ -67,10 +78,12 @@ class Store {
67
78
  return this.read()[key] as T | undefined
68
79
  }
69
80
 
70
- async set<T>(key: string, value: T): Promise<void> {
71
- if (this.redis) return this.redis.setItem(`oauth:${key}`, value)
81
+ /** `ttl` in seconds lets Redis drop the entry itself; the file is pruned on every write. */
82
+ async set<T>(key: string, value: T, ttl?: number): Promise<void> {
83
+ if (this.redis) return this.redis.setItem(`oauth:${key}`, value, ttl)
72
84
  const data = this.read()
73
85
  data[key] = value
86
+ for (const [name, stored] of Object.entries(data)) if (expired(name, stored)) delete data[name]
74
87
  writeFileSync(this.file, JSON.stringify(data), { mode: 0o600 })
75
88
  }
76
89
 
@@ -92,18 +105,37 @@ function constantTimeEquals(a: string, b: string): boolean {
92
105
  return left.length === right.length && timingSafeEqual(left, right)
93
106
  }
94
107
 
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 ')
108
+ /**
109
+ * How a deployment decides who is at the authorize screen.
110
+ *
111
+ * Deliberately free of any type from the MCP SDK, a chain library, or this package: an
112
+ * implementation renders a page and reads back a form, and everything about *how* somebody
113
+ * proves who they are — a shared token, a wallet signature, a company SSO — stays outside.
114
+ * Returning an address makes the issued token that person's; returning none authenticates
115
+ * the deployment rather than anybody in particular.
116
+ */
117
+ export interface Login {
118
+ /**
119
+ * The page served at GET /authorize. `hidden` must be placed inside its form. `request`
120
+ * names who is asking and where the code will be sent, so a person can tell a phishing
121
+ * client from their own; like `error`, its values arrive HTML-escaped.
122
+ */
123
+ page(hidden: string, error?: string, request?: { client: string; redirectHost: string }): string | Promise<string>
124
+ /** Reads the posted form. An `error` re-renders the page; an `address` names the person. */
125
+ identify(form: Record<string, string>): Promise<{ address?: string; error?: string }>
126
+ /**
127
+ * Form fields this login owns. They are kept out of the hidden inputs replayed into the
128
+ * page, so a credential can never be echoed back into the HTML.
129
+ */
130
+ owns?: string[]
131
+ /**
132
+ * A shared secret this login checks against, if it has one. Everything issued is bound to
133
+ * it, so rotating the secret revokes every code and token handed out under the old one.
134
+ */
135
+ credential?: string
136
+ }
101
137
 
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>
138
+ const PAGE_STYLE = `
107
139
  :root { color-scheme: light dark; }
108
140
  body { font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; max-width: 26rem;
109
141
  margin: 12vh auto; padding: 0 1.25rem; }
@@ -113,22 +145,51 @@ function loginPage(params: { error?: string; fields: Record<string, string> }):
113
145
  box-sizing: border-box; }
114
146
  input { border: 1px solid #ccd; font-family: ui-monospace, monospace; }
115
147
  button { margin-top: .75rem; border: 0; background: #3856d6; color: #fff; font-weight: 600; }
116
- .error { color: #b3251d; font-weight: 600; }
148
+ .error { color: #b3251d; font-weight: 600; }`
149
+
150
+ /** The default: one shared credential, so a self-hoster needs no identity provider. */
151
+ export function tokenLogin(accessToken: string): Login {
152
+ return {
153
+ owns: ['token'],
154
+ credential: accessToken,
155
+ page: (hidden, error, request) => `<!doctype html>
156
+ <meta charset="utf-8">
157
+ <meta name="viewport" content="width=device-width, initial-scale=1">
158
+ <title>Connect to web3-tools-mcp</title>
159
+ <style>${PAGE_STYLE}
117
160
  </style>
118
161
  <h1>Connect to web3-tools-mcp</h1>
162
+ ${request ? `<p><strong>${request.client}</strong> is asking for access, and will be sent back to <strong>${request.redirectHost}</strong>.</p>` : ''}
119
163
  <p>Paste this server's access token to authorise the client.</p>
120
- ${params.error ? `<p class="error">${params.error}</p>` : ''}
164
+ ${error ? `<p class="error">${error}</p>` : ''}
121
165
  <form method="post">
122
166
  ${hidden}
123
167
  <input name="token" type="password" placeholder="MCP_TOKEN" autocomplete="off" autofocus>
124
168
  <button type="submit">Authorise</button>
125
- </form>`
169
+ </form>`,
170
+ async identify(form) {
171
+ if (!constantTimeEquals(form.token ?? '', accessToken)) return { error: 'That token was not accepted.' }
172
+ // No address: this authenticates the deployment, not a person.
173
+ return {}
174
+ }
175
+ }
126
176
  }
127
177
 
128
- export class SingleUserOAuthProvider implements OAuthServerProvider {
178
+ export class OAuthProvider implements OAuthServerProvider {
129
179
  private store = new Store()
180
+ // A hash, so the secret itself never lands in the store.
181
+ private credential: string | undefined
130
182
 
131
- constructor(private accessToken: string) {}
183
+ /**
184
+ * `staticToken` is accepted verbatim in an Authorization header, for clients that can send
185
+ * one and skip the flow entirely. It names no person, so it carries no identity.
186
+ */
187
+ constructor(
188
+ private login: Login,
189
+ private staticToken?: string
190
+ ) {
191
+ this.credential = login.credential && createHash('sha256').update(login.credential).digest('hex').slice(0, 16)
192
+ }
132
193
 
133
194
  get clientsStore(): OAuthRegisteredClientsStore {
134
195
  const store = this.store
@@ -151,82 +212,118 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
151
212
  }
152
213
 
153
214
  /**
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.
215
+ * GET renders the login; POST hands the form to it and, if it names somebody, redirects
216
+ * back with a code. Handling both here keeps the whole login inside the SDK's authorize
217
+ * route, which is the only place the client will follow a redirect from.
156
218
  */
157
219
  async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
158
220
  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
221
+ const form = { ...(request.query as Record<string, string>), ...(request.body as Record<string, string>) }
222
+
223
+ // The SDK's authorize handler reads its parameters from the body on POST, so the form
224
+ // has to carry the whole authorization request back — minus whatever the login owns,
225
+ // which would mean echoing a credential into the HTML.
226
+ const owned = new Set(this.login.owns ?? [])
227
+ const hidden = Object.entries(form)
228
+ .filter(([name, value]) => !owned.has(name) && typeof value === 'string')
229
+ .map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`)
230
+ .join('\n ')
231
+
232
+ const redirectTo = new URL(params.redirectUri)
233
+ const shown = {
234
+ client: escapeHtml(client.client_name || 'An unnamed client'),
235
+ redirectHost: escapeHtml(redirectTo.host || redirectTo.href)
172
236
  }
173
237
 
174
- if (!constantTimeEquals(submitted, this.accessToken)) {
238
+ // A login page that can be framed can be clickjacked into authorising somebody else.
239
+ res.setHeader('X-Frame-Options', 'DENY')
240
+ res.setHeader('Content-Security-Policy', "frame-ancestors 'none'")
241
+
242
+ const render = async (status: number, error?: string) => {
175
243
  res
176
- .status(401)
244
+ .status(status)
177
245
  .setHeader('content-type', 'text/html; charset=utf-8')
178
- .send(loginPage({ fields, error: 'That token was not accepted.' }))
179
- return
246
+ .send(await this.login.page(hidden, error ? escapeHtml(error) : undefined, shown))
180
247
  }
181
248
 
249
+ if (request.method !== 'POST') return render(200)
250
+
251
+ const identified = await this.login.identify(form)
252
+ if (identified.error) return render(401, identified.error)
253
+
182
254
  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)
255
+ await this.store.set<StoredCode>(
256
+ `code:${code}`,
257
+ {
258
+ clientId: client.client_id,
259
+ codeChallenge: params.codeChallenge,
260
+ redirectUri: params.redirectUri,
261
+ resource: params.resource?.href,
262
+ scopes: params.scopes ?? [],
263
+ address: identified.address,
264
+ credential: this.credential,
265
+ expiresAt: Date.now() + CODE_TTL
266
+ },
267
+ CODE_TTL / 1000
268
+ )
269
+
270
+ redirectTo.searchParams.set('code', code)
271
+ if (params.state) redirectTo.searchParams.set('state', params.state)
272
+ res.redirect(redirectTo.href)
196
273
  }
197
274
 
198
- async challengeForAuthorizationCode(_client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
275
+ // Refusals here must be InvalidGrantError: the SDK answers anything else with a 500,
276
+ // where a client needs the 400 invalid_grant to know it should start the flow again.
277
+ private async validCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<StoredCode> {
199
278
  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
279
+ if (!stored || stored.expiresAt < Date.now() || stored.credential !== this.credential) {
280
+ throw new InvalidGrantError('Authorization code is invalid or expired')
281
+ }
282
+ if (stored.clientId !== client.client_id) throw new InvalidGrantError('Authorization code was issued to a different client')
283
+ return stored
202
284
  }
203
285
 
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')
286
+ async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
287
+ return (await this.validCode(client, authorizationCode)).codeChallenge
288
+ }
289
+
290
+ async exchangeAuthorizationCode(
291
+ client: OAuthClientInformationFull,
292
+ authorizationCode: string,
293
+ _codeVerifier?: string,
294
+ redirectUri?: string
295
+ ): Promise<OAuthTokens> {
296
+ const stored = await this.validCode(client, authorizationCode)
297
+ // Optional at the token endpoint because the SDK lets a single-URI client omit it at
298
+ // /authorize too; when sent, it must be the one the code was issued for.
299
+ if (redirectUri !== undefined && redirectUri !== stored.redirectUri) {
300
+ throw new InvalidGrantError('redirect_uri does not match the authorization request')
301
+ }
208
302
 
209
303
  // One use only: a replayed code must not mint a second token.
210
304
  await this.store.remove(`code:${authorizationCode}`)
211
- return this.issue(client.client_id, stored.scopes)
305
+ return this.issue(client.client_id, stored.scopes, stored.address)
212
306
  }
213
307
 
214
308
  async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens> {
215
309
  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')
310
+ if (!stored || stored.credential !== this.credential) throw new InvalidGrantError('Refresh token is invalid')
311
+ if (stored.clientId !== client.client_id) throw new InvalidGrantError('Refresh token was issued to a different client')
218
312
 
219
313
  await this.store.remove(`refresh:${refreshToken}`)
220
- return this.issue(client.client_id, scopes ?? stored.scopes)
314
+ if (stored.expiresAt * 1000 < Date.now()) throw new InvalidGrantError('Refresh token has expired')
315
+ // The refreshed token stays the same person's: a refresh proves possession, not identity.
316
+ return this.issue(client.client_id, scopes ?? stored.scopes, stored.address)
221
317
  }
222
318
 
223
- private async issue(clientId: string, scopes: string[]): Promise<OAuthTokens> {
319
+ private async issue(clientId: string, scopes: string[], address?: string): Promise<OAuthTokens> {
224
320
  const accessToken = randomBytes(32).toString('hex')
225
321
  const refreshToken = randomBytes(32).toString('hex')
226
322
  const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_TTL
323
+ const stored: StoredToken = { clientId, scopes, address, credential: this.credential, expiresAt }
227
324
 
228
- await this.store.set<StoredToken>(`token:${accessToken}`, { clientId, scopes, expiresAt })
229
- await this.store.set<StoredToken>(`refresh:${refreshToken}`, { clientId, scopes, expiresAt })
325
+ await this.store.set(`token:${accessToken}`, stored, TOKEN_TTL)
326
+ await this.store.set(`refresh:${refreshToken}`, stored, TOKEN_TTL)
230
327
 
231
328
  return {
232
329
  access_token: accessToken,
@@ -237,9 +334,9 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
237
334
  }
238
335
  }
239
336
 
240
- /** Accepts an issued token, or MCP_TOKEN itself so a client that can send a header still works. */
337
+ /** Accepts an issued token, or the static one so a client that can send a header still works. */
241
338
  async verifyAccessToken(token: string): Promise<AuthInfo> {
242
- if (constantTimeEquals(token, this.accessToken)) {
339
+ if (this.staticToken && constantTimeEquals(token, this.staticToken)) {
243
340
  // requireBearerAuth refuses a token with no expiry, and this one never expires.
244
341
  return { token, clientId: 'static', scopes: [], expiresAt: Math.floor(Date.now() / 1000) + TOKEN_TTL }
245
342
  }
@@ -247,13 +344,21 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
247
344
  // Must be InvalidTokenError specifically: the SDK turns anything else into a 500, and
248
345
  // a client needs the 401 to know it should authenticate again.
249
346
  const stored = await this.store.get<StoredToken>(`token:${token}`)
250
- if (!stored) throw new InvalidTokenError('Invalid access token')
347
+ if (!stored || stored.credential !== this.credential) throw new InvalidTokenError('Invalid access token')
251
348
  if (stored.expiresAt * 1000 < Date.now()) {
252
349
  await this.store.remove(`token:${token}`)
253
350
  throw new InvalidTokenError('Access token has expired')
254
351
  }
255
352
 
256
- return { token, clientId: stored.clientId, scopes: stored.scopes, expiresAt: stored.expiresAt }
353
+ // `extra.address` is where the tools read the caller's identity from; a token that names
354
+ // nobody leaves it unset, and everything falls back to the shared identity.
355
+ return {
356
+ token,
357
+ clientId: stored.clientId,
358
+ scopes: stored.scopes,
359
+ expiresAt: stored.expiresAt,
360
+ ...(stored.address ? { extra: { address: stored.address } } : {})
361
+ }
257
362
  }
258
363
 
259
364
  async revokeToken(_client: OAuthClientInformationFull, request: { token: string }): Promise<void> {
@@ -262,6 +367,13 @@ export class SingleUserOAuthProvider implements OAuthServerProvider {
262
367
  }
263
368
  }
264
369
 
370
+ /** One shared credential, which is all a self-hosted server needs. */
371
+ export class SingleUserOAuthProvider extends OAuthProvider {
372
+ constructor(accessToken: string) {
373
+ super(tokenLogin(accessToken), accessToken)
374
+ }
375
+ }
376
+
265
377
  /** PKCE helper, used by the tests to drive the flow the way a real client would. */
266
378
  export function pkce(): { verifier: string; challenge: string } {
267
379
  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 {
@@ -83,15 +85,17 @@ function stringify(value: unknown): string {
83
85
  * Decode calldata into labelled fields (clear signing). Token amounts on the standard
84
86
  * ERC20 selectors are formatted with on-chain decimals and unlimited approvals flagged.
85
87
  */
86
- async function decodeCalldata(chain: ChainName, tx: RawTx): Promise<TxPreview['decoded']> {
88
+ async function decodeCalldata(chain: ChainName, tx: RawTx, from?: string): Promise<TxPreview['decoded']> {
87
89
  if (!tx.data || tx.data === '0x') return undefined
88
90
 
89
91
  const { abi, source, proxy } = await loadAbi(chain, tx.to)
90
92
  const { functionName, args } = decodeFunctionData({ abi, data: tx.data as `0x${string}` })
91
93
 
92
- const abiItem = abi.find((item): item is AbiFunction => item.type === 'function' && item.name === functionName)
94
+ // By selector, not name: an overloaded name would otherwise pick the wrong inputs.
95
+ const selector = tx.data.slice(0, 10).toLowerCase()
96
+ const abiItem = abi.find((item): item is AbiFunction => item.type === 'function' && toFunctionSelector(item) === selector)
93
97
  const inputs = abiItem?.inputs ?? []
94
- const erc20 = ERC20_AMOUNT_ARGS[tx.data.slice(0, 10)]
98
+ const erc20 = ERC20_AMOUNT_ARGS[selector]
95
99
  const meta = erc20 ? await tokenMeta(chain, tx.to).catch(() => ({ symbol: undefined, decimals: undefined })) : undefined
96
100
 
97
101
  const fields: PreviewField[] = (args ?? []).map((value, i) => {
@@ -135,7 +139,7 @@ async function decodeCalldata(chain: ChainName, tx: RawTx): Promise<TxPreview['d
135
139
 
136
140
  // A registry descriptor says what the call means and how the protocol wants each field
137
141
  // labelled, which beats raw ABI argument names. Fall back to those when it has none.
138
- const clearSigning = await resolveClearSigning(chain, tx, named).catch(() => null)
142
+ const clearSigning = await resolveClearSigning(chain, { ...tx, from }, named).catch(() => null)
139
143
  if (clearSigning) {
140
144
  return {
141
145
  functionName,
@@ -162,13 +166,30 @@ function topicToAddress(topic: string): string {
162
166
  return `0x${topic.slice(-40)}`
163
167
  }
164
168
 
165
- async function enrichTransfers(chain: ChainName, logs: readonly { address: string; topics: readonly string[]; data: string }[]) {
169
+ export async function enrichTransfers(
170
+ chain: ChainName,
171
+ logs: readonly { address: string; topics: readonly string[]; data: string }[]
172
+ ) {
166
173
  const transfers = logs.filter((log) => log.topics[0]?.toLowerCase() === TRANSFER_TOPIC && log.topics.length >= 3)
167
174
 
168
175
  return Promise.all(
169
176
  transfers.map(async (log): Promise<AssetChange> => {
170
- const amount = log.data && log.data !== '0x' ? BigInt(log.data).toString() : '0'
171
177
  const meta = await tokenMeta(chain, log.address).catch(() => ({ symbol: undefined, decimals: undefined }))
178
+
179
+ // ERC-721 shares ERC-20's Transfer topic but indexes the token id as a fourth topic,
180
+ // leaving the data empty — read as ERC-20, a mint looks like a transfer of nothing.
181
+ if (log.topics.length === 4) {
182
+ return {
183
+ token: log.address,
184
+ symbol: meta.symbol,
185
+ from: topicToAddress(log.topics[1]!),
186
+ to: topicToAddress(log.topics[2]!),
187
+ amount: '1',
188
+ tokenId: BigInt(log.topics[3]!).toString()
189
+ }
190
+ }
191
+
192
+ const amount = log.data && log.data !== '0x' ? BigInt(log.data).toString() : '0'
172
193
  return {
173
194
  token: log.address,
174
195
  symbol: meta.symbol,
@@ -237,7 +258,7 @@ export async function buildTxPreview(chain: ChainName, tx: RawTx, from?: string)
237
258
  const [decoded, simulation, toLabel] = await Promise.all([
238
259
  // A preview that cannot be built must not block the transaction, but swallowing the
239
260
  // reason makes "no details shown" impossible to diagnose.
240
- decodeCalldata(chain, tx).catch((error) => {
261
+ decodeCalldata(chain, tx, from).catch((error) => {
241
262
  console.error('[Preview] Could not decode calldata:', error instanceof Error ? error.message : error)
242
263
  return undefined
243
264
  }),
@@ -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
+ }