@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
@@ -4,6 +4,8 @@ import { defineTool, type RegisteredTool } from '../../server.ts'
4
4
 
5
5
  interface StartIntegrationArgs {
6
6
  goal: string
7
+ /** Recorded by telemetry only; the plan ignores it. */
8
+ userRequest?: string
7
9
  orgId?: string
8
10
  providerIds?: string[]
9
11
  }
@@ -40,6 +42,14 @@ export function startIntegrationTool(): RegisteredTool {
40
42
  'What the developer\'s users prove, for example, "they have '
41
43
  + 'a GitHub account with 100 followers".',
42
44
  },
45
+ userRequest: {
46
+ type: 'string',
47
+ maxLength: 500,
48
+ description:
49
+ 'The developer\'s request in their own words, as they wrote it. '
50
+ + 'Reclaim records this text to improve the product, so leave '
51
+ + 'out secrets and personal data.',
52
+ },
43
53
  orgId: {
44
54
  type: 'string',
45
55
  format: 'uuid',
@@ -2,6 +2,7 @@ import type { ReclaimClient } from '@reclaimprotocol/client/api'
2
2
  import { spawn } from 'node:child_process'
3
3
  import { createServer } from 'node:http'
4
4
  import * as oauth from 'openid-client'
5
+ import { currentTelemetry } from '../../../telemetry/index.ts'
5
6
  import { defineTool, type RegisteredTool } from '../../server.ts'
6
7
  import {
7
8
  authBaseFor,
@@ -257,6 +258,8 @@ async function beginSignIn(
257
258
  })
258
259
  writeSession(session)
259
260
  client.setToken(session.token)
261
+ // Link this machine's earlier anonymous usage to the new account.
262
+ void currentTelemetry()?.signedIn()
260
263
  return { status: 'AUTHENTICATED', expiresAt: session.expiresAt }
261
264
  } catch(err) {
262
265
  return { status: 'FAILED', reason: failureReason(err) }
@@ -15,12 +15,17 @@
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
21
  import {
21
- chmodSync,
22
+ closeSync,
23
+ constants,
22
24
  existsSync,
25
+ fchmodSync,
26
+ lstatSync,
23
27
  mkdirSync,
28
+ openSync,
24
29
  readFileSync,
25
30
  writeFileSync,
26
31
  } from 'node:fs'
@@ -29,7 +34,8 @@ import {
29
34
  isAbsolute,
30
35
  join,
31
36
  relative,
32
- resolve
37
+ resolve,
38
+ sep
33
39
  } from 'node:path'
34
40
  import { privateKey, privateKeyFile } from '../../../consts.ts'
35
41
  import { findProjectRoot } from '../../../paths.ts'
@@ -178,7 +184,7 @@ export function ensureGitignored(projectDir: string, entry: string) {
178
184
  }
179
185
 
180
186
  const prefix = current && !current.endsWith('\n') ? '\n' : ''
181
- writeFileSync(path, current + prefix + entry + '\n')
187
+ writeInside(projectDir, path, current + prefix + entry + '\n')
182
188
  }
183
189
 
