@reclaimprotocol/agent 0.1.39 → 0.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +32 -0
  2. package/lib/config/store.d.ts +7 -0
  3. package/lib/consts.d.ts +5 -0
  4. package/lib/consts.js +10 -0
  5. package/lib/mcp/api/operations.gen.d.ts +7 -0
  6. package/lib/mcp/api/operations.gen.js +54 -4
  7. package/lib/mcp/api/tools.js +4 -0
  8. package/lib/mcp/instructions.js +10 -8
  9. package/lib/mcp/server.js +6 -1
  10. package/lib/mcp/start.js +27 -1
  11. package/lib/mcp/tools/agent/example-app.d.ts +5 -1
  12. package/lib/mcp/tools/agent/example-app.js +87 -24
  13. package/lib/mcp/tools/agent/propose.js +7 -0
  14. package/lib/mcp/tools/agent/start-integration.d.ts +2 -0
  15. package/lib/mcp/tools/agent/start-integration.js +7 -0
  16. package/lib/mcp/tools/authenticate/authenticate.js +3 -0
  17. package/lib/mcp/tools/authenticate/eth-key.d.ts +15 -0
  18. package/lib/mcp/tools/authenticate/eth-key.js +72 -6
  19. package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +3 -2
  20. package/lib/mcp/tools/authenticate/issue-org-keypair.js +83 -18
  21. package/lib/mcp/tools/authenticate/issue-org-token.js +9 -2
  22. package/lib/telemetry/index.d.ts +9 -0
  23. package/lib/telemetry/index.js +13 -0
  24. package/lib/telemetry/telemetry.d.ts +54 -0
  25. package/lib/telemetry/telemetry.js +175 -0
  26. package/lib/telemetry/tool-call.d.ts +24 -0
  27. package/lib/telemetry/tool-call.js +102 -0
  28. package/package.json +2 -2
  29. package/skill/SKILL.builder.md +30 -11
  30. package/skill/SKILL.old.md +23 -5
  31. package/skill/builder-mode.md +29 -164
  32. package/skill/old-mode.md +40 -95
  33. package/skill/start-integration.md +12 -9
  34. package/skill/verification-client-integration.md +39 -9
  35. package/src/config/store.ts +4 -0
  36. package/src/consts.ts +11 -0
  37. package/src/mcp/api/operations.gen.ts +56 -4
  38. package/src/mcp/api/tools.ts +4 -0
  39. package/src/mcp/instructions.ts +10 -8
  40. package/src/mcp/server.ts +6 -1
  41. package/src/mcp/start.ts +34 -0
  42. package/src/mcp/tools/agent/example-app.ts +97 -30
  43. package/src/mcp/tools/agent/propose.ts +8 -0
  44. package/src/mcp/tools/agent/start-integration.ts +10 -0
  45. package/src/mcp/tools/authenticate/authenticate.ts +3 -0
  46. package/src/mcp/tools/authenticate/eth-key.ts +114 -6
  47. package/src/mcp/tools/authenticate/issue-org-keypair.ts +97 -19
  48. package/src/mcp/tools/authenticate/issue-org-token.ts +13 -2
  49. package/src/telemetry/index.ts +18 -0
  50. package/src/telemetry/telemetry.ts +206 -0
  51. package/src/telemetry/tool-call.ts +115 -0
@@ -4,7 +4,7 @@ Follow this plan end to end. Mark each step done as you go. Do not ask the devel
4
4
 
5
5
  **Order.** Do the steps that need the developer first: sign in to Builder, attach the browser, capture, and prove the provider (step 2). These need the developer at the browser, and they show early whether a proof is possible on this site. Write app code (step 3) after the proof works, or while you wait on the developer. Never spend a long stretch on app code before the developer has signed in and the browser is attached.
6
6
 
7
- **Asking the developer.** When you need an answer or an action from the developer before you can continue, end your turn with the question. Don't use background or asynchronous question tools for blocking questions: the developer may never see them. Put every link the developer needs, such as a sign-in or verification URL, in that same message.
7
+ **Asking the developer.** When you need an answer or an action, end your turn with the question. Don't use background or asynchronous question tools: the developer may never see them. Put every link they need in that same message. Ask each question once.
8
8
 
9
9
  ## 1. Sign in and pick the organization
10
10
 
@@ -15,6 +15,7 @@ Follow this plan end to end. Mark each step done as you go. Do not ask the devel
15
15
  <!-- ifnot:orgId -->
16
16
  - [ ] Call `list_orgs`. Use the only organization, or ask the developer which one when there are several.
