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.
- package/CHANGELOG.md +182 -1
- package/README.md +76 -47
- package/dist/anvil.d.ts +17 -19
- package/dist/anvil.d.ts.map +1 -1
- package/dist/anvil.js +124 -228
- package/dist/anvil.js.map +1 -1
- package/dist/chain-meta.d.ts.map +1 -1
- package/dist/chain-meta.js +19 -8
- package/dist/chain-meta.js.map +1 -1
- package/dist/chains.d.ts +9 -1
- package/dist/chains.d.ts.map +1 -1
- package/dist/chains.js +11 -2
- package/dist/chains.js.map +1 -1
- package/dist/clear-signing.d.ts.map +1 -1
- package/dist/clear-signing.js +17 -6
- package/dist/clear-signing.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/context.d.ts +22 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +24 -0
- package/dist/context.js.map +1 -0
- package/dist/hosted.d.ts +12 -0
- package/dist/hosted.d.ts.map +1 -0
- package/dist/hosted.js +12 -0
- package/dist/hosted.js.map +1 -0
- package/dist/http-server.d.ts +13 -0
- package/dist/http-server.d.ts.map +1 -1
- package/dist/http-server.js +22 -7
- package/dist/http-server.js.map +1 -1
- package/dist/index.js +20 -12
- package/dist/index.js.map +1 -1
- package/dist/kv-storage.d.ts +2 -1
- package/dist/kv-storage.d.ts.map +1 -1
- package/dist/kv-storage.js +5 -2
- package/dist/kv-storage.js.map +1 -1
- package/dist/lib.d.ts +24 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +23 -0
- package/dist/lib.js.map +1 -0
- package/dist/mcp-server.d.ts +4 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +43 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/oauth.d.ts +57 -8
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +133 -63
- package/dist/oauth.js.map +1 -1
- package/dist/preview.d.ts +7 -0
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +21 -7
- package/dist/preview.js.map +1 -1
- package/dist/signing-requests.d.ts +81 -0
- package/dist/signing-requests.d.ts.map +1 -0
- package/dist/signing-requests.js +138 -0
- package/dist/signing-requests.js.map +1 -0
- package/dist/tools/index.d.ts +27 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +50 -15
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/{advanced.d.ts → read/advanced.d.ts} +39 -30
- package/dist/tools/read/advanced.d.ts.map +1 -0
- package/dist/tools/{advanced.js → read/advanced.js} +53 -41
- package/dist/tools/read/advanced.js.map +1 -0
- package/dist/tools/{balance.d.ts → read/balance.d.ts} +8 -7
- package/dist/tools/read/balance.d.ts.map +1 -0
- package/dist/tools/{balance.js → read/balance.js} +18 -33
- package/dist/tools/read/balance.js.map +1 -0
- package/dist/tools/{contract-info.d.ts → read/contract-info.d.ts} +20 -15
- package/dist/tools/read/contract-info.d.ts.map +1 -0
- package/dist/tools/{contract-info.js → read/contract-info.js} +54 -123
- package/dist/tools/read/contract-info.js.map +1 -0
- package/dist/tools/{contract.d.ts → read/contract.d.ts} +14 -12
- package/dist/tools/read/contract.d.ts.map +1 -0
- package/dist/tools/{contract.js → read/contract.js} +3 -2
- package/dist/tools/read/contract.js.map +1 -0
- package/dist/tools/read/ens.d.ts +104 -0
- package/dist/tools/read/ens.d.ts.map +1 -0
- package/dist/tools/{ens.js → read/ens.js} +25 -33
- package/dist/tools/read/ens.js.map +1 -0
- package/dist/tools/read/gas.d.ts +171 -0
- package/dist/tools/read/gas.d.ts.map +1 -0
- package/dist/tools/read/gas.js +333 -0
- package/dist/tools/read/gas.js.map +1 -0
- package/dist/tools/read/logs.d.ts +101 -0
- package/dist/tools/read/logs.d.ts.map +1 -0
- package/dist/tools/read/logs.js +211 -0
- package/dist/tools/read/logs.js.map +1 -0
- package/dist/tools/{signatures.d.ts → read/signatures.d.ts} +8 -4
- package/dist/tools/read/signatures.d.ts.map +1 -0
- package/dist/tools/{signatures.js → read/signatures.js} +4 -3
- package/dist/tools/read/signatures.js.map +1 -0
- package/dist/tools/read/signing.d.ts +27 -0
- package/dist/tools/read/signing.d.ts.map +1 -0
- package/dist/tools/read/signing.js +81 -0
- package/dist/tools/read/signing.js.map +1 -0
- package/dist/tools/sign/errors.d.ts +9 -0
- package/dist/tools/sign/errors.d.ts.map +1 -0
- package/dist/tools/sign/errors.js +29 -0
- package/dist/tools/sign/errors.js.map +1 -0
- package/dist/tools/sign/transactions.d.ts +141 -0
- package/dist/tools/sign/transactions.d.ts.map +1 -0
- package/dist/tools/sign/transactions.js +361 -0
- package/dist/tools/sign/transactions.js.map +1 -0
- package/dist/tools/sign/wallet.d.ts +50 -0
- package/dist/tools/sign/wallet.d.ts.map +1 -0
- package/dist/tools/sign/wallet.js +180 -0
- package/dist/tools/sign/wallet.js.map +1 -0
- package/dist/utils.d.ts +23 -2
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +48 -3
- package/dist/utils.js.map +1 -1
- package/dist/wallet-client.d.ts +34 -4
- package/dist/wallet-client.d.ts.map +1 -1
- package/dist/wallet-client.js +106 -27
- package/dist/wallet-client.js.map +1 -1
- package/dist/walletconnect.d.ts +100 -13
- package/dist/walletconnect.d.ts.map +1 -1
- package/dist/walletconnect.js +215 -50
- package/dist/walletconnect.js.map +1 -1
- package/package.json +12 -9
- package/src/anvil.ts +150 -271
- package/src/chain-meta.ts +16 -7
- package/src/chains.ts +14 -2
- package/src/clear-signing.ts +15 -9
- package/src/client.ts +0 -1
- package/src/context.ts +27 -0
- package/src/hosted.ts +11 -0
- package/src/http-server.ts +38 -8
- package/src/index.ts +20 -13
- package/src/kv-storage.ts +6 -3
- package/src/lib.ts +24 -0
- package/src/mcp-server.ts +47 -0
- package/src/oauth.ts +181 -69
- package/src/preview.ts +28 -7
- package/src/signing-requests.ts +198 -0
- package/src/tools/index.ts +78 -16
- package/src/tools/{advanced.ts → read/advanced.ts} +74 -43
- package/src/tools/{balance.ts → read/balance.ts} +20 -35
- package/src/tools/{contract-info.ts → read/contract-info.ts} +66 -146
- package/src/tools/{contract.ts → read/contract.ts} +4 -3
- package/src/tools/{ens.ts → read/ens.ts} +27 -34
- package/src/tools/read/gas.ts +443 -0
- package/src/tools/read/logs.ts +281 -0
- package/src/tools/{signatures.ts → read/signatures.ts} +5 -5
- package/src/tools/read/signing.ts +92 -0
- package/src/tools/sign/errors.ts +28 -0
- package/src/tools/sign/transactions.ts +479 -0
- package/src/tools/sign/wallet.ts +216 -0
- package/src/utils.ts +52 -3
- package/src/wallet-client.ts +107 -24
- package/src/walletconnect.ts +250 -48
- package/dist/tools/advanced.d.ts.map +0 -1
- package/dist/tools/advanced.js.map +0 -1
- package/dist/tools/balance.d.ts.map +0 -1
- package/dist/tools/balance.js.map +0 -1
- package/dist/tools/contract-info.d.ts.map +0 -1
- package/dist/tools/contract-info.js.map +0 -1
- package/dist/tools/contract.d.ts.map +0 -1
- package/dist/tools/contract.js.map +0 -1
- package/dist/tools/ens.d.ts +0 -119
- package/dist/tools/ens.d.ts.map +0 -1
- package/dist/tools/ens.js.map +0 -1
- package/dist/tools/gas.d.ts +0 -99
- package/dist/tools/gas.d.ts.map +0 -1
- package/dist/tools/gas.js +0 -181
- package/dist/tools/gas.js.map +0 -1
- package/dist/tools/logs.d.ts +0 -39
- package/dist/tools/logs.d.ts.map +0 -1
- package/dist/tools/logs.js +0 -230
- package/dist/tools/logs.js.map +0 -1
- package/dist/tools/signatures.d.ts.map +0 -1
- package/dist/tools/signatures.js.map +0 -1
- package/dist/tools/transactions.d.ts +0 -155
- package/dist/tools/transactions.d.ts.map +0 -1
- package/dist/tools/transactions.js +0 -365
- package/dist/tools/transactions.js.map +0 -1
- package/src/tools/gas.ts +0 -219
- package/src/tools/logs.ts +0 -293
- 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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
|
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
|
-
|
|
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
|
|
155
|
-
* both here keeps the whole login inside the SDK's authorize
|
|
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
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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(
|
|
244
|
+
.status(status)
|
|
177
245
|
.setHeader('content-type', 'text/html; charset=utf-8')
|
|
178
|
-
.send(
|
|
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>(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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()
|
|
201
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
217
|
-
if (stored.clientId !== client.client_id) throw new
|
|
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
|
-
|
|
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
|
|
229
|
-
await this.store.set
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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(
|
|
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
|
+
}
|