@reclaimprotocol/agent 0.1.40 → 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.
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;
@@ -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:ecdb0ee6d82e).
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
  }
@@ -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
  });
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
+ }
@@ -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) {
@@ -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;
@@ -0,0 +1,102 @@
1
+ import { isProblemError } from '@reclaimprotocol/client/api';
2
+ import { currentTelemetry } from "./index.js";
3
+ const GOAL_MAX = 300;
4
+ const USER_REQUEST_MAX = 500;
5
+ const DOMAIN_MAX = 200;
6
+ const DATA_POINTS_MAX = 20;
7
+ const DATA_POINT_MAX = 200;
8
+ /**
9
+ * Times a tool call and records it. Call `end()` once when the call
10
+ * finishes. It never throws, so it can't change the outcome of the call.
11
+ */
12
+ export function startToolCall(tool, args) {
13
+ const startedAt = Date.now();
14
+ return {
15
+ end(err) {
16
+ try {
17
+ const telemetry = currentTelemetry();
18
+ if (!telemetry) {
19
+ return;
20
+ }
21
+ const input = allowlistedInput(tool, args);
22
+ telemetry.track('tool_call', {
23
+ tool,
24
+ ok: err === undefined,
25
+ ...(err === undefined ? {} : { errorCode: errorCode(err) }),
26
+ durationMs: Date.now() - startedAt,
27
+ mode: telemetry.mode,
28
+ ...(input ? { input } : {}),
29
+ });
30
+ }
31
+ catch {
32
+ // Telemetry must never affect a tool call.
33
+ }
34
+ },
35
+ };
36
+ }
37
+ /**
38
+ * A category for the error, such as `http_401`. It's never the message,
39
+ * because messages can contain URLs, tokens, or file paths.
40
+ */
41
+ export function errorCode(err) {
42
+ if (isProblemError(err)) {
43
+ return `http_${err.output.statusCode}`;
44
+ }
45
+ if (isRecord(err) && err.isBoom === true && isRecord(err.output)) {
46
+ return `http_${String(err.output.statusCode)}`;
47
+ }
48
+ if (err instanceof Error) {
49
+ if (err.name === 'ZodError') {
50
+ return 'invalid_input';
51
+ }
52
+ if (err.name === 'TimeoutError' || err.name === 'AbortError') {
53
+ return 'timeout';
54
+ }
55
+ }
56
+ return 'error';
57
+ }
58
+ /**
59
+ * The only arguments Reclaim records, and only for these tools. They are the
60
+ * developer's own description of what they want to build, which tells us
61
+ * which providers to add. Every other argument is dropped here, so a new tool
62
+ * records nothing until you add it to this list on purpose.
63
+ */
64
+ export function allowlistedInput(tool, args) {
65
+ if (!isRecord(args)) {
66
+ return undefined;
67
+ }
68
+ switch (tool) {
69
+ case 'start_integration':
70
+ return compact({
71
+ goal: cut(args.goal, GOAL_MAX),
72
+ userRequest: cut(args.userRequest, USER_REQUEST_MAX),
73
+ });
74
+ case 'propose_provider':
75
+ return compact({ userRequest: cut(args.userRequest, USER_REQUEST_MAX) });
76
+ case 'discover_resolve': {
77
+ const query = isRecord(args.query) ? args.query : {};
78
+ const dataPoints = Array.isArray(query.dataPoints)
79
+ ? query.dataPoints
80
+ .slice(0, DATA_POINTS_MAX)
81
+ .map((p) => cut(p, DATA_POINT_MAX))
82
+ .filter((p) => p !== undefined)
83
+ : undefined;
84
+ return compact({
85
+ domain: cut(query.domain, DOMAIN_MAX),
86
+ dataPoints: dataPoints?.length ? dataPoints : undefined,
87
+ });
88
+ }
89
+ default:
90
+ return undefined;
91
+ }
92
+ }
93
+ function cut(value, max) {
94
+ return typeof value === 'string' && value ? value.slice(0, max) : undefined;
95
+ }
96
+ /** Returns the object, or undefined when every field is undefined. */
97
+ function compact(obj) {
98
+ return Object.values(obj).some((v) => v !== undefined) ? obj : undefined;
99
+ }
100
+ function isRecord(value) {
101
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
102
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reclaimprotocol/agent",
3
3
  "title": "Reclaim Agent",
4
- "version": "0.1.40",
4
+ "version": "0.1.41",
5
5
  "description": "Reclaim Protocol tools for AI coding agents. Reclaim lets your app verify data points about its users, such as a follower count, an account balance, or an order history. This package is the Reclaim MCP server: it gives your agent the tools to find, build, test, and fix data verification providers by capturing and replaying browser requests, drafting provider recipes, running zkTLS proofs, and publishing provider versions.",
6
6
  "type": "module",
7
7
  "main": "./lib/index.js",
@@ -84,7 +84,7 @@
84
84
  "@noble/hashes": "^2.2.0",
85
85
  "@puppeteer/browsers": "3.2.4",
86
86
  "@reclaimprotocol/attestor-core": "^5.1.6",
87
- "@reclaimprotocol/client": "0.1.40",
87
+ "@reclaimprotocol/client": "0.1.41",
88
88
  "@reclaimprotocol/tls": "^0.1.3",
89
89
  "chrome-remote-interface": "^0.33.0",
90
90
  "domhandler": "^6.0.0",
@@ -27,6 +27,9 @@ export interface ReclaimAgentConfig {
27
27
  * - `old_devtools` — externally-supplied identity for OLD-devtools mode
28
28
  * (USE_OLD_DEVTOOLS); independent of `session`'s
29
29
  * builder token. Deletable with the rest of `src/old/`.
30
+ * - `analytics` — the random, anonymous client ID the usage data is
31
+ * sent under (see telemetry/telemetry.ts); holds no
32
+ * personal data
30
33
  * - `org_keys` — organization API keys saved by `issue_org_token`, so
31
34
  * the org-key tools work without the key in a transcript
32
35
  * Sections are persisted independently via `ConfigStore.write`, which
@@ -43,6 +46,7 @@ export interface ReclaimConfig {
43
46
  session?: CachedSession
44
47
  old_devtools?: OldIdentity
45
48
  org_keys?: SavedOrgKeys
49
+ analytics?: { clientId: string, createdAt: string }
46
50
  }
47
51
 
48
52
  /** Organization API keys (`rorg_…`) saved by `issue_org_token`. */
package/src/consts.ts CHANGED
@@ -143,6 +143,17 @@ export function agentDisabled(): boolean {
143
143
  return process.env.RECLAIM_AGENT_DISABLED === '1'
144
144
  }
145
145
 
146
+ /**
147
+ * Whether the server may send anonymous usage data. Off when you set
148
+ * `RECLAIM_TELEMETRY=0` or the cross-tool `DO_NOT_TRACK=1` convention.
149
+ */
150
+ export function telemetryEnabled(): boolean {
151
+ const doNotTrack = process.env.DO_NOT_TRACK?.toLowerCase()
152
+ return process.env.RECLAIM_TELEMETRY !== '0'
153
+ && doNotTrack !== '1'
154
+ && doNotTrack !== 'true'
155
+ }
156
+
146
157
  /** Old-devtools backend base URL. */
147
158
  export function oldApiUrl(): string {
148
159
  return process.env.RECLAIM_OLD_API_URL || DEFAULT_OLD_API_URL
@@ -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:ecdb0ee6d82e).
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.
@@ -123,7 +123,11 @@ export const MCP_TOOLS = {
123
123
  "enum": [
124
124
  "providers",
125
125
  "revenue",
126
- "usage"
126
+ "usage",
127
+ "goals",
128
+ "tools",
129
+ "agents",
130
+ "funnel"
127
131
  ]
128
132
  }
129
133
  }
@@ -7,6 +7,7 @@ import type {
7
7
  } from '@reclaimprotocol/client/openapi'
8
8
  import assert from 'node:assert'
9
9
  import { orgSecret } from '../../consts.ts'
10
+ import { startToolCall } from '../../telemetry/index.ts'
10
11
  import { remapErrorAsResponse } from '../remap_errors.ts'
11
12
  import { successResponse } from '../success-response.ts'
12
13
  import { savedOrgKey } from '../tools/authenticate/org-key-store.ts'
@@ -68,6 +69,7 @@ export function registerTools(
68
69
  toSnakeCase(opId),
69
70
  shape,
70
71
  async(input: unknown) => {
72
+ const call = startToolCall(toSnakeCase(opId), input)
71
73
  try {
72
74
  if(!isRecord(input)) {
73
75
  throw new Error(`Invalid generated MCP arguments for ${opId}`)
@@ -85,8 +87,10 @@ export function registerTools(
85
87
  opId,
86
88
  args,
87
89
  )
90
+ call.end()
88
91
  return successResponse(result.data)
89
92
  } catch(err) {
93
+ call.end(err)
90
94
  return remapErrorAsResponse(err)
91
95
  }
92
96
  },
package/src/mcp/server.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
+ import { startToolCall } from '../telemetry/index.ts'
2
3
  import { type RegisteredTool } from '../tools/registry.ts'
3
4
  import { toZod } from './api/to-zod.ts'
4
5
  import { remapErrorAsResponse } from './remap_errors.ts'
@@ -31,9 +32,13 @@ export function registerToolList(server: McpServer, tools: RegisteredTool[]) {
31
32
  : {}),
32
33
  },
33
34
  async(args) => {
35
+ const call = startToolCall(tool.definition.name, args)
34
36
  try {
35
- return successResponse(await tool.handler(args))
37
+ const result = successResponse(await tool.handler(args))
38
+ call.end()
39
+ return result
36
40
  } catch(err) {
41
+ call.end(err)
37
42
  return remapErrorAsResponse(err)
38
43
  }
39
44
  },
package/src/mcp/start.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  oldLogsUrl,
11
11
  resolveOldMode,
12
12
  sdkApiUrl,
13
+ telemetryEnabled,
13
14
  } from '../consts.ts'
14
15
  import { LOGGER } from '../logger.ts'
15
16
  import { ReclaimOldClient } from '../old/client.ts'
@@ -19,6 +20,11 @@ import { ReclaimSdkClient } from '../old/sdk-client.ts'
19
20
  import { buildOldBackendTools } from '../old/tools/build.ts'
20
21
  import { NAME, VERSION } from '../pkg.ts'
21
22
  import { startZkFilesDownload } from '../proof/zk-files.ts'
23
+ import {
24
+ currentTelemetry,
25
+ installTelemetry,
26
+ Telemetry
27
+ } from '../telemetry/index.ts'
22
28
  import { registerTools } from './api/tools.ts'
23
29
  import {
24
30
  type AgentState,
@@ -78,6 +84,13 @@ export async function startMcpServer() {
78
84
  }),
79
85
  headers: { 'User-Agent': `${NAME}/${VERSION}` },
80
86
  })
