@reclaimprotocol/agent 0.1.15 → 0.1.18

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 (64) 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 +15 -0
  6. package/lib/consts.js +35 -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/agent/publish.js +33 -0
  14. package/lib/mcp/tools/authenticate/authenticate.js +178 -93
  15. package/lib/mcp/tools/authenticate/eth-key.js +2 -3
  16. package/lib/mcp/tools/index.d.ts +1 -0
  17. package/lib/mcp/tools/index.js +1 -0
  18. package/lib/mcp/tools/mode/index.d.ts +1 -0
  19. package/lib/mcp/tools/mode/index.js +3 -0
  20. package/lib/mcp/tools/mode/mode.d.ts +17 -0
  21. package/lib/mcp/tools/mode/mode.js +69 -0
  22. package/lib/old/to-register.d.ts +16 -8
  23. package/lib/old/to-register.js +29 -21
  24. package/lib/old/tools/publish.js +86 -8
  25. package/lib/provider/injection-doc.js +4 -3
  26. package/lib/provider/schema.d.ts +26 -1
  27. package/lib/provider/to-version.d.ts +19 -2
  28. package/lib/provider/to-version.js +22 -4
  29. package/lib/tools/registry.d.ts +6 -13
  30. package/lib/tools/registry.js +29 -0
  31. package/package.json +5 -8
  32. package/scripts/prepend-shebang.js +1 -1
  33. package/skill/builder-mode.md +2 -0
  34. package/skill/old-mode.md +3 -1
  35. package/src/authoring/index.ts +1 -0
  36. package/src/authoring/util.ts +3 -0
  37. package/src/cdp/launcher.ts +1 -1
  38. package/src/config/store.ts +8 -0
  39. package/src/consts.ts +44 -0
  40. package/src/index.ts +1 -1
  41. package/src/mcp/api/operations.gen.ts +430 -487
  42. package/src/mcp/remap_errors.ts +8 -15
  43. package/src/mcp/start.ts +11 -2
  44. package/src/mcp/tools/agent/proof.ts +3 -3
  45. package/src/mcp/tools/agent/publish.ts +42 -0
  46. package/src/mcp/tools/authenticate/authenticate.ts +238 -108
  47. package/src/mcp/tools/authenticate/eth-key.ts +7 -5
  48. package/src/mcp/tools/index.ts +1 -0
  49. package/src/mcp/tools/mode/index.ts +3 -0
  50. package/src/mcp/tools/mode/mode.ts +82 -0
  51. package/src/old/to-register.ts +50 -27
  52. package/src/old/tools/publish.ts +108 -8
  53. package/src/proof/debug-response-dump.ts +1 -1
  54. package/src/proof/proof-core.ts +1 -1
  55. package/src/proof/zk-files.ts +2 -2
  56. package/src/provider/dom-xpath.ts +1 -1
  57. package/src/provider/draft.ts +3 -3
  58. package/src/provider/injection-doc.ts +4 -3
  59. package/src/provider/schema.ts +27 -1
  60. package/src/provider/to-version.ts +45 -6
  61. package/src/tools/registry.ts +19 -0
  62. package/src/cli/args.ts +0 -20
  63. package/src/cli/commands.ts +0 -27
  64. package/src/cli.ts +0 -7
@@ -1,27 +1,20 @@
1
1
  import { isBoom } from '@hapi/boom';
2
- // Remap all errors here if we return different response based on error.
3
- // Doing this so we can reuse tool error handler that returns same response
4
- // across different tools or error.
5
- // For every other error, let them get thrown so the main try-catch
6
- // handles them in tool registration.
2
+ import { errorAsToolText } from "../tools/registry.js";
3
+ // One error shape for every MCP tool. The text comes from errorAsToolText
4
+ // (shared with the cloud loop); Boom problems also attach structuredContent.
7
5
  export const remapErrorAsResponse = (err) => {
6
+ const text = errorAsToolText(err);
8
7
  // The client throws a Boom-style ProblemError, so the status lives on
9
8
  // `output.statusCode` / `problem.status`, not `status`.
10
9
  if (!isBoom(err) || !err.output?.statusCode) {
11
10
  return {
12
11
  isError: true,
13
- content: [{ type: 'text', text: err.message }],
12
+ content: [{ type: 'text', text }],
14
13
  };
15
14
  }
16
- const problemError = err;
17
15
  return {
18
16
  isError: true,
19
- structuredContent: problemError.data,
20
- content: [
21
- {
22
- type: 'text',
23
- text: `${problemError.message}: ${problemError.data?.detail}`
24
- }
25
- ],
17
+ structuredContent: err.data,
18
+ content: [{ type: 'text', text }],
26
19
  };
27
20
  };
