@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
@@ -15,10 +15,11 @@
15
15
  * signing key only — unrelated to org auth (the org secret) or encryption
16
16
  * (the eth decrypt key).
17
17
  */
18
+ import { badRequest } from '@hapi/boom';
18
19
  import { deriveFromPrivateKey, generateKeypair } from '@reclaimprotocol/client';
19
20
  import assert from 'node:assert';
20
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
21
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
21
+ import { closeSync, constants, existsSync, fchmodSync, lstatSync, mkdirSync, openSync, readFileSync, writeFileSync, } from 'node:fs';
22
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
22
23
  import { privateKey, privateKeyFile } from "../../../consts.js";
23
24
  import { findProjectRoot } from "../../../paths.js";
24
25
  const ENV_VAR = 'RECLAIM_PRIVATE_KEY';
@@ -111,7 +112,7 @@ export function ensureGitignored(projectDir, entry) {
111
112
  return;
112
113
  }
113
114
  const prefix = current && !current.endsWith('\n') ? '\n' : '';
114
- writeFileSync(path, current + prefix + entry + '\n');
115
+ writeInside(projectDir, path, current + prefix + entry + '\n');
115
116
  }
116
117
  /**
117
118
  * Set `name=value` in a dotenv file, replacing an existing line or appending
@@ -132,12 +133,41 @@ export function upsertEnvVar(projectDir, envFile, name, value) {
132
133
  const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
133
134
  next = existing + prefix + line + '\n';
134
135
  }
135
- mkdirSync(dirname(envPath), { recursive: true });
136
- writeFileSync(envPath, next, { mode: 0o600 });
137
- chmodSync(envPath, 0o600);
136
+ writeInside(projectDir, envPath, next, { secret: true });
138
137
  ensureGitignored(projectDir, relative(projectDir, envPath));
139
138
  return envPath;
140
139
  }
140
+ /** Remove the `name=` line from a dotenv file, if there is one. */
141
+ export function removeEnvVar(projectDir, envFile, name) {
142
+ const envPath = resolve(projectDir, envFile);
143
+ if (!existsSync(envPath)) {
144
+ return;
145
+ }
146
+ const existing = readFileSync(envPath, 'utf8');
147
+ const re = new RegExp(`^\\s*${name}\\s*=.*(\\n|$)`, 'm');
148
+ writeInside(projectDir, envPath, existing.replace(re, ''), { secret: true });
149
+ }
150
+ /**
151
+ * Before `name` is overwritten, append its current value to
152
+ * `<envFile>.backup` (same directory) as a timestamped comment plus the
153
+ * `NAME=value` line. Appends, so repeated rotations keep history. Writes with
154
+ * mode 0600 and adds the backup to `.gitignore`. Returns the backup's absolute
155
+ * path, or `undefined` when the env file holds no value for `name`. Never
156
+ * returns or logs the value.
157
+ */
158
+ export function backupEnvVar(projectDir, envFile, name, reason) {
159
+ const envPath = resolve(projectDir, envFile);
160
+ assertNoSymlinks(projectDir, relative(projectDir, envPath));
161
+ const value = readDotenvKey(envPath, name);
162
+ if (!value) {
163
+ return undefined;
164
+ }
165
+ const backupPath = `${envPath}.backup`;
166
+ const stamp = new Date().toISOString();
167
+ writeInside(projectDir, backupPath, `# ${stamp} ${reason}\n${name}=${value}\n`, { secret: true, append: true });
168
+ ensureGitignored(projectDir, relative(projectDir, backupPath));
169
+ return backupPath;
170
+ }
141
171
  /**
142
172
  * Persist a raw eth private key to the project `.env` as
143
173
  * `RECLAIM_PRIVATE_KEY=0x…` (upsert; mode 0600; `.env` added to `.gitignore`).
@@ -149,3 +179,39 @@ export function writeEthKey(privateKeyHex, projectDir = findProjectRoot(process.
149
179
  const path = upsertEnvVar(projectDir, ENV_FILE, ENV_VAR, hex);
150
180
  return { path, address: deriveFromPrivateKey(hex).address };
151
181
  }
182
+ /** Throws when `path` (relative to `root`), or any of its existing parents
183
+ * under `root`, is a symlink: writing through one could land outside the
184
+ * project. */
185
+ export function assertNoSymlinks(root, path) {
186
+ assert(path && !path.startsWith('..') && !isAbsolute(path), badRequest(`${path} is not inside ${root}`));
187
+ let current = root;
188
+ for (const part of path.split(/[\\/]/)) {
189
+ current += sep + part;
190
+ const stat = lstatSync(current, { throwIfNoEntry: false });
191
+ assert(!stat?.isSymbolicLink(), badRequest(`${current} is a symlink; Reclaim tools only write to real files `
192
+ + 'and directories inside the project'));
193
+ }
194
+ }
195
+ /**
196
+ * Write `data` to `path` inside `projectDir` without following a symlink: the
197
+ * file and its parents must be real, and the open uses `O_NOFOLLOW`, so a
198
+ * symlink that appears after the check still fails. `secret` writes with
199
+ * mode 0600; `append` adds to the file instead of replacing it.
200
+ */
201
+ function writeInside(projectDir, path, data, { secret = false, append = false } = {}) {
202
+ const rel = relative(projectDir, path);
203
+ assertNoSymlinks(projectDir, rel);
204
+ mkdirSync(dirname(path), { recursive: true });
205
+ assertNoSymlinks(projectDir, rel);
206
+ const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW
207
+ | (append ? constants.O_APPEND : constants.O_TRUNC), secret ? 0o600 : 0o666);
208
+ try {
209
+ if (secret) {
210
+ fchmodSync(fd, 0o600);
211
+ }
212
+ writeFileSync(fd, data);
213
+ }
214
+ finally {
215
+ closeSync(fd);
216
+ }
217
+ }
@@ -1,10 +1,11 @@
1
- import type { ReclaimClient } from '@reclaimprotocol/client/api';
1
+ import { type ReclaimClient } from '@reclaimprotocol/client/api';
2
2
  import { type RegisteredTool } from '../../server.ts';