17
17
  <!-- endif -->
18
+ - [ ] Call `get_org_keypair`. If the org has a key pair (any `canEncryptResult`), the app's callback must pass `orgEthPrivateKey` to `results.receive`: it decrypts encrypted results and verifies TEE-bound proofs. In the same message as any other step 1 question, ask the developer to choose one: reuse the existing key (they put its private key in the app's server env file as `RECLAIM_ORG_ETH_PRIVATE_KEY` themselves, never in chat), or rotate it (`issue_org_keypair` with `replace: true`; other apps holding the old key stop working with new results). If `canEncryptResult` is true, they can also turn encryption off with `update_org_keypair({ orgId, canEncryptResult: false })`, which keeps the key. Name the exact action in your question. Act on the answer in step 3.
18
19
 
19
20
  ## 2. Get the provider and prove it
20
21
 
@@ -39,7 +40,7 @@ Build a new organization-private provider for exactly "{{goal}}". Do not search
39
40
  - [ ] Call `replay_request`, then `replay_request` with `withoutSecrets: true` to confirm the request is auth-bound.
40
41
  - [ ] Call `run_proof`. It creates a local test signing key if none exists; tell the developer once afterwards.
41
42
  - [ ] Call `create_provider_version_from_capture`. It publishes the kept script as `jsUserScripts`. Follow its `_notes`, then publish the version.
42
- - [ ] Call `dispose_browser` after asking the developer.
43
+ - [ ] Don't ask whether to close the browser. Mention once in your final summary that it stays open for provider changes and you can close it on request. Call `dispose_browser` only when the developer says so.
43
44
  <!-- endif -->
44
45
 
45
46
  ## 3. Set up the app
@@ -65,21 +66,23 @@ Do this yourself, not in a subagent.
65
66
 
66
67
  - [ ] Call `create_example_app` with the workspace root you were started in as `projectDir`. It writes the example there and sets its backend to the latest `@reclaimprotocol/client`. Don't clone the repository or create a subdirectory: commands you run inside a clone count as running external code, and permission checks may block them.
67
68
  - [ ] Call `issue_org_token` with the same `projectDir` and `envFile: "packages/backend/.env"`.
68
- - [ ] Only when the developer asks for session binding or encrypted results: call `issue_org_keypair` with the same `projectDir` and `envFile` (add `encryptResults: true` for encrypted results). It writes `RECLAIM_ORG_ETH_PRIVATE_KEY` and registers the public key. Then pass `orgEthPrivateKey: process.env.RECLAIM_ORG_ETH_PRIVATE_KEY` to `reclaim.sessions.create` in `packages/backend/src/routes/verifications.ts` (binding) and to `reclaim.results.receive` in `packages/backend/src/routes/callback.ts` (decryption and TEE proof checks), and add an empty `RECLAIM_ORG_ETH_PRIVATE_KEY=` line to `packages/backend/example.env`.
69
- - [ ] From the workspace root, run `npm install`, then `npm run dev`. The backend listens on port 3000; the app opens at <http://localhost:5173>. If a permission check blocks either command, don't work around it: end your turn, say which command was blocked and that it installs and runs the Reclaim example app, and ask the developer to approve it.
70
- - [ ] Call `proxy_callback_events({ port: 3000, path: "/reclaim/callback" })`.
71
- - [ ] Skip the rest of this step. In step 4, the provider goes in `packages/backend/config.json`: replace the example entry with `{ "id": "<provider ID>", "name": "<what it proves>" }`, then restart the backend, because it reads the file at startup.
69
+ - [ ] Key pair, by the step 1 answer. No key pair on the org and the developer wants none: skip. Reuse: the developer has put the key in `packages/backend/.env`. Rotate, or no key pair and the developer asks for binding or encrypted results: call `issue_org_keypair` with the same `projectDir` and `envFile` (`encryptResults: true` for encrypted results, `replace: true` to rotate). It writes `RECLAIM_ORG_ETH_PRIVATE_KEY` and backs up a replaced key to `<envFile>.backup`; tell the developer that path (never the key). Turn encryption off with `update_org_keypair`. The example's callback (`packages/backend/src/routes/callback.ts`) passes `RECLAIM_ORG_ETH_PRIVATE_KEY` to `reclaim.results.receive`; if it doesn't, add `orgEthPrivateKey: process.env.RECLAIM_ORG_ETH_PRIVATE_KEY` there. For session binding, also pass it to `reclaim.sessions.create` in `packages/backend/src/routes/verifications.ts`.
70
+ - [ ] From the workspace root, run `npm install`. Then follow the `next` array that `create_example_app` returned for the dev command, ports, log location, notes, and the `proxy_callback_events` call. Don't assume default ports.
71
+ - [ ] Run the dev server in the background with its output redirected to a log file, and read that file for backend output. Don't depend on the shell's own output.
72
+ - [ ] After you restart the backend, its in-memory store is empty. Ask the developer for a new verification; don't retry the old session.
73
+ - [ ] If a permission check blocks a command, follow "Permission blocks" in the server instructions.
74
+ - [ ] Skip the rest of this step. In step 4, the provider goes in `packages/backend/config.json`: replace the example entry with `{ "id": "<provider ID>", "name": "<what it proves>" }`, then restart the backend, because it reads the file at startup. Then ask for a new verification.
72
75
 