87
+ // Anonymous usage data. Installed before any tool registers so every tool
88
+ // call is covered. When the developer opts out, nothing is created.
89
+ const mode = oldMode ? 'devtools' : 'builder'
90
+ if(telemetryEnabled()) {
91
+ installTelemetry(new Telemetry({ client: builderClient, mode }))
92
+ }
93
+
81
94
  // Backend-mode toggle — available in both modes and regardless of whether
82
95
  // the agent tools are enabled, so a user can always switch either direction.
83
96
  registerToolList(server, buildModeTools(config, oldMode))
@@ -151,6 +164,19 @@ export async function startMcpServer() {
151
164
  installShutdownHooks(() => agentState)
152
165
  }
153
166
 
167
+ // The client's name and version arrive in the `initialize` handshake, so
168
+ // the session starts only after it completes.
169
+ server.server.oninitialized = () => {
170
+ const telemetry = currentTelemetry()
171
+ telemetry?.setAgent(server.server.getClientVersion())
172
+ telemetry?.track('session_start', {
173
+ mode,
174
+ agentTools: agentToolsEnabled,
175
+ os: knownOs(process.platform),
176
+ nodeVersion: process.versions.node,
177
+ })
178
+ }
179
+
154
180
  const transport = new StdioServerTransport()
155
181
  await server.connect(transport)