3
3
  /**
4
4
  * Creates the organization's eth key pair for session binding and encrypted
5
5
  * results: writes the private key to the app's server env file and registers
6
6
  * only the public key with Builder, so the private key never passes through
7
7
  * the agent's transcript. Reuses a key the env file already holds, so a
8
- * repeat call (for example, to turn on encryption) keeps the same key.
8
+ * repeat call (for example, to turn on encryption) keeps the same key. With
9
+ * `replace`, always generates a fresh key and rotates the org's key to it.
9
10
  */
10
11
  export declare function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool;
@@ -1,14 +1,17 @@
1
+ import { conflict } from '@hapi/boom';
1
2
  import { deriveFromPrivateKey, generateKeypair } from '@reclaimprotocol/client';
3
+ import { isProblemError } from '@reclaimprotocol/client/api';
2
4
  import assert from 'node:assert';
3
5
  import { isAbsolute, relative, resolve } from 'node:path';
4
6
  import { defineTool } from "../../server.js";
5
- import { normalizeEthHex, readDotenvKey, upsertEnvVar } from "./eth-key.js";
7
+ import { backupEnvVar, normalizeEthHex, readDotenvKey, removeEnvVar, upsertEnvVar, } from "./eth-key.js";
6
8
  /**
7
9
  * Creates the organization's eth key pair for session binding and encrypted
8
10
  * results: writes the private key to the app's server env file and registers
9
11
  * only the public key with Builder, so the private key never passes through
10
12
  * the agent's transcript. Reuses a key the env file already holds, so a
11
- * repeat call (for example, to turn on encryption) keeps the same key.
13
+ * repeat call (for example, to turn on encryption) keeps the same key. With
14
+ * `replace`, always generates a fresh key and rotates the org's key to it.
12
15
  */
13
16
  export function issueOrgKeypairTool(client) {
14
17
  return defineTool({
@@ -20,8 +23,12 @@ export function issueOrgKeypairTool(client) {
20
23
  + 'private key to a server env file in the project (reusing a key '
21
24
  + 'already there) and registers only the public key. Returns the '
22
25
  + 'address, file, and variable; never the key. Returns 409 when the '
23
- + 'org already has a different key: ask the developer for that key '
24
- + 'rather than replacing it.',
26
+ + 'org already has a different key. Set `replace: true` only when '
27
+ + 'the developer asked to rotate or replace the key: it generates '
28
+ + 'a new key and swaps it in one step, and apps holding the old '
29
+ + 'key can no longer decrypt new results. The old key is first '
30
+ + 'appended to `<envFile>.backup` (gitignored); tell the developer '
31
+ + 'its path.',
25
32
  inputSchema: {
26
33
  type: 'object',
27
34
  required: ['orgId', 'projectDir'],
@@ -42,11 +49,17 @@ export function issueOrgKeypairTool(client) {
42
49
  },
43
50
  encryptResults: {
44
51
  type: 'boolean',
45
- description: 'Encrypt delivered results to this key. Default false.',
52
+ description: 'Encrypt delivered results to this key. Default false, or the '
53
+ + 'org\'s current setting with `replace`.',
54
+ },
55
+ replace: {
56
+ type: 'boolean',
57
+ description: 'Rotate: generate a new key and replace the org\'s current '
58
+ + 'one. Default false.',
46
59
  },
47
60
  },
48
61
  },
49
- annotations: { openWorldHint: true, destructiveHint: false },
62
+ annotations: { openWorldHint: true, destructiveHint: true },
50
63
  }, async (args) => {
51
64
  assert(isAbsolute(args.projectDir), new Error('`projectDir` must be an absolute path.'));
52
65
  const envFile = args.envFile || '.env';
@@ -54,22 +67,53 @@ export function issueOrgKeypairTool(client) {
54
67
  const envPath = resolve(args.projectDir, envFile);
55
68
  const inside = relative(args.projectDir, envPath);
56
69
  assert(inside && !inside.startsWith('..') && !isAbsolute(inside), new Error('`envFile` must be inside `projectDir`.'));
57
- const existing = readDotenvKey(envPath, envVar);
70
+ const replace = args.replace ?? false;
71
+ // Rotation always generates a fresh key: the env file's key may or
72
+ // may not be the one the org holds, and reuse would be a no-op.
73
+ const existing = replace ? undefined : readDotenvKey(envPath, envVar);
58
74
  const keypair = existing
59
75
  ? deriveFromPrivateKey(normalizeEthHex(existing))
60
76
  : generateKeypair();
61
- // Register first: a 409 (the org has another key) must not leave a
62
- // key Builder doesn't know in the env file.
63
- const canEncryptResult = args.encryptResults ?? false;
64
- await client.call('SetOrgKeypair', {
65
- params: { orgId: args.orgId },
66
- body: {
67
- publicKey: keypair.publicKey,
68
- canEncryptResult,
69
- replace: false,
70
- },
71
- });
77
+ // A rotation keeps the org's current setting unless the caller sets it.
78
+ const canEncryptResult = args.encryptResults
79
+ ?? (replace && await currentCanEncryptResult());
80
+ // Back up and save the key before the org switches to it, so a
81
+ // failed write can't leave the org on a key no app holds. A failed
82
+ // registration (for example, a 409) puts the env file back.
83
+ const old = readDotenvKey(envPath, envVar);
84
+ const backupFile = old && !existing
85
+ ? backupEnvVar(args.projectDir, envFile, envVar, 'replaced by issue_org_keypair')
86
+ : undefined;
72
87
  const path = upsertEnvVar(args.projectDir, envFile, envVar, keypair.privateKey);
88
+ try {
89
+ await client.call('SetOrgKeypair', {
90
+ params: { orgId: args.orgId },
91
+ body: {
92
+ publicKey: keypair.publicKey,
93
+ canEncryptResult,
94
+ replace,
95
+ },
96
+ });
97
+ }
98
+ catch (err) {
99
+ if (old) {
100
+ upsertEnvVar(args.projectDir, envFile, envVar, old);
101
+ }
102
+ else {
103
+ removeEnvVar(args.projectDir, envFile, envVar);
104
+ }
105
+ if (!replace && isProblemError(err) && err.output.statusCode === 409) {
106
+ throw conflict('The org already has a different key. Ask the developer to '
107
+ + 'choose: (a) reuse it: they paste its private key into '
108
+ + `\`${envFile}\` as \`${envVar}\` themselves, never into the `
109
+ + 'chat; (b) rotate it: call `issue_org_keypair` again with '
110
+ + '`replace: true`, after which apps holding the old key can no '
111
+ + 'longer decrypt new results; (c) turn off result encryption: '
112
+ + 'call `update_org_keypair` with `canEncryptResult: false`, '
113
+ + 'which keeps the key.');
114
+ }
115
+ throw err;
116
+ }
73
117
  return {
74
118
  orgId: args.orgId,
75
119
  address: keypair.address,
@@ -77,6 +121,27 @@ export function issueOrgKeypairTool(client) {
77
121
  envFile: path,
78
122
  envVar,
79
123
  reused: Boolean(existing),
124
+ backupFile: backupFile ?? null,
125
+ ...(replace && !backupFile && !old
126
+ ? {
127
+ note: 'Nothing was backed up: the old private key is not in '
128
+ + 'this file. Another app may hold it.',
129
+ }
130
+ : {}),
80
131
  };
132
+ async function currentCanEncryptResult() {
133
+ try {
134
+ const { data } = await client.call('GetOrgKeypair', {
135
+ params: { orgId: args.orgId },
136
+ });
137
+ return data.canEncryptResult;
138
+ }
139
+ catch (err) {
140
+ if (isProblemError(err) && err.output.statusCode === 404) {
141
+ return false;
142
+ }
143
+ throw err;
144
+ }
145
+ }
81
146
  });
82
147
  }
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert';
2
2
  import { isAbsolute, relative, resolve } from 'node:path';
3
3
  import { defineTool } from "../../server.js";
4
- import { upsertEnvVar } from "./eth-key.js";
4
+ import { backupEnvVar, readDotenvKey, upsertEnvVar } from "./eth-key.js";
5
5
  import { saveOrgKey } from "./org-key-store.js";
6
6
  /**
7
7
  * Issues an organization API key and stores it on disk, so the key never
@@ -15,7 +15,9 @@ export function issueOrgTokenTool(client) {
15
15
  + 'server env file in the project, so the app can read it at runtime. '
16
16
  + 'Also saves it on this machine for the org-key tools. Returns the '
17
17
  + 'org, file, variable, and last four characters; never the key. '
18
- + 'Replaces an existing variable of the same name in the file. '
18
+ + 'Replaces an existing variable of the same name in the file, first '
19
+ + 'appending the old value to `<envFile>.backup` (gitignored); tell '
20
+ + 'the developer its path. '
19
21
  + 'Adds the file to .gitignore. Use when the app needs a key, or '
20
22
  + 'before `create_verification_session`.',
21
23
  inputSchema: {
@@ -48,6 +50,10 @@ export function issueOrgTokenTool(client) {
48
50
  const { data } = await client.call('IssueOrgToken', {
49
51
  params: { orgId: args.orgId },
50
52
  });
53
+ const old = readDotenvKey(resolve(args.projectDir, envFile), envVar);
54
+ const backupFile = old && old !== data.orgSecret
55
+ ? backupEnvVar(args.projectDir, envFile, envVar, 'replaced by issue_org_token')
56
+ : undefined;
51
57
  const path = upsertEnvVar(args.projectDir, envFile, envVar, data.orgSecret);
52
58
  saveOrgKey(args.orgId, data.orgSecret);
53
59
  return {
@@ -55,6 +61,7 @@ export function issueOrgTokenTool(client) {
55
61
  envFile: path,
56
62
  envVar,
57
63
  last4: data.last4,
64
+ backupFile: backupFile ?? null,
58
65
  };
59
66
  });
60
67
  }
@@ -0,0 +1,9 @@
1
+ import type { Telemetry } from './telemetry.ts';
2
+ export { startToolCall } from './tool-call.ts';
3
+ export { Telemetry } from './telemetry.ts';
4
+ /**
5
+ * Makes `telemetry` the instance the hooks report to. Until you call this,
6
+ * the hooks do nothing, which is how opt-out and tests stay silent.
7
+ */
8
+ export declare function installTelemetry(telemetry: Telemetry | undefined): void;
9
+ export declare function currentTelemetry(): Telemetry | undefined;
@@ -0,0 +1,13 @@
1
+ export { startToolCall } from "./tool-call.js";
2
+ export { Telemetry } from "./telemetry.js";
3
+ let current;
4
+ /**
5
+ * Makes `telemetry` the instance the hooks report to. Until you call this,
6
+ * the hooks do nothing, which is how opt-out and tests stay silent.
7
+ */
8
+ export function installTelemetry(telemetry) {
9
+ current = telemetry;
10
+ }
11
+ export function currentTelemetry() {
12
+ return current;
13
+ }
@@ -0,0 +1,54 @@
1
+ import type { ReclaimClient } from '@reclaimprotocol/client/api';
2
+ import type { IOperationArgs } from '@reclaimprotocol/client/openapi';
3
+ import { ConfigStore } from '../config/store.ts';
4
+ type TrackArgs = IOperationArgs<'TrackAnalyticsEvents'>;
5
+ type Batch = TrackArgs['body'];
6
+ type Event = Batch['events'][number];
7
+ /** Backend mode, as the analytics API names it. */
8
+ export type TelemetryMode = 'builder' | 'devtools';
9
+ export interface TelemetryOptions {
10
+ client: ReclaimClient;
11
+ mode: TelemetryMode;
12
+ store?: ConfigStore;
13
+ /** How long to wait after the first queued event before sending. */
14
+ flushIntervalMs?: number;
15
+ }
16
+ /**
17
+ * Sends anonymous usage events to Builder. It never throws and never blocks
18
+ * a tool call: `track()` only queues, and a timer that doesn't keep the
19
+ * process alive sends the queue in the background. Failed batches are
20
+ * dropped, because usage data isn't worth retry loops in a tool server.
21
+ *
22
+ * Nothing is written to stdout. Stdio is the MCP transport.
23
+ */
24
+ export declare class Telemetry {
25
+ #private;
26
+ readonly mode: TelemetryMode;
27
+ readonly clientId: string;
28
+ readonly sessionId: string;
29
+ constructor({ client, mode, store, flushIntervalMs, }: TelemetryOptions);
30
+ /** Records the MCP client (for example, Claude Code) that connected. */
31
+ setAgent(info: {
32
+ name: string;
33
+ version?: string;
34
+ } | undefined): void;
35
+ /** Queues an event. Synchronous, cheap, and safe to call anywhere. */
36
+ track<N extends Event['name']>(name: N, properties: Extract<Event, {
37
+ name: N;
38
+ }>['properties']): void;
39
+ /** Sends everything queued. Never rejects. */
40
+ flush(): Promise<void>;
41
+ /**
42
+ * Links this client's earlier anonymous events to the signed-in user.
43
+ * Needs a bearer token, so call it after sign-in.
44
+ */
45
+ identify(): Promise<void>;
46
+ /**
47
+ * Call when sign-in finishes. Sends the `login` event first so that
48
+ * `identify()` can attribute it to the user.
49
+ */
50
+ signedIn(): Promise<void>;
51
+ /** Sends what's left, waiting at most two seconds. */
52
+ close(): Promise<void>;
53
+ }
54
+ export {};
@@ -0,0 +1,175 @@
1
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
2
+ if (kind === "m") throw new TypeError("Private method is not writable");
3
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
4
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
5
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
6
+ };
7
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
8
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
9
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
+ };
12
+ var _Telemetry_instances, _Telemetry_client, _Telemetry_flushIntervalMs, _Telemetry_queue, _Telemetry_timer, _Telemetry_agent, _Telemetry_sending, _Telemetry_schedule, _Telemetry_send;
13
+ import { randomUUID } from 'node:crypto';
14
+ import { ConfigStore } from "../config/store.js";
15
+ import { LOGGER } from "../logger.js";
16
+ import { VERSION } from "../pkg.js";
17
+ /** The API accepts up to 50 events per request. */
18
+ const MAX_BATCH = 50;
19
+ /** Bounds memory while the backend is unreachable. Oldest events go first. */
20
+ const MAX_QUEUE = 500;
21
+ /** How long `close()` waits, so a slow network can't hold up exit. */
22
+ const CLOSE_LIMIT_MS = 2000;
23
+ /**
24
+ * Sends anonymous usage events to Builder. It never throws and never blocks
25
+ * a tool call: `track()` only queues, and a timer that doesn't keep the
26
+ * process alive sends the queue in the background. Failed batches are
27
+ * dropped, because usage data isn't worth retry loops in a tool server.
28
+ *
29
+ * Nothing is written to stdout. Stdio is the MCP transport.
30
+ */
31
+ export class Telemetry {
32
+ constructor({ client, mode, store = new ConfigStore(), flushIntervalMs = 5000, }) {
33
+ _Telemetry_instances.add(this);
34
+ this.sessionId = String(Math.floor(Date.now() / 1000));
35
+ _Telemetry_client.set(this, void 0);
36
+ _Telemetry_flushIntervalMs.set(this, void 0);
37
+ _Telemetry_queue.set(this, []);
38
+ _Telemetry_timer.set(this, void 0);
39
+ _Telemetry_agent.set(this, void 0);
40
+ /** Serializes sends so batches go out in order. */
41
+ _Telemetry_sending.set(this, Promise.resolve());
42
+ __classPrivateFieldSet(this, _Telemetry_client, client, "f");
43
+ this.mode = mode;
44
+ __classPrivateFieldSet(this, _Telemetry_flushIntervalMs, flushIntervalMs, "f");
45
+ this.clientId = loadClientId(store);
46
+ }
47
+ /** Records the MCP client (for example, Claude Code) that connected. */
48
+ setAgent(info) {
49
+ if (info?.name) {
50
+ __classPrivateFieldSet(this, _Telemetry_agent, { name: info.name, version: info.version }, "f");
51
+ }
52
+ }
53
+ /** Queues an event. Synchronous, cheap, and safe to call anywhere. */
54
+ track(name, properties) {
55
+ try {
56
+ // The union is keyed by `name`; TypeScript can't correlate the two
57
+ // generic parameters, so build the event through the union type.
58
+ const event = {
59
+ id: randomUUID(),
60
+ name,
61
+ timestamp: new Date().toISOString(),
62
+ properties,
63
+ };
64
+ __classPrivateFieldGet(this, _Telemetry_queue, "f").push(event);
65
+ if (__classPrivateFieldGet(this, _Telemetry_queue, "f").length > MAX_QUEUE) {
66
+ __classPrivateFieldGet(this, _Telemetry_queue, "f").splice(0, __classPrivateFieldGet(this, _Telemetry_queue, "f").length - MAX_QUEUE);
67
+ }
68
+ __classPrivateFieldGet(this, _Telemetry_instances, "m", _Telemetry_schedule).call(this);
69
+ }
70
+ catch (err) {
71
+ LOGGER.debug({ err }, 'telemetry: track failed');
72
+ }
73
+ }
74
+ /** Sends everything queued. Never rejects. */
75
+ flush() {
76
+ clearTimeout(__classPrivateFieldGet(this, _Telemetry_timer, "f"));
77
+ __classPrivateFieldSet(this, _Telemetry_timer, undefined, "f");
78
+ const events = __classPrivateFieldGet(this, _Telemetry_queue, "f").splice(0);
79
+ if (!events.length) {
80
+ return __classPrivateFieldGet(this, _Telemetry_sending, "f");
81
+ }
82
+ __classPrivateFieldSet(this, _Telemetry_sending, __classPrivateFieldGet(this, _Telemetry_sending, "f").then(() => __classPrivateFieldGet(this, _Telemetry_instances, "m", _Telemetry_send).call(this, events)), "f");
83
+ return __classPrivateFieldGet(this, _Telemetry_sending, "f");
84
+ }
85
+ /**
86
+ * Links this client's earlier anonymous events to the signed-in user.
87
+ * Needs a bearer token, so call it after sign-in.
88
+ */
89
+ async identify() {
90
+ try {
91
+ await __classPrivateFieldGet(this, _Telemetry_client, "f").call('IdentifyAnalyticsClient', {
92
+ body: { clientId: this.clientId },
93
+ });
94
+ }
95
+ catch (err) {
96
+ LOGGER.debug({ err }, 'telemetry: identify failed');
97
+ }
98
+ }
99
+ /**
100
+ * Call when sign-in finishes. Sends the `login` event first so that
101
+ * `identify()` can attribute it to the user.
102
+ */
103
+ async signedIn() {
104
+ this.track('login', { method: 'oauth' });
105
+ await this.flush();
106
+ await this.identify();
107
+ }
108
+ /** Sends what's left, waiting at most two seconds. */
109
+ async close() {
110
+ let timer;
111
+ const limit = new Promise((resolve) => {
112
+ timer = setTimeout(resolve, CLOSE_LIMIT_MS);
113
+ timer.unref();
114
+ });
115
+ await Promise.race([this.flush(), limit]);
116
+ clearTimeout(timer);
117
+ }
118
+ }
119
+ _Telemetry_client = new WeakMap(), _Telemetry_flushIntervalMs = new WeakMap(), _Telemetry_queue = new WeakMap(), _Telemetry_timer = new WeakMap(), _Telemetry_agent = new WeakMap(), _Telemetry_sending = new WeakMap(), _Telemetry_instances = new WeakSet(), _Telemetry_schedule = function _Telemetry_schedule() {
120
+ if (__classPrivateFieldGet(this, _Telemetry_timer, "f")) {
121
+ return;
122
+ }
123
+ // Unref'd so a pending flush never keeps the process alive.
124
+ __classPrivateFieldSet(this, _Telemetry_timer, setTimeout(() => {
125
+ __classPrivateFieldSet(this, _Telemetry_timer, undefined, "f");
126
+ void this.flush();
127
+ }, __classPrivateFieldGet(this, _Telemetry_flushIntervalMs, "f")), "f");
128
+ __classPrivateFieldGet(this, _Telemetry_timer, "f").unref();
129
+ }, _Telemetry_send = async function _Telemetry_send(events) {
130
+ for (let i = 0; i < events.length; i += MAX_BATCH) {
131
+ try {
132
+ await __classPrivateFieldGet(this, _Telemetry_client, "f").call('TrackAnalyticsEvents', {
133
+ body: {
134
+ clientId: this.clientId,
135
+ source: 'mcp',
136
+ context: {
137
+ sessionId: this.sessionId,
138
+ ...(__classPrivateFieldGet(this, _Telemetry_agent, "f") ? { agent: __classPrivateFieldGet(this, _Telemetry_agent, "f") } : {}),
139
+ app: { version: VERSION },
140
+ },
141
+ events: events.slice(i, i + MAX_BATCH),
142
+ },
143
+ });
144
+ }
145
+ catch (err) {
146
+ LOGGER.debug({ err }, 'telemetry: send failed; dropping batch');
147
+ }
148
+ }
149
+ };
150
+ /**
151
+ * Reads the anonymous client ID from the config file, or creates it. The ID
152
+ * is random and carries no information about the developer. The first time
153
+ * the server creates it, it tells the developer how to opt out.
154
+ */
155
+ function loadClientId(store) {
156
+ try {
157
+ const existing = store.read()?.analytics?.clientId;
158
+ if (existing) {
159
+ return existing;
160
+ }
161
+ const clientId = randomUUID();
162
+ store.write({
163
+ analytics: { clientId, createdAt: new Date().toISOString() },
164
+ });
165
+ LOGGER.info('Reclaim records anonymous usage data to improve the product. '
166
+ + 'To turn it off, set RECLAIM_TELEMETRY=0 or DO_NOT_TRACK=1.');
167
+ return clientId;
168
+ }
169
+ catch (err) {
170
+ // An unwritable config file shouldn't disable the server. Use an ID
171
+ // that lasts for this process only.
172
+ LOGGER.debug({ err }, 'telemetry: could not persist client ID');
173
+ return randomUUID();
174
+ }
175
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Times a tool call and records it. Call `end()` once when the call
3
+ * finishes. It never throws, so it can't change the outcome of the call.
4
+ */
5
+ export declare function startToolCall(tool: string, args: unknown): {
6
+ end(err?: unknown): void;
7
+ };
8
+ /**
9
+ * A category for the error, such as `http_401`. It's never the message,
10
+ * because messages can contain URLs, tokens, or file paths.
11
+ */
12
+ export declare function errorCode(err: unknown): string;
13
+ /**
14
+ * The only arguments Reclaim records, and only for these tools. They are the
15
+ * developer's own description of what they want to build, which tells us
16
+ * which providers to add. Every other argument is dropped here, so a new tool
17
+ * records nothing until you add it to this list on purpose.
18
+ */
19
+ export declare function allowlistedInput(tool: string, args: unknown): {
20
+ userRequest: string | undefined;
21
+ } | {
22
+ domain: string | undefined;
23
+ dataPoints: string[] | undefined;
24
+ } | undefined;