73
76
  **Existing app.** Add verification to it:
74
77
 
75
78
  - [ ] Detect the app's framework and package manager from the repository, and use them. Read `how_it_works({ topic: "integration" })`.
76
- - [ ] Call `issue_org_token` with the organization, this project's absolute path, and the server env file. It writes the key to that file and never returns it. Never print or commit the key. Confirm the env file is in `.gitignore`.
79
+ - [ ] Call `issue_org_token` with the organization, this project's absolute path, and the server env file. It writes the key to that file and never returns it; it backs up a replaced value to `<envFile>.backup`, so tell the developer that path. Never print or commit the key. Confirm the env file is in `.gitignore`.
77
80
  - [ ] Add a backend route that creates a verification session (`POST /verifications/sessions`) and returns `verificationUrl`. Read the provider ID from server config (an env variable such as `RECLAIM_PROVIDER_ID`), so the route works before the provider exists.
78
- - [ ] Only when the developer asks for session binding or encrypted results: call `issue_org_keypair` with the server env file (`encryptResults: true` for encrypted results), and pass `orgEthPrivateKey` from that env variable to `sessions.create` and `results.receive`.
81
+ - [ ] Key pair, by the step 1 answer. Reuse: the developer has put the key in the server env file. Rotate, or no key pair and the developer asks for binding or encrypted results: call `issue_org_keypair` with the server env file (`encryptResults: true` for encrypted results, `replace: true` to rotate). It backs up a replaced key to `<envFile>.backup`; tell the developer that path (never the key). Turn encryption off with `update_org_keypair`. Whenever the org has a key pair, pass `orgEthPrivateKey` from `RECLAIM_ORG_ETH_PRIVATE_KEY` to `sessions.create` and `results.receive`.
79
82
  - [ ] Add a callback route that verifies each result with `results.receive` from `@reclaimprotocol/client`, then stores only the requested data points. Store every failure on the verification session record and log it server-side: a failure event (`verification_rejected`, `verification_error`, `verification_cancelled`, `session_expired`), or a thrown `ResultVerificationError` (`error.reason` plus each failing proof's `reason`). Respond `error.retryable ? 503 : 200`. A result that failed verification gets 2xx, because retrying can't fix it. Read the exact pattern in `how_it_works({ topic: "integration" })`.
80
83
  - [ ] Add a status route the frontend polls. It returns the stored status, data points, and failure reason. While the app has no result, it also reads `GET /verifications/sessions/{sessionId}`, so a session that ended in Builder never stays pending.
81
84
  - [ ] Add a frontend Verify button that opens `verificationUrl`, and render every state in "UI states" in `how_it_works({ topic: "integration" })`: idle, waiting in the other tab, verifying, verified (with the data points), failed (plain-language reason and **Try again**), and expired or cancelled (**Try again**). Never leave a spinner as the only feedback.
82
- - [ ] Start the app. Call `proxy_callback_events({ port, path })` with the callback route's port and path. It forwards the org's verification events to that route on localhost, with no public URL.
85
+ - [ ] Start the app in the background with output redirected to a log file. Call `proxy_callback_events({ port, path })` with the callback route's port and path. It forwards the org's verification events to that route on localhost, with no public URL.
83
86
 
84
87
  ## 4. Wire the provider and test end to end
85
88
 
@@ -22,26 +22,44 @@ configuration, and callback conventions. Complete the integration in its codebas
22
22
  relevant checks. The MCP tools configure Builder resources; your file tools
23
23
  edit the app.
24
24
 
25
- If the app depends on `@reclaimprotocol/js-sdk`, ask the developer first:
26
- keep it (`set_devtools_mode({ mode: "old" })`, then they restart the MCP
27
- server), or migrate to the session flow below. When migrating, remove
28
- `@reclaimprotocol/js-sdk` and replace its `ReclaimProofRequest` and
29
- `verifyProof` calls.
25
+ If `package.json` depends on `@reclaimprotocol/js-sdk`, ask first: "This
26
+ project uses `@reclaimprotocol/js-sdk`. Keep it (I switch this MCP server to
27
+ legacy devtools mode), or migrate to the Builder HTTP API?"
28
+
29
+ - **Keep:** `set_devtools_mode({ mode: "old" })`, then tell them to restart or
30
+ reconnect the MCP server. An explicit `USE_OLD_DEVTOOLS` env var overrides
31
+ it. After the restart, read `get_js_sdk_integration_guide`.
32
+ - **Migrate:** stay in Builder mode, remove `@reclaimprotocol/js-sdk`, and
33
+ replace its `ReclaimProofRequest` and `verifyProof` calls with the session
34
+ flow below.
30
35
 
31
36
  1. `get_devtools_mode` confirms Builder mode. `get_me` or `authenticate` signs
32
37
  in the developer. Find the organization with `list_orgs`; call `create_org`
33
38
  only when they want a new one.
34
39
  2. Receive callbacks. Local development: call
35
40
  `proxy_callback_events({ port, path })` to forward the org's events to the
36
- app's callback route on localhost; no public URL. Deployed app (required for
37
- production): register a
41
+ app's callback route on localhost; no public URL. The local listener works
42
+ only while this MCP server runs. It keeps events in order per session and
43
+ retries a session the app rejects with backoff, so other sessions keep
44
+ flowing. When its status lists `blocked` sessions, read that session's
45
+ callback route response and logs, and fix the app. Deployed app (required
46
+ for production): register a
38
47
  public callback URL with `create_org_callback_subscription`, including