package/lib/mcp/start.js CHANGED
@@ -1,7 +1,8 @@
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 { agentDisabled, apiToken, apiUrl, oldApiUrl, oldLogsUrl, sdkApiUrl, useOldDevtools, } from "../consts.js";
4
+ import { ConfigStore } from "../config/store.js";
5
+ import { agentDisabled, apiToken, apiUrl, oldApiUrl, oldLogsUrl, resolveOldMode, sdkApiUrl, } from "../consts.js";
5
6
  import { ReclaimOldClient } from "../old/client.js";
6
7
  import { resolveInitialIdentity } from "../old/identity.js";
7
8
  import { OLD_MODE_INSTRUCTIONS } from "../old/instructions.js";
@@ -9,11 +10,14 @@ import { ReclaimSdkClient } from "../old/sdk-client.js";
9
10
  import { buildOldBackendTools } from "../old/tools/build.js";
10
11
  import { NAME, VERSION } from "../pkg.js";
11
12
  import { registerTools } from "./api/tools.js";
12
- import { buildAgentTools, buildCredentialTools, buildLocalCredentialTools, buildVerificationTools, readSession, } from "./tools/index.js";
13
+ import { buildAgentTools, buildCredentialTools, buildLocalCredentialTools, buildModeTools, buildVerificationTools, readSession, } from "./tools/index.js";
13
14
  import { BUILDER_MODE_INSTRUCTIONS } from "./instructions.js";
14
15
  import { registerToolList } from "./server.js";
15
16
  export async function startMcpServer() {
16
- const oldMode = useOldDevtools();
17
+ // Mode precedence: explicit USE_OLD_DEVTOOLS env → persisted config → old.
18
+ // The same store instance backs the set_devtools_mode toggle below.
19
+ const config = new ConfigStore();
20
+ const oldMode = resolveOldMode(config.read()?.mode);
17
21
  const server = new McpServer({ name: NAME, version: VERSION }, {
18
22
  instructions: oldMode
19
23
  ? OLD_MODE_INSTRUCTIONS
@@ -24,6 +28,9 @@ export async function startMcpServer() {
24
28
  // deployments — set RECLAIM_AGENT_DISABLED=1 to register only the API
25
29
  // surface.
26
30
  const agentToolsEnabled = !agentDisabled();
31
+ // Backend-mode toggle — available in both modes and regardless of whether
32
+ // the agent tools are enabled, so a user can always switch either direction.
33
+ registerToolList(server, buildModeTools(config, oldMode));
27
34
  if (oldMode) {
28
35
  // OLD-devtools mode (the default): the legacy backend is still in
29
36
  // production while builder is built. All auto-generated builder tools
@@ -118,11 +118,11 @@ export function shapeRichProofResult(result, opts) {
118
118
  }
119
119
  // Trim the LLM-visible logs — full stream goes to runLogPath.
120
120
  if (result.attestorLogs !== undefined && result.attestorLogs.length > 0) {
121
- out.attestorLogs = result.verified
121
+ const logs = result.verified
122
122
  ? warnsAndErrors(result.attestorLogs)
123
123
  : tailLogs(result.attestorLogs);
124
- if (out.attestorLogs.length === 0) {
125
- delete out.attestorLogs;
124
+ if (logs.length > 0) {
125
+ out.attestorLogs = logs;
126
126
  }
127
127
  }
128
128
  if (result.sentParams !== undefined) {
@@ -13,6 +13,11 @@ const RESPONSE_MATCHES_SCHEMA = {
13
13
  properties: {
14
14
  type: { type: 'string', enum: ['regex', 'contains'] },
15
15
  value: { type: 'string' },
16
+ isOptional: {
17
+ type: 'boolean',
18
+ description: 'Mark this match non-fatal (verification continues when it ' +
19
+ 'fails). Default false.',
20
+ },
16
21
  },
17
22
  required: ['type', 'value'],
18
23
  },
@@ -44,6 +49,12 @@ const REQUEST_DRAFT_PROPERTIES = {
44
49
  },
45
50
  method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH'] },
46
51
  body: { type: 'string', description: 'Request body template, if any.' },
52
+ credentials: {
53
+ type: 'string',
54
+ enum: ['omit', 'same-origin', 'include'],
55
+ description: 'How the verification client supplies cookies/auth when replaying ' +
56
+ 'this request. Default `include`.',
57
+ },
47
58
  responseMatches: RESPONSE_MATCHES_SCHEMA,
48
59
  responseRedactions: RESPONSE_REDACTIONS_SCHEMA,
49
60
  };
@@ -173,6 +184,27 @@ export function createVersionFromCaptureTool(client) {
173
184
  '`multiple`/`required` default to true.',
174
185
  items: ALLOWED_JS_REQUEST_SCHEMA,
175
186
  },
187
+ interceptorOptions: {
188
+ type: 'object',
189
+ description: 'In-app interception settings, stored as ' +
190
+ 'webSettings.clientOptions.inapp.interceptorOptions. ' +
191
+ '`isDocumentRequestReplayEnabled` is the INVERSE of ' +
192
+ 'old-devtools\' `disableRequestReplay`: true replays the ' +
193
+ 'main document request via the attestor (default false).',
194
+ properties: {
195
+ interceptorType: {
196
+ type: 'string',
197
+ enum: ['HAWKEYE', 'MSWJS', 'CDP'],
198
+ description: 'How traffic interception is implemented.',
199
+ },
200
+ isDocumentRequestReplayEnabled: { type: 'boolean' },
201
+ interceptorSettings: {
202
+ type: 'string',
203
+ description: 'Free-form JSON config for the chosen interceptor.',
204
+ },
205
+ },
206
+ required: ['interceptorType'],
207
+ },
176
208
  },
177
209
  required: ['providerId', 'initialUrl'],
178
210
  },
@@ -192,6 +224,7 @@ export function createVersionFromCaptureTool(client) {
192
224
  version: ver,
193
225
  jsUserScripts: args.jsUserScripts,
194
226
  allowedJsRequests: args.allowedJsRequests,
227
+ interceptorOptions: args.interceptorOptions,
195
228
  });
196
229
  const result = await client.call('CreateProviderVersion', {
197
230
  params: { providerId: args.providerId },
@@ -1,107 +1,192 @@
1
+ import assert from 'node:assert';
2
+ import { spawn } from 'node:child_process';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import { createServer } from 'node:http';
1
5
  import { defineTool } from "../../server.js";
2
6
  import { writeSession } from "./session-cache.js";
7
+ const SCOPE = 'openid profile email';
8
+ const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;
3
9
  export function authenticateTool(client) {
4
10
  return defineTool({
5
11
  name: 'reclaim_authenticate',
6
- description: 'Authorize the CLI / MCP to make authenticated API calls by having ' +
7
- 'the user sign in on the dashboard. Two steps:\n\n' +
8
- 'Step 1 — call with no arguments. Returns a `code` and `attachUrl`. ' +
9
- 'Tell the user to open the URL, sign in, and authorize.\n\n' +
10
- 'Step 2 — once the user says they have authorized, call again ' +
11
- 'with the same `code` from step 1 to check if confirmed. ' +
12
- 'Returns `LINKED` with a 2-day bearer token when confirmed, ' +
13
- '`PENDING` if not yet, or `EXPIRED` if too long has passed.\n\n' +
14
- 'The token is cached in ~/.reclaim/config.json automatically.\n\n' +
15
- 'This bearer token authenticates dashboard/org-management calls ' +
16
- '(e.g. list_orgs, issuing an org secret). Programmatic verification ' +
17
- 'calls use a separate org secret (rorg_…, set as RECLAIM_ORG_SECRET).',
18
- inputSchema: {
19
- type: 'object',
20
- properties: {
21
- code: {
22
- type: 'string',
23
- pattern: '^[A-HJ-NP-Z2-9]{5}$',
24
- description: '5-character code from a previous step-1 call. ' +
25
- 'When provided, checks whether the user has ' +
26
- 'confirmed that session instead of opening a new one.',
27
- },
28
- },
29
- },
30
- }, async (args) => {
31
- // Step 2: check an existing session
32
- if (args.code) {
33
- return checkSession(client, args.code);
34
- }
35
- // Step 1: open a new auth-only session
36
- const sessionResult = (await client.call('CreateAttachSession'));
37
- const session = sessionResult.data;
38
- return {
39
- status: 'PENDING',
40
- code: session.code,
41
- attachUrl: session.attachUrl,
42
- expiresAt: session.expiresAt,
43
- instructions: `Open ${session.attachUrl} in your browser, sign in, and click ` +
44
- '"Authorize this device". ' +
45
- 'Once done, call authenticate again with code: ' +
46
- `"${session.code}" to confirm.`,
47
- };
48
- });
12
+ description: 'Authorize the CLI / MCP to make authenticated API calls. Opens the ' +
13
+ 'browser to the builder sign-in page (standard OAuth 2.0 ' +
14
+ 'authorization-code + PKCE with a localhost redirect) and waits for ' +
15
+ 'the user to sign in and approve. On success the access token is ' +
16
+ 'cached in ~/.reclaim/config.json and activated for this session.\n\n' +
17
+ 'Call with no arguments. This bearer token authenticates ' +
18
+ 'dashboard/org-management calls (e.g. list_orgs, issuing an org ' +
19
+ 'secret). Programmatic verification calls use a separate org secret ' +
20
+ '(rorg_…, set as RECLAIM_ORG_SECRET).',
21
+ inputSchema: { type: 'object', properties: {} },
22
+ }, async () => runOAuthLoopback(client));
49
23
  }
50
- async function checkSession(client, code) {
51
- let session;
24
+ async function runOAuthLoopback(client) {
25
+ const authBase = `${client.baseUrl}/auth`;
26
+ const verifier = base64url(randomBytes(32));
27
+ const challenge = base64url(createHash('sha256').update(verifier).digest());
28
+ const state = base64url(randomBytes(16));
29
+ // Bind the callback port first: the client is registered for this exact
30
+ // loopback redirect, and the same client_id has to be presented again at the
31
+ // token endpoint (public clients authenticate by client_id alone).
32
+ const callback = await listenForCallback(state);
33
+ let token;
52
34
  try {
53
- const sessionResult = (await client.call('GetAttachSession', {
54
- params: { code }
35
+ const clientId = await registerClient(authBase, callback.redirectUri);
36
+ openBrowser(authorizeUrl(authBase, {
37
+ clientId,
38
+ redirectUri: callback.redirectUri,
39
+ challenge,
40
+ state,
55
41
  }));
56
- session = sessionResult.data;
42
+ const code = await callback.code;
43
+ token = await exchangeCode(authBase, {
44
+ code,
45
+ verifier,
46
+ redirectUri: callback.redirectUri,
47
+ clientId,
48
+ });
57
49
  }
58
- catch (err) {
59
- const status = err.status;
60
- if (status === 404) {
61
- return {
62
- status: 'NOT_FOUND',
63
- hint: 'Session not found — it may have expired (15-min window). ' +
64
- 'Call authenticate without a code to start a new session.',
65
- };
66
- }
67
- throw new Error(`Failed to check session: ${err.message}`);
50
+ finally {
51
+ callback.close();
68
52
  }
69
- if (session.status === 'LINKED') {
70
- const token = session.token?.trim();
71
- if (token) {
72
- writeSession({
73
- token,
74
- expiresAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(),
75
- });
76
- client.setToken(token);
77
- return {
78
- status: 'LINKED',
79
- token,
80
- note: 'Token cached in ~/.reclaim/config.json and active now.',
81
- };
53
+ const expiresAt = new Date(Date.now() + token.expires_in * 1000).toISOString();
54
+ writeSession({ token: token.access_token, expiresAt });
55
+ client.setToken(token.access_token);
56
+ return {
57
+ status: 'AUTHENTICATED',
58
+ note: 'Access token cached in ~/.reclaim/config.json and active now.',
59
+ expiresAt,
60
+ };
61
+ }
62
+ /**
63
+ * Bind an ephemeral loopback port and start listening for the provider's
64
+ * redirect. Resolves as soon as the port is known — the `code` promise settles
65
+ * later, when the browser hits `/callback` — so the caller can register a
66
+ * client for this exact redirect URI before sending the user off to authorize.
67
+ * The ephemeral port lets any number of CLIs run concurrently. Defined below
68
+ * its caller per repo convention.
69
+ */
70
+ async function listenForCallback(state) {
71
+ let resolveCode;
72
+ let rejectCode;
73
+ const code = new Promise((resolve, reject) => {
74
+ resolveCode = resolve;
75
+ rejectCode = reject;
76
+ });
77
+ const server = createServer();
78
+ const timer = setTimeout(() => rejectCode(new Error('Timed out waiting for sign-in (5 min).')), CALLBACK_TIMEOUT_MS);
79
+ server.on('request', (req, res) => {
80
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
81
+ if (url.pathname !== '/callback') {
82
+ res.writeHead(404).end();
83
+ return;
82
84
  }
83
- return {
84
- status: 'LINKED_NO_TOKEN',
85
- hint: 'Session confirmed but no token issued. ' +
86
- 'Restart the backend (npm run dev) and try again.',
87
- };
88
- }
89
- if (session.status === 'EXPIRED') {
90
- return {
91
- status: 'EXPIRED',
92
- hint: 'Session expired (15-min window passed). ' +
93
- 'Call authenticate without a code to start a new session.',
94
- };
95
- }
96
- // Still PENDING
85
+ res.writeHead(200, { 'content-type': 'text/html' });
86
+ res.end('<!doctype html><meta charset=utf-8><body style="font:16px '
87
+ + 'system-ui;padding:3rem;text-align:center">Signed in. You can '
88
+ + 'close this tab and return to your terminal.</body>');
89
+ const authCode = url.searchParams.get('code');
90
+ if (!authCode || url.searchParams.get('state') !== state) {
91
+ // A denied or failed authorization comes back as `error`/
92
+ // `error_description` (RFC 6749 §4.1.2.1) — surface it verbatim.
93
+ const err = url.searchParams.get('error_description')
94
+ ?? url.searchParams.get('error');
95
+ rejectCode(new Error(err ?? 'Sign-in failed or was cancelled.'));
96
+ return;
97
+ }
98
+ resolveCode(authCode);
99
+ });
100
+ const port = await new Promise((resolve, reject) => {
101
+ server.once('error', reject);
102
+ server.listen(0, '127.0.0.1', () => {
103
+ const addr = server.address();
104
+ if (!addr || typeof addr === 'string') {
105
+ reject(new Error('Could not bind a local callback port.'));
106
+ return;
107
+ }
108
+ resolve(addr.port);
109
+ });
110
+ });
111
+ // Past bind, a socket error fails the pending wait rather than throwing.
112
+ server.on('error', (err) => rejectCode(err));
97
113
  return {
98
- status: 'PENDING',
99
- code: session.code,
100
- attachUrl: session.attachUrl,
101
- hint: 'Session is still waiting. ' +
102
- `Make sure you opened ${session.attachUrl}, ` +
103
- 'signed in, and clicked "Authorize this device". ' +
104
- `Call authenticate again with code: "${session.code}"` +
105
- ' once you have done so.',
114
+ redirectUri: `http://127.0.0.1:${port}/callback`,
115
+ code,
116
+ close: () => {
117
+ clearTimeout(timer);
118
+ server.close();
119
+ },
106
120
  };
107
121
  }
122
+ function authorizeUrl(authBase, opts) {
123
+ return `${authBase}/oauth2/authorize?`
124
+ + new URLSearchParams({
125
+ response_type: 'code',
126
+ client_id: opts.clientId,
127
+ redirect_uri: opts.redirectUri,
128
+ scope: SCOPE,
129
+ state: opts.state,
130
+ code_challenge: opts.challenge,
131
+ code_challenge_method: 'S256',
132
+ }).toString();
133
+ }
134
+ /** Dynamic client registration (RFC 7591) for a public PKCE client. */
135
+ async function registerClient(authBase, redirectUri) {
136
+ const res = await fetch(`${authBase}/oauth2/register`, {
137
+ method: 'POST',
138
+ headers: { 'content-type': 'application/json' },
139
+ body: JSON.stringify({
140
+ client_name: 'Reclaim CLI',
141
+ redirect_uris: [redirectUri],
142
+ token_endpoint_auth_method: 'none',
143
+ grant_types: ['authorization_code', 'refresh_token'],
144
+ response_types: ['code'],
145
+ scope: SCOPE,
146
+ }),
147
+ });
148
+ assert(res.ok, new Error(`Client registration failed (${res.status}).`));
149
+ const json = (await res.json());
150
+ assert(json.client_id, new Error('Client registration returned no client_id.'));
151
+ return json.client_id;
152
+ }
153
+ async function exchangeCode(authBase, opts) {
154
+ const res = await fetch(`${authBase}/oauth2/token`, {
155
+ method: 'POST',
156
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
157
+ body: new URLSearchParams({
158
+ grant_type: 'authorization_code',
159
+ code: opts.code,
160
+ redirect_uri: opts.redirectUri,
161
+ code_verifier: opts.verifier,
162
+ // Required even though this is a public client with no secret: it's
163
+ // how the token endpoint identifies the client (RFC 6749 §4.1.3).
164
+ client_id: opts.clientId,
165
+ }).toString(),
166
+ });
167
+ assert(res.ok, new Error(`Token exchange failed (${res.status}): ${await res.text()}`));
168
+ const json = (await res.json());
169
+ assert(json.access_token, new Error('Token exchange returned no access_token.'));
170
+ return {
171
+ access_token: json.access_token,
172
+ expires_in: json.expires_in ?? 3600,
173
+ };
174
+ }
175
+ function base64url(buf) {
176
+ return buf.toString('base64url');
177
+ }
178
+ function openBrowser(url) {
179
+ const cmd = process.platform === 'darwin'
180
+ ? 'open'
181
+ : process.platform === 'win32'
182
+ ? 'cmd'
183
+ : 'xdg-open';
184
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
185
+ try {
186
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
187
+ }
188
+ catch {
189
+ // Non-fatal: the URL is still logged for the user to open manually.
190
+ }
191
+ process.stderr.write(`\nOpen this URL to sign in:\n${url}\n\n`);
192
+ }
@@ -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.js";
@@ -25,9 +26,7 @@ export const ENV_FILE = '.env';
25
26
  /** Validate + normalize a raw Ethereum private key to lowercase 0x-hex. */
26
27
  export function normalizeEthHex(value) {
27
28
  const v = value.trim().replace(/^['"]|['"]$/g, '');
28
- if (!/^(0x)?[0-9a-fA-F]{64}$/.test(v)) {
29
- throw new Error('not a valid Ethereum private key (expected a 32-byte / 64-hex scalar)');
30
- }
29
+ assert(/^(0x)?[0-9a-fA-F]{64}$/.test(v), new Error('not a valid Ethereum private key (expected a 32-byte / 64-hex scalar)'));
31
30
  return '0x' + v.replace(/^0x/, '').toLowerCase();
32
31
  }
33
32
  /** Read `RECLAIM_PRIVATE_KEY` from a dotenv file (simple KEY=VALUE parse). */
@@ -1,3 +1,4 @@
1
1
  export * from './agent/index.ts';
2
2
  export * from './authenticate/index.ts';
3
+ export * from './mode/index.ts';
3
4
  export * from './verification/index.ts';
@@ -3,4 +3,5 @@
3
3
  // individual tool files.
4
4
  export * from "./agent/index.js";
5
5
  export * from "./authenticate/index.js";
6
+ export * from "./mode/index.js";
6
7
  export * from "./verification/index.js";
@@ -0,0 +1 @@
1
+ export { buildModeTools } from './mode.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.js";
@@ -0,0 +1,17 @@
1
+ import type { ConfigStore } from '../../../config/store.ts';
2
+ import { type RegisteredTool } from '../../server.ts';
3
+ /**
4
+ * The `set_devtools_mode` / `get_devtools_mode` tools: persist which backend
5
+ * the MCP server targets (`old` devtools vs the `builder`), and
6
+ * report the mode currently in effect. The choice is written to
7
+ * `~/.reclaim/config.json` and applied the NEXT time the server starts —
8
+ * the registered tool set, server instructions, and backend client are all
9
+ * fixed at connect time, so the switch requires a restart/reconnect. Follows
10
+ * the `attach_chrome` persist-through-config pattern.
11
+ *
12
+ * Registered in BOTH modes (so the user can switch either direction) and
13
+ * outside `buildAgentTools` (so it survives `RECLAIM_AGENT_DISABLED=1`). An
14
+ * explicit `USE_OLD_DEVTOOLS` env var still overrides the persisted value —
15
+ * see `resolveOldMode` in consts.ts.
16
+ */
17
+ export declare function buildModeTools(config: ConfigStore, currentOldMode: boolean): RegisteredTool[];
@@ -0,0 +1,69 @@
1
+ import { defaultConfigPath } from "../../../config/store.js";
2
+ import { oldModeSource } from "../../../consts.js";
3
+ import { defineTool } from "../../server.js";
4
+ /**
5
+ * The `set_devtools_mode` / `get_devtools_mode` tools: persist which backend
6
+ * the MCP server targets (`old` devtools vs the `builder`), and
7
+ * report the mode currently in effect. 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(config, currentOldMode) {
19
+ const current = currentOldMode ? 'old' : 'builder';
20
+ return [
21
+ defineTool({
22
+ name: 'set_devtools_mode',
23
+ description: 'Choose which backend the MCP server targets: "old" (devtools) '
24
+ + 'or "builder" (the builder backend). '
25
+ + 'Persists the choice to ~/.reclaim/config.json; it takes effect '
26
+ + 'the next time the MCP server starts, so restart/reconnect after '
27
+ + 'switching. An explicit USE_OLD_DEVTOOLS env var overrides this.',
28
+ inputSchema: {
29
+ type: 'object',
30
+ properties: {
31
+ mode: { type: 'string', enum: ['old', 'builder'] },
32
+ },
33
+ required: ['mode'],
34
+ },
35
+ }, async (parsed) => {
36
+ config.write({ mode: parsed.mode });
37
+ const restartRequired = parsed.mode !== current;
38
+ return {
39
+ saved: true,
40
+ mode: parsed.mode,
41
+ effectiveNow: current,
42
+ restartRequired,
43
+ configPath: defaultConfigPath(),
44
+ message: restartRequired
45
+ ? 'Saved. Restart / reconnect the MCP server for '
46
+ + `"${parsed.mode}" mode to take effect.`
47
+ : `Already running in "${parsed.mode}" mode; nothing to restart.`,
48
+ };
49
+ }),
50
+ defineTool({
51
+ name: 'get_devtools_mode',
52
+ description: 'Report which backend this MCP server is currently targeting: '
53
+ + '"old" (devtools) or "builder" (the builder '
54
+ + 'backend). Also reports WHY that mode is in effect (source: '
55
+ + 'a USE_OLD_DEVTOOLS env override, the persisted config, or '
56
+ + 'the default) and which mode is persisted for the next '
57
+ + 'start. Use set_devtools_mode to change it.',
58
+ inputSchema: { type: 'object', properties: {} },
59
+ }, async () => {
60
+ const persisted = config.read()?.mode;
61
+ return {
62
+ mode: current,
63
+ source: oldModeSource(persisted),
64
+ persistedMode: persisted ?? null,
65
+ configPath: defaultConfigPath(),
66
+ };
67
+ }),
68
+ ];
69
+ }
@@ -3,15 +3,15 @@ import type { AllowedJsRequest } from '../provider/to-version.ts';
3
3
  import type { RegisterProviderBody, UpdateProviderConfigBody } from './client.ts';
4
4
  /**
5
5
  * Translate a drafted `ReclaimProvider` (from `propose_provider`) into the
6
- * OLD devtools backend shapes. Deliberately a SEPARATE module from
6
+ * devtools backend shapes. Deliberately a SEPARATE module from
7
7
  * `provider/to-version.ts` (the builder translator) — the two backends have
8
8
  * structurally different bodies:
9
9
  *
10
10
  * - builder NESTS each redaction into its match's `extract`;
11
- * - old devtools keeps `responseMatches` and `responseRedactions` as
11
+ * - devtools keeps `responseMatches` and `responseRedactions` as
12
12
  * PARALLEL arrays on each `requestData` entry.
13
13
  *
14
- * Old-devtools limit we enforce here (a clear error beats a confusing
14
+ * Devtools limit we enforce here (a clear error beats a confusing
15
15
  * server-side 400): only `GET` / `POST` methods. OPRF redactions are passed
16
16
  * through unchanged — the backend's `hash` field accepts them.
17
17
  */
@@ -23,7 +23,7 @@ export interface OldProviderOpts {
23
23
  /** Defaults to `true` when omitted. Register only (Provider-level flag). */
24
24
  useProxy?: boolean;
25
25
  providerType?: 'PRIVATE' | 'PUBLIC';
26
- /** Page-injection script — old-devtools' name for the builder's
26
+ /** Page-injection script — the devtools name for the builder's
27
27
  * `webSettings.jsUserScripts`. Top-level on register, nested under
28
28
  * `providerConfig` on config (add-version). The backend rejects a few
29
29
  * deprecated injection APIs (e.g. `window.payloadData`) with a 400. */
@@ -50,6 +50,14 @@ export interface OldProviderOpts {
50
50
  /** Config (add-version) ONLY — the register endpoint never reads this
51
51
  * field from the request body. Free-form JSON. */
52
52
  extensionConfig?: unknown;
53
+ /** Provider-level `disableRequestReplay` (HAWKEYE document replay skip).
54
+ * Config (add-version) ONLY: the register endpoint hardcodes it to false
55
+ * — the publish tool follows a register up with an add-version call to
56
+ * apply it for new providers (see `old/tools/publish.ts`). NOTE the
57
+ * builder backend has NO equivalent; its inverse is
58
+ * `webSettings.clientOptions.inapp.interceptorOptions
59
+ * .isDocumentRequestReplayEnabled`. */
60
+ disableRequestReplay?: boolean;
53
61
  }
54
62
  interface OldResponseMatch {
55
63
  value: string;
@@ -68,11 +76,11 @@ interface OldResponseRedaction {
68
76
  }
69
77
  interface OldRequestData {
70
78
  url: string;
71
- urlType: 'CONSTANT' | 'TEMPLATE';
79
+ urlType: 'REGEX' | 'CONSTANT' | 'TEMPLATE';
72
80
  method: 'GET' | 'POST';
73
81
  responseMatches: OldResponseMatch[];
74
82
  responseRedactions: OldResponseRedaction[];
75
- credentials: 'include';
83
+ credentials: 'omit' | 'same-origin' | 'include';
76
84
  bodySniff?: {
77
85
  enabled: boolean;
78
86
  template: string;
@@ -88,7 +96,7 @@ export interface OldInjectedRequestData extends OldRequestData {
88
96
  }
89
97
  /** Translate fresh `allowedInjectedRequestData` drafts (the author's input
90
98
  * shape, `AllowedJsRequest[]` — same shape the builder translator uses for
91
- * its own `allowedJsRequests`) into the old backend's shape. A
99
+ * its own `allowedJsRequests`) into the devtools backend's shape. A
92
100
  * carried-forward value read back from an existing version is ALREADY in
93
101
  * this shape and should be passed straight through instead — don't run it
94
102
  * through this a second time. */
@@ -101,7 +109,7 @@ export declare function providerToRegisterBody(providers: ReclaimProvider[], opt
101
109
  * an EXISTING provider. `version` must not already exist; `versionInfo` is
102
110
  * required and must be ≥10 chars (enforced by the backend). */
103
111
  export declare function providerToConfigBody(providers: ReclaimProvider[], opts: OldProviderOpts, version: string, versionInfo: string): UpdateProviderConfigBody;
104
- /** Minimal version triplet as the old backend stores it. */
112
+ /** Minimal version triplet as the devtools backend stores it. */
105
113
  export interface SemverTriplet {
106
114
  major: number;
107
115
  minor: number;