@reclaimprotocol/agent 0.1.15 → 0.1.17

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 (50) hide show
  1. package/lib/authoring/index.d.ts +1 -1
  2. package/lib/authoring/index.js +1 -1
  3. package/lib/authoring/util.js +3 -0
  4. package/lib/config/store.d.ts +7 -0
  5. package/lib/consts.d.ts +8 -0
  6. package/lib/consts.js +20 -0
  7. package/lib/index.js +1 -1
  8. package/lib/mcp/api/operations.gen.d.ts +8 -16
  9. package/lib/mcp/api/operations.gen.js +428 -479
  10. package/lib/mcp/remap_errors.js +7 -14
  11. package/lib/mcp/start.js +10 -3
  12. package/lib/mcp/tools/agent/proof.js +3 -3
  13. package/lib/mcp/tools/authenticate/authenticate.js +178 -93
  14. package/lib/mcp/tools/authenticate/eth-key.js +2 -3
  15. package/lib/mcp/tools/index.d.ts +1 -0
  16. package/lib/mcp/tools/index.js +1 -0
  17. package/lib/mcp/tools/mode/index.d.ts +1 -0
  18. package/lib/mcp/tools/mode/index.js +3 -0
  19. package/lib/mcp/tools/mode/mode.d.ts +16 -0
  20. package/lib/mcp/tools/mode/mode.js +49 -0
  21. package/lib/provider/to-version.js +4 -0
  22. package/lib/tools/registry.d.ts +6 -13
  23. package/lib/tools/registry.js +29 -0
  24. package/package.json +5 -8
  25. package/scripts/prepend-shebang.js +1 -1
  26. package/src/authoring/index.ts +1 -0
  27. package/src/authoring/util.ts +3 -0
  28. package/src/cdp/launcher.ts +1 -1
  29. package/src/config/store.ts +8 -0
  30. package/src/consts.ts +24 -0
  31. package/src/index.ts +1 -1
  32. package/src/mcp/api/operations.gen.ts +430 -487
  33. package/src/mcp/remap_errors.ts +8 -15
  34. package/src/mcp/start.ts +11 -2
  35. package/src/mcp/tools/agent/proof.ts +3 -3
  36. package/src/mcp/tools/authenticate/authenticate.ts +238 -108
  37. package/src/mcp/tools/authenticate/eth-key.ts +7 -5
  38. package/src/mcp/tools/index.ts +1 -0
  39. package/src/mcp/tools/mode/index.ts +3 -0
  40. package/src/mcp/tools/mode/mode.ts +58 -0
  41. package/src/proof/debug-response-dump.ts +1 -1
  42. package/src/proof/proof-core.ts +1 -1
  43. package/src/proof/zk-files.ts +2 -2
  44. package/src/provider/dom-xpath.ts +1 -1
  45. package/src/provider/draft.ts +3 -3
  46. package/src/provider/to-version.ts +4 -0
  47. package/src/tools/registry.ts +19 -0
  48. package/src/cli/args.ts +0 -20
  49. package/src/cli/commands.ts +0 -27
  50. package/src/cli.ts +0 -7
@@ -2,34 +2,27 @@ import type { Boom } from '@hapi/boom'
2
2
  import { isBoom } from '@hapi/boom'
3
3
  import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
4
4
  import type { ProblemDetails } from '../index.ts'
5
+ import { errorAsToolText } from '../tools/registry.ts'
5
6
 