39
48
  all five session-ending events. If the
40
49
  app needs encrypted results or session binding, call `issue_org_keypair`
41
50
  (`encryptResults: true` for encrypted results). It writes the private key to
42
51
  the backend's env file and registers only the public key. It returns 409
43
- when the org already has a different key: ask the developer for that key
44
- rather than replacing it.
52
+ when the org already has a different key. Check `get_org_keypair` first;
53
+ when the org has a key pair, the app must pass `orgEthPrivateKey` to
54
+ `results.receive`, with or without encryption: it decrypts results and
55
+ verifies TEE-bound proofs. Ask the developer to choose: reuse the key (they
56
+ put its private key in the backend's server env file as
57
+ `RECLAIM_ORG_ETH_PRIVATE_KEY` themselves, never in chat), rotate it with
58
+ `issue_org_keypair` and `replace: true` (apps holding the old key stop
59
+ working with new results; the old key is saved to `<envFile>.backup`, so
60
+ tell the developer that path), or turn encryption off with
61
+ `update_org_keypair({ orgId, canEncryptResult: false })`, which keeps the
62
+ key. Name the exact action when you ask.
45
63
  3. Org key: reuse the `RECLAIM_ORG_SECRET` the app already has. Otherwise an
46
64
  organization owner calls `issue_org_token`. It writes the key to the
47
65
  project's env file (default `.env`, variable `RECLAIM_ORG_SECRET`) and
@@ -254,6 +272,18 @@ Builder snapshots the organization's most recently updated active theme. A
254
272
  localized consent block tells supported clients to show consent before the
255
273
  first provider. Branding and theme data don't affect proof trust.
256
274
 
275
+ ## Try a provider without an app
276
+
277
+ Session creation returns 409 while the org has no callback subscription.
278
+ `read_callback_events` creates the org's pull listener, which satisfies that
279
+ check and holds results for you to read. No tunnel or public URL is needed.
280
+
281
+ 1. Call `read_callback_events`.
282
+ 2. `create_verification_session`, then give the developer `verificationUrl`.
283
+ 3. Poll `get_verification_session` until it is terminal.
284
+ `list_verification_events` shows progress.
285
+ 4. Call `read_callback_events({ sessionId })` for the verified result.
286
+
257
287
  ## Call the direct Builder API
258
288
 
259
289
  In `api=2` mode, send the registered client UUID in `x-reclaim-vc-id` on every
@@ -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:96339e8dff91).
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
  }
@@ -5116,7 +5120,7 @@ export const MCP_TOOLS = {
5116
5120
  annotations: {"destructiveHint":true,"idempotentHint":true,"openWorldHint":false},
5117
5121
  },