156
182
 
@@ -192,6 +218,7 @@ function installShutdownHooks(state: () => AgentState | undefined): void {
192
218
  void Promise.all([
193
219
  stopAllForwarders(),
194
220
  releaseAllListeners(),
221
+ currentTelemetry()?.close(),
195
222
  current && disposeAgentState(current),
196
223
  ])
197
224
  .catch((err: unknown) => {
@@ -209,3 +236,10 @@ function installShutdownHooks(state: () => AgentState | undefined): void {
209
236
  process.stdin.once('end', () => shutdown('stdin ended'))
210
237
  process.stdin.once('close', () => shutdown('stdin closed'))
211
238
  }
239
+
240
+ /** Maps the platform to the values the analytics API accepts. */
241
+ function knownOs(platform: NodeJS.Platform) {
242
+ return platform === 'darwin' || platform === 'linux' || platform === 'win32'
243
+ ? platform
244
+ : 'other'
245
+ }
@@ -28,6 +28,14 @@ export function proposeTool(resolveBackend: ResolveBackend): RegisteredTool {
28
28
  {
29
29
  requestId: { type: 'string' },
30
30
  name: { type: 'string' },
31
+ userRequest: {
32
+ type: 'string',
33
+ maxLength: 500,
34
+ description:
35
+ 'The developer\'s request in their own words, as they wrote it. '
36
+ + 'Reclaim records this text to improve the product, so leave '
37
+ + 'out secrets and personal data.',
38
+ },
31
39
  shortenNames: {
32
40
  type: 'boolean',
33
41
  description:
@@ -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) }
@@ -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
+ }
@@ -0,0 +1,115 @@
1
+ import { isProblemError } from '@reclaimprotocol/client/api'
2
+ import { currentTelemetry } from './index.ts'
3
+
4
+ const GOAL_MAX = 300
5
+ const USER_REQUEST_MAX = 500
6
+ const DOMAIN_MAX = 200
7
+ const DATA_POINTS_MAX = 20
8
+ const DATA_POINT_MAX = 200
9
+
10
+ /**
11
+ * Times a tool call and records it. Call `end()` once when the call
12
+ * finishes. It never throws, so it can't change the outcome of the call.
13
+ */
14
+ export function startToolCall(tool: string, args: unknown) {
15
+ const startedAt = Date.now()
16
+ return {
17
+ end(err?: unknown) {
18
+ try {
19
+ const telemetry = currentTelemetry()
20
+ if(!telemetry) {
21
+ return
22
+ }
23
+
24
+ const input = allowlistedInput(tool, args)
25
+ telemetry.track('tool_call', {
26
+ tool,
27
+ ok: err === undefined,
28
+ ...(err === undefined ? {} : { errorCode: errorCode(err) }),
29
+ durationMs: Date.now() - startedAt,
30
+ mode: telemetry.mode,
31
+ ...(input ? { input } : {}),
32
+ })
33
+ } catch{
34
+ // Telemetry must never affect a tool call.
35
+ }
36
+ },
37
+ }
38
+ }
39
+
40
+ /**
41
+ * A category for the error, such as `http_401`. It's never the message,
42
+ * because messages can contain URLs, tokens, or file paths.
43
+ */
44
+ export function errorCode(err: unknown): string {
45
+ if(isProblemError(err)) {
46
+ return `http_${err.output.statusCode}`
47
+ }
48
+
49
+ if(isRecord(err) && err.isBoom === true && isRecord(err.output)) {
50
+ return `http_${String(err.output.statusCode)}`
51
+ }
52
+
53
+ if(err instanceof Error) {
54
+ if(err.name === 'ZodError') {
55
+ return 'invalid_input'
56
+ }
57
+
58
+ if(err.name === 'TimeoutError' || err.name === 'AbortError') {
59
+ return 'timeout'
60
+ }
61
+ }
62
+
63
+ return 'error'
64
+ }
65
+
66
+ /**
67
+ * The only arguments Reclaim records, and only for these tools. They are the
68
+ * developer's own description of what they want to build, which tells us
69
+ * which providers to add. Every other argument is dropped here, so a new tool
70
+ * records nothing until you add it to this list on purpose.
71
+ */
72
+ export function allowlistedInput(tool: string, args: unknown) {
73
+ if(!isRecord(args)) {
74
+ return undefined
75
+ }
76
+
77
+ switch (tool) {
78
+ case 'start_integration':
79
+ return compact({
80
+ goal: cut(args.goal, GOAL_MAX),
81
+ userRequest: cut(args.userRequest, USER_REQUEST_MAX),
82
+ })
83
+ case 'propose_provider':
84
+ return compact({ userRequest: cut(args.userRequest, USER_REQUEST_MAX) })
85
+ case 'discover_resolve': {
86
+ const query = isRecord(args.query) ? args.query : {}
87
+ const dataPoints = Array.isArray(query.dataPoints)
88
+ ? query.dataPoints
89
+ .slice(0, DATA_POINTS_MAX)
90
+ .map((p) => cut(p, DATA_POINT_MAX))
91
+ .filter((p) => p !== undefined)
92
+ : undefined
93
+ return compact({
94
+ domain: cut(query.domain, DOMAIN_MAX),
95
+ dataPoints: dataPoints?.length ? dataPoints : undefined,
96
+ })
97
+ }
98
+
99
+ default:
100
+ return undefined
101
+ }
102
+ }
103
+
104
+ function cut(value: unknown, max: number): string | undefined {
105
+ return typeof value === 'string' && value ? value.slice(0, max) : undefined
106
+ }
107
+
108
+ /** Returns the object, or undefined when every field is undefined. */
109
+ function compact<T extends object>(obj: T): T | undefined {
110
+ return Object.values(obj).some((v) => v !== undefined) ? obj : undefined
111
+ }
112
+
113
+ function isRecord(value: unknown): value is Record<string, unknown> {
114
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
115
+ }