6
- // Remap all errors here if we return different response based on error.
7
- // Doing this so we can reuse tool error handler that returns same response
8
- // across different tools or error.
9
- // For every other error, let them get thrown so the main try-catch
10
- // handles them in tool registration.
7
+ // One error shape for every MCP tool. The text comes from errorAsToolText
8
+ // (shared with the cloud loop); Boom problems also attach structuredContent.
11
9
  export const remapErrorAsResponse = (
12
10
  err: unknown,
13
11
  ): CallToolResult => {
12
+ const text = errorAsToolText(err)
13
+
14
14
  // The client throws a Boom-style ProblemError, so the status lives on
15
15
  // `output.statusCode` / `problem.status`, not `status`.
16
16
  if(!isBoom(err) || !err.output?.statusCode) {
17
17
  return {
18
18
  isError: true as const,
19
- content: [{ type: 'text' as const, text: (err as Error).message }],
19
+ content: [{ type: 'text' as const, text }],
20
20
  }
21
21
  }
22
22
 
23
- const problemError = err as Boom<ProblemDetails>
24
-
25
23
  return {
26
24
  isError: true as const,
27
- structuredContent: problemError.data,
28
- content: [
29
- {
30
- type: 'text' as const,
31
- text: `${problemError.message}: ${problemError.data?.detail}`
32
- }
33
- ],
25
+ structuredContent: (err as Boom<ProblemDetails>).data,
26
+ content: [{ type: 'text' as const, text }],
34
27
  }
35
28
  }
package/src/mcp/start.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { ReclaimClient } from '@reclaimprotocol/client'
4
+ import { ConfigStore } from '../config/store.ts'
4
5
  import {
5
6
  agentDisabled,
6
7
  apiToken,
7
8
  apiUrl,
8
9
  oldApiUrl,
9
10
  oldLogsUrl,
11
+ resolveOldMode,
10
12
  sdkApiUrl,
11
- useOldDevtools,
12
13
  } from '../consts.ts'
13
14
  import { ReclaimOldClient } from '../old/client.ts'
14
15
  import { resolveInitialIdentity } from '../old/identity.ts'
@@ -21,6 +22,7 @@ import {
21
22
  buildAgentTools,
22
23
  buildCredentialTools,
23
24
  buildLocalCredentialTools,
25
+ buildModeTools,
24
26
  buildVerificationTools,
25
27
  readSession,
26
28
  } from './tools/index.ts'
@@ -28,7 +30,10 @@ import { BUILDER_MODE_INSTRUCTIONS } from './instructions.ts'
28
30
  import { registerToolList } from './server.ts'
29
31
 
