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