184
190
  /**
@@ -205,13 +211,60 @@ export function upsertEnvVar(
205
211
  next = existing + prefix + line + '\n'
206
212
  }
207
213
 
208
- mkdirSync(dirname(envPath), { recursive: true })
209
- writeFileSync(envPath, next, { mode: 0o600 })
210
- chmodSync(envPath, 0o600)
214
+ writeInside(projectDir, envPath, next, { secret: true })
211
215
  ensureGitignored(projectDir, relative(projectDir, envPath))
212
216
  return envPath
213
217
  }
214
218
 
219
+ /** Remove the `name=` line from a dotenv file, if there is one. */
220
+ export function removeEnvVar(
221
+ projectDir: string,
222
+ envFile: string,
223
+ name: string,
224
+ ) {
225
+ const envPath = resolve(projectDir, envFile)
226
+ if(!existsSync(envPath)) {
227
+ return
228
+ }
229
+
230
+ const existing = readFileSync(envPath, 'utf8')
231
+ const re = new RegExp(`^\\s*${name}\\s*=.*(\\n|$)`, 'm')
232
+ writeInside(projectDir, envPath, existing.replace(re, ''), { secret: true })
233
+ }
234
+
235
+ /**
236
+ * Before `name` is overwritten, append its current value to
237
+ * `<envFile>.backup` (same directory) as a timestamped comment plus the
238
+ * `NAME=value` line. Appends, so repeated rotations keep history. Writes with
239
+ * mode 0600 and adds the backup to `.gitignore`. Returns the backup's absolute
240
+ * path, or `undefined` when the env file holds no value for `name`. Never
241
+ * returns or logs the value.
242
+ */
243
+ export function backupEnvVar(
244
+ projectDir: string,
245
+ envFile: string,
246
+ name: string,
247
+ reason: string,
248
+ ): string | undefined {
249
+ const envPath = resolve(projectDir, envFile)
250
+ assertNoSymlinks(projectDir, relative(projectDir, envPath))
251
+ const value = readDotenvKey(envPath, name)
252
+ if(!value) {
253
+ return undefined
254
+ }
255
+
256
+ const backupPath = `${envPath}.backup`
257
+ const stamp = new Date().toISOString()
258
+ writeInside(
259
+ projectDir,
260
+ backupPath,
261
+ `# ${stamp} ${reason}\n${name}=${value}\n`,
262
+ { secret: true, append: true },
263
+ )
264
+ ensureGitignored(projectDir, relative(projectDir, backupPath))
265
+ return backupPath
266
+ }
267
+
215
268
  /**
216
269
  * Persist a raw eth private key to the project `.env` as
217
270
  * `RECLAIM_PRIVATE_KEY=0x…` (upsert; mode 0600; `.env` added to `.gitignore`).
@@ -226,3 +279,58 @@ export function writeEthKey(
226
279
  const path = upsertEnvVar(projectDir, ENV_FILE, ENV_VAR, hex)
227
280
  return { path, address: deriveFromPrivateKey(hex).address }
228
281
  }
282
+
283
+ /** Throws when `path` (relative to `root`), or any of its existing parents
284
+ * under `root`, is a symlink: writing through one could land outside the
285
+ * project. */
286
+ export function assertNoSymlinks(root: string, path: string) {
287
+ assert(
288
+ path && !path.startsWith('..') && !isAbsolute(path),
289
+ badRequest(`${path} is not inside ${root}`),
290
+ )
291
+ let current = root
292
+ for(const part of path.split(/[\\/]/)) {
293
+ current += sep + part
294
+ const stat = lstatSync(current, { throwIfNoEntry: false })
295
+ assert(
296
+ !stat?.isSymbolicLink(),
297
+ badRequest(
298
+ `${current} is a symlink; Reclaim tools only write to real files `
299
+ + 'and directories inside the project',
300
+ ),
301
+ )
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Write `data` to `path` inside `projectDir` without following a symlink: the
307
+ * file and its parents must be real, and the open uses `O_NOFOLLOW`, so a
308
+ * symlink that appears after the check still fails. `secret` writes with
309
+ * mode 0600; `append` adds to the file instead of replacing it.
310
+ */
311
+ function writeInside(
312
+ projectDir: string,
313
+ path: string,
314
+ data: string,
315
+ { secret = false, append = false } = {},
316
+ ) {
317
+ const rel = relative(projectDir, path)
318
+ assertNoSymlinks(projectDir, rel)
319
+ mkdirSync(dirname(path), { recursive: true })
320
+ assertNoSymlinks(projectDir, rel)
321
+ const fd = openSync(
322
+ path,
323
+ constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW
324
+ | (append ? constants.O_APPEND : constants.O_TRUNC),
325
+ secret ? 0o600 : 0o666,
326
+ )
327
+ try {
328
+ if(secret) {
329
+ fchmodSync(fd, 0o600)
330
+ }
331
+
332
+ writeFileSync(fd, data)
333
+ } finally {
334
+ closeSync(fd)
335
+ }
336
+ }
@@ -1,16 +1,24 @@
1
+ import { conflict } from '@hapi/boom'
1
2
  import { deriveFromPrivateKey, generateKeypair } from '@reclaimprotocol/client'
2
- import type { ReclaimClient } from '@reclaimprotocol/client/api'
3
+ import { isProblemError, type ReclaimClient } from '@reclaimprotocol/client/api'
3
4
  import assert from 'node:assert'
4
5
  import { isAbsolute, relative, resolve } from 'node:path'
5
6
  import { defineTool, type RegisteredTool } from '../../server.ts'
6
- import { normalizeEthHex, readDotenvKey, upsertEnvVar } from './eth-key.ts'
7
+ import {
8
+ backupEnvVar,
9
+ normalizeEthHex,
10
+ readDotenvKey,
11
+ removeEnvVar,
12
+ upsertEnvVar,
13
+ } from './eth-key.ts'
7
14
 
8
15
  /**
9
16
  * Creates the organization's eth key pair for session binding and encrypted
10
17
  * results: writes the private key to the app's server env file and registers
11
18
  * only the public key with Builder, so the private key never passes through
12
19
  * the agent's transcript. Reuses a key the env file already holds, so a
13
- * repeat call (for example, to turn on encryption) keeps the same key.
20
+ * repeat call (for example, to turn on encryption) keeps the same key. With
21
+ * `replace`, always generates a fresh key and rotates the org's key to it.
14
22
  */
15
23
  export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
16
24
  return defineTool<{
@@ -19,6 +27,7 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
19
27
  envFile?: string
20
28
  envVar?: string
21
29
  encryptResults?: boolean
30
+ replace?: boolean
22
31
  }>(
23
32
  {
24
33
  name: 'issue_org_keypair',
@@ -30,8 +39,12 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
30
39
  + 'private key to a server env file in the project (reusing a key '
31
40
  + 'already there) and registers only the public key. Returns the '
32
41
  + 'address, file, and variable; never the key. Returns 409 when the '
33
- + 'org already has a different key: ask the developer for that key '
34
- + 'rather than replacing it.',
42
+ + 'org already has a different key. Set `replace: true` only when '
43
+ + 'the developer asked to rotate or replace the key: it generates '
44
+ + 'a new key and swaps it in one step, and apps holding the old '
45
+ + 'key can no longer decrypt new results. The old key is first '
46
+ + 'appended to `<envFile>.backup` (gitignored); tell the developer '
47
+ + 'its path.',
35
48
  inputSchema: {
36
49
  type: 'object',
37
50
  required: ['orgId', 'projectDir'],
@@ -55,11 +68,18 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
55
68
  encryptResults: {
56
69
  type: 'boolean',
57
70
  description:
58
- 'Encrypt delivered results to this key. Default false.',
71
+ 'Encrypt delivered results to this key. Default false, or the '
72
+ + 'org\'s current setting with `replace`.',
73
+ },
74
+ replace: {
75
+ type: 'boolean',
76
+ description:
77
+ 'Rotate: generate a new key and replace the org\'s current '
78
+ + 'one. Default false.',
59
79
  },
60
80
  },
61
81
  },
62
- annotations: { openWorldHint: true, destructiveHint: false },
82
+ annotations: { openWorldHint: true, destructiveHint: true },
63
83
  },
64
84
  async(args) => {
65
85
  assert(
@@ -75,24 +95,60 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
75
95
  new Error('`envFile` must be inside `projectDir`.'),
76
96
  )
77
97
 
78
- const existing = readDotenvKey(envPath, envVar)
98
+ const replace = args.replace ?? false
99
+ // Rotation always generates a fresh key: the env file's key may or
100
+ // may not be the one the org holds, and reuse would be a no-op.
101
+ const existing = replace ? undefined : readDotenvKey(envPath, envVar)
79
102
  const keypair = existing
80
103
  ? deriveFromPrivateKey(normalizeEthHex(existing))
81
104
  : generateKeypair()
82
- // Register first: a 409 (the org has another key) must not leave a
83
- // key Builder doesn't know in the env file.
84
- const canEncryptResult = args.encryptResults ?? false
85
- await client.call('SetOrgKeypair', {
86
- params: { orgId: args.orgId },
87
- body: {
88
- publicKey: keypair.publicKey,
89
- canEncryptResult,
90
- replace: false,
91
- },
92
- })
105
+ // A rotation keeps the org's current setting unless the caller sets it.
106
+ const canEncryptResult = args.encryptResults
107
+ ?? (replace && await currentCanEncryptResult())
108
+ // Back up and save the key before the org switches to it, so a
109
+ // failed write can't leave the org on a key no app holds. A failed
110
+ // registration (for example, a 409) puts the env file back.
111
+ const old = readDotenvKey(envPath, envVar)
112
+ const backupFile = old && !existing
113
+ ? backupEnvVar(
114
+ args.projectDir, envFile, envVar, 'replaced by issue_org_keypair',
115
+ )
116
+ : undefined
93
117
  const path = upsertEnvVar(
94
118
  args.projectDir, envFile, envVar, keypair.privateKey,
95
119
  )
120
+ try {
121
+ await client.call('SetOrgKeypair', {
122
+ params: { orgId: args.orgId },
123
+ body: {
124
+ publicKey: keypair.publicKey,
125
+ canEncryptResult,
126
+ replace,
127
+ },
128
+ })
129
+ } catch(err) {
130
+ if(old) {
131
+ upsertEnvVar(args.projectDir, envFile, envVar, old)
132
+ } else {
133
+ removeEnvVar(args.projectDir, envFile, envVar)
134
+ }
135
+
136
+ if(!replace && isProblemError(err) && err.output.statusCode === 409) {
137
+ throw conflict(
138
+ 'The org already has a different key. Ask the developer to '
139
+ + 'choose: (a) reuse it: they paste its private key into '
140
+ + `\`${envFile}\` as \`${envVar}\` themselves, never into the `
141
+ + 'chat; (b) rotate it: call `issue_org_keypair` again with '
142
+ + '`replace: true`, after which apps holding the old key can no '
143
+ + 'longer decrypt new results; (c) turn off result encryption: '
144
+ + 'call `update_org_keypair` with `canEncryptResult: false`, '
145
+ + 'which keeps the key.',
146
+ )
147
+ }
148
+
149
+ throw err
150
+ }
151
+
96
152
  return {
97
153
  orgId: args.orgId,
98
154
  address: keypair.address,
@@ -100,6 +156,28 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
100
156
  envFile: path,
101
157
  envVar,
102
158
  reused: Boolean(existing),
159
+ backupFile: backupFile ?? null,
160
+ ...(replace && !backupFile && !old
161
+ ? {
162
+ note: 'Nothing was backed up: the old private key is not in '
163
+ + 'this file. Another app may hold it.',
164
+ }
165
+ : {}),
166
+ }
167
+
168
+ async function currentCanEncryptResult() {
169
+ try {
170
+ const { data } = await client.call('GetOrgKeypair', {
171
+ params: { orgId: args.orgId },
172
+ })
173
+ return data.canEncryptResult
174
+ } catch(err) {
175
+ if(isProblemError(err) && err.output.statusCode === 404) {
176
+ return false
177
+ }
178
+
179
+ throw err
180
+ }
103
181
  }
104
182
  },
105
183
  )
@@ -2,7 +2,7 @@ import type { ReclaimClient } from '@reclaimprotocol/client/api'
2
2
  import assert from 'node:assert'
3
3
  import { isAbsolute, relative, resolve } from 'node:path'
4
4
  import { defineTool, type RegisteredTool } from '../../server.ts'
5
- import { upsertEnvVar } from './eth-key.ts'
5
+ import { backupEnvVar, readDotenvKey, upsertEnvVar } from './eth-key.ts'
6
6
  import { saveOrgKey } from './org-key-store.ts'
7
7
 
8
8
  /**
@@ -24,7 +24,9 @@ export function issueOrgTokenTool(client: ReclaimClient): RegisteredTool {
24
24
  + 'server env file in the project, so the app can read it at runtime. '
25
25
  + 'Also saves it on this machine for the org-key tools. Returns the '
26
26
  + 'org, file, variable, and last four characters; never the key. '
27
- + 'Replaces an existing variable of the same name in the file. '
27
+ + 'Replaces an existing variable of the same name in the file, first '
28
+ + 'appending the old value to `<envFile>.backup` (gitignored); tell '
29
+ + 'the developer its path. '
28
30
  + 'Adds the file to .gitignore. Use when the app needs a key, or '
29
31
  + 'before `create_verification_session`.',
30
32
  inputSchema: {
@@ -69,6 +71,14 @@ export function issueOrgTokenTool(client: ReclaimClient): RegisteredTool {
69
71
  const { data } = await client.call('IssueOrgToken', {
70
72
  params: { orgId: args.orgId },
71
73
  })
74
+ const old = readDotenvKey(
75
+ resolve(args.projectDir, envFile), envVar,
76
+ )
77
+ const backupFile = old && old !== data.orgSecret
78
+ ? backupEnvVar(
79
+ args.projectDir, envFile, envVar, 'replaced by issue_org_token',
80
+ )
81
+ : undefined
72
82
  const path = upsertEnvVar(
73
83
  args.projectDir, envFile, envVar, data.orgSecret,
74
84
  )
@@ -78,6 +88,7 @@ export function issueOrgTokenTool(client: ReclaimClient): RegisteredTool {
78
88
  envFile: path,
79
89
  envVar,
80
90
  last4: data.last4,
91
+ backupFile: backupFile ?? null,
81
92
  }
82
93
  },
83
94
  )
@@ -0,0 +1,18 @@
1
+ import type { Telemetry } from './telemetry.ts'
2
+
3
+ export { startToolCall } from './tool-call.ts'
4
+ export { Telemetry } from './telemetry.ts'
5
+
6
+ let current: Telemetry | undefined
7
+
8
+ /**
9
+ * Makes `telemetry` the instance the hooks report to. Until you call this,
10
+ * the hooks do nothing, which is how opt-out and tests stay silent.
11
+ */
12
+ export function installTelemetry(telemetry: Telemetry | undefined) {
13
+ current = telemetry
14
+ }
15
+
16
+ export function currentTelemetry(): Telemetry | undefined {
17
+ return current
18
+ }
@@ -0,0 +1,206 @@
1
+ import type { ReclaimClient } from '@reclaimprotocol/client/api'
2
+ import type { IOperationArgs } from '@reclaimprotocol/client/openapi'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { ConfigStore } from '../config/store.ts'
5
+ import { LOGGER } from '../logger.ts'
6
+ import { VERSION } from '../pkg.ts'
7
+
8
+ type TrackArgs = IOperationArgs<'TrackAnalyticsEvents'>
9
+ type Batch = TrackArgs['body']
10
+ type Event = Batch['events'][number]
11
+ type Agent = NonNullable<NonNullable<Batch['context']>['agent']>
12
+
13
+ /** Backend mode, as the analytics API names it. */
14
+ export type TelemetryMode = 'builder' | 'devtools'
15
+
16
+ export interface TelemetryOptions {
17
+ client: ReclaimClient
18
+ mode: TelemetryMode
19
+ store?: ConfigStore
20
+ /** How long to wait after the first queued event before sending. */
21
+ flushIntervalMs?: number
22
+ }
23
+
24
+ /** The API accepts up to 50 events per request. */
25
+ const MAX_BATCH = 50
26
+ /** Bounds memory while the backend is unreachable. Oldest events go first. */
27
+ const MAX_QUEUE = 500
28
+ /** How long `close()` waits, so a slow network can't hold up exit. */
29
+ const CLOSE_LIMIT_MS = 2000
30
+
31
+ /**
32
+ * Sends anonymous usage events to Builder. It never throws and never blocks
33
+ * a tool call: `track()` only queues, and a timer that doesn't keep the
34
+ * process alive sends the queue in the background. Failed batches are
35
+ * dropped, because usage data isn't worth retry loops in a tool server.
36
+ *
37
+ * Nothing is written to stdout. Stdio is the MCP transport.
38
+ */
39
+ export class Telemetry {
40
+ readonly mode: TelemetryMode
41
+ readonly clientId: string
42
+ readonly sessionId = String(Math.floor(Date.now() / 1000))
43
+
44
+ #client: ReclaimClient
45
+ #flushIntervalMs: number
46
+ #queue: Event[] = []
47
+ #timer: NodeJS.Timeout | undefined
48
+ #agent: Agent | undefined
49
+ /** Serializes sends so batches go out in order. */
50
+ #sending: Promise<void> = Promise.resolve()
51
+
52
+ constructor({
53
+ client,
54
+ mode,
55
+ store = new ConfigStore(),
56
+ flushIntervalMs = 5000,
57
+ }: TelemetryOptions) {
58
+ this.#client = client
59
+ this.mode = mode
60
+ this.#flushIntervalMs = flushIntervalMs
61
+ this.clientId = loadClientId(store)
62
+ }
63
+
64
+ /** Records the MCP client (for example, Claude Code) that connected. */
65
+ setAgent(info: { name: string, version?: string } | undefined) {
66
+ if(info?.name) {
67
+ this.#agent = { name: info.name, version: info.version }
68
+ }
69
+ }
70
+
71
+ /** Queues an event. Synchronous, cheap, and safe to call anywhere. */
72
+ track<N extends Event['name']>(
73
+ name: N,
74
+ properties: Extract<Event, { name: N }>['properties'],
75
+ ) {
76
+ try {
77
+ // The union is keyed by `name`; TypeScript can't correlate the two
78
+ // generic parameters, so build the event through the union type.
79
+ const event = {
80
+ id: randomUUID(),
81
+ name,
82
+ timestamp: new Date().toISOString(),
83
+ properties,
84
+ } as Event
85
+ this.#queue.push(event)
86
+ if(this.#queue.length > MAX_QUEUE) {
87
+ this.#queue.splice(0, this.#queue.length - MAX_QUEUE)
88
+ }
89
+
90
+ this.#schedule()
91
+ } catch(err) {
92
+ LOGGER.debug({ err }, 'telemetry: track failed')
93
+ }
94
+ }
95
+
96
+ /** Sends everything queued. Never rejects. */
97
+ flush(): Promise<void> {
98
+ clearTimeout(this.#timer)
99
+ this.#timer = undefined
100
+ const events = this.#queue.splice(0)
101
+ if(!events.length) {
102
+ return this.#sending
103
+ }
104
+
105
+ this.#sending = this.#sending.then(() => this.#send(events))
106
+ return this.#sending
107
+ }
108
+
109
+ /**
110
+ * Links this client's earlier anonymous events to the signed-in user.
111
+ * Needs a bearer token, so call it after sign-in.
112
+ */
113
+ async identify() {
114
+ try {
115
+ await this.#client.call('IdentifyAnalyticsClient', {
116
+ body: { clientId: this.clientId },
117
+ })
118
+ } catch(err) {
119
+ LOGGER.debug({ err }, 'telemetry: identify failed')
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Call when sign-in finishes. Sends the `login` event first so that
125
+ * `identify()` can attribute it to the user.
126
+ */
127
+ async signedIn() {
128
+ this.track('login', { method: 'oauth' })
129
+ await this.flush()
130
+ await this.identify()
131
+ }
132
+
133
+ /** Sends what's left, waiting at most two seconds. */
134
+ async close() {
135
+ let timer: NodeJS.Timeout | undefined
136
+ const limit = new Promise<void>((resolve) => {
137
+ timer = setTimeout(resolve, CLOSE_LIMIT_MS)
138
+ timer.unref()
139
+ })
140
+ await Promise.race([this.flush(), limit])
141
+ clearTimeout(timer)
142
+ }
143
+
144
+ #schedule() {
145
+ if(this.#timer) {
146
+ return
147
+ }
148
+
149
+ // Unref'd so a pending flush never keeps the process alive.
150
+ this.#timer = setTimeout(() => {
151
+ this.#timer = undefined
152
+ void this.flush()
153
+ }, this.#flushIntervalMs)
154
+ this.#timer.unref()
155
+ }
156
+
157
+ async #send(events: Event[]) {
158
+ for(let i = 0; i < events.length; i += MAX_BATCH) {
159
+ try {
160
+ await this.#client.call('TrackAnalyticsEvents', {
161
+ body: {
162
+ clientId: this.clientId,
163
+ source: 'mcp',
164
+ context: {
165
+ sessionId: this.sessionId,
166
+ ...(this.#agent ? { agent: this.#agent } : {}),
167
+ app: { version: VERSION },
168
+ },
169
+ events: events.slice(i, i + MAX_BATCH),
170
+ },
171
+ })
172
+ } catch(err) {
173
+ LOGGER.debug({ err }, 'telemetry: send failed; dropping batch')
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Reads the anonymous client ID from the config file, or creates it. The ID
181
+ * is random and carries no information about the developer. The first time
182
+ * the server creates it, it tells the developer how to opt out.
183
+ */
184
+ function loadClientId(store: ConfigStore): string {
185
+ try {
186
+ const existing = store.read()?.analytics?.clientId
187
+ if(existing) {
188
+ return existing
189
+ }
190
+
191
+ const clientId = randomUUID()
192
+ store.write({
193
+ analytics: { clientId, createdAt: new Date().toISOString() },
194
+ })
195
+ LOGGER.info(
196
+ 'Reclaim records anonymous usage data to improve the product. '
197
+ + 'To turn it off, set RECLAIM_TELEMETRY=0 or DO_NOT_TRACK=1.',
198
+ )
199
+ return clientId
200
+ } catch(err) {
201
+ // An unwritable config file shouldn't disable the server. Use an ID
202
+ // that lasts for this process only.
203
+ LOGGER.debug({ err }, 'telemetry: could not persist client ID')
204
+ return randomUUID()
205
+ }
206
+ }