5118
5122
  "DeleteOrgKeypair": {
5119
- 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.",
5123
+ 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.",
5120
5124
  inputSchema: toZod({
5121
5125
  "type": "object",
5122
5126
  "properties": {
@@ -6700,7 +6704,7 @@ export const MCP_TOOLS = {
6700
6704
  annotations: {"destructiveHint":true,"idempotentHint":true,"openWorldHint":false},
6701
6705
  },
6702
6706
  "SetOrgKeypair": {
6703
- 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.",
6707
+ 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.",
6704
6708
  inputSchema: toZod({
6705
6709
  "type": "object",
6706
6710
  "properties": {
@@ -6985,6 +6989,54 @@ export const MCP_TOOLS = {
6985
6989
  }),
6986
6990
  annotations: {"openWorldHint":false},
6987
6991
  },
6992
+ "UpdateOrgKeypair": {
6993
+ 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`.",
6994
+ inputSchema: toZod({
6995
+ "type": "object",
6996
+ "properties": {
6997
+ "params": {
6998
+ "type": "object",
6999
+ "properties": {
7000
+ "orgId": {
7001
+ "type": "string",
7002
+ "format": "uuid"
7003
+ }
7004
+ },
7005
+ "required": [
7006
+ "orgId"
7007
+ ],
7008
+ "additionalProperties": false
7009
+ },
7010
+ "query": {
7011
+ "type": "object",
7012
+ "properties": {
7013
+
7014
+ }
7015
+ },
7016
+ "body": {
7017
+ "type": "object",
7018
+ "additionalProperties": false,
7019
+ "description": "Body for `PATCH /orgs/{orgId}/keypair`.",
7020
+ "required": [
7021
+ "canEncryptResult"
7022
+ ],
7023
+ "properties": {
7024
+ "canEncryptResult": {
7025
+ "type": "boolean",
7026
+ "description": "Set to true to encrypt results to the organization's public key. Set to false to deliver results as plaintext."
7027
+ }
7028
+ }
7029
+ }
7030
+ },
7031
+ "required": [
7032
+ "params",
7033
+ "query",
7034
+ "body"
7035
+ ],
7036
+ "additionalProperties": false
7037
+ }),
7038
+ annotations: {"openWorldHint":false},
7039
+ },
6988
7040
  "UpdateProvider": {
6989
7041
  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.",
6990
7042
  inputSchema: toZod({
@@ -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
  },
@@ -59,7 +59,8 @@ const TOPIC_SUMMARIES: Record<string, string> = {
59
59
  'overview': 'Terminology, your stance, and the authoring checklist.',
60
60
  'contributions': 'Contributing a public provider as a volunteer: the '
61
61
  + 'candidate-and-review workflow and the extra rules it adds.',
62
- 'auth': 'Signing in to the backend, and which token authorizes what.',
62
+ 'auth': 'Signing in to the backend, which token authorizes what, and '
63
+ + 'the backend mode (Builder or legacy devtools).',
63
64
  'devtools-migration': 'Importing a public legacy provider with the same '
64
65
  + 'UUID.',
65
66
  'browser': 'The default local browser, the opt-in modes (attach, the '
@@ -71,15 +72,16 @@ const TOPIC_SUMMARIES: Record<string, string> = {
71
72
  + 'drafted shape.',
72
73
  'params': 'Extracted vs consumer-supplied vs secret params, the URL '
73
74
  + 'character budget, and runtime resolution.',
74
- 'prove': 'replay_request, the mandatory auth-bound check, run_proof, and '
75
- + 'diagnosing failures.',
75
+ 'prove': 'replay_request, the mandatory auth-bound check, run_proof '
76
+ + '(including the egress country), and diagnosing failures.',
76
77
  'publish': 'Creating a provider, provider versions, multi-request '
77
78
  + 'providers, and going live.',
78
- 'discover': 'Reusing existing providers: finding, resolving, and '
79
- + 'creating a verification session.',
80
- 'integration': 'After start_integration: org key, callback (local '
81
- + 'proxy_callback_events or deployed webhook), frontend and backend '
82
- + 'over the HTTP session API; `api=2` and result verification.',
79
+ 'discover': 'Reusing existing providers: finding, resolving, copying one '
80
+ + 'that needs changes, and creating a verification session.',
81
+ 'integration': 'After start_integration: org key and keypair, callback '
82
+ + '(local proxy_callback_events or deployed webhook), frontend and '
83
+ + 'backend over the HTTP session API, js-sdk projects, trying a '
84
+ + 'session without an app; `api=2` and result verification.',
83
85
  'user-script': 'The navigation script (jsUserScripts): sign-in redirect, '
84
86
  + 'multi-page walk, test_user_script with keep.',
85
87
  'interception': 'injectionType / interceptorType, live interception, the '
package/src/mcp/server.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
+ import { startToolCall } from '../telemetry/index.ts'
2
3
  import { type RegisteredTool } from '../tools/registry.ts'
3
4
  import { toZod } from './api/to-zod.ts'
4
5
  import { remapErrorAsResponse } from './remap_errors.ts'
@@ -31,9 +32,13 @@ export function registerToolList(server: McpServer, tools: RegisteredTool[]) {
31
32
  : {}),
32
33
  },
33
34
  async(args) => {
35
+ const call = startToolCall(tool.definition.name, args)
34
36
  try {
35
- return successResponse(await tool.handler(args))
37
+ const result = successResponse(await tool.handler(args))
38
+ call.end()
39
+ return result
36
40
  } catch(err) {
41
+ call.end(err)
37
42
  return remapErrorAsResponse(err)
38
43
  }
39
44
  },
package/src/mcp/start.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  oldLogsUrl,
11
11
  resolveOldMode,
12
12
  sdkApiUrl,
13
+ telemetryEnabled,
13
14
  } from '../consts.ts'
14
15
  import { LOGGER } from '../logger.ts'
15
16
  import { ReclaimOldClient } from '../old/client.ts'
@@ -19,6 +20,11 @@ import { ReclaimSdkClient } from '../old/sdk-client.ts'
19
20
  import { buildOldBackendTools } from '../old/tools/build.ts'
20
21
  import { NAME, VERSION } from '../pkg.ts'
21
22
  import { startZkFilesDownload } from '../proof/zk-files.ts'
23
+ import {
24
+ currentTelemetry,
25
+ installTelemetry,
26
+ Telemetry
27
+ } from '../telemetry/index.ts'
22
28
  import { registerTools } from './api/tools.ts'
23
29
  import {
24
30
  type AgentState,
@@ -78,6 +84,13 @@ export async function startMcpServer() {
78
84
  }),
79
85
  headers: { 'User-Agent': `${NAME}/${VERSION}` },
80
86
  })
87
+ // Anonymous usage data. Installed before any tool registers so every tool
88
+ // call is covered. When the developer opts out, nothing is created.
89
+ const mode = oldMode ? 'devtools' : 'builder'
90
+ if(telemetryEnabled()) {
91
+ installTelemetry(new Telemetry({ client: builderClient, mode }))
92
+ }
93
+
81
94
  // Backend-mode toggle — available in both modes and regardless of whether
82
95
  // the agent tools are enabled, so a user can always switch either direction.
83
96
  registerToolList(server, buildModeTools(config, oldMode))
@@ -151,6 +164,19 @@ export async function startMcpServer() {
151
164
  installShutdownHooks(() => agentState)
152
165
  }
153
166
 
167
+ // The client's name and version arrive in the `initialize` handshake, so
168
+ // the session starts only after it completes.
169
+ server.server.oninitialized = () => {
170
+ const telemetry = currentTelemetry()
171
+ telemetry?.setAgent(server.server.getClientVersion())
172
+ telemetry?.track('session_start', {
173
+ mode,
174
+ agentTools: agentToolsEnabled,
175
+ os: knownOs(process.platform),
176
+ nodeVersion: process.versions.node,
177
+ })
178
+ }
179
+
154
180
  const transport = new StdioServerTransport()
155
181
  await server.connect(transport)
156
182
 
@@ -192,6 +218,7 @@ function installShutdownHooks(state: () => AgentState | undefined): void {
192
218
  void Promise.all([
193
219
  stopAllForwarders(),
194
220
  releaseAllListeners(),
221
+ currentTelemetry()?.close(),
195
222
  current && disposeAgentState(current),
196
223
  ])
197
224
  .catch((err: unknown) => {
@@ -209,3 +236,10 @@ function installShutdownHooks(state: () => AgentState | undefined): void {
209
236
  process.stdin.once('end', () => shutdown('stdin ended'))
210
237
  process.stdin.once('close', () => shutdown('stdin closed'))
211
238
  }
239
+
240
+ /** Maps the platform to the values the analytics API accepts. */
241
+ function knownOs(platform: NodeJS.Platform) {
242
+ return platform === 'darwin' || platform === 'linux' || platform === 'win32'
243
+ ? platform
244
+ : 'other'
245
+ }
@@ -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, lstatSync, readFileSync } from 'node:fs'
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, type RegisteredTool } from '../../server.ts'
18
+ import { assertNoSymlinks, upsertEnvVar } from '../authenticate/eth-key.ts'
17
19
 
18
20
  const REPO = 'reclaimprotocol/builder-example'
19
21
  /** GitHub tree paths are always `/`-separated, on every OS. */
@@ -22,8 +24,20 @@ const CLIENT_PACKAGE = '@reclaimprotocol/client'
22
24
  /** The example is ~50 small files; anything far past that isn't it. */
23
25
  const MAX_FILES = 500
24
26
  const MAX_BYTES = 10_000_000
27
+ const BACKEND_ENV_FILE = 'packages/backend/.env'
28
+ const DEFAULT_BACKEND_PORT = 3000
29
+ /** Ports probed for the backend: 3000, then 3001 through 3020. */
30
+ const BACKEND_PORT_TRIES = 21
31
+ const LOOPBACK_HOSTS = ['127.0.0.1', '::1']
32
+ const PROBE_TIMEOUT_MS = 500
33
+ /** Files a workspace may already have. An existing copy is kept, not a
34
+ * conflict. The env-file tools add their own entries to `.gitignore`. */
35
+ const KEEP_EXISTING = new Set(['AGENTS.md', '.gitignore'])
25
36
 
26
- export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
37
+ export function exampleAppTool(
38
+ fetchFn: typeof fetch = fetch,
39
+ isPortFree: (port: number) => Promise<boolean> = isLoopbackPortFree,
40
+ ): RegisteredTool {
27
41
  return defineTool<{ projectDir: string }>(
28
42
  {
29
43
  name: 'create_example_app',
@@ -32,12 +46,14 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
32
46
  + 'a Fastify backend and a Vite frontend that create sessions, '
33
47
  + 'verify callbacks, and render every verification state) into '
34
48
  + '`projectDir`, and sets its backend to the latest '
35
- + '`@reclaimprotocol/client`. Use it when the workspace has no app '
49
+ + '`@reclaimprotocol/client`. It picks a free backend port (3000, else '
50
+ + 'the next free one, written to `packages/backend/.env`). Use it '
51
+ + 'when the workspace has no app '
36
52
  + 'yet, with the workspace root you were started in as '
37
53
  + '`projectDir`; don\'t clone the repository yourself. Refuses '
38
54
  + 'when a file it would write already exists with different '
39
- + 'content. Returns the commit, file count, client version, and '
40
- + 'next steps.',
55
+ + 'content. Returns the commit, file count, client version, '
56
+ + '`backendPort`, and next steps.',
41
57
  inputSchema: {
42
58
  type: 'object',
43
59
  required: ['projectDir'],
@@ -69,11 +85,17 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
69
85
  : bytes,
70
86
  }
71
87
  }))
72
- for(const { path } of staged) {
88
+ for(const path of [...staged.map((f) => f.path), BACKEND_ENV_FILE]) {
73
89
  assertNoSymlinks(projectDir, path)
74
90
  }
75
91
 
76
- const conflicts = staged
92
+ const kept = staged
93
+ .filter(({ path }) => {
94
+ return KEEP_EXISTING.has(path) && existsSync(join(projectDir, path))
95
+ })
96
+ .map(({ path }) => path)
97
+ const toWrite = staged.filter(({ path }) => !kept.includes(path))
98
+ const conflicts = toWrite
77
99
  .filter(({ path, bytes }) => {
78
100
  const dest = join(projectDir, path)
79
101
  return existsSync(dest) && !readFileSync(dest).equals(bytes)
@@ -88,31 +110,72 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
88
110
  ),
89
111
  )
90
112
 
91
- for(const { path, bytes } of staged) {
113
+ for(const { path, bytes } of toWrite) {
92
114
  await installFile(projectDir, path, bytes)
93
115
  }
94
116
 
117
+ const backendPort = await findBackendPort()
118
+ // Always written, so a re-run never leaves a stale port behind.
119
+ upsertEnvVar(projectDir, BACKEND_ENV_FILE, 'PORT', String(backendPort))
120
+
121
+ const dev = backendPort === DEFAULT_BACKEND_PORT
122
+ ? 'npm run dev'
123
+ : `API_URL=http://localhost:${backendPort} npm run dev`
124
+
95
125
  return {
96
126
  projectDir,
97
127
  repository: `https://github.com/${REPO}`,
98
128
  commit,
99
- files: staged.length,
129
+ files: toWrite.length,
130
+ kept,
100
131
  clientVersion,
132
+ backendPort,
101
133
  next: [
102
134
  'Call issue_org_token with this projectDir and envFile '
103
135
  + '"packages/backend/.env".',
104
- 'Run `npm install`, then `npm run dev`, from projectDir. The '
105
- + 'backend listens on port 3000; the app opens at '
106
- + 'http://localhost:5173.',
107
- 'Call proxy_callback_events({ port: 3000, path: '
136
+ 'Run `npm install` from projectDir, then start the dev '
137
+ + `server with \`${dev}\`. The backend listens on port `
138
+ + `${backendPort}`
139
+ + (backendPort === DEFAULT_BACKEND_PORT
140
+ ? '.'
141
+ : ` (port ${DEFAULT_BACKEND_PORT} is in use; the frontend `
142
+ + 'proxy needs `API_URL`).'),
143
+ 'Run the dev server in the background and send its output to a '
144
+ + 'log file in projectDir, for example `npm run dev > '
145
+ + 'reclaim-dev.log 2>&1`, so callback errors stay readable even '
146
+ + 'when a shell output filter buffers output.',
147
+ 'Give the developer the frontend URL Vite prints in that log. '
148
+ + 'Don\'t assume port 5173: Vite moves to the next free port '
149
+ + 'when it is taken.',
150
+ `Call proxy_callback_events({ port: ${backendPort}, path: `
108
151
  + '"/reclaim/callback" }).',
152
+ 'If get_org_keypair shows `canEncryptResult: true`, the '
153
+ + 'callback needs `orgEthPrivateKey`; see the plan.',
109
154
  'Put the provider in packages/backend/config.json and restart '
110
155
  + 'the backend.',
156
+ 'The backend stores verifications in memory: restarting it '
157
+ + 'drops sessions in progress, so start a new verification '
158
+ + 'after any restart.',
111
159
  ],
112
160
  }
113
161
  },
114
162
  )
115
163
 
164
+ async function findBackendPort() {
165
+ for(let i = 0; i < BACKEND_PORT_TRIES; i++) {
166
+ const port = DEFAULT_BACKEND_PORT + i
167
+ if(await isPortFree(port)) {
168
+ return port
169
+ }
170
+ }
171
+
172
+ throw conflict(
173
+ `No free port from ${DEFAULT_BACKEND_PORT} to `
174
+ + `${DEFAULT_BACKEND_PORT + BACKEND_PORT_TRIES - 1}. Ask the developer `
175
+ + 'to stop another dev server.',
176
+ )
177
+ }
178
+
116
179
  async function latestCommit() {
117
180
  const res = await fetchFn(
118
181
  `https://api.github.com/repos/${REPO}/commits/main`,
@@ -239,23 +302,6 @@ function isEexist(err: unknown) {
239
302
  return err instanceof Error && 'code' in err && err.code === 'EEXIST'
240
303
  }
241
304
 
242
- /** Throws when `path`, or any of its existing parents under `root`, is a
243
- * symlink: writing through one would land outside the workspace. */
244
- function assertNoSymlinks(root: string, path: string) {
245
- let current = root
246
- for(const part of path.split(/[\\/]/)) {
247
- current += sep + part
248
- const stat = lstatSync(current, { throwIfNoEntry: false })
249
- assert(
250
- !stat?.isSymbolicLink(),
251
- badRequest(
252
- `${current} is a symlink; the example app is only written to `
253
- + 'real directories inside the workspace',
254
- ),
255
- )
256
- }
257
- }
258
-
259
305
  /** The backend's package.json with the client range set to `^version`. */
260
306
  function withClientVersion(bytes: Buffer, version: string) {
261
307
  const text = bytes.toString('utf8')
@@ -277,3 +323,24 @@ function gitBlobSha(bytes: Buffer) {
277
323
  .update(bytes)
278
324
  .digest('hex')
279
325
  }
326
+
327
+ /** True when nothing accepts connections on `port` at 127.0.0.1 or ::1.
328
+ * Connecting, rather than binding, also catches servers bound to `0.0.0.0`:
329
+ * macOS lets a loopback bind succeed alongside them. Exported for tests. */
330
+ export async function isLoopbackPortFree(port: number) {
331
+ const results = await Promise.all(LOOPBACK_HOSTS.map((host) => {
332
+ return new Promise<boolean>((resolve) => {
333
+ const socket = connect({ port, host, timeout: PROBE_TIMEOUT_MS })
334
+ socket.once('connect', () => {
335
+ socket.destroy()
336
+ resolve(false)
337
+ })
338
+ socket.once('timeout', () => {
339
+ socket.destroy()
340
+ resolve(true)
341
+ })
342
+ socket.once('error', () => resolve(true))
343
+ })
344
+ }))
345
+ return results.every(Boolean)
346
+ }
@@ -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: