@reclaimprotocol/agent 0.1.39 → 0.1.41

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 (51) hide show
  1. package/README.md +32 -0
  2. package/lib/config/store.d.ts +7 -0
  3. package/lib/consts.d.ts +5 -0
  4. package/lib/consts.js +10 -0
  5. package/lib/mcp/api/operations.gen.d.ts +7 -0
  6. package/lib/mcp/api/operations.gen.js +54 -4
  7. package/lib/mcp/api/tools.js +4 -0
  8. package/lib/mcp/instructions.js +10 -8
  9. package/lib/mcp/server.js +6 -1
  10. package/lib/mcp/start.js +27 -1
  11. package/lib/mcp/tools/agent/example-app.d.ts +5 -1
  12. package/lib/mcp/tools/agent/example-app.js +87 -24
  13. package/lib/mcp/tools/agent/propose.js +7 -0
  14. package/lib/mcp/tools/agent/start-integration.d.ts +2 -0
  15. package/lib/mcp/tools/agent/start-integration.js +7 -0
  16. package/lib/mcp/tools/authenticate/authenticate.js +3 -0
  17. package/lib/mcp/tools/authenticate/eth-key.d.ts +15 -0
  18. package/lib/mcp/tools/authenticate/eth-key.js +72 -6
  19. package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +3 -2
  20. package/lib/mcp/tools/authenticate/issue-org-keypair.js +83 -18
  21. package/lib/mcp/tools/authenticate/issue-org-token.js +9 -2
  22. package/lib/telemetry/index.d.ts +9 -0
  23. package/lib/telemetry/index.js +13 -0
  24. package/lib/telemetry/telemetry.d.ts +54 -0
  25. package/lib/telemetry/telemetry.js +175 -0
  26. package/lib/telemetry/tool-call.d.ts +24 -0
  27. package/lib/telemetry/tool-call.js +102 -0
  28. package/package.json +2 -2
  29. package/skill/SKILL.builder.md +30 -11
  30. package/skill/SKILL.old.md +23 -5
  31. package/skill/builder-mode.md +29 -164
  32. package/skill/old-mode.md +40 -95
  33. package/skill/start-integration.md +12 -9
  34. package/skill/verification-client-integration.md +39 -9
  35. package/src/config/store.ts +4 -0
  36. package/src/consts.ts +11 -0
  37. package/src/mcp/api/operations.gen.ts +56 -4
  38. package/src/mcp/api/tools.ts +4 -0
  39. package/src/mcp/instructions.ts +10 -8
  40. package/src/mcp/server.ts +6 -1
  41. package/src/mcp/start.ts +34 -0
  42. package/src/mcp/tools/agent/example-app.ts +97 -30
  43. package/src/mcp/tools/agent/propose.ts +8 -0
  44. package/src/mcp/tools/agent/start-integration.ts +10 -0
  45. package/src/mcp/tools/authenticate/authenticate.ts +3 -0
  46. package/src/mcp/tools/authenticate/eth-key.ts +114 -6
  47. package/src/mcp/tools/authenticate/issue-org-keypair.ts +97 -19
  48. package/src/mcp/tools/authenticate/issue-org-token.ts +13 -2
  49. package/src/telemetry/index.ts +18 -0
  50. package/src/telemetry/telemetry.ts +206 -0
  51. package/src/telemetry/tool-call.ts +115 -0
package/README.md CHANGED
@@ -70,6 +70,38 @@ Defaults target the Builder backend, so **no env is needed for the common case**
70
70
 
71
71
  See [Backends](#backends) for the full env-var list.
72
72
 
73
+ ### Usage data
74
+
75
+ The MCP server sends anonymous usage data to Builder so that Reclaim can see which tools developers use and where they get stuck.
76
+
77
+ **What the server records**
78
+
79
+ - When a session starts: the mode (Builder or devtools), whether the browser tools are on, your operating system family, your Node.js version, and the name and version of your MCP client, such as Claude Code.
80
+ - For each tool call: the tool name, whether it succeeded, how long it took, and an error category such as `http_401`.
81
+ - When you sign in.
82
+ - For three tools only, the text you give them. `start_integration` records `goal` and `userRequest`. `propose_provider` records `userRequest`. `discover_resolve` records the domain and data points you search for. Don't put secrets or personal data in these fields.
83
+ - A random client ID that the server saves in `~/.reclaim/config.json`. It isn't derived from your machine or account. After you sign in, Builder links earlier events from this client ID to your account.
84
+
85
+ **What the server never records**
86
+
87
+ - Any other tool argument.
88
+ - Tool output, captured requests or responses, or file contents.
89
+ - Error messages, tokens, keys, or cookies.
90
+
91
+ **Where the data goes**
92
+
93
+ Builder stores the events in its database. Builder forwards only a short list of event names and counts to Google Analytics, and never the text you typed.
94
+
95
+ **Turn it off**
96
+
97
+ Set `RECLAIM_TELEMETRY=0`, or set `DO_NOT_TRACK=1`, in the `env` block of your MCP client configuration. With either set, the server doesn't create a client ID and makes no analytics requests.
98
+
99
+ ```json
100
+ "env": {
101
+ "RECLAIM_TELEMETRY": "0"
102
+ }
103
+ ```
104
+
73
105
  ### Provider-authoring guide
74
106
 
75
107
  The MCP server is all you need — it ships a `how_it_works` tool that serves the capture → prove → publish guide **one topic at a time** (`overview`, `auth`, `browser`, `capture`, `draft`, `params`, `prove`, `publish`, `discover`, `user-script`, `interception`, `hash-validation`, `credentials`, `never-do`, `troubleshooting`). Call it with no arguments for the overview plus the topic index; every response repeats that index, so an agent that guessed wrong self-corrects without a second round-trip. Any MCP client can call it on demand; there's nothing extra to install.
@@ -16,6 +16,9 @@ export interface ReclaimAgentConfig {
16
16
  * - `old_devtools` — externally-supplied identity for OLD-devtools mode
17
17
  * (USE_OLD_DEVTOOLS); independent of `session`'s
18
18
  * builder token. Deletable with the rest of `src/old/`.
19
+ * - `analytics` — the random, anonymous client ID the usage data is
20
+ * sent under (see telemetry/telemetry.ts); holds no
21
+ * personal data
19
22
  * - `org_keys` — organization API keys saved by `issue_org_token`, so
20
23
  * the org-key tools work without the key in a transcript
21
24
  * Sections are persisted independently via `ConfigStore.write`, which
@@ -32,6 +35,10 @@ export interface ReclaimConfig {
32
35
  session?: CachedSession;
33
36
  old_devtools?: OldIdentity;
34
37
  org_keys?: SavedOrgKeys;
38
+ analytics?: {
39
+ clientId: string;
40
+ createdAt: string;
41
+ };
35
42
  }
36
43
  /** Organization API keys (`rorg_…`) saved by `issue_org_token`. */
37
44
  export interface SavedOrgKeys {
package/lib/consts.d.ts CHANGED
@@ -59,6 +59,11 @@ export declare function chromePath(): string | undefined;
59
59
  * deployments by setting `RECLAIM_AGENT_DISABLED=1`. Shared by mcp/start and
60
60
  * the old-mode authenticate tool. */
61
61
  export declare function agentDisabled(): boolean;
62
+ /**
63
+ * Whether the server may send anonymous usage data. Off when you set
64
+ * `RECLAIM_TELEMETRY=0` or the cross-tool `DO_NOT_TRACK=1` convention.
65
+ */
66
+ export declare function telemetryEnabled(): boolean;
62
67
  /** Old-devtools backend base URL. */
63
68
  export declare function oldApiUrl(): string;
64
69
  /** Old-devtools analytics-logs service base URL (separate host). */
package/lib/consts.js CHANGED
@@ -111,6 +111,16 @@ export function chromePath() {
111
111
  export function agentDisabled() {
112
112
  return process.env.RECLAIM_AGENT_DISABLED === '1';
113
113
  }
114
+ /**
115
+ * Whether the server may send anonymous usage data. Off when you set
116
+ * `RECLAIM_TELEMETRY=0` or the cross-tool `DO_NOT_TRACK=1` convention.
117
+ */
118
+ export function telemetryEnabled() {
119
+ const doNotTrack = process.env.DO_NOT_TRACK?.toLowerCase();
120
+ return process.env.RECLAIM_TELEMETRY !== '0'
121
+ && doNotTrack !== '1'
122
+ && doNotTrack !== 'true';
123
+ }
114
124
  /** Old-devtools backend base URL. */
115
125
  export function oldApiUrl() {
116
126
  return process.env.RECLAIM_OLD_API_URL || DEFAULT_OLD_API_URL;
@@ -444,6 +444,13 @@ export declare const MCP_TOOLS: {
444
444
  openWorldHint: boolean;
445
445
  };
446
446
  };
447
+ UpdateOrgKeypair: {
448
+ description: string;
449
+ inputSchema: import("zod").ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>>;
450
+ annotations: {
451
+ openWorldHint: boolean;
452
+ };
453
+ };
447
454
  UpdateProvider: {
448
455
  description: string;
449
456
  inputSchema: import("zod").ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>>;
@@ -1,5 +1,5 @@
1
1
  // AUTO-GENERATED by packages/app/scripts/mcp-tools-gen.ts
2
- // from packages/app/openapi.yaml (spec v0.1.0, source sha256:96339e8dff91).
2
+ // from packages/app/openapi.yaml (spec v0.1.0, source sha256:c3421d6bcf76).
3
3
  // The hash catches drift the version misses (a spec can change without a
4
4
  // version bump) — do not remove it as "redundant" with the version.
5
5
  // Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
@@ -117,7 +117,11 @@ export const MCP_TOOLS = {
117
117
  "enum": [
118
118
  "providers",
119
119
  "revenue",
120
- "usage"
120
+ "usage",
121
+ "goals",
122
+ "tools",
123
+ "agents",
124
+ "funnel"
121
125
  ]
122
126
  }
123
127
  }
@@ -5058,7 +5062,7 @@ export const MCP_TOOLS = {
5058
5062
  annotations: { "destructiveHint": true, "idempotentHint": true, "openWorldHint": false },
5059
5063
  },
5060
5064
  "DeleteOrgKeypair": {
5061
- description: "Clear the organization's encryption keypair\n\nRemoves the organization's result encryption public key. Results are then delivered unencrypted until you set a new key. Run it only when the developer asks.",
5065
+ description: "Clear the organization's encryption keypair\n\nRemoves the organization's result encryption public key. Results are then delivered unencrypted until you set a new key. Run it only when the developer asks. To rotate the key, use `issue_org_keypair` with `replace: true` instead of deleting first.",
5062
5066
  inputSchema: toZod({
5063
5067
  "type": "object",
5064
5068
  "properties": {
@@ -6578,7 +6582,7 @@ export const MCP_TOOLS = {
6578
6582
  annotations: { "destructiveHint": true, "idempotentHint": true, "openWorldHint": false },
6579
6583
  },
6580
6584
  "SetOrgKeypair": {
6581
- description: "Set (or rotate) the organization's encryption keypair\n\nRegisters the organization's result encryption public key and turns on encrypted result delivery. Send only the public key; the private key stays with the developer. If the organization already has a different key, the call returns 409 unless you pass replace: true. Pass it only when the developer asked to rotate the key, because results encrypted to the old key can no longer be decrypted. Setting the same key again succeeds.",
6585
+ description: "Set (or rotate) the organization's encryption keypair\n\nRegisters the organization's result encryption public key and turns on encrypted result delivery. Send only the public key; the private key stays with the developer. If the organization already has a different key, the call returns 409 unless you pass replace: true. Pass it only when the developer asked to rotate the key, because results encrypted to the old key can no longer be decrypted. Setting the same key again succeeds. To generate and store the private key in the project, use `issue_org_keypair` instead.",
6582
6586
  inputSchema: toZod({
6583
6587
  "type": "object",
6584
6588
  "properties": {
@@ -6855,6 +6859,52 @@ export const MCP_TOOLS = {
6855
6859
  }),
6856
6860
  annotations: { "openWorldHint": false },
6857
6861
  },
6862
+ "UpdateOrgKeypair": {
6863
+ description: "Turn result encryption on or off\n\nTurns result encryption on or off for the organization's existing key pair without changing the key. Use it when the developer asks to turn encryption on or off. When on, the app's callback needs `orgEthPrivateKey` to decrypt results. Returns 404 when the organization has no key pair; create one with `issue_org_keypair`.",
6864
+ inputSchema: toZod({
6865
+ "type": "object",
6866
+ "properties": {
6867
+ "params": {
6868
+ "type": "object",
6869
+ "properties": {
6870
+ "orgId": {
6871
+ "type": "string",
6872
+ "format": "uuid"
6873
+ }
6874
+ },
6875
+ "required": [
6876
+ "orgId"
6877
+ ],
6878
+ "additionalProperties": false
6879
+ },
6880
+ "query": {
6881
+ "type": "object",
6882
+ "properties": {}
6883
+ },
6884
+ "body": {
6885
+ "type": "object",
6886
+ "additionalProperties": false,
6887
+ "description": "Body for `PATCH /orgs/{orgId}/keypair`.",
6888
+ "required": [
6889
+ "canEncryptResult"
6890
+ ],
6891
+ "properties": {
6892
+ "canEncryptResult": {
6893
+ "type": "boolean",
6894
+ "description": "Set to true to encrypt results to the organization's public key. Set to false to deliver results as plaintext."
6895
+ }
6896
+ }
6897
+ }
6898
+ },
6899
+ "required": [
6900
+ "params",
6901
+ "query",
6902
+ "body"
6903
+ ],
6904
+ "additionalProperties": false
6905
+ }),
6906
+ annotations: { "openWorldHint": false },
6907
+ },
6858
6908
  "UpdateProvider": {
6859
6909
  description: "Update a provider\n\nUpdates a provider's metadata, such as title, description, or tags. Partial update — pass only the fields to change. Requires active membership. To change the recipe (initialUrl or requests), cut a new version with create_provider_version instead.",
6860
6910
  inputSchema: toZod({
@@ -2,6 +2,7 @@ import { unauthorized } from '@hapi/boom';
2
2
  import { ReclaimClient } from '@reclaimprotocol/client/api';
3
3
  import assert from 'node:assert';
4
4
  import { orgSecret } from "../../consts.js";
5
+ import { startToolCall } from "../../telemetry/index.js";
5
6
  import { remapErrorAsResponse } from "../remap_errors.js";
6
7
  import { successResponse } from "../success-response.js";
7
8
  import { savedOrgKey } from "../tools/authenticate/org-key-store.js";
@@ -48,6 +49,7 @@ export function registerTools(server, client, orgClient = (orgToken) => new Recl
48
49
  // and generated names must stay disjoint (see start.ts), so a clash is a
49
50
  // bug to surface, not silently shadow.
50
51
  server.registerTool(toSnakeCase(opId), shape, async (input) => {
52
+ const call = startToolCall(toSnakeCase(opId), input);
51
53
  try {
52
54
  if (!isRecord(input)) {
53
55
  throw new Error(`Invalid generated MCP arguments for ${opId}`);
@@ -60,9 +62,11 @@ export function registerTools(server, client, orgClient = (orgToken) => new Recl
60
62
  ? orgClient(orgTokenFor(toSnakeCase(opId), orgToken, args))
61
63
  : client;
62
64
  const result = await caller.call(opId, args);
65
+ call.end();
63
66
  return successResponse(result.data);
64
67
  }
65
68
  catch (err) {
69
+ call.end(err);
66
70
  return remapErrorAsResponse(err);
67
71
  }
68
72
  });
@@ -48,7 +48,8 @@ const TOPIC_SUMMARIES = {
48
48
  'overview': 'Terminology, your stance, and the authoring checklist.',
49
49
  'contributions': 'Contributing a public provider as a volunteer: the '
50
50
  + 'candidate-and-review workflow and the extra rules it adds.',
51
- 'auth': 'Signing in to the backend, and which token authorizes what.',
51
+ 'auth': 'Signing in to the backend, which token authorizes what, and '
52
+ + 'the backend mode (Builder or legacy devtools).',
52
53
  'devtools-migration': 'Importing a public legacy provider with the same '
53
54
  + 'UUID.',
54
55
  'browser': 'The default local browser, the opt-in modes (attach, the '
@@ -60,15 +61,16 @@ const TOPIC_SUMMARIES = {
60
61
  + 'drafted shape.',
61
62
  'params': 'Extracted vs consumer-supplied vs secret params, the URL '
62
63
  + 'character budget, and runtime resolution.',
63
- 'prove': 'replay_request, the mandatory auth-bound check, run_proof, and '
64
- + 'diagnosing failures.',
64
+ 'prove': 'replay_request, the mandatory auth-bound check, run_proof '
65
+ + '(including the egress country), and diagnosing failures.',
65
66
  'publish': 'Creating a provider, provider versions, multi-request '
66
67
  + 'providers, and going live.',
67
- 'discover': 'Reusing existing providers: finding, resolving, and '
68
- + 'creating a verification session.',
69
- 'integration': 'After start_integration: org key, callback (local '
70
- + 'proxy_callback_events or deployed webhook), frontend and backend '
71
- + 'over the HTTP session API; `api=2` and result verification.',
68
+ 'discover': 'Reusing existing providers: finding, resolving, copying one '
69
+ + 'that needs changes, and creating a verification session.',
70
+ 'integration': 'After start_integration: org key and keypair, callback '
71
+ + '(local proxy_callback_events or deployed webhook), frontend and '
72
+ + 'backend over the HTTP session API, js-sdk projects, trying a '
73
+ + 'session without an app; `api=2` and result verification.',
72
74
  'user-script': 'The navigation script (jsUserScripts): sign-in redirect, '
73
75
  + 'multi-page walk, test_user_script with keep.',
74
76
  'interception': 'injectionType / interceptorType, live interception, the '
package/lib/mcp/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { startToolCall } from "../telemetry/index.js";
1
2
  import { toZod } from "./api/to-zod.js";
2
3
  import { remapErrorAsResponse } from "./remap_errors.js";
3
4
  import { successResponse } from "./success-response.js";
@@ -19,10 +20,14 @@ export function registerToolList(server, tools) {
19
20
  ? { annotations: tool.definition.annotations }
20
21
  : {}),
21
22
  }, async (args) => {
23
+ const call = startToolCall(tool.definition.name, args);
22
24
  try {
23
- return successResponse(await tool.handler(args));
25
+ const result = successResponse(await tool.handler(args));
26
+ call.end();
27
+ return result;
24
28
  }
25
29
  catch (err) {
30
+ call.end(err);
26
31
  return remapErrorAsResponse(err);
27
32
  }
28
33
  });
package/lib/mcp/start.js CHANGED
@@ -2,7 +2,7 @@ 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/api';
4
4
  import { ConfigStore } from "../config/store.js";
5
- import { agentDisabled, apiToken, apiUrl, oldApiUrl, oldLogsUrl, resolveOldMode, sdkApiUrl, } from "../consts.js";
5
+ import { agentDisabled, apiToken, apiUrl, oldApiUrl, oldLogsUrl, resolveOldMode, sdkApiUrl, telemetryEnabled, } from "../consts.js";
6
6
  import { LOGGER } from "../logger.js";
7
7
  import { ReclaimOldClient } from "../old/client.js";
8
8
  import { resolveInitialIdentity } from "../old/identity.js";
@@ -11,6 +11,7 @@ import { ReclaimSdkClient } from "../old/sdk-client.js";
11
11
  import { buildOldBackendTools } from "../old/tools/build.js";
12
12
  import { NAME, VERSION } from "../pkg.js";
13
13
  import { startZkFilesDownload } from "../proof/zk-files.js";
14
+ import { currentTelemetry, installTelemetry, Telemetry } from "../telemetry/index.js";
14
15
  import { registerTools } from "./api/tools.js";
15
16
  import { buildAgentState, buildAgentTools, buildBuilderAuthenticationTools, buildBuilderOrganizationTools, buildCredentialTools, buildLocalCredentialTools, buildModeTools, buildVerificationTools, disposeAgentState, readSession, releaseAllListeners, sessionRefreshingFetch, stopAllForwarders, } from "./tools/index.js";
16
17
  import { BUILDER_MODE_INSTRUCTIONS } from "./instructions.js";
@@ -47,6 +48,12 @@ export async function startMcpServer() {
47
48
  }),
48
49
  headers: { 'User-Agent': `${NAME}/${VERSION}` },
49
50
  });
51
+ // Anonymous usage data. Installed before any tool registers so every tool
52
+ // call is covered. When the developer opts out, nothing is created.
53
+ const mode = oldMode ? 'devtools' : 'builder';
54
+ if (telemetryEnabled()) {
55
+ installTelemetry(new Telemetry({ client: builderClient, mode }));
56
+ }
50
57
  // Backend-mode toggle — available in both modes and regardless of whether
51
58
  // the agent tools are enabled, so a user can always switch either direction.
52
59
  registerToolList(server, buildModeTools(config, oldMode));
@@ -106,6 +113,18 @@ export async function startMcpServer() {
106
113
  if (agentToolsEnabled || !oldMode) {
107
114
  installShutdownHooks(() => agentState);
108
115
  }
116
+ // The client's name and version arrive in the `initialize` handshake, so
117
+ // the session starts only after it completes.
118
+ server.server.oninitialized = () => {
119
+ const telemetry = currentTelemetry();
120
+ telemetry?.setAgent(server.server.getClientVersion());
121
+ telemetry?.track('session_start', {
122
+ mode,
123
+ agentTools: agentToolsEnabled,
124
+ os: knownOs(process.platform),
125
+ nodeVersion: process.versions.node,
126
+ });
127
+ };
109
128
  const transport = new StdioServerTransport();
110
129
  await server.connect(transport);
111
130
  // Fetch the proof files in the background so the first proof doesn't wait
@@ -143,6 +162,7 @@ function installShutdownHooks(state) {
143
162
  void Promise.all([
144
163
  stopAllForwarders(),
145
164
  releaseAllListeners(),
165
+ currentTelemetry()?.close(),
146
166
  current && disposeAgentState(current),
147
167
  ])
148
168
  .catch((err) => {
@@ -159,3 +179,9 @@ function installShutdownHooks(state) {
159
179
  process.stdin.once('end', () => shutdown('stdin ended'));
160
180
  process.stdin.once('close', () => shutdown('stdin closed'));
161
181
  }
182
+ /** Maps the platform to the values the analytics API accepts. */
183
+ function knownOs(platform) {
184
+ return platform === 'darwin' || platform === 'linux' || platform === 'win32'
185
+ ? platform
186
+ : 'other';
187
+ }
@@ -1,2 +1,6 @@
1
1
  import { type RegisteredTool } from '../../server.ts';
2
- export declare function exampleAppTool(fetchFn?: typeof fetch): RegisteredTool;
2
+ export declare function exampleAppTool(fetchFn?: typeof fetch, isPortFree?: (port: number) => Promise<boolean>): RegisteredTool;
3
+ /** True when nothing accepts connections on `port` at 127.0.0.1 or ::1.
4
+ * Connecting, rather than binding, also catches servers bound to `0.0.0.0`:
5
+ * macOS lets a loopback bind succeed alongside them. Exported for tests. */
6
+ export declare function isLoopbackPortFree(port: number): Promise<boolean>;
@@ -10,10 +10,12 @@
10
10
  import { badGateway, badRequest, conflict } from '@hapi/boom';
11
11
  import assert from 'node:assert';
12
12
  import { createHash } from 'node:crypto';
13
- import { existsSync, lstatSync, readFileSync } from 'node:fs';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
14
  import { mkdir, realpath, rm, writeFile } from 'node:fs/promises';
15
+ import { connect } from 'node:net';
15
16
  import { isAbsolute, join, relative, sep } from 'node:path';
16
17
  import { defineTool } from "../../server.js";
18
+ import { assertNoSymlinks, upsertEnvVar } from "../authenticate/eth-key.js";
17
19
  const REPO = 'reclaimprotocol/builder-example';
18
20
  /** GitHub tree paths are always `/`-separated, on every OS. */
19
21
  const BACKEND_PACKAGE = 'packages/backend/package.json';
@@ -21,19 +23,30 @@ const CLIENT_PACKAGE = '@reclaimprotocol/client';
21
23
  /** The example is ~50 small files; anything far past that isn't it. */
22
24
  const MAX_FILES = 500;
23
25
  const MAX_BYTES = 10000000;
24
- export function exampleAppTool(fetchFn = fetch) {
26
+ const BACKEND_ENV_FILE = 'packages/backend/.env';
27
+ const DEFAULT_BACKEND_PORT = 3000;
28
+ /** Ports probed for the backend: 3000, then 3001 through 3020. */
29
+ const BACKEND_PORT_TRIES = 21;
30
+ const LOOPBACK_HOSTS = ['127.0.0.1', '::1'];
31
+ const PROBE_TIMEOUT_MS = 500;
32
+ /** Files a workspace may already have. An existing copy is kept, not a
33
+ * conflict. The env-file tools add their own entries to `.gitignore`. */
34
+ const KEEP_EXISTING = new Set(['AGENTS.md', '.gitignore']);
35
+ export function exampleAppTool(fetchFn = fetch, isPortFree = isLoopbackPortFree) {
25
36
  return defineTool({
26
37
  name: 'create_example_app',
27
38
  description: 'Writes the Reclaim example app (reclaimprotocol/builder-example: '
28
39
  + 'a Fastify backend and a Vite frontend that create sessions, '
29
40
  + 'verify callbacks, and render every verification state) into '
30
41
  + '`projectDir`, and sets its backend to the latest '
31
- + '`@reclaimprotocol/client`. Use it when the workspace has no app '
42
+ + '`@reclaimprotocol/client`. It picks a free backend port (3000, else '
43
+ + 'the next free one, written to `packages/backend/.env`). Use it '
44
+ + 'when the workspace has no app '
32
45
  + 'yet, with the workspace root you were started in as '
33
46
  + '`projectDir`; don\'t clone the repository yourself. Refuses '
34
47
  + 'when a file it would write already exists with different '
35
- + 'content. Returns the commit, file count, client version, and '
36
- + 'next steps.',
48
+ + 'content. Returns the commit, file count, client version, '
49
+ + '`backendPort`, and next steps.',
37
50
  inputSchema: {
38
51
  type: 'object',
39
52
  required: ['projectDir'],
@@ -60,10 +73,16 @@ export function exampleAppTool(fetchFn = fetch) {
60
73
  : bytes,
61
74
  };
62
75
  }));
63
- for (const { path } of staged) {
76
+ for (const path of [...staged.map((f) => f.path), BACKEND_ENV_FILE]) {
64
77
  assertNoSymlinks(projectDir, path);
65
78
  }
66
- const conflicts = staged
79
+ const kept = staged
80
+ .filter(({ path }) => {
81
+ return KEEP_EXISTING.has(path) && existsSync(join(projectDir, path));
82
+ })
83
+ .map(({ path }) => path);
84
+ const toWrite = staged.filter(({ path }) => !kept.includes(path));
85
+ const conflicts = toWrite
67
86
  .filter(({ path, bytes }) => {
68
87
  const dest = join(projectDir, path);
69
88
  return existsSync(dest) && !readFileSync(dest).equals(bytes);
@@ -72,28 +91,63 @@ export function exampleAppTool(fetchFn = fetch) {
72
91
  assert(!conflicts.length, conflict('These files already exist with different content: '
73
92
  + `${conflicts.join(', ')}. Ask the developer whether to use `
74
93
  + 'another directory or move them.'));
75
- for (const { path, bytes } of staged) {
94
+ for (const { path, bytes } of toWrite) {
76
95
  await installFile(projectDir, path, bytes);
77
96
  }
97
+ const backendPort = await findBackendPort();
98
+ // Always written, so a re-run never leaves a stale port behind.
99
+ upsertEnvVar(projectDir, BACKEND_ENV_FILE, 'PORT', String(backendPort));
100
+ const dev = backendPort === DEFAULT_BACKEND_PORT
101
+ ? 'npm run dev'
102
+ : `API_URL=http://localhost:${backendPort} npm run dev`;
78
103
  return {
79
104
  projectDir,
80
105
  repository: `https://github.com/${REPO}`,
81
106
  commit,
82
- files: staged.length,
107
+ files: toWrite.length,
108
+ kept,
83
109
  clientVersion,
110
+ backendPort,
84
111
  next: [
85
112
  'Call issue_org_token with this projectDir and envFile '
86
113
  + '"packages/backend/.env".',
87
- 'Run `npm install`, then `npm run dev`, from projectDir. The '
88
- + 'backend listens on port 3000; the app opens at '
89
- + 'http://localhost:5173.',
90
- 'Call proxy_callback_events({ port: 3000, path: '
114
+ 'Run `npm install` from projectDir, then start the dev '
115
+ + `server with \`${dev}\`. The backend listens on port `
116
+ + `${backendPort}`
117
+ + (backendPort === DEFAULT_BACKEND_PORT
118
+ ? '.'
119
+ : ` (port ${DEFAULT_BACKEND_PORT} is in use; the frontend `
120
+ + 'proxy needs `API_URL`).'),
121
+ 'Run the dev server in the background and send its output to a '
122
+ + 'log file in projectDir, for example `npm run dev > '
123
+ + 'reclaim-dev.log 2>&1`, so callback errors stay readable even '
124
+ + 'when a shell output filter buffers output.',
125
+ 'Give the developer the frontend URL Vite prints in that log. '
126
+ + 'Don\'t assume port 5173: Vite moves to the next free port '
127
+ + 'when it is taken.',
128
+ `Call proxy_callback_events({ port: ${backendPort}, path: `
91
129
  + '"/reclaim/callback" }).',
130
+ 'If get_org_keypair shows `canEncryptResult: true`, the '
131
+ + 'callback needs `orgEthPrivateKey`; see the plan.',
92
132
  'Put the provider in packages/backend/config.json and restart '
93
133
  + 'the backend.',
134
+ 'The backend stores verifications in memory: restarting it '
135
+ + 'drops sessions in progress, so start a new verification '
136
+ + 'after any restart.',
94
137
  ],
95
138
  };
96
139
  });
140
+ async function findBackendPort() {
141
+ for (let i = 0; i < BACKEND_PORT_TRIES; i++) {
142
+ const port = DEFAULT_BACKEND_PORT + i;
143
+ if (await isPortFree(port)) {
144
+ return port;
145
+ }
146
+ }
147
+ throw conflict(`No free port from ${DEFAULT_BACKEND_PORT} to `
148
+ + `${DEFAULT_BACKEND_PORT + BACKEND_PORT_TRIES - 1}. Ask the developer `
149
+ + 'to stop another dev server.');
150
+ }
97
151
  async function latestCommit() {
98
152
  const res = await fetchFn(`https://api.github.com/repos/${REPO}/commits/main`, { headers: { Accept: 'application/vnd.github.sha' } });
99
153
  assert(res.ok, badGateway(`${REPO} commit lookup: HTTP ${res.status}`));
@@ -184,17 +238,6 @@ async function installFile(root, path, bytes) {
184
238
  function isEexist(err) {
185
239
  return err instanceof Error && 'code' in err && err.code === 'EEXIST';
186
240
  }
187
- /** Throws when `path`, or any of its existing parents under `root`, is a
188
- * symlink: writing through one would land outside the workspace. */
189
- function assertNoSymlinks(root, path) {
190
- let current = root;
191
- for (const part of path.split(/[\\/]/)) {
192
- current += sep + part;
193
- const stat = lstatSync(current, { throwIfNoEntry: false });
194
- assert(!stat?.isSymbolicLink(), badRequest(`${current} is a symlink; the example app is only written to `
195
- + 'real directories inside the workspace'));
196
- }
197
- }
198
241
  /** The backend's package.json with the client range set to `^version`. */
199
242
  function withClientVersion(bytes, version) {
200
243
  const text = bytes.toString('utf8');
@@ -209,3 +252,23 @@ function gitBlobSha(bytes) {
209
252
  .update(bytes)
210
253
  .digest('hex');
211
254
  }
255
+ /** True when nothing accepts connections on `port` at 127.0.0.1 or ::1.
256
+ * Connecting, rather than binding, also catches servers bound to `0.0.0.0`:
257
+ * macOS lets a loopback bind succeed alongside them. Exported for tests. */
258
+ export async function isLoopbackPortFree(port) {
259
+ const results = await Promise.all(LOOPBACK_HOSTS.map((host) => {
260
+ return new Promise((resolve) => {
261
+ const socket = connect({ port, host, timeout: PROBE_TIMEOUT_MS });
262
+ socket.once('connect', () => {
263
+ socket.destroy();
264
+ resolve(false);
265
+ });
266
+ socket.once('timeout', () => {
267
+ socket.destroy();
268
+ resolve(true);
269
+ });
270
+ socket.once('error', () => resolve(true));
271
+ });
272
+ }));
273
+ return results.every(Boolean);
274
+ }
@@ -23,6 +23,13 @@ export function proposeTool(resolveBackend) {
23
23
  inputSchema: objectSchema({
24
24
  requestId: { type: 'string' },
25
25
  name: { type: 'string' },
26
+ userRequest: {
27
+ type: 'string',
28
+ maxLength: 500,
29
+ description: 'The developer\'s request in their own words, as they wrote it. '
30
+ + 'Reclaim records this text to improve the product, so leave '
31
+ + 'out secrets and personal data.',
32
+ },
26
33
  shortenNames: {
27
34
  type: 'boolean',
28
35
  description: 'Default true: shorten each target `name` to its last '
@@ -1,6 +1,8 @@
1
1
  import { type RegisteredTool } from '../../server.ts';
2
2
  interface StartIntegrationArgs {
3
3
  goal: string;
4
+ /** Recorded by telemetry only; the plan ignores it. */
5
+ userRequest?: string;
4
6
  orgId?: string;
5
7
  providerIds?: string[];
6
8
  }
@@ -30,6 +30,13 @@ export function startIntegrationTool() {
30
30
  description: 'What the developer\'s users prove, for example, "they have '
31
31
  + 'a GitHub account with 100 followers".',
32
32
  },
33
+ userRequest: {
34
+ type: 'string',
35
+ maxLength: 500,
36
+ description: 'The developer\'s request in their own words, as they wrote it. '
37
+ + 'Reclaim records this text to improve the product, so leave '
38
+ + 'out secrets and personal data.',
39
+ },
33
40
  orgId: {
34
41
  type: 'string',
35
42
  format: 'uuid',
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createServer } from 'node:http';
3
3
  import * as oauth from 'openid-client';
4
+ import { currentTelemetry } from "../../../telemetry/index.js";
4
5
  import { defineTool } from "../../server.js";
5
6
  import { authBaseFor, oauthRequestOptions, SCOPE, sessionFromTokens, } from "./oauth-config.js";
6
7
  import { readSession, writeSession } from "./session-cache.js";
@@ -181,6 +182,8 @@ async function beginSignIn(client, openBrowserFn) {
181
182
  });
182
183
  writeSession(session);
183
184
  client.setToken(session.token);
185
+ // Link this machine's earlier anonymous usage to the new account.
186
+ void currentTelemetry()?.signedIn();
184
187
  return { status: 'AUTHENTICATED', expiresAt: session.expiresAt };
185
188
  }
186
189
  catch (err) {
@@ -38,6 +38,17 @@ export declare function ensureGitignored(projectDir: string, entry: string): voi
38
38
  * `envFile` is relative to `projectDir`.
39
39
  */
40
40
  export declare function upsertEnvVar(projectDir: string, envFile: string, name: string, value: string): string;
41
+ /** Remove the `name=` line from a dotenv file, if there is one. */
42
+ export declare function removeEnvVar(projectDir: string, envFile: string, name: string): void;
43
+ /**
44
+ * Before `name` is overwritten, append its current value to
45
+ * `<envFile>.backup` (same directory) as a timestamped comment plus the
46
+ * `NAME=value` line. Appends, so repeated rotations keep history. Writes with
47
+ * mode 0600 and adds the backup to `.gitignore`. Returns the backup's absolute
48
+ * path, or `undefined` when the env file holds no value for `name`. Never
49
+ * returns or logs the value.
50
+ */
51
+ export declare function backupEnvVar(projectDir: string, envFile: string, name: string, reason: string): string | undefined;
41
52
  /**
42
53
  * Persist a raw eth private key to the project `.env` as
43
54
  * `RECLAIM_PRIVATE_KEY=0x…` (upsert; mode 0600; `.env` added to `.gitignore`).
@@ -48,3 +59,7 @@ export declare function writeEthKey(privateKeyHex: string, projectDir?: string):
48
59
  path: string;
49
60
  address: string;
50
61
  };
62
+ /** Throws when `path` (relative to `root`), or any of its existing parents
63
+ * under `root`, is a symlink: writing through one could land outside the
64
+ * project. */
65
+ export declare function assertNoSymlinks(root: string, path: string): void;