30
32
  export async function startMcpServer() {
31
- const oldMode = useOldDevtools()
33
+ // Mode precedence: explicit USE_OLD_DEVTOOLS env → persisted config → old.
34
+ // The same store instance backs the set_devtools_mode toggle below.
35
+ const config = new ConfigStore()
36
+ const oldMode = resolveOldMode(config.read()?.mode)
32
37
 
33
38
  const server = new McpServer(
34
39
  { name: NAME, version: VERSION },
@@ -45,6 +50,10 @@ export async function startMcpServer() {
45
50
  // surface.
46
51
  const agentToolsEnabled = !agentDisabled()
47
52
 
53
+ // Backend-mode toggle — available in both modes and regardless of whether
54
+ // the agent tools are enabled, so a user can always switch either direction.
55
+ registerToolList(server, buildModeTools(config, oldMode))
56
+
48
57
  if(oldMode) {
49
58
  // OLD-devtools mode (the default): the legacy backend is still in
50
59
  // production while builder is built. All auto-generated builder tools
@@ -150,11 +150,11 @@ export function shapeRichProofResult(
150
150
 
151
151
  // Trim the LLM-visible logs — full stream goes to runLogPath.
152
152
  if(result.attestorLogs !== undefined && result.attestorLogs.length > 0) {
153
- out.attestorLogs = result.verified
153
+ const logs = result.verified
154
154
  ? warnsAndErrors(result.attestorLogs)
155
155
  : tailLogs(result.attestorLogs)
156
- if((out.attestorLogs as unknown[]).length === 0) {
157
- delete out.attestorLogs
156
+ if(logs.length > 0) {
157
+ out.attestorLogs = logs
158
158
  }
159
159
  }
160
160
 
@@ -1,134 +1,264 @@
1
1
  import type { ReclaimClient } from '@reclaimprotocol/client'
2
- import type { CredentialAttachSession } from '@reclaimprotocol/client/openapi'
2
+ import assert from 'node:assert'
3
+ import { spawn } from 'node:child_process'
4
+ import { createHash, randomBytes } from 'node:crypto'
5
+ import { createServer } from 'node:http'
3
6
  import { defineTool, type RegisteredTool } from '../../server.ts'
4
7
  import { writeSession } from './session-cache.ts'
5
8
 
9
+ const SCOPE = 'openid profile email'
10
+ const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
11
+
6
12
  export function authenticateTool(client: ReclaimClient): RegisteredTool {
7
- return defineTool<{
8
- code?: string
9
- }>(
13
+ return defineTool<Record<string, never>>(
10
14
  {
11
15
  name: 'reclaim_authenticate',
12
16
  description:
13
- 'Authorize the CLI / MCP to make authenticated API calls by having ' +
14
- 'the user sign in on the dashboard. Two steps:\n\n' +
15
- 'Step 1 — call with no arguments. Returns a `code` and `attachUrl`. ' +
16
- 'Tell the user to open the URL, sign in, and authorize.\n\n' +
17
- 'Step 2 — once the user says they have authorized, call again ' +
18
- 'with the same `code` from step 1 to check if confirmed. ' +
19
- 'Returns `LINKED` with a 2-day bearer token when confirmed, ' +
20
- '`PENDING` if not yet, or `EXPIRED` if too long has passed.\n\n' +
21
- 'The token is cached in ~/.reclaim/config.json automatically.\n\n' +
22
- 'This bearer token authenticates dashboard/org-management calls ' +
23
- '(e.g. list_orgs, issuing an org secret). Programmatic verification ' +
24
- 'calls use a separate org secret (rorg_…, set as RECLAIM_ORG_SECRET).',
25
- inputSchema: {
26
- type: 'object',
27
- properties: {
28
- code: {
29
- type: 'string',
30
- pattern: '^[A-HJ-NP-Z2-9]{5}$',
31
- description:
32
- '5-character code from a previous step-1 call. ' +
33
- 'When provided, checks whether the user has ' +
34
- 'confirmed that session instead of opening a new one.',
35
- },
36
- },
37
- },
38
- },
39
- async(args) => {
40
- // Step 2: check an existing session
41
- if(args.code) {
42
- return checkSession(client, args.code)
43
- }
44
-
45
- // Step 1: open a new auth-only session
46
- const sessionResult = (await client.call('CreateAttachSession'))
47
- const session = sessionResult.data
48
-
49
- return {
50
- status: 'PENDING',
51
- code: session.code,
52
- attachUrl: session.attachUrl,
53
- expiresAt: session.expiresAt,
54
- instructions:
55
- `Open ${session.attachUrl} in your browser, sign in, and click ` +
56
- '"Authorize this device". ' +
57
- 'Once done, call authenticate again with code: ' +
58
- `"${session.code}" to confirm.`,
59
- }
17
+ 'Authorize the CLI / MCP to make authenticated API calls. Opens the ' +
18
+ 'browser to the builder sign-in page (standard OAuth 2.0 ' +
19
+ 'authorization-code + PKCE with a localhost redirect) and waits for ' +
20
+ 'the user to sign in and approve. On success the access token is ' +
21
+ 'cached in ~/.reclaim/config.json and activated for this session.\n\n' +
22
+ 'Call with no arguments. This bearer token authenticates ' +
23
+ 'dashboard/org-management calls (e.g. list_orgs, issuing an org ' +
24
+ 'secret). Programmatic verification calls use a separate org secret ' +
25
+ '(rorg_…, set as RECLAIM_ORG_SECRET).',
26
+ inputSchema: { type: 'object', properties: {} },
60
27
  },
28
+ async() => runOAuthLoopback(client),
61
29
  )
62
30
  }
63
31
 
64
- async function checkSession(
65
- client: ReclaimClient,
66
- code: string,
67
- ): Promise<unknown> {
68
- let session: CredentialAttachSession
32
+ async function runOAuthLoopback(client: ReclaimClient): Promise<unknown> {
33
+ const authBase = `${client.baseUrl}/auth`
34
+ const verifier = base64url(randomBytes(32))
35
+ const challenge = base64url(createHash('sha256').update(verifier).digest())
36
+ const state = base64url(randomBytes(16))
37
+
38
+ // Bind the callback port first: the client is registered for this exact
39
+ // loopback redirect, and the same client_id has to be presented again at the
40
+ // token endpoint (public clients authenticate by client_id alone).
41
+ const callback = await listenForCallback(state)
42
+ let token: { access_token: string, expires_in: number }
69
43
  try {
70
- const sessionResult = (await client.call('GetAttachSession', {
71
- params: { code }
72
- }))
73
- session = sessionResult.data
74
- } catch(err) {
75
- const status = (err as { status?: number }).status
76
- if(status === 404) {
77
- return {
78
- status: 'NOT_FOUND',
79
- hint:
80
- 'Session not found — it may have expired (15-min window). ' +
81
- 'Call authenticate without a code to start a new session.',
82
- }
83
- }
44
+ const clientId = await registerClient(authBase, callback.redirectUri)
45
+ openBrowser(
46
+ authorizeUrl(authBase, {
47
+ clientId,
48
+ redirectUri: callback.redirectUri,
49
+ challenge,
50
+ state,
51
+ }),
52
+ )
53
+ const code = await callback.code
54
+ token = await exchangeCode(authBase, {
55
+ code,
56
+ verifier,
57
+ redirectUri: callback.redirectUri,
58
+ clientId,
59
+ })
60
+ } finally {
61
+ callback.close()
62
+ }
84
63
 
85
- throw new Error(`Failed to check session: ${(err as Error).message}`)
64
+ const expiresAt = new Date(
65
+ Date.now() + token.expires_in * 1000,
66
+ ).toISOString()
67
+ writeSession({ token: token.access_token, expiresAt })
68
+ client.setToken(token.access_token)
69
+
70
+ return {
71
+ status: 'AUTHENTICATED',
72
+ note: 'Access token cached in ~/.reclaim/config.json and active now.',
73
+ expiresAt,
86
74
  }
75
+ }
87
76
 
88
- if(session.status === 'LINKED') {
89
- const token = session.token?.trim()
90
- if(token) {
91
- writeSession({
92
- token,
93
- expiresAt: new Date(
94
- Date.now() + 2 * 24 * 60 * 60 * 1000
95
- ).toISOString(),
96
- })
97
- client.setToken(token)
98
- return {
99
- status: 'LINKED',
100
- token,
101
- note: 'Token cached in ~/.reclaim/config.json and active now.',
102
- }
77
+ /**
78
+ * Bind an ephemeral loopback port and start listening for the provider's
79
+ * redirect. Resolves as soon as the port is known — the `code` promise settles
80
+ * later, when the browser hits `/callback` — so the caller can register a
81
+ * client for this exact redirect URI before sending the user off to authorize.
82
+ * The ephemeral port lets any number of CLIs run concurrently. Defined below
83
+ * its caller per repo convention.
84
+ */
85
+ async function listenForCallback(state: string): Promise<{
86
+ redirectUri: string
87
+ code: Promise<string>
88
+ close: () => void
89
+ }> {
90
+ let resolveCode: (code: string) => void
91
+ let rejectCode: (err: Error) => void
92
+ const code = new Promise<string>((resolve, reject) => {
93
+ resolveCode = resolve
94
+ rejectCode = reject
95
+ })
96
+
97
+ const server = createServer()
98
+ const timer = setTimeout(
99
+ () => rejectCode(new Error('Timed out waiting for sign-in (5 min).')),
100
+ CALLBACK_TIMEOUT_MS,
101
+ )
102
+
103
+ server.on('request', (req, res) => {
104
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
105
+ if(url.pathname !== '/callback') {
106
+ res.writeHead(404).end()
107
+ return
103
108
  }
104
109
 
105
- return {
106
- status: 'LINKED_NO_TOKEN',
107
- hint:
108
- 'Session confirmed but no token issued. ' +
109
- 'Restart the backend (npm run dev) and try again.',
110
+ res.writeHead(200, { 'content-type': 'text/html' })
111
+ res.end(
112
+ '<!doctype html><meta charset=utf-8><body style="font:16px '
113
+ + 'system-ui;padding:3rem;text-align:center">Signed in. You can '
114
+ + 'close this tab and return to your terminal.</body>',
115
+ )
116
+
117
+ const authCode = url.searchParams.get('code')
118
+ if(!authCode || url.searchParams.get('state') !== state) {
119
+ // A denied or failed authorization comes back as `error`/
120
+ // `error_description` (RFC 6749 §4.1.2.1) — surface it verbatim.
121
+ const err = url.searchParams.get('error_description')
122
+ ?? url.searchParams.get('error')
123
+ rejectCode(new Error(err ?? 'Sign-in failed or was cancelled.'))
124
+ return
110
125
  }
126
+
127
+ resolveCode(authCode)
128
+ })
129
+
130
+ const port = await new Promise<number>((resolve, reject) => {
131
+ server.once('error', reject)
132
+ server.listen(0, '127.0.0.1', () => {
133
+ const addr = server.address()
134
+ if(!addr || typeof addr === 'string') {
135
+ reject(new Error('Could not bind a local callback port.'))
136
+ return
137
+ }
138
+
139
+ resolve(addr.port)
140
+ })
141
+ })
142
+ // Past bind, a socket error fails the pending wait rather than throwing.
143
+ server.on('error', (err) => rejectCode(err))
144
+
145
+ return {
146
+ redirectUri: `http://127.0.0.1:${port}/callback`,
147
+ code,
148
+ close: () => {
149
+ clearTimeout(timer)
150
+ server.close()
151
+ },
111
152
  }
153
+ }
112
154
 
113
- if(session.status === 'EXPIRED') {
114
- return {
115
- status: 'EXPIRED',
116
- hint:
117
- 'Session expired (15-min window passed). ' +
118
- 'Call authenticate without a code to start a new session.',
119
- }
155
+ function authorizeUrl(
156
+ authBase: string,
157
+ opts: {
158
+ clientId: string
159
+ redirectUri: string
160
+ challenge: string
161
+ state: string
162
+ },
163
+ ): string {
164
+ return `${authBase}/oauth2/authorize?`
165
+ + new URLSearchParams({
166
+ response_type: 'code',
167
+ client_id: opts.clientId,
168
+ redirect_uri: opts.redirectUri,
169
+ scope: SCOPE,
170
+ state: opts.state,
171
+ code_challenge: opts.challenge,
172
+ code_challenge_method: 'S256',
173
+ }).toString()
174
+ }
175
+
176
+ /** Dynamic client registration (RFC 7591) for a public PKCE client. */
177
+ async function registerClient(
178
+ authBase: string,
179
+ redirectUri: string,
180
+ ): Promise<string> {
181
+ const res = await fetch(`${authBase}/oauth2/register`, {
182
+ method: 'POST',
183
+ headers: { 'content-type': 'application/json' },
184
+ body: JSON.stringify({
185
+ client_name: 'Reclaim CLI',
186
+ redirect_uris: [redirectUri],
187
+ token_endpoint_auth_method: 'none',
188
+ grant_types: ['authorization_code', 'refresh_token'],
189
+ response_types: ['code'],
190
+ scope: SCOPE,
191
+ }),
192
+ })
193
+ assert(res.ok, new Error(`Client registration failed (${res.status}).`))
194
+
195
+ const json = (await res.json()) as { client_id?: string }
196
+ assert(
197
+ json.client_id,
198
+ new Error('Client registration returned no client_id.'),
199
+ )
200
+
201
+ return json.client_id
202
+ }
203
+
204
+ async function exchangeCode(
205
+ authBase: string,
206
+ opts: {
207
+ code: string
208
+ verifier: string
209
+ redirectUri: string
210
+ clientId: string
211
+ },
212
+ ): Promise<{ access_token: string, expires_in: number }> {
213
+ const res = await fetch(`${authBase}/oauth2/token`, {
214
+ method: 'POST',
215
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
216
+ body: new URLSearchParams({
217
+ grant_type: 'authorization_code',
218
+ code: opts.code,
219
+ redirect_uri: opts.redirectUri,
220
+ code_verifier: opts.verifier,
221
+ // Required even though this is a public client with no secret: it's
222
+ // how the token endpoint identifies the client (RFC 6749 §4.1.3).
223
+ client_id: opts.clientId,
224
+ }).toString(),
225
+ })
226
+ assert(
227
+ res.ok,
228
+ new Error(`Token exchange failed (${res.status}): ${await res.text()}`),
229
+ )
230
+
231
+ const json = (await res.json()) as {
232
+ access_token?: string
233
+ expires_in?: number
120
234
  }
235
+ assert(
236
+ json.access_token,
237
+ new Error('Token exchange returned no access_token.'),
238
+ )
121
239
 
122
- // Still PENDING
123
240
  return {
124
- status: 'PENDING',
125
- code: session.code,
126
- attachUrl: session.attachUrl,
127
- hint:
128
- 'Session is still waiting. ' +
129
- `Make sure you opened ${session.attachUrl}, ` +
130
- 'signed in, and clicked "Authorize this device". ' +
131
- `Call authenticate again with code: "${session.code}"` +
132
- ' once you have done so.',
241
+ access_token: json.access_token,
242
+ expires_in: json.expires_in ?? 3600,
243
+ }
244
+ }
245
+
246
+ function base64url(buf: Buffer): string {
247
+ return buf.toString('base64url')
248
+ }
249
+
250
+ function openBrowser(url: string) {
251
+ const cmd = process.platform === 'darwin'
252
+ ? 'open'
253
+ : process.platform === 'win32'
254
+ ? 'cmd'
255
+ : 'xdg-open'
256
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]
257
+ try {
258
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref()
259
+ } catch{
260
+ // Non-fatal: the URL is still logged for the user to open manually.
133
261
  }
262
+
263
+ process.stderr.write(`\nOpen this URL to sign in:\n${url}\n\n`)
134
264
  }
@@ -15,6 +15,7 @@
15
15
  * (the eth decrypt key).
16
16
  */
17
17
  import { deriveFromPrivateKey } from '@reclaimprotocol/client'
18
+ import assert from 'node:assert'
18
19
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
19
20
  import { isAbsolute, join } from 'node:path'
20
21
  import { privateKey, privateKeyFile } from '../../../consts.ts'
@@ -40,11 +41,12 @@ export type ResolvedEthKey = {
40
41
  /** Validate + normalize a raw Ethereum private key to lowercase 0x-hex. */
41
42
  export function normalizeEthHex(value: string): string {
42
43
  const v = value.trim().replace(/^['"]|['"]$/g, '')
43
- if(!/^(0x)?[0-9a-fA-F]{64}$/.test(v)) {
44
- throw new Error(
44
+ assert(
45
+ /^(0x)?[0-9a-fA-F]{64}$/.test(v),
46
+ new Error(
45
47
  'not a valid Ethereum private key (expected a 32-byte / 64-hex scalar)',
46
- )
47
- }
48
+ ),
49
+ )
48
50
 
49
51
  return '0x' + v.replace(/^0x/, '').toLowerCase()
50
52
  }
@@ -122,7 +124,7 @@ export function resolveEthKey(
122
124
  }
123
125
 
124
126
  /** Append `entry` to the project `.gitignore` if not already present. */
125
- function ensureGitignored(projectDir: string, entry: string): void {
127
+ function ensureGitignored(projectDir: string, entry: string) {
126
128
  const path = join(projectDir, '.gitignore')
127
129
  const current = existsSync(path) ? readFileSync(path, 'utf8') : ''
128
130
  if(current.split('\n').some((l) => l.trim() === entry)) {
@@ -3,4 +3,5 @@
3
3
  // individual tool files.
4
4
  export * from './agent/index.ts'
5
5
  export * from './authenticate/index.ts'
6
+ export * from './mode/index.ts'
6
7
  export * from './verification/index.ts'
@@ -0,0 +1,3 @@
1
+ // Barrel for the mode tool group (backend mode toggle). Import from here, not
2
+ // the individual files.
3
+ export { buildModeTools } from './mode.ts'
@@ -0,0 +1,58 @@
1
+ import type { ConfigStore, DevtoolsMode } from '../../../config/store.ts'
2
+ import { defaultConfigPath } from '../../../config/store.ts'
3
+ import { defineTool, type RegisteredTool } from '../../server.ts'
4
+
5
+ /**
6
+ * The `set_devtools_mode` tool: persist which backend the MCP server targets
7
+ * (legacy `old` devtools vs the new `builder`). The choice is written to
8
+ * `~/.reclaim/config.json` and applied the NEXT time the server starts —
9
+ * the registered tool set, server instructions, and backend client are all
10
+ * fixed at connect time, so the switch requires a restart/reconnect. Follows
11
+ * the `attach_chrome` persist-through-config pattern.
12
+ *
13
+ * Registered in BOTH modes (so the user can switch either direction) and
14
+ * outside `buildAgentTools` (so it survives `RECLAIM_AGENT_DISABLED=1`). An
15
+ * explicit `USE_OLD_DEVTOOLS` env var still overrides the persisted value —
16
+ * see `resolveOldMode` in consts.ts.
17
+ */
18
+ export function buildModeTools(
19
+ config: ConfigStore,
20
+ currentOldMode: boolean,
21
+ ): RegisteredTool[] {
22
+ const current: DevtoolsMode = currentOldMode ? 'old' : 'builder'
23
+ return [
24
+ defineTool<{ mode: DevtoolsMode }>(
25
+ {
26
+ name: 'set_devtools_mode',
27
+ description:
28
+ 'Choose which backend the MCP server targets: "old" (legacy '
29
+ + 'Reclaim devtools) or "builder" (the new builder backend). '
30
+ + 'Persists the choice to ~/.reclaim/config.json; it takes effect '
31
+ + 'the next time the MCP server starts, so restart/reconnect after '
32
+ + 'switching. An explicit USE_OLD_DEVTOOLS env var overrides this.',
33
+ inputSchema: {
34
+ type: 'object',
35
+ properties: {
36
+ mode: { type: 'string', enum: ['old', 'builder'] },
37
+ },
38
+ required: ['mode'],
39
+ },
40
+ },
41
+ async(parsed) => {
42
+ config.write({ mode: parsed.mode })
43
+ const restartRequired = parsed.mode !== current
44
+ return {
45
+ saved: true,
46
+ mode: parsed.mode,
47
+ effectiveNow: current,
48
+ restartRequired,
49
+ configPath: defaultConfigPath(),
50
+ message: restartRequired
51
+ ? 'Saved. Restart / reconnect the MCP server for '
52
+ + `"${parsed.mode}" mode to take effect.`
53
+ : `Already running in "${parsed.mode}" mode; nothing to restart.`,
54
+ }
55
+ },
56
+ ),
57
+ ]
58
+ }
@@ -26,7 +26,7 @@ let installed = false
26
26
  /** Wrap attestor-core's http `getResponseRedactions` to dump the response it
27
27
  * runs against. Idempotent; no-op unless RECLAIM_AGENT_DUMP_RESPONSE is
28
28
  * set. */
29
- export function installResponseDump(): void {
29
+ export function installResponseDump() {
30
30
  if(installed || !process.env['RECLAIM_AGENT_DUMP_RESPONSE']) {
31
31
  return
32
32
  }
@@ -20,7 +20,7 @@ let cryptoInitialized = false
20
20
  * caller; without this the first proof dies inside the TLS client with
21
21
  * `crypto.randomBytes is not a function`. Shared by every attestor caller —
22
22
  * the agent MCP `run_proof` tool and the app's verification runner. */
23
- export function ensureAttestorCrypto(): void {
23
+ export function ensureAttestorCrypto() {
24
24
  if(cryptoInitialized) {
25
25
  return
26
26
  }
@@ -27,7 +27,7 @@ let inFlight: Promise<void> | undefined
27
27
  * All output goes to stderr (fd 2): the MCP server speaks JSON-RPC over stdout,
28
28
  * so a stray write to fd 1 mid-session would corrupt the protocol.
29
29
  */
30
- export function ensureZkFiles(): Promise<void> {
30
+ export function ensureZkFiles() {
31
31
  inFlight ??= download().catch((err) => {
32
32
  inFlight = undefined
33
33
  throw err
@@ -35,7 +35,7 @@ export function ensureZkFiles(): Promise<void> {
35
35
  return inFlight
36
36
  }
37
37
 
38
- async function download(): Promise<void> {
38
+ async function download() {
39
39
  let mainPath: string
40
40
  try {
41
41
  mainPath = require.resolve('@reclaimprotocol/zk-symmetric-crypto')
@@ -190,7 +190,7 @@ function findElement(root: Node, targets: string[]): Element | undefined {
190
190
  const complete: Element[] = []
191
191
  const embedded: Element[] = []
192
192
 
193
- const walk = (node: Node): void => {
193
+ const walk = (node: Node) => {
194
194
  if(isTag(node)) {
195
195
  const el = node
196
196
  const texts = el.children.filter(isText).map((c) => c.data)
@@ -160,7 +160,7 @@ export function assertPortableMatches(
160
160
  matches: ResponseMatch[],
161
161
  redactions: ResponseRedaction[],
162
162
  targets: DraftTarget[],
163
- ): void {
163
+ ) {
164
164
  // A baked email LOCAL-PART ("jane.doe@…") pins the provider to one user
165
165
  // even when only the domain is captured as a param — the classic result of
166
166
  // proving an email's domain substring. Reject it regardless of the declared
@@ -172,7 +172,7 @@ export function assertPortableMatches(
172
172
  .replace(/\{\{[^}]*\}\}/g, '')
173
173
  .replace(/\(\?<[^>]*>[^)]*\)/g, '')
174
174
  .replace(/\\(.)/g, '$1')
175
- const scanEmail = (text: string, where: string): void => {
175
+ const scanEmail = (text: string, where: string) => {
176
176
  if(EMAIL_LOCAL_BAKED.test(deregex(text))) {
177
177
  throw new Error(
178
178
  `drafted ${where} bakes a per-user email local-part as a literal — `
@@ -203,7 +203,7 @@ export function assertPortableMatches(
203
203
  }
204
204
 
205
205
  const stripParams = (s: string): string => s.replace(/\{\{[^}]*\}\}/g, '')
206
- const scan = (text: string, where: string, own: string | null): void => {
206
+ const scan = (text: string, where: string, own: string | null) => {
207
207
  const bare = stripParams(text)
208
208
  for(const v of guarded) {
209
209
  if(bare.includes(v)) {