@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 +32 -0
- package/lib/config/store.d.ts +7 -0
- package/lib/consts.d.ts +5 -0
- package/lib/consts.js +10 -0
- package/lib/mcp/api/operations.gen.js +6 -2
- package/lib/mcp/api/tools.js +4 -0
- package/lib/mcp/server.js +6 -1
- package/lib/mcp/start.js +27 -1
- package/lib/mcp/tools/agent/propose.js +7 -0
- package/lib/mcp/tools/agent/start-integration.d.ts +2 -0
- package/lib/mcp/tools/agent/start-integration.js +7 -0
- package/lib/mcp/tools/authenticate/authenticate.js +3 -0
- package/lib/telemetry/index.d.ts +9 -0
- package/lib/telemetry/index.js +13 -0
- package/lib/telemetry/telemetry.d.ts +54 -0
- package/lib/telemetry/telemetry.js +175 -0
- package/lib/telemetry/tool-call.d.ts +24 -0
- package/lib/telemetry/tool-call.js +102 -0
- package/package.json +2 -2
- package/src/config/store.ts +4 -0
- package/src/consts.ts +11 -0
- package/src/mcp/api/operations.gen.ts +6 -2
- package/src/mcp/api/tools.ts +4 -0
- package/src/mcp/server.ts +6 -1
- package/src/mcp/start.ts +34 -0
- package/src/mcp/tools/agent/propose.ts +8 -0
- package/src/mcp/tools/agent/start-integration.ts +10 -0
- package/src/mcp/tools/authenticate/authenticate.ts +3 -0
- package/src/telemetry/index.ts +18 -0
- package/src/telemetry/telemetry.ts +206 -0
- package/src/telemetry/tool-call.ts +115 -0
package/README.md
CHANGED
|
@@ -70,6 +70,38 @@ Defaults target the Builder backend, so **no env is needed for the common case**
|
|
|
70
70
|
|
|
71
71
|
See [Backends](#backends) for the full env-var list.
|
|
72
72
|
|
|
73
|
+
### Usage data
|
|
74
|
+
|
|
75
|
+
The MCP server sends anonymous usage data to Builder so that Reclaim can see which tools developers use and where they get stuck.
|
|
76
|
+
|
|
77
|
+
**What the server records**
|
|
78
|
+
|
|
79
|
+
- When a session starts: the mode (Builder or devtools), whether the browser tools are on, your operating system family, your Node.js version, and the name and version of your MCP client, such as Claude Code.
|
|
80
|
+
- For each tool call: the tool name, whether it succeeded, how long it took, and an error category such as `http_401`.
|
|
81
|
+
- When you sign in.
|
|
82
|
+
- For three tools only, the text you give them. `start_integration` records `goal` and `userRequest`. `propose_provider` records `userRequest`. `discover_resolve` records the domain and data points you search for. Don't put secrets or personal data in these fields.
|
|
83
|
+
- A random client ID that the server saves in `~/.reclaim/config.json`. It isn't derived from your machine or account. After you sign in, Builder links earlier events from this client ID to your account.
|
|
84
|
+
|
|
85
|
+
**What the server never records**
|
|
86
|
+
|
|
87
|
+
- Any other tool argument.
|
|
88
|
+
- Tool output, captured requests or responses, or file contents.
|
|
89
|
+
- Error messages, tokens, keys, or cookies.
|
|
90
|
+
|
|
91
|
+
**Where the data goes**
|
|
92
|
+
|
|
93
|
+
Builder stores the events in its database. Builder forwards only a short list of event names and counts to Google Analytics, and never the text you typed.
|
|
94
|
+
|
|
95
|
+
**Turn it off**
|
|
96
|
+
|
|
97
|
+
Set `RECLAIM_TELEMETRY=0`, or set `DO_NOT_TRACK=1`, in the `env` block of your MCP client configuration. With either set, the server doesn't create a client ID and makes no analytics requests.
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
"env": {
|
|
101
|
+
"RECLAIM_TELEMETRY": "0"
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
73
105
|
### Provider-authoring guide
|
|
74
106
|
|
|
75
107
|
The MCP server is all you need — it ships a `how_it_works` tool that serves the capture → prove → publish guide **one topic at a time** (`overview`, `auth`, `browser`, `capture`, `draft`, `params`, `prove`, `publish`, `discover`, `user-script`, `interception`, `hash-validation`, `credentials`, `never-do`, `troubleshooting`). Call it with no arguments for the overview plus the topic index; every response repeats that index, so an agent that guessed wrong self-corrects without a second round-trip. Any MCP client can call it on demand; there's nothing extra to install.
|
package/lib/config/store.d.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/lib/mcp/api/tools.js
CHANGED
|
@@ -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
|
-
|
|
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 '
|
|
@@ -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.
|
|
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.
|
|
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",
|
package/src/config/store.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/src/mcp/api/tools.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|