@reclaimprotocol/agent 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -111,13 +111,13 @@ Defaults target the production old-devtools backend, so **no env is needed for t
111
111
 
112
112
  See [Backends](#backends) for builder mode (`USE_OLD_DEVTOOLS=false`) and the full env-var list.
113
113
 
114
- ### Companion skill (optional, recommended)
114
+ ### Provider-authoring guide
115
115
 
116
- The MCP server is all you need to start — but installing the companion skill makes the agent markedly better at the capture → prove → publish flow (login detection, choosing auth-bound endpoints, OPRF decisions, troubleshooting). See [Skill](#skill) for install steps.
116
+ The MCP server is all you need — it ships a `how_it_works` tool that returns the full capture → prove → publish guide (login detection, choosing auth-bound endpoints, OPRF decisions, troubleshooting). Any MCP client can call it on demand; there's nothing extra to install.
117
117
 
118
118
  ## Using it from your agent
119
119
 
120
- Optional but recommended: install the [skill](#skill) so the agent follows the full authoring flow.
120
+ Tip: have your agent call the `how_it_works` tool first so it follows the full authoring flow.
121
121
 
122
122
  Then just ask in plain language, e.g.:
123
123
 
@@ -138,28 +138,9 @@ The agent orchestrates the tools for you:
138
138
 
139
139
  Your only manual steps are signing in when the browser windows open and confirming the value you want to prove.
140
140
 
141
- ## Skill
141
+ ## How it works (guide tool)
142
142
 
143
- Optional but recommended. The companion skill (`skill/SKILL.md`) is a Claude Code **Agent Skill** that teaches the agent the full mode-aware authoring flow — it ships inside the npm package and is also in this repo.
144
-
145
- ```bash
146
- # macOS / Linux — from a global install of the package
147
- npm i -g @reclaimprotocol/agent
148
- mkdir -p ~/.claude/skills/reclaim-provider
149
- cp "$(npm root -g)/@reclaimprotocol/agent/skill/SKILL.md" ~/.claude/skills/reclaim-provider/SKILL.md
150
-
151
- # …or from a checkout of this repo
152
- cp packages/agent/skill/SKILL.md ~/.claude/skills/reclaim-provider/SKILL.md
153
- ```
154
-
155
- ```powershell
156
- # Windows (PowerShell) — from a global install
157
- npm i -g @reclaimprotocol/agent
158
- New-Item -ItemType Directory -Force $env:USERPROFILE\.claude\skills\reclaim-provider | Out-Null
159
- Copy-Item "$(npm root -g)\@reclaimprotocol\agent\skill\SKILL.md" $env:USERPROFILE\.claude\skills\reclaim-provider\SKILL.md
160
- ```
161
-
162
- Claude Code auto-loads it once it's in `~/.claude/skills/`. **Other MCP clients** (Cursor, opencode, Antigravity, …) don't read `SKILL.md` automatically — the server's built-in instructions already cover the essentials, and you can paste or adapt the skill's content into that client's rules / system-prompt for the same depth.
143
+ The full, mode-aware authoring guide (`skill/SKILL.md`) is served on demand by the **`how_it_works`** MCP tool — call it from any MCP client (Claude Code, Cursor, opencode, Antigravity, …) to get the deep capture → prove → publish workflow, decision frameworks, and troubleshooting. The server's always-on `instructions` cover the essentials; `how_it_works` is the in-depth version, with nothing to install.
163
144
 
164
145
  ## Provider-authoring tools
165
146
 
@@ -285,18 +266,6 @@ claude mcp add reclaim -- node "$(pwd)/packages/agent/lib/mcp-bin.js"
285
266
 
286
267
  To target a specific backend while developing, add an `env` block (Claude Code / `.mcp.json`) or `environment` (opencode) — e.g. `"USE_OLD_DEVTOOLS": "false"` for builder mode. See [Backends](#backends).
287
268
 
288
- ### Skill
289
-
290
- Symlink the source `skill/` folder into `~/.claude/skills/` so edits to `SKILL.md` are picked up live (no re-copy):
291
-
292
- ```bash
293
- # macOS / Linux — from the repo root
294
- ln -s "$(pwd)/packages/agent/skill" ~/.claude/skills/reclaim-provider
295
- ```
296
-
297
- ```powershell
298
- # Windows (PowerShell, Developer Mode or elevated) — from the repo root
299
- New-Item -ItemType SymbolicLink -Path $env:USERPROFILE\.claude\skills\reclaim-provider -Target (Resolve-Path packages\agent\skill)
300
- ```
269
+ ### Authoring guide
301
270
 
302
- Claude Code auto-loads it once linked. opencode doesn't read `SKILL.md` automatically — paste or adapt its content into that client's rules / system-prompt for the same depth.
271
+ `skill/SKILL.builder.md` and `skill/SKILL.old.md` are the sources the `how_it_works` tool serves — one per backend, so an agent is never shown tools its mode doesn't have. The server picks by `USE_OLD_DEVTOOLS`. Edit the relevant file and rebuild (`npm run build`) to update what the tool returns.
@@ -30,6 +30,13 @@ export interface ResponseReceivedLike {
30
30
  * `:`. Stripping at capture time keeps every downstream consumer
31
31
  * (`get_request`, draft, replay, proof) clean by construction. */
32
32
  export declare function stripPseudoHeaders(headers: Record<string, string>): Record<string, string>;
33
+ /** True when the URL's path names a static asset (by file extension; query and
34
+ * hash are ignored, so `chunk.js?v=1` still counts). The capture engine drops
35
+ * these up front — they never carry JSON/HTML a recipe matches. */
36
+ export declare function isStaticAsset(url: string): boolean;
37
+ /** True when a response Content-Type is a static asset (scripts/styles/fonts/
38
+ * images/media). Parameters (`; charset=…`) and case are ignored. */
39
+ export declare function isStaticAssetMime(mimeType: string | undefined): boolean;
33
40
  export interface CaptureSession {
34
41
  captureId: string;
35
42
  requests: Map<string, CapturedRequest>;
@@ -25,6 +25,64 @@ export function stripPseudoHeaders(headers) {
25
25
  }
26
26
  return out;
27
27
  }
28
+ /** Extensions for static assets we never prove over — scripts, styles, fonts,
29
+ * images, media, source maps. A real page pulls hundreds of these; tracking
30
+ * them (and fetching their bodies) just bloats the request map and body store
31
+ * with traffic no recipe ever matches. Note: `.json` is deliberately absent —
32
+ * that's the API shape recipes care about. */
33
+ const STATIC_ASSET_EXTENSIONS = new Set([
34
+ 'js', 'mjs', 'cjs', 'css', 'map',
35
+ 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'ico', 'bmp',
36
+ 'woff', 'woff2', 'ttf', 'otf', 'eot',
37
+ 'mp4', 'webm', 'mp3', 'ogg', 'wav',
38
+ ]);
39
+ /** True when the URL's path names a static asset (by file extension; query and
40
+ * hash are ignored, so `chunk.js?v=1` still counts). The capture engine drops
41
+ * these up front — they never carry JSON/HTML a recipe matches. */
42
+ export function isStaticAsset(url) {
43
+ let path;
44
+ try {
45
+ path = new URL(url).pathname;
46
+ }
47
+ catch {
48
+ path = url.split(/[?#]/)[0] ?? url;
49
+ }
50
+ // Extension of the last path segment only, so `/v1.2/data` isn't misread.
51
+ const base = path.slice(path.lastIndexOf('/') + 1);
52
+ const dot = base.lastIndexOf('.');
53
+ if (dot <= 0) {
54
+ return false;
55
+ }
56
+ return STATIC_ASSET_EXTENSIONS.has(base.slice(dot + 1).toLowerCase());
57
+ }
58
+ /** Response Content-Types for static assets. The URL-extension filter misses
59
+ * extension-less bundler/CDN URLs (e.g. `/assets/3b8f1c`), so we also drop by
60
+ * MIME at response time. `text/html`, `application/json`, and `text/plain`
61
+ * are NOT here — those are the document/API shapes recipes match. */
62
+ const STATIC_ASSET_MIME_EXACT = new Set([
63
+ 'text/css',
64
+ 'application/javascript', 'text/javascript', 'application/x-javascript',
65
+ 'application/ecmascript', 'text/ecmascript',
66
+ 'application/wasm',
67
+ 'application/font-woff', 'application/font-woff2',
68
+ 'application/vnd.ms-fontobject', 'application/x-font-ttf',
69
+ ]);
70
+ const STATIC_ASSET_MIME_PREFIXES = ['image/', 'font/', 'audio/', 'video/'];
71
+ /** True when a response Content-Type is a static asset (scripts/styles/fonts/
72
+ * images/media). Parameters (`; charset=…`) and case are ignored. */
73
+ export function isStaticAssetMime(mimeType) {
74
+ if (!mimeType) {
75
+ return false;
76
+ }
77
+ const m = mimeType.toLowerCase().split(';')[0].trim();
78
+ if (!m) {
79
+ return false;
80
+ }
81
+ if (STATIC_ASSET_MIME_PREFIXES.some((p) => m.startsWith(p))) {
82
+ return true;
83
+ }
84
+ return STATIC_ASSET_MIME_EXACT.has(m);
85
+ }
28
86
  export function createCaptureEngine(opts) {
29
87
  const captureId = randomUUID();
30
88
  const requests = new Map();
@@ -79,6 +137,13 @@ export function createCaptureEngine(opts) {
79
137
  pendingResponseExtra.set(requestId, { ...buffered, ...clean });
80
138
  }
81
139
  function onRequestWillBeSent(p) {
140
+ // Static assets never match a recipe — never track them (so we don't
141
+ // fetch their bodies or grow the store). Drop any extra-info that raced
142
+ // ahead of this event so it can't linger.
143
+ if (isStaticAsset(p.request.url)) {
144
+ pendingRequestExtra.delete(p.requestId);
145
+ return;
146
+ }
82
147
  const f = {
83
148
  requestId: p.requestId,
84
149
  method: p.request.method,
@@ -100,6 +165,19 @@ export function createCaptureEngine(opts) {
100
165
  function onResponseReceived(p) {
101
166
  const f = inFlight.get(p.requestId);
102
167
  if (!f) {
168
+ // No in-flight entry → the request was skipped (e.g. a static asset)
169
+ // or never seen. Discard any buffered response extra-info so a skipped
170
+ // asset can't leave a dangling entry.
171
+ pendingResponseExtra.delete(p.requestId);
172
+ return;
173
+ }
174
+ // Second-line asset filter by Content-Type: catches extension-less
175
+ // bundler/CDN URLs the request-time URL filter can't. Forget the
176
+ // in-flight entry so it never reaches `requests` and a later
177
+ // loadingFinished/extra-info for it becomes a no-op.
178
+ if (isStaticAssetMime(p.response.mimeType)) {
179
+ inFlight.delete(p.requestId);
180
+ pendingResponseExtra.delete(p.requestId);
103
181
  return;
104
182
  }
105
183
  const captured = {
@@ -6,6 +6,6 @@
6
6
  */
7
7
  import type CDP from 'chrome-remote-interface';
8
8
  import { type CaptureSession, type StartCaptureOptions } from './capture-engine.ts';
9
- export { stripPseudoHeaders } from './capture-engine.ts';
9
+ export { isStaticAsset, isStaticAssetMime, stripPseudoHeaders, } from './capture-engine.ts';
10
10
  export type { CaptureSession, StartCaptureOptions } from './capture-engine.ts';
11
11
  export declare function startCapture(tab: CDP.Client, opts?: StartCaptureOptions): Promise<CaptureSession>;
@@ -1,5 +1,5 @@
1
1
  import { createCaptureEngine, } from "./capture-engine.js";
2
- export { stripPseudoHeaders } from "./capture-engine.js";
2
+ export { isStaticAsset, isStaticAssetMime, stripPseudoHeaders, } from "./capture-engine.js";
3
3
  export async function startCapture(tab, opts = {}) {
4
4
  const engine = createCaptureEngine({
5
5
  ...(opts.maxBodyBytes !== undefined && { maxBodyBytes: opts.maxBodyBytes }),
package/lib/consts.d.ts CHANGED
@@ -16,12 +16,12 @@
16
16
  * Each var's default/fallback literal is co-located with its getter.
17
17
  */
18
18
  import { DEFAULT_BASE_URL } from '@reclaimprotocol/client';
19
- import { DEFAULT_OLD_API_URL } from './old/client.ts';
19
+ import { DEFAULT_OLD_API_URL, DEFAULT_OLD_LOGS_URL } from './old/client.ts';
20
20
  /** Public attestor websocket URL — fallback for `RECLAIM_ATTESTOR_URL`. */
21
21
  export declare const FALLBACK_ATTESTOR_URL = "wss://attestor.reclaimprotocol.org:444/ws";
22
22
  /** The devtools dashboard login page — fallback for `RECLAIM_OLD_LOGIN_URL`. */
23
23
  export declare const DEFAULT_LOGIN_URL = "https://dev.reclaimprotocol.org";
24
- export { DEFAULT_BASE_URL, DEFAULT_OLD_API_URL };
24
+ export { DEFAULT_BASE_URL, DEFAULT_OLD_API_URL, DEFAULT_OLD_LOGS_URL };
25
25
  /** Pino log level. Read once at logger construction, so an eager const is
26
26
  * safe (no test mutates LOG_LEVEL at runtime). */
27
27
  export declare const LOG_LEVEL: string;
@@ -45,6 +45,8 @@ export declare function chromePath(): string | undefined;
45
45
  export declare function agentDisabled(): boolean;
46
46
  /** Old-devtools backend base URL. */
47
47
  export declare function oldApiUrl(): string;
48
+ /** Old-devtools analytics-logs service base URL (separate host). */
49
+ export declare function oldLogsUrl(): string;
48
50
  /** Builder backend base URL. Shared by mcp/start and the verification tool. */
49
51
  export declare function apiUrl(): string;
50
52
  /** Explicit builder API token (overrides the cached session token). */
package/lib/consts.js CHANGED
@@ -18,7 +18,7 @@
18
18
  import { DEFAULT_BASE_URL } from '@reclaimprotocol/client';
19
19
  import { homedir } from 'node:os';
20
20
  import { join } from 'node:path';
21
- import { DEFAULT_OLD_API_URL } from "./old/client.js";
21
+ import { DEFAULT_OLD_API_URL, DEFAULT_OLD_LOGS_URL } from "./old/client.js";
22
22
  // ---------------------------------------------------------------------------
23
23
  // Default / fallback literals (used only as env fallbacks).
24
24
  // ---------------------------------------------------------------------------
@@ -28,7 +28,7 @@ export const FALLBACK_ATTESTOR_URL = 'wss://attestor.reclaimprotocol.org:444/ws'
28
28
  export const DEFAULT_LOGIN_URL = 'https://dev.reclaimprotocol.org';
29
29
  // Re-export the two non-env-fallback defaults so callers can keep importing
30
30
  // their URL defaults from a single place if they wish.
31
- export { DEFAULT_BASE_URL, DEFAULT_OLD_API_URL };
31
+ export { DEFAULT_BASE_URL, DEFAULT_OLD_API_URL, DEFAULT_OLD_LOGS_URL };
32
32
  // ---------------------------------------------------------------------------
33
33
  // Logging.
34
34
  // ---------------------------------------------------------------------------
@@ -79,6 +79,10 @@ export function agentDisabled() {
79
79
  export function oldApiUrl() {
80
80
  return process.env.RECLAIM_OLD_API_URL || DEFAULT_OLD_API_URL;
81
81
  }
82
+ /** Old-devtools analytics-logs service base URL (separate host). */
83
+ export function oldLogsUrl() {
84
+ return process.env.RECLAIM_OLD_LOGS_URL || DEFAULT_OLD_LOGS_URL;
85
+ }
82
86
  /** Builder backend base URL. Shared by mcp/start and the verification tool. */
83
87
  export function apiUrl() {
84
88
  return process.env.RECLAIM_API_URL || DEFAULT_BASE_URL;
@@ -1 +1,3 @@
1
1
  export declare const BUILDER_MODE_INSTRUCTIONS: string;
2
+ export declare const PROVIDER_GUIDE_BUILDER: string;
3
+ export declare const PROVIDER_GUIDE_OLD: string;
@@ -9,3 +9,19 @@
9
9
  import { readFileSync } from 'node:fs';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  export const BUILDER_MODE_INSTRUCTIONS = readFileSync(fileURLToPath(new URL('../../skill/builder-mode.md', import.meta.url)), 'utf8').trim();
12
+ /**
13
+ * The full provider-authoring guide — the deep capture→prove→publish workflow,
14
+ * decision frameworks, and troubleshooting. Served on demand by the
15
+ * `how_it_works` MCP tool so every client (not just Claude Code, which
16
+ * auto-loads skills) can pull it. There's a guide PER MODE so an agent is never
17
+ * shown auth/publish tools that don't exist in its backend: the server picks
18
+ * one by `oldMode` (see `mcp/start.ts`). Same `../../skill/` path resolution as
19
+ * the instructions above; any leading frontmatter is stripped defensively.
20
+ */
21
+ function readGuide(file) {
22
+ return readFileSync(fileURLToPath(new URL(file, import.meta.url)), 'utf8')
23
+ .replace(/^---\n[\s\S]*?\n---\n/, '')
24
+ .trim();
25
+ }
26
+ export const PROVIDER_GUIDE_BUILDER = readGuide('../../skill/SKILL.builder.md');
27
+ export const PROVIDER_GUIDE_OLD = readGuide('../../skill/SKILL.old.md');
package/lib/mcp/start.js CHANGED
@@ -1,7 +1,7 @@
1
1
  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';
4
- import { agentDisabled, apiToken, apiUrl, oldApiUrl, useOldDevtools, } from "../consts.js";
4
+ import { agentDisabled, apiToken, apiUrl, oldApiUrl, oldLogsUrl, useOldDevtools, } from "../consts.js";
5
5
  import { ReclaimOldClient } from "../old/client.js";
6
6
  import { resolveInitialIdentity } from "../old/identity.js";
7
7
  import { OLD_MODE_INSTRUCTIONS } from "../old/instructions.js";
@@ -30,6 +30,7 @@ export async function startMcpServer() {
30
30
  // get_me_providers instead.
31
31
  const oldClient = new ReclaimOldClient({
32
32
  baseUrl: oldApiUrl(),
33
+ logsBaseUrl: oldLogsUrl(),
33
34
  identity: resolveInitialIdentity(),
34
35
  });
35
36
  registerToolList(server, buildLocalCredentialTools());
@@ -14,6 +14,7 @@ import { attachTool } from "./attach.js";
14
14
  import { captureTools } from "./capture.js";
15
15
  import { browserTools } from "./eval.js";
16
16
  import { findTool } from "./find.js";
17
+ import { howItWorksTool } from "./how-it-works.js";
17
18
  import { proofTool } from "./proof.js";
18
19
  import { makeSecretStore, proposeTool, } from "./propose.js";
19
20
  import { createVersionFromCaptureTool } from "./publish.js";
@@ -35,6 +36,9 @@ export function buildAgentTools(client) {
35
36
  const state = buildAgentState();
36
37
  ensureAttestorCrypto();
37
38
  const tools = [];
39
+ // Stateless docs tool — serve the mode-specific authoring guide to any
40
+ // client. No `client` ⇒ old-devtools mode (see `mcp/start.ts`).
41
+ tools.push(howItWorksTool(!client));
38
42
  tools.push(attachTool(state.attach));
39
43
  for (const t of captureTools(state.attach, state.captures)) {
40
44
  tools.push(t);
@@ -0,0 +1,11 @@
1
+ import { type RegisteredTool } from '../../server.ts';
2
+ /**
3
+ * Serves the full provider-authoring guide on demand. The MCP server's
4
+ * always-on `instructions` are a terse tool-flow reference; this tool returns
5
+ * the deep version (workflow, decision frameworks, gotchas, "never do" rules)
6
+ * so any MCP client — not just Claude Code, which alone auto-loads skills — can
7
+ * pull it. `oldMode` selects the backend-specific guide so the agent is never
8
+ * told to call auth/publish tools that aren't registered in its mode.
9
+ * Stateless: callable before `attach_chrome`.
10
+ */
11
+ export declare function howItWorksTool(oldMode: boolean): RegisteredTool;
@@ -0,0 +1,24 @@
1
+ import { PROVIDER_GUIDE_BUILDER, PROVIDER_GUIDE_OLD, } from "../../instructions.js";
2
+ import { defineTool } from "../../server.js";
3
+ /**
4
+ * Serves the full provider-authoring guide on demand. The MCP server's
5
+ * always-on `instructions` are a terse tool-flow reference; this tool returns
6
+ * the deep version (workflow, decision frameworks, gotchas, "never do" rules)
7
+ * so any MCP client — not just Claude Code, which alone auto-loads skills — can
8
+ * pull it. `oldMode` selects the backend-specific guide so the agent is never
9
+ * told to call auth/publish tools that aren't registered in its mode.
10
+ * Stateless: callable before `attach_chrome`.
11
+ */
12
+ export function howItWorksTool(oldMode) {
13
+ const guide = oldMode ? PROVIDER_GUIDE_OLD : PROVIDER_GUIDE_BUILDER;
14
+ return defineTool({
15
+ name: 'how_it_works',
16
+ description: 'Return the full Reclaim provider-authoring guide for THIS server: '
17
+ + 'the capture → draft → replay → prove → publish workflow, decision '
18
+ + 'frameworks (login detection, OPRF privacy, context vs bare params), '
19
+ + 'multi-request handling, troubleshooting, and what never to do. '
20
+ + 'Call this BEFORE building a provider unless you are already '
21
+ + 'fluent in the flow.',
22
+ inputSchema: { type: 'object', properties: {} },
23
+ }, async () => ({ guide }));
24
+ }
@@ -2,6 +2,9 @@
2
2
  * `/api` route prefix itself, so this is the bare origin. Override with
3
3
  * `RECLAIM_OLD_API_URL`. */
4
4
  export declare const DEFAULT_OLD_API_URL = "https://devapi.reclaimprotocol.org";
5
+ /** Default origin of the OLD-devtools analytics-logs service — a SEPARATE host
6
+ * from the devtools API. Override with `RECLAIM_OLD_LOGS_URL`. */
7
+ export declare const DEFAULT_OLD_LOGS_URL = "https://logs.reclaimprotocol.org";
5
8
  /**
6
9
  * Externally-supplied caller identity. We do NOT mint these — the dev
7
10
  * provides one:
@@ -53,8 +56,10 @@ export interface MyProvidersQuery {
53
56
  export declare class ReclaimOldClient {
54
57
  #private;
55
58
  readonly baseUrl: string;
59
+ readonly logsBaseUrl: string;
56
60
  constructor(opts?: {
57
61
  baseUrl?: string;
62
+ logsBaseUrl?: string;
58
63
  identity?: OldIdentity;
59
64
  fetch?: typeof fetch;
60
65
  });
@@ -66,5 +71,10 @@ export declare class ReclaimOldClient {
66
71
  registerProvider(body: RegisterProviderBody): Promise<unknown>;
67
72
  updateProviderConfig(providerId: string, body: UpdateProviderConfigBody): Promise<unknown>;
68
73
  listVersions(providerId: string): Promise<unknown>;
74
+ /** Analytics logs for a verification session, from the logs service (a
75
+ * DIFFERENT host than the devtools API — hence its own fetch, not
76
+ * `#request`). Attaches the caller identity when one is set; queried
77
+ * anonymously otherwise. */
78
+ getSessionAnalyticsLogs(sessionId: string): Promise<unknown>;
69
79
  getMyProviders(query?: MyProvidersQuery): Promise<unknown>;
70
80
  }
package/lib/old/client.js CHANGED
@@ -4,6 +4,9 @@ import { NAME, VERSION } from "../pkg.js";
4
4
  * `/api` route prefix itself, so this is the bare origin. Override with
5
5
  * `RECLAIM_OLD_API_URL`. */
6
6
  export const DEFAULT_OLD_API_URL = 'https://devapi.reclaimprotocol.org';
7
+ /** Default origin of the OLD-devtools analytics-logs service — a SEPARATE host
8
+ * from the devtools API. Override with `RECLAIM_OLD_LOGS_URL`. */
9
+ export const DEFAULT_OLD_LOGS_URL = 'https://logs.reclaimprotocol.org';
7
10
  /**
8
11
  * Minimal REST client for the OLD devtools Express backend (base `/api`).
9
12
  * Deliberately NOT a clone of `@reclaimprotocol/client`'s operationId
@@ -16,10 +19,13 @@ export const DEFAULT_OLD_API_URL = 'https://devapi.reclaimprotocol.org';
16
19
  */
17
20
  export class ReclaimOldClient {
18
21
  baseUrl;
22
+ logsBaseUrl;
19
23
  #identity;
20
24
  #fetch;
21
25
  constructor(opts = {}) {
22
26
  this.baseUrl = (opts.baseUrl || DEFAULT_OLD_API_URL).replace(/\/+$/, '');
27
+ this.logsBaseUrl = (opts.logsBaseUrl || DEFAULT_OLD_LOGS_URL)
28
+ .replace(/\/+$/, '');
23
29
  this.#identity = opts.identity;
24
30
  this.#fetch = opts.fetch ?? fetch;
25
31
  }
@@ -43,6 +49,27 @@ export class ReclaimOldClient {
43
49
  listVersions(providerId) {
44
50
  return this.#request('GET', `/providers/${encodeURIComponent(providerId)}/versions`, { auth: true });
45
51
  }
52
+ /** Analytics logs for a verification session, from the logs service (a
53
+ * DIFFERENT host than the devtools API — hence its own fetch, not
54
+ * `#request`). Attaches the caller identity when one is set; queried
55
+ * anonymously otherwise. */
56
+ async getSessionAnalyticsLogs(sessionId) {
57
+ const url = `${this.logsBaseUrl}/api/analytics-logs/session/`
58
+ + encodeURIComponent(sessionId);
59
+ const headers = {
60
+ Accept: 'application/json',
61
+ 'User-Agent': `${NAME}/${VERSION}`,
62
+ };
63
+ if (this.#identity) {
64
+ headers[this.#authHeaderName()] = this.#authHeaderValue();
65
+ }
66
+ const res = await this.#fetch(url, { method: 'GET', headers });
67
+ const json = await this.#parseBody(res);
68
+ if (!res.ok) {
69
+ throw this.#asBoom(res, json);
70
+ }
71
+ return json;
72
+ }
46
73
  getMyProviders(query = {}) {
47
74
  // Every one of these query params is REQUIRED by the backend
48
75
  // validator (missing/invalid → 400), so default them all here.
@@ -3,7 +3,7 @@ import type { ReclaimOldClient } from '../client.ts';
3
3
  /**
4
4
  * The backend-facing tools for OLD-devtools mode. Mirrors
5
5
  * `buildCredentialTools` / `buildAgentTools`: returns a flat `RegisteredTool`
6
- * list for `registerToolList`. These three replace the builder credential +
7
- * publish tools and the (disabled) generated provider tools.
6
+ * list for `registerToolList`. These replace the builder credential + publish
7
+ * tools and the (disabled) generated provider tools.
8
8
  */
9
9
  export declare function buildOldBackendTools(client: ReclaimOldClient): RegisteredTool[];
@@ -1,16 +1,18 @@
1
1
  import { authenticateTool } from "./authenticate.js";
2
2
  import { getMeProvidersTool } from "./me-providers.js";
3
3
  import { createVersionFromCaptureTool } from "./publish.js";
4
+ import { sessionAnalyticsLogsTool } from "./session-analytics-logs.js";
4
5
  /**
5
6
  * The backend-facing tools for OLD-devtools mode. Mirrors
6
7
  * `buildCredentialTools` / `buildAgentTools`: returns a flat `RegisteredTool`
7
- * list for `registerToolList`. These three replace the builder credential +
8
- * publish tools and the (disabled) generated provider tools.
8
+ * list for `registerToolList`. These replace the builder credential + publish
9
+ * tools and the (disabled) generated provider tools.
9
10
  */
10
11
  export function buildOldBackendTools(client) {
11
12
  return [
12
13
  authenticateTool(client),
13
14
  createVersionFromCaptureTool(client),
14
15
  getMeProvidersTool(client),
16
+ sessionAnalyticsLogsTool(client),
15
17
  ];
16
18
  }
@@ -0,0 +1,9 @@
1
+ import { type RegisteredTool } from '../../mcp/server.ts';
2
+ import type { ReclaimOldClient } from '../client.ts';
3
+ /**
4
+ * Fetch analytics logs for a verification session from the OLD-devtools logs
5
+ * service (`GET https://logs.reclaimprotocol.org/api/analytics-logs/session/
6
+ * :sessionId`). Old mode only — useful for diagnosing why a session failed or
7
+ * stalled. The logs service is a separate host from the devtools API.
8
+ */
9
+ export declare function sessionAnalyticsLogsTool(client: ReclaimOldClient): RegisteredTool;
@@ -0,0 +1,25 @@
1
+ import { defineTool } from "../../mcp/server.js";
2
+ /**
3
+ * Fetch analytics logs for a verification session from the OLD-devtools logs
4
+ * service (`GET https://logs.reclaimprotocol.org/api/analytics-logs/session/
5
+ * :sessionId`). Old mode only — useful for diagnosing why a session failed or
6
+ * stalled. The logs service is a separate host from the devtools API.
7
+ */
8
+ export function sessionAnalyticsLogsTool(client) {
9
+ return defineTool({
10
+ name: 'session_analytics_logs',
11
+ description: 'Fetch analytics logs for a verification session by id from the '
12
+ + 'old-devtools logs service — use to diagnose what happened in a '
13
+ + 'session (matches, claims, failures). Pass the `sessionId`.',
14
+ inputSchema: {
15
+ type: 'object',
16
+ properties: {
17
+ sessionId: {
18
+ type: 'string',
19
+ description: 'The verification session id to fetch logs for.',
20
+ },
21
+ },
22
+ required: ['sessionId'],
23
+ },
24
+ }, async (args) => client.getSessionAnalyticsLogs(args.sessionId));
25
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reclaimprotocol/agent",
3
3
  "title": "Reclaim Agent",
4
- "version": "0.1.3",
4
+ "version": "0.1.4",
5
5
  "description": "Reclaim Protocol agent — SDK, CLI, and MCP server in one package. SDK + CLI talk to the builder backend; MCP server adds Chrome CDP capture, provider synthesis, replay, and zkTLS proof.",
6
6
  "type": "module",
7
7
  "main": "./lib/index.js",
@@ -26,8 +26,7 @@
26
26
  "./mcp/register": {
27
27
  "types": "./lib/mcp/register.d.ts",
28
28
  "import": "./lib/mcp/register.js"
29
- },
30
- "./skill": "./skill/SKILL.md"
29
+ }
31
30
  },
32
31
  "bin": {
33
32
  "reclaim": "./lib/cli.js",
@@ -1,8 +1,3 @@
1
- ---
2
- name: reclaim-provider
3
- description: Activate when the developer asks how to verify a value from a website (e.g. "create a provider for X", "verify my GitHub username", "prove my X follower count"). Drives the reclaim-agent MCP server to capture browser traffic, draft a Reclaim provider JSON, replay it, and run an end-to-end zkTLS proof.
4
- ---
5
-
6
1
  # Authoring a Reclaim Provider / Verification
7
2
 
8
3
  > **Terminology note:** "provider" and "verification" refer to the same
@@ -13,27 +8,8 @@ description: Activate when the developer asks how to verify a value from a websi
13
8
 
14
9
  You drive the browser through the capture → draft → replay → prove loop using the `reclaim-agent` MCP server. **Your default stance is driver, not orchestrator.** Only pause to ask the dev for things that genuinely require a human: typing credentials, completing 2FA, solving a captcha, confirming an ambiguous value. Navigation, reloads, DOM inspection, request capture — all yours.
15
10
 
16
- ## Modes
17
-
18
- This MCP server targets one of two backends. Detect which from the tools available to you:
19
-
20
- - **Old devtools (the default).** `get_me_providers` is present; the generated API tools (`get_me`, `create_verification`, `list_orgs`, …) are absent.
21
- - **Builder.** The generated API tools are present; `get_me_providers` is absent.
22
-
23
- Only **authentication** (below), **publishing** (the final step), and the **consumer-supplied param namespace** differ between modes: values the Consumer supplies at verification time are templated `{{context.<name>}}` on builder vs `{{context_<name>}}` on old devtools (no dotted interpolation there) — see step 10. The capture → draft → replay → prove loop in between is identical, and OPRF is optional and supported in both.
24
-
25
11
  ## Getting authenticated
26
12
 
27
- ### Old devtools mode
28
-
29
- Call `reclaim_authenticate({})` with no arguments. It opens the Reclaim dashboard in Chrome, waits for the dev to sign in, reads the token from the page automatically, and closes the tab — a still-valid cached identity is reused first, so it's often instant. Tell the dev once: _"A browser window is opening the Reclaim dashboard — please sign in and I'll continue automatically."_
30
-
31
- - Force a fresh login (ignore the cached identity): `reclaim_authenticate({ useBrowser: true })`.
32
- - Skip the browser: `reclaim_authenticate({ token: "<firebase-id-token>" })`, or `reclaim_authenticate({ ethAddress: "0x..." })` for an already-provisioned eth user.
33
- - Tokens are short-lived (~1h). If a later call returns 401, call `reclaim_authenticate({})` again.
34
-
35
- ### Builder mode
36
-
37
13
  **Check first:** try `get_me`. If it returns the user's profile, you're authenticated — proceed to the loop.
38
14
 
39
15
  **On 401:** call `authenticate({})` to start a device-pairing session:
@@ -150,15 +126,14 @@ The 2-day token is cached in `~/.reclaim/config.json` and reused on later runs (
150
126
 
151
127
  10. **Parameterize user-specific URL segments** so the provider isn't user-specific at the URL level. The matcher already populates `paramValues` with the _captured value_ (e.g. `{username: "Scratch-net"}` for a username target). If the URL ALSO contains user-identifying segments, those are **consumer-supplied context params** — at verification time their values come from the Consumer's session `context`, not from extraction. They use a namespaced placeholder, and the namespace differs by mode:
152
128
 
153
- - **Builder mode:** replace with `{{context.<name>}}`. E.g. `https://github.com/Scratch-net/repos` → `https://github.com/{{context.username}}/repos`, with `paramValues: { ..., "context.username": "Scratch-net" }` for replay/proof (the paramValues key must match the placeholder name exactly, dot included). Declare these in the version's `requiredContext` at publish time (step 13).
154
- - **Old devtools mode:** same idea, but the legacy backend doesn't support dotted interpolation — use `{{context_<name>}}` and `paramValues: { ..., "context_username": "Scratch-net" }`.
129
+ replace with `{{context.<name>}}`. E.g. `https://github.com/Scratch-net/repos` → `https://github.com/{{context.username}}/repos`, with `paramValues: { ..., "context.username": "Scratch-net" }` for replay/proof (the paramValues key must match the placeholder name exactly, dot included). Declare these in the version's `requiredContext` at publish time (step 13).
155
130
  - Query parameters with user-specific values → same treatment.
156
131
  - Generic segments (`/api/v3`, `/users`, `/repos`) stay literal.
157
132
  - If the URL has no user-identifying segments (e.g. `/settings/profile`), leave it untouched — the matcher already handled the value via paramValues.
158
133
 
159
134
  **Do not rename the match-template param.** The synthesizer's `{{name}}` in `responseMatches[].value` is an _extraction_ template (the attestor pulls the value out of the response) — it stays bare and never gets the `context.` prefix. Only URL/body placeholders whose values the Consumer must supply are context params.
160
135
 
161
- **How the verification client resolves these at runtime** (builder mode — use this to make the context-vs-bare call):
136
+ **How the verification client resolves these at runtime**:
162
137
 
163
138
  - Requests are matched by **exact origin+path** (query ignored). A `{{context.<name>}}` segment matches **only the value the consumer sent** in the session context (validated by `requiredContext`); a bare `{{name}}` segment matches any one path segment and its value is **auto-extracted from the live traffic**.
164
139
  - Interpolation and `contains` matching are **plain text** — they work for any captured content (JSON, XML, HTML, form-encoded bodies, …).
@@ -198,16 +173,8 @@ The 2-day token is cached in `~/.reclaim/config.json` and reused on later runs (
198
173
 
199
174
  **Missing ZK circuit files:** if the error says "ZK circuit resources are missing" and points at a `circuit.wasm` path, the npm postinstall didn't run (commonly because the dev used `npm install --ignore-scripts` or a corp policy disabled it). Tell the dev to run `npm run download:zk-files` from the project root and then retry `run_proof` — no need to redo capture or propose. The MCP server doesn't need a restart either; the next `run_proof` call will see the files.
200
175
 
201
- 13. **Publish the proved provider.** This is the step that differs by mode (see [Modes](#modes)). In both, `provider` is the exact JSON from step 9 with the step-10 URL parameterisation applied, and `initialUrl` is the page where the user logs in / the value is displayed (e.g. `https://github.com/Syed`) — **not** the API endpoint URL inside the provider.
202
-
203
- **Old devtools mode** — call `create_provider_version_from_capture`:
204
- - **New verification:** omit `providerId`. It registers a new provider (version 1.0.0). Pass `provider`, `initialUrl`, and optionally `description`, `geoLocation`, `providerType` (`PRIVATE` default | `PUBLIC`).
205
- - **New version of an existing verification:** pass its `providerId` — the patch is auto-bumped above the highest existing version. Pass `notes` (≥10 chars) as the change note.
206
- - `get_me_providers({ searchQuery? })` lists the verifications you own — use it to find a `providerId` or to confirm the result. It's the only provider-listing tool in this mode.
207
- - There's no separate "create-then-version" step and no org / public-review workflow here.
208
- - Consumer-supplied params (step 10) keep the `{{context_<name>}}` form in this mode; there is no `requiredContext` declaration on the legacy backend.
176
+ 13. **Publish the proved provider.** `provider` is the exact JSON from step 9 with the step-10 URL parameterisation applied, and `initialUrl` is the page where the user logs in / the value is displayed (e.g. `https://github.com/Syed`) — **not** the API endpoint URL inside the provider.
209
177
 
210
- **Builder mode:**
211
178
  1. **Create the verification if it doesn't exist:** `create_verification(orgId, domain, slug, title, description)` — find `orgId` via `list_orgs`. `domain` e.g. `github.com`; `slug` e.g. `github-username`; `title` e.g. "GitHub Username". Starts PRIVATE with an empty seed version. Skip if the dev already has a `providerId`.
212
179
  2. **Persist the version:** `create_provider_version_from_capture(providerId, provider, initialUrl, notes)` — `notes` e.g. `"Initial draft from browser capture"`. The version lands on the `draft` branch.
213
180
  3. **`requiredContext` is derived for you.** The publish tool scans the recipe's url/body for `{{context.<name>}}` placeholders (step 10) and emits a JSON Schema requiring each one — the Builder then validates every session-create's `context` against it (a clear 400 instead of a late attestor failure). Extra context fields are always allowed. The derived properties are plain strings; to refine types or add human-readable descriptions, call `update_provider_version` with a `requiredContext` override while the version is on `draft`, e.g.:
@@ -0,0 +1,221 @@
1
+ # Authoring a Reclaim Provider / Verification
2
+
3
+ > **Terminology note:** "provider" and "verification" refer to the same
4
+ > thing. The API and tool parameters use **provider** / **providerId**
5
+ > (the technical term). Conversations with developers prefer
6
+ > **verification** (the user-facing term). Either is acceptable — never
7
+ > correct the developer for using one or the other.
8
+
9
+ You drive the browser through the capture → draft → replay → prove loop using the `reclaim-agent` MCP server. **Your default stance is driver, not orchestrator.** Only pause to ask the dev for things that genuinely require a human: typing credentials, completing 2FA, solving a captcha, confirming an ambiguous value. Navigation, reloads, DOM inspection, request capture — all yours.
10
+
11
+ ## Getting authenticated
12
+
13
+ Call `reclaim_authenticate({})` with no arguments. It opens the Reclaim dashboard in Chrome, waits for the dev to sign in, reads the token from the page automatically, and closes the tab — a still-valid cached identity is reused first, so it's often instant. Tell the dev once: _"A browser window is opening the Reclaim dashboard — please sign in and I'll continue automatically."_
14
+
15
+ - Force a fresh login (ignore the cached identity): `reclaim_authenticate({ useBrowser: true })`.
16
+ - Skip the browser: `reclaim_authenticate({ token: "<firebase-id-token>" })`, or `reclaim_authenticate({ ethAddress: "0x..." })` for an already-provisioned eth user.
17
+ - Tokens are short-lived (~1h). If a later call returns 401, call `reclaim_authenticate({})` again.
18
+
19
+ ## The loop
20
+
21
+ 1. **Attach.** Call `attach_chrome`. If it returns `needsChoice: true`, ask the dev:
22
+ - `dedicated` — agent auto-launches Chrome with a dedicated profile under `~/.reclaim/chrome-profile`. Easiest path; works out of the box.
23
+ - `attach` — connect to a Chrome the dev is already running. **Important Chrome 136+ gotcha:** the dev's Chrome must be launched with BOTH `--remote-debugging-port=9222` AND `--user-data-dir=<some-non-default-path>`. Since Chrome 136 (May 2025), the debug-port flag is silently ignored when the default user profile is in use. If `attach` fails, tell them this verbatim and offer to switch to `dedicated`.
24
+
25
+ Re-call with the chosen `attachMode`.
26
+
27
+ 2. **Pick a tab and open the site yourself.** Call `list_tabs`. If there's already a tab on the right origin, use it. Otherwise pick any tab (the first one is usually fine) and call `navigate(tabId, url)` to send it to the site you've inferred from the dev's request (e.g. "GitHub username" → `https://github.com`). Don't ask the dev to navigate.
28
+
29
+ 3. **Check login state, wait if needed.** Use `eval_in_page(tabId, ...)` to evaluate a site-specific check.
30
+
31
+ **Combine a positive signal (logged-in-only element present) with a negative signal (login button absent), AND them together.** Single signals are too prone to false positives — most notably, `<meta>` tags often exist on every page regardless of login state; only their `.content` attribute is populated. The check `!!document.querySelector('meta[name="user-login"]')` returns true even when logged out — DO NOT use existence-only checks on meta tags or other persistent-but-empty elements.
32
+
33
+ Examples (always verify against the live page if these break — sites change selectors):
34
+ - GitHub: `(document.querySelector('meta[name="user-login"]')?.content?.length || 0) > 0 && !document.querySelector('a.HeaderMenu-link[href="/login"]')`
35
+ - X / Twitter: `!!document.querySelector('[data-testid="AppTabBar_Profile_Link"]') && !document.querySelector('[data-testid="loginButton"]')`
36
+ - Generic shape: `<element-present-only-when-logged-in> && !<login-button-present-only-when-logged-out>`
37
+
38
+ If the result is truthy, skip to step 4.
39
+
40
+ If falsy: **navigate to the site's login URL yourself first**, so the dev lands directly on the login form instead of having to find it. Common patterns: `https://<host>/login`, `https://<host>/signin`, `https://<host>/i/flow/login` (Twitter/X). Pick the one you know works for the site. Call `navigate(tabId, "<login-url>")`. Then tell the dev once: "I've opened the login page on `<site>` — please log in (with 2FA if needed). I'll detect when you're done." Then call `wait_for_page(tabId, expression="<same composite expression>", timeoutMs=180000)`. The MCP polls every second and returns when the expression goes truthy. **Do not** instruct the dev to "tell me when you're done" — just wait via the tool.
41
+
42
+ **Self-check before trusting the result:** if your detector says "already logged in" on a freshly-launched Chrome profile (cookies-clean), the detector is wrong. Brand-new profile means brand-new cookie jar — there's no way to be already authenticated. If you see that contradiction, your check is matching something that's present regardless of login (a meta tag, a generic class). Rewrite the expression and re-evaluate.
43
+
44
+ 4. **Navigate to the page that displays the value.** Use what you know about the site to construct the URL. If you need the dev's username, eval it (e.g. on GitHub: `document.querySelector('meta[name="user-login"]')?.content`). Then `navigate(tabId, url)`. Don't ask the dev to navigate.
45
+
46
+ 5. **Start capture, then trigger the request.** Call `start_capture(tabId)` first. Then issue a reload so the relevant API call fires while capture is live: `eval_in_page(tabId, "location.reload()")`. For some pages you may need to click a specific element or call a SPA route — use `eval_in_page` to do that programmatically rather than asking the dev to click.
47
+
48
+ 6. **Identify the value from the DOM.** `eval_in_page` to read the displayed value (e.g. the logged-in username from `meta[name="user-login"]`'s `content` attribute). If the value is unambiguous, proceed without asking. Only confirm with the dev when there are multiple plausible interpretations (e.g. the page shows both a display name "John Doe" and a handle "@jdoe" and you need to pick which to prove).
49
+
50
+ 7. **Find the request.** Call `find_requests_containing(captureId, value)`. For each top candidate (limit ≤ 5), call `analyze_request_constraints` and surface its `concerns` to the dev verbatim. Skip candidates flagged `signedRequest` or `botChallenge` unless none others are available. Concerns flagged `csrfDependency`, `shortLivedToken`, or `graphql` are informational — surface them and proceed.
51
+
52
+ **Strongly prefer authenticated, user-specific endpoints over public pages.** A proof against a public endpoint (e.g. `https://github.com/<username>`) attests only that "this URL returned this value at this time" — anyone in the world could fetch the same URL and prove the same thing. The proof says nothing about who the prover _is_. To prove a fact about the dev, the captured request needs to be one whose **response varies with authentication** — typically an API endpoint that returns the logged-in user's data (e.g. `GET https://api.github.com/user`, `https://twitter.com/i/api/.../UserByScreenName?...&forCurrentUser=true`).
53
+
54
+ When ranking candidates from `find_requests_containing`, prefer ones that:
55
+ - Have `Cookie`, `Authorization`, or other secret headers (analyze_request_constraints will surface these via `splitSecrets`).
56
+ - Have URL paths suggesting authenticated data: `/me`, `/user`, `/settings`, `/api/...`, `/graphql` with viewer queries.
57
+ - Return JSON keyed to the dev's identity (login/username/id fields next to the target value).
58
+
59
+ If the only candidates are public-page HTML responses, tell the dev: "The value appears on a public page, so a proof against this endpoint won't bind to your identity — anyone could prove the same fact. I can still build it if you just want to attest the value, but for an identity-bound proof we need to capture an authenticated request. Want to keep digging, or proceed anyway?" Then wait for their call.
60
+
61
+ 8. **Decide whether to OPRF the value** (privacy from the attestor). Before calling `propose_provider`, decide if the captured value should be hashed end-to-end so the attestor never sees it in cleartext. Four options:
62
+ - **No hash** (default). Value travels via `paramValues` + `{{name}}` template; attestor sees the cleartext substring. Simplest, fewest moving parts. Right call for non-sensitive values (public IPs from `ifconfig.me`, public usernames the dev is happy to expose, public repo names).
63
+ - **`oprf-raw`**. Server-side OPRF. **The only variant that runs on Windows / without external deps.** Use this when the dev wants any privacy and you're in a normal dev loop. Privacy is the weakest of the three (attestor still participates in hashing) — surface that honestly.
64
+ - **`oprf`**. Client-side ZK OPRF via gnark. Strongest privacy. Requires gnark install (Linux/macOS in practice). Don't suggest this on Windows.
65
+ - **`oprf-mpc`**. TEE-based MPC OPRF. Requires a TEE attestor deployment. Niche; only suggest if the dev brings it up.
66
+
67
+ **Default skip-the-question heuristic.** If the value is obviously non-sensitive (public IP, public username, public repo name, public follower/star counts on sites where the captured endpoint is auth-bound), default to **no hash** and proceed without asking. If the value is or could be sensitive (email, real name, private ID, anything the dev wouldn't post publicly), surface the choice verbatim:
68
+
69
+ > "Do you want this value kept private from the attestor via OPRF?
70
+ >
71
+ > - **No** (default) — value visible to attestor
72
+ > - **`oprf-raw`** — works on Windows / without extra deps; weakest of the OPRF variants
73
+ > - **`oprf`** — gnark/ZK; strongest privacy but requires Linux/macOS + gnark install
74
+ > - **`oprf-mpc`** — TEE-based; requires a TEE attestor"
75
+
76
+ **Hard constraint:** OPRF-hashed values are capped at **62 bytes**. If the value is longer and the dev picks any hash, surface that and propose either a tighter `target` (e.g. just the email local-part) or no hash. `propose_provider` will return a clean error if you try anyway.
77
+
78
+ 9. **Draft.** Call `propose_provider(captureId, requestId, target, name, hash?)`. Pass `hash` only if the dev opted in. Inspect the returned provider:
79
+ - Is the right header marked secret? (cookies, Authorization, X-\* tokens)
80
+ - Is `responseMatches[0].value` a `contains` with a `{{name}}` template (matching the captured value's structural context)?
81
+ - Is `paramValues[name]` set to the original captured value?
82
+ - Does `responseRedactions[0]` carry an `xPath` or `jsonPath` anchor? (And if `hash` was requested, also a `regex` with a named capture and a `hash: oprf-…` field?)
83
+
84
+ **Canonical drafted shape** (no hash):
85
+
86
+ ```json
87
+ {
88
+ "responseMatches": [
89
+ { "type": "contains", "value": "\"login\":\"{{username}}\"" }
90
+ ],
91
+ "responseRedactions": [{ "xPath": "//script[@id=\"client-env\"]" }],
92
+ "paramValues": { "username": "Scratch-net" }
93
+ }
94
+ ```
95
+
96
+ **Canonical drafted shape** (with `hash: 'oprf-raw'`):
97
+
98
+ ```json
99
+ {
100
+ "responseMatches": [
101
+ { "type": "contains", "value": "\"login\":\"{{username}}\"" }
102
+ ],
103
+ "responseRedactions": [
104
+ {
105
+ "xPath": "//script[@id=\"client-env\"]",
106
+ "regex": "\"login\":\"(?<username>[A-Za-z0-9_-]+)\"",
107
+ "hash": "oprf-raw"
108
+ }
109
+ ],
110
+ "paramValues": { "username": "Scratch-net" }
111
+ }
112
+ ```
113
+
114
+ **Do NOT rewrite the shape.** The synthesizer's output is the canonical Reclaim shape — `contains` template + paramValues + xPath/jsonPath redaction (optionally + regex+hash). Specifically:
115
+ - Do not replace the `contains` template with a hand-crafted regex.
116
+ - Do not hand-write a named-capture regex unless `hash` was requested AND the synthesizer's output doesn't already have one. (It will, for any digit/slug/email-shaped target.)
117
+ - Do not remove `paramValues` — that's how the template resolves at proof time.
118
+ - The redaction's xPath/jsonPath is what the prover reveals; the match's template is what the attestor verifies post-substitution. They serve different purposes — don't conflate them.
119
+ - Do not prefix the match-template's param with `context.`/`context_` — that namespace is reserved for consumer-supplied URL/body params (step 10). Match templates are extraction params and stay bare.
120
+
121
+ Permitted edits: narrowing the `contains` template, picking a more distinctive xPath class/id if the synthesizer's pick is too generic, fixing the `name` field, adding/removing secret headers. If you're tempted to rewrite the provider from scratch, call `propose_provider` again with different inputs instead.
122
+
123
+ 10. **Parameterize user-specific URL segments** so the provider isn't user-specific at the URL level. The matcher already populates `paramValues` with the _captured value_ (e.g. `{username: "Scratch-net"}` for a username target). If the URL ALSO contains user-identifying segments, those are **consumer-supplied context params** — at verification time their values come from the Consumer's session `context`, not from extraction. They use a namespaced placeholder, and the namespace differs by mode:
124
+
125
+ - Replace user-specific segments with `{{context_<name>}}` and `paramValues: { ..., "context_username": "Scratch-net" }`.
126
+ - Query parameters with user-specific values → same treatment.
127
+ - Generic segments (`/api/v3`, `/users`, `/repos`) stay literal.
128
+ - If the URL has no user-identifying segments (e.g. `/settings/profile`), leave it untouched — the matcher already handled the value via paramValues.
129
+
130
+ **Do not rename the match-template param.** The synthesizer's `{{name}}` in `responseMatches[].value` is an _extraction_ template (the attestor pulls the value out of the response) — it stays bare and never gets the `context.` prefix. Only URL/body placeholders whose values the Consumer must supply are context params.
131
+
132
+ `propose_provider` returns `verificationWarnings` for the calls only an author can make (currently: a captured value baked literally into the URL, needing the `{{context_<name>}}`-vs-bare-vs-re-capture decision). They are advisory, never blocking — **surface each warning to the dev verbatim** and resolve it before publishing.
133
+
134
+ 11. **Replay.** Call `replay_request(provider, secretParamsRef)`. Three outcomes:
135
+
136
+ - `matchesOriginal: true` → proceed.
137
+ - `matchesOriginal: false` with `hints` mentioning auth/csrf → re-trigger the action yourself: navigate or reload via the tools, then loop to step 5. Don't ask the dev to redo it manually.
138
+ - `matchesOriginal: false` with stable diff (timestamps etc.) → tighten the matcher and retry.
139
+
140
+ **After replay succeeds, run an authentication-bound check.** Call `replay_request(provider)` _without_ the `secretParamsRef` (omit it). This sends the request stripped of all secret headers — the same shape an unauthenticated visitor would send.
141
+
142
+ - If `matchesOriginal: false` (e.g. 401/403, or the value differs) → the endpoint is auth-bound. Good. The proof will actually mean "the holder of these secrets saw this value."
143
+ - If `matchesOriginal: true` with the same value → the endpoint is fully public. Tell the dev verbatim: "I verified this endpoint returns the same value with no authentication — the proof won't prove your identity, only that this URL returned the value. Anyone could generate the same proof. Want to keep digging for an auth-bound endpoint, or proceed anyway?" Wait for their decision before continuing.
144
+
145
+ 12. **Prove.** Call `run_proof(provider, secretParamsRef)`. On `verified: true`, the full claim has been written to `~/.reclaim/claims/<timestamp>.json` and the tool returns `{verified, extractedValue, extractedParameters, identifier, owner, claimPath, claim}`. The `claim` field is a display-ready version of the proof — its huge `request` payload has been stripped and all byte arrays converted to base64, so it renders cleanly. **Show it to the dev as a fenced JSON code block at the end** along with the provider JSON. That's the final result. On `verified: false`, diagnose using the returned error and iterate.
146
+
147
+ **Missing signing credential (`errorKind: "missing-owner-key"`)** — the dev hasn't set up an eth proof-owner key yet. Tell them what's happening, then drive the bootstrap yourself: call `resolve_owner_key`; if it returns no key, ask once _"shall I mint a fresh eth key via `issue_credentials` (or `import_credentials` if you already hold one)? It's written to the project `.env` (mode 0600, gitignored) for this and future sessions."_; on yes, call `issue_credentials` (or `import_credentials`) and retry the same `run_proof` call. **No restart needed** — the key is read live from `.env` on each `run_proof`, and the existing `secretParamsRef` from `propose_provider` is still valid.
148
+
149
+ **On any `verified: false` response, the result includes (inline):**
150
+ - `error` — the SDK's thrown error message verbatim
151
+ - `sentParams` — the exact `params` object the SDK saw, post-template substitution (lets you spot mutation / substitution surprises)
152
+
153
+ **Deeper tracing (opt-in).** If the dev launches Claude Code with `RECLAIM_AGENT_TRACE=1` in the MCP server's env, `run_proof` ALSO captures the SDK's full pino log stream into `result.attestorLogs` and persists a per-call dump to `~/.reclaim/proof-runs/<timestamp>.json` (surfaced as `runLogPath`). Off by default because the trace stream is verbose. Suggest turning it on when investigating a recurring proof failure.
154
+
155
+ **The attestor is reliable. If it errors, the cause is in the provider, the network, or the prover-side code — not "the attestor can't handle this site".** Do not invent failure modes like "HTML is too large", "TLS records are chunked", "the attestor matches against gzipped bytes" (it doesn't if the provider has `Accept-Encoding: identity`, which `propose_provider` always sets), or "xPath doesn't work on this DOM". These are wrong and they waste the dev's time. Instead:
156
+ 1. Show the `error` field to the dev verbatim.
157
+ 2. If `sentParams` differs from what you expected — e.g. `{{name}}` substituted to the wrong value, headers got mutated, redactions look different from `propose_provider`'s output — surface that.
158
+ 3. Only then propose a hypothesis, and tie it to that evidence. If the diagnosis is unclear, suggest the dev set `RECLAIM_AGENT_TRACE=1` and rerun for full attestor logs.
159
+
160
+ **Do not auto-retry with a different `attestorUrl`.** There is one live public attestor right now; backup hostnames (`attestor-2.*`, etc.) **do not exist**. `attestorUrl` is only for when the dev explicitly supplies a private/staging URL.
161
+
162
+ **Missing ZK circuit files:** if the error says "ZK circuit resources are missing" and points at a `circuit.wasm` path, the npm postinstall didn't run (commonly because the dev used `npm install --ignore-scripts` or a corp policy disabled it). Tell the dev to run `npm run download:zk-files` from the project root and then retry `run_proof` — no need to redo capture or propose. The MCP server doesn't need a restart either; the next `run_proof` call will see the files.
163
+
164
+ 13. **Publish the proved provider.** `provider` is the exact JSON from step 9 with the step-10 URL parameterisation applied, and `initialUrl` is the page where the user logs in / the value is displayed (e.g. `https://github.com/Syed`) — **not** the API endpoint URL inside the provider.
165
+
166
+ Call `create_provider_version_from_capture`:
167
+ - **New verification:** omit `providerId`. It registers a new provider (version 1.0.0). Pass `provider`, `initialUrl`, and optionally `description`, `geoLocation`, `providerType` (`PRIVATE` default | `PUBLIC`).
168
+ - **New version of an existing verification:** pass its `providerId` — the patch is auto-bumped above the highest existing version. Pass `notes` (≥10 chars) as the change note.
169
+ - `get_me_providers({ searchQuery? })` lists the verifications you own — use it to find a `providerId` or to confirm the result. It's the only provider-listing tool in this mode.
170
+ - There's no separate "create-then-version" step and no org / public-review workflow here.
171
+ - Consumer-supplied params (step 10) keep the `{{context_<name>}}` form in this mode; there is no `requiredContext` declaration on the legacy backend.
172
+
173
+ When done, tell the developer the version is live (PRIVATE) or submitted for review (PUBLIC), and that they can integrate using the `providerId`.
174
+
175
+ ## Concerns the developer should see verbatim
176
+
177
+ When `analyze_request_constraints` returns concerns, repeat each `message` to the dev exactly. Don't paraphrase, don't omit. These are the dev's signal that a provider is or isn't worth pursuing.
178
+
179
+ ## Things to never do
180
+
181
+ - Never echo a raw `Authorization`, `Cookie`, or `X-*-Token` value back to the dev or write one to any file. The MCP guarantees these don't appear in tool responses — keep that guarantee.
182
+ - Never call `run_proof` before `replay_request` succeeds. Attestor calls are expensive.
183
+ - Never pretend a proof succeeded when `verified: false` came back. Show the error and iterate.
184
+ - **Never hand-craft `responseMatches` or `responseRedactions`.** If you find yourself typing a regex by hand, OR writing `(?<name>...)`-style patterns by hand, OR copying the captured value literally into the match — stop. The synthesizer in `propose_provider` already produces the canonical shape: `contains` template + paramValues for the match, xPath/jsonPath (optionally + regex+hash when `hash` is passed) for the redaction. The named-capture regex in the redaction is the synthesizer's job, not yours.
185
+ - Never write a literal value into `responseMatches.value` — the synthesizer always emits `{{name}}` templates with paramValues. A literal value bakes the original user into the provider.
186
+ - Never pass `hash` to `propose_provider` for a free-form (unicode / spaces / punctuation) target — the synthesizer will error, and the right move is either to use a tighter `target` or accept cleartext (no `hash`).
187
+ - Never accept "the synthesizer's xPath looks too narrow, let me write a regex" — the xPath is fine, the attestor will reveal the whole matched element which is naturally ≥16 bytes.
188
+
189
+ ## Common first-time issues — surface these proactively
190
+
191
+ If `attach_chrome` errors, times out, or `waitForCdpReady` reports the port isn't answering, walk through these in order before retrying:
192
+
193
+ - **Browser binary not found.** If the error lists candidate paths it tried (Program Files Chrome, LOCALAPPDATA Chrome, Edge), every one missed. Either the dev has Chrome installed somewhere unusual, or doesn't have Chrome/Edge at all. Walk them through setting `RECLAIM_AGENT_CHROME_PATH` to the actual binary path:
194
+ ```powershell
195
+ setx RECLAIM_AGENT_CHROME_PATH "C:\path\to\chrome.exe"
196
+ ```
197
+ `setx` (not `$env:`) writes to the user-level Windows env, so future shells and future Claude Code launches inherit it. **Then fully restart Claude Code** — MCP servers snapshot env vars at launch, so the running reclaim-agent has the old empty value. After restart, the new MCP process picks up the env. On macOS/Linux: `export RECLAIM_AGENT_CHROME_PATH=/path/to/chrome` in the shell, then restart Claude Code from that same shell so the var propagates.
198
+ - **Pre-existing `chrome.exe` processes hijack the new launch (Windows).** If any Chrome is already running, a new launch with `--remote-debugging-port` gets delegated to the existing process group and the debug port silently doesn't open. Tell the dev to kill all `chrome.exe` first (`taskkill /F /IM chrome.exe` in cmd/PowerShell, or via Task Manager), then retry. Affects both `attach` and `dedicated` modes when the existing instance was launched with the same profile dir.
199
+ - **Chrome 136+ silently disables remote debugging on the default user profile.** Already noted in step 1, but worth raising again here: the only fix is a non-default `--user-data-dir`. `dedicated` mode is always safe; `attach` mode needs the dev to have launched with both flags.
200
+
201
+ **Credential for signing the proof.** `run_proof` requires an `ownerAddress` — the eth proof-owner key whose private half signs the claim. It does **no** resolution itself; get the address from `resolve_owner_key` first. The private key is resolved locally (`RECLAIM_PRIVATE_KEY` env → a `RECLAIM_PRIVATE_KEY=0x…` line in the project `.env` → a file at `RECLAIM_PRIVATE_KEY_FILE`) and never enters the conversation.
202
+
203
+ 1. **Call `resolve_owner_key`.** It checks `RECLAIM_PRIVATE_KEY` (MCP env) → the project `.env` → `RECLAIM_PRIVATE_KEY_FILE`, and returns only the derived address.
204
+ - `{ ready: true, address }` → call `run_proof({ provider, ownerAddress: address, … })`. No prompts.
205
+ - `{ ready: false }` → no key exists yet. Ask the dev once how to set one up:
206
+ - **Provide their own key** → `import_credentials({ privateKey: "0x…" })` (also accepts a `keyFile` path).
207
+ - **Create a new one** → `issue_credentials({})`. (Fine default for test proofs — owner identity doesn't matter.)
208
+ - Either way the key is written to the project `.env` (mode 0600; `.env` is added to `.gitignore`) — never to `~/.reclaim`.
209
+ - Then call `resolve_owner_key` again and `run_proof` with the returned address.
210
+ 2. All fully local — no network, no server registration. The key is read live from `.env` on each `run_proof`, so no restart is needed after issuing/importing.
211
+ - **Advanced:** setting `RECLAIM_PRIVATE_KEY` directly in the MCP server's env also works, but env vars are snapshotted at Claude Code startup, so that route needs a full restart (which invalidates every `secretParamsRef`). The `.env` flow above avoids that — prefer it.
212
+ - **Multiple MCP config files exist on Windows.** `~/.claude.json` takes precedence over `~/.claude/mcp.json`. When advising config edits, point them at the right file or tell them to use `claude mcp list` / `claude mcp add --scope user` rather than editing JSON by hand.
213
+
214
+ If `replay_request` or `run_proof` errors with `unknown secretParamsRef`:
215
+
216
+ - **The ref is bound to the MCP server process.** Anything that restarts the server (Claude Code restart, `reset_session`, machine reboot) invalidates every previously-issued ref. The fix is always: re-run `start_capture` → interact → `propose_provider` to mint a fresh ref. Don't ask the dev to "re-enter the value" — walk them back through capture.
217
+ - **Exception:** if the drafted provider has empty `secretRefs: []` (no secret headers were captured), you can call `replay_request` and `run_proof` without `secretParamsRef` at all — both tools accept it as optional in that case.
218
+
219
+ ## Out of scope (v0)
220
+
221
+ If the dev needs a provider for a flow that requires multiple sequential requests (CSRF preflight, refresh-token chains), tell them: "multi-request providers are v1. For now, we can produce a single-request provider for the target value — let's see if one exists in this capture."
package/skill/old-mode.md CHANGED
@@ -12,4 +12,6 @@ This server is running against the LEGACY Reclaim devtools backend (temporary, w
12
12
 
13
13
  Only GET/POST endpoints can be captured into providers here. There is no organization, credential-linking, or public-review workflow in this mode.
14
14
 
15
+ **Diagnosing a session:** `session_analytics_logs` fetches the analytics logs for a verification session by id (from the logs service) — use it to see what happened in a session (matches, claims, failures).
16
+
15
17
  **Templating:** bare `{{name}}` placeholders are provider params (values extracted from the response). Values the CONSUMER must supply at verification time (e.g. a username in the URL path) use the `{{context_<name>}}` form — this backend does not support the dotted `{{context.<name>}}` interpolation the new builder uses.
@@ -55,6 +55,73 @@ export function stripPseudoHeaders(
55
55
  return out
56
56
  }
57
57
 
58
+ /** Extensions for static assets we never prove over — scripts, styles, fonts,
59
+ * images, media, source maps. A real page pulls hundreds of these; tracking
60
+ * them (and fetching their bodies) just bloats the request map and body store
61
+ * with traffic no recipe ever matches. Note: `.json` is deliberately absent —
62
+ * that's the API shape recipes care about. */
63
+ const STATIC_ASSET_EXTENSIONS = new Set([
64
+ 'js', 'mjs', 'cjs', 'css', 'map',
65
+ 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'ico', 'bmp',
66
+ 'woff', 'woff2', 'ttf', 'otf', 'eot',
67
+ 'mp4', 'webm', 'mp3', 'ogg', 'wav',
68
+ ])
69
+
70
+ /** True when the URL's path names a static asset (by file extension; query and
71
+ * hash are ignored, so `chunk.js?v=1` still counts). The capture engine drops
72
+ * these up front — they never carry JSON/HTML a recipe matches. */
73
+ export function isStaticAsset(url: string): boolean {
74
+ let path: string
75
+ try {
76
+ path = new URL(url).pathname
77
+ } catch{
78
+ path = url.split(/[?#]/)[0] ?? url
79
+ }
80
+
81
+ // Extension of the last path segment only, so `/v1.2/data` isn't misread.
82
+ const base = path.slice(path.lastIndexOf('/') + 1)
83
+ const dot = base.lastIndexOf('.')
84
+ if(dot <= 0) {
85
+ return false
86
+ }
87
+
88
+ return STATIC_ASSET_EXTENSIONS.has(base.slice(dot + 1).toLowerCase())
89
+ }
90
+
91
+ /** Response Content-Types for static assets. The URL-extension filter misses
92
+ * extension-less bundler/CDN URLs (e.g. `/assets/3b8f1c`), so we also drop by
93
+ * MIME at response time. `text/html`, `application/json`, and `text/plain`
94
+ * are NOT here — those are the document/API shapes recipes match. */
95
+ const STATIC_ASSET_MIME_EXACT = new Set([
96
+ 'text/css',
97
+ 'application/javascript', 'text/javascript', 'application/x-javascript',
98
+ 'application/ecmascript', 'text/ecmascript',
99
+ 'application/wasm',
100
+ 'application/font-woff', 'application/font-woff2',
101
+ 'application/vnd.ms-fontobject', 'application/x-font-ttf',
102
+ ])
103
+
104
+ const STATIC_ASSET_MIME_PREFIXES = ['image/', 'font/', 'audio/', 'video/']
105
+
106
+ /** True when a response Content-Type is a static asset (scripts/styles/fonts/
107
+ * images/media). Parameters (`; charset=…`) and case are ignored. */
108
+ export function isStaticAssetMime(mimeType: string | undefined): boolean {
109
+ if(!mimeType) {
110
+ return false
111
+ }
112
+
113
+ const m = mimeType.toLowerCase().split(';')[0].trim()
114
+ if(!m) {
115
+ return false
116
+ }
117
+
118
+ if(STATIC_ASSET_MIME_PREFIXES.some((p) => m.startsWith(p))) {
119
+ return true
120
+ }
121
+
122
+ return STATIC_ASSET_MIME_EXACT.has(m)
123
+ }
124
+
58
125
  export interface CaptureSession {
59
126
  captureId: string
60
127
  requests: Map<string, CapturedRequest>
@@ -173,6 +240,14 @@ export function createCaptureEngine(opts: CaptureEngineOptions): CaptureEngine {
173
240
  }
174
241
 
175
242
  function onRequestWillBeSent(p: RequestWillBeSentLike) {
243
+ // Static assets never match a recipe — never track them (so we don't
244
+ // fetch their bodies or grow the store). Drop any extra-info that raced
245
+ // ahead of this event so it can't linger.
246
+ if(isStaticAsset(p.request.url)) {
247
+ pendingRequestExtra.delete(p.requestId)
248
+ return
249
+ }
250
+
176
251
  const f: InFlight = {
177
252
  requestId: p.requestId,
178
253
  method: p.request.method,
@@ -197,6 +272,20 @@ export function createCaptureEngine(opts: CaptureEngineOptions): CaptureEngine {
197
272
  function onResponseReceived(p: ResponseReceivedLike) {
198
273
  const f = inFlight.get(p.requestId)
199
274
  if(!f) {
275
+ // No in-flight entry → the request was skipped (e.g. a static asset)
276
+ // or never seen. Discard any buffered response extra-info so a skipped
277
+ // asset can't leave a dangling entry.
278
+ pendingResponseExtra.delete(p.requestId)
279
+ return
280
+ }
281
+
282
+ // Second-line asset filter by Content-Type: catches extension-less
283
+ // bundler/CDN URLs the request-time URL filter can't. Forget the
284
+ // in-flight entry so it never reaches `requests` and a later
285
+ // loadingFinished/extra-info for it becomes a no-op.
286
+ if(isStaticAssetMime(p.response.mimeType)) {
287
+ inFlight.delete(p.requestId)
288
+ pendingResponseExtra.delete(p.requestId)
200
289
  return
201
290
  }
202
291
 
@@ -11,7 +11,11 @@ import {
11
11
  type StartCaptureOptions,
12
12
  } from './capture-engine.ts'
13
13
 
14
- export { stripPseudoHeaders } from './capture-engine.ts'
14
+ export {
15
+ isStaticAsset,
16
+ isStaticAssetMime,
17
+ stripPseudoHeaders,
18
+ } from './capture-engine.ts'
15
19
  export type { CaptureSession, StartCaptureOptions } from './capture-engine.ts'
16
20
 
17
21
  export async function startCapture(
package/src/consts.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  import { DEFAULT_BASE_URL } from '@reclaimprotocol/client'
19
19
  import { homedir } from 'node:os'
20
20
  import { join } from 'node:path'
21
- import { DEFAULT_OLD_API_URL } from './old/client.ts'
21
+ import { DEFAULT_OLD_API_URL, DEFAULT_OLD_LOGS_URL } from './old/client.ts'
22
22
 
23
23
  // ---------------------------------------------------------------------------
24
24
  // Default / fallback literals (used only as env fallbacks).
@@ -32,7 +32,7 @@ export const DEFAULT_LOGIN_URL = 'https://dev.reclaimprotocol.org'
32
32
 
33
33
  // Re-export the two non-env-fallback defaults so callers can keep importing
34
34
  // their URL defaults from a single place if they wish.
35
- export { DEFAULT_BASE_URL, DEFAULT_OLD_API_URL }
35
+ export { DEFAULT_BASE_URL, DEFAULT_OLD_API_URL, DEFAULT_OLD_LOGS_URL }
36
36
 
37
37
  // ---------------------------------------------------------------------------
38
38
  // Logging.
@@ -96,6 +96,11 @@ export function oldApiUrl(): string {
96
96
  return process.env.RECLAIM_OLD_API_URL || DEFAULT_OLD_API_URL
97
97
  }
98
98
 
99
+ /** Old-devtools analytics-logs service base URL (separate host). */
100
+ export function oldLogsUrl(): string {
101
+ return process.env.RECLAIM_OLD_LOGS_URL || DEFAULT_OLD_LOGS_URL
102
+ }
103
+
99
104
  /** Builder backend base URL. Shared by mcp/start and the verification tool. */
100
105
  export function apiUrl(): string {
101
106
  return process.env.RECLAIM_API_URL || DEFAULT_BASE_URL
@@ -13,3 +13,21 @@ export const BUILDER_MODE_INSTRUCTIONS = readFileSync(
13
13
  fileURLToPath(new URL('../../skill/builder-mode.md', import.meta.url)),
14
14
  'utf8',
15
15
  ).trim()
16
+
17
+ /**
18
+ * The full provider-authoring guide — the deep capture→prove→publish workflow,
19
+ * decision frameworks, and troubleshooting. Served on demand by the
20
+ * `how_it_works` MCP tool so every client (not just Claude Code, which
21
+ * auto-loads skills) can pull it. There's a guide PER MODE so an agent is never
22
+ * shown auth/publish tools that don't exist in its backend: the server picks
23
+ * one by `oldMode` (see `mcp/start.ts`). Same `../../skill/` path resolution as
24
+ * the instructions above; any leading frontmatter is stripped defensively.
25
+ */
26
+ function readGuide(file: string): string {
27
+ return readFileSync(fileURLToPath(new URL(file, import.meta.url)), 'utf8')
28
+ .replace(/^---\n[\s\S]*?\n---\n/, '')
29
+ .trim()
30
+ }
31
+
32
+ export const PROVIDER_GUIDE_BUILDER = readGuide('../../skill/SKILL.builder.md')
33
+ export const PROVIDER_GUIDE_OLD = readGuide('../../skill/SKILL.old.md')
package/src/mcp/start.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  apiToken,
7
7
  apiUrl,
8
8
  oldApiUrl,
9
+ oldLogsUrl,
9
10
  useOldDevtools,
10
11
  } from '../consts.ts'
11
12
  import { ReclaimOldClient } from '../old/client.ts'
@@ -49,6 +50,7 @@ export async function startMcpServer() {
49
50
  // get_me_providers instead.
50
51
  const oldClient = new ReclaimOldClient({
51
52
  baseUrl: oldApiUrl(),
53
+ logsBaseUrl: oldLogsUrl(),
52
54
  identity: resolveInitialIdentity(),
53
55
  })
54
56
 
@@ -17,6 +17,7 @@ import { type AttachState, attachTool } from './attach.ts'
17
17
  import { type CaptureStore, captureTools } from './capture.ts'
18
18
  import { browserTools } from './eval.ts'
19
19
  import { findTool } from './find.ts'
20
+ import { howItWorksTool } from './how-it-works.ts'
20
21
  import { proofTool } from './proof.ts'
21
22
  import {
22
23
  makeSecretStore,
@@ -52,6 +53,9 @@ export function buildAgentTools(
52
53
  const state = buildAgentState()
53
54
  ensureAttestorCrypto()
54
55
  const tools: RegisteredTool[] = []
56
+ // Stateless docs tool — serve the mode-specific authoring guide to any
57
+ // client. No `client` ⇒ old-devtools mode (see `mcp/start.ts`).
58
+ tools.push(howItWorksTool(!client))
55
59
  tools.push(attachTool(state.attach))
56
60
  for(const t of captureTools(state.attach, state.captures)) {
57
61
  tools.push(t)
@@ -0,0 +1,32 @@
1
+ import {
2
+ PROVIDER_GUIDE_BUILDER,
3
+ PROVIDER_GUIDE_OLD,
4
+ } from '../../instructions.ts'
5
+ import { defineTool, type RegisteredTool } from '../../server.ts'
6
+
7
+ /**
8
+ * Serves the full provider-authoring guide on demand. The MCP server's
9
+ * always-on `instructions` are a terse tool-flow reference; this tool returns
10
+ * the deep version (workflow, decision frameworks, gotchas, "never do" rules)
11
+ * so any MCP client — not just Claude Code, which alone auto-loads skills — can
12
+ * pull it. `oldMode` selects the backend-specific guide so the agent is never
13
+ * told to call auth/publish tools that aren't registered in its mode.
14
+ * Stateless: callable before `attach_chrome`.
15
+ */
16
+ export function howItWorksTool(oldMode: boolean): RegisteredTool {
17
+ const guide = oldMode ? PROVIDER_GUIDE_OLD : PROVIDER_GUIDE_BUILDER
18
+ return defineTool<Record<string, never>>(
19
+ {
20
+ name: 'how_it_works',
21
+ description:
22
+ 'Return the full Reclaim provider-authoring guide for THIS server: '
23
+ + 'the capture → draft → replay → prove → publish workflow, decision '
24
+ + 'frameworks (login detection, OPRF privacy, context vs bare params), '
25
+ + 'multi-request handling, troubleshooting, and what never to do. '
26
+ + 'Call this BEFORE building a provider unless you are already '
27
+ + 'fluent in the flow.',
28
+ inputSchema: { type: 'object', properties: {} },
29
+ },
30
+ async() => ({ guide }),
31
+ )
32
+ }
package/src/old/client.ts CHANGED
@@ -6,6 +6,10 @@ import { NAME, VERSION } from '../pkg.ts'
6
6
  * `RECLAIM_OLD_API_URL`. */
7
7
  export const DEFAULT_OLD_API_URL = 'https://devapi.reclaimprotocol.org'
8
8
 
9
+ /** Default origin of the OLD-devtools analytics-logs service — a SEPARATE host
10
+ * from the devtools API. Override with `RECLAIM_OLD_LOGS_URL`. */
11
+ export const DEFAULT_OLD_LOGS_URL = 'https://logs.reclaimprotocol.org'
12
+
9
13
  /**
10
14
  * Externally-supplied caller identity. We do NOT mint these — the dev
11
15
  * provides one:
@@ -63,15 +67,19 @@ interface RequestOpts {
63
67
  */
64
68
  export class ReclaimOldClient {
65
69
  readonly baseUrl: string
70
+ readonly logsBaseUrl: string
66
71
  #identity?: OldIdentity
67
72
  #fetch: typeof fetch
68
73
 
69
74
  constructor(opts: {
70
75
  baseUrl?: string
76
+ logsBaseUrl?: string
71
77
  identity?: OldIdentity
72
78
  fetch?: typeof fetch
73
79
  } = {}) {
74
80
  this.baseUrl = (opts.baseUrl || DEFAULT_OLD_API_URL).replace(/\/+$/, '')
81
+ this.logsBaseUrl = (opts.logsBaseUrl || DEFAULT_OLD_LOGS_URL)
82
+ .replace(/\/+$/, '')
75
83
  this.#identity = opts.identity
76
84
  this.#fetch = opts.fetch ?? fetch
77
85
  }
@@ -113,6 +121,30 @@ export class ReclaimOldClient {
113
121
  )
114
122
  }
115
123
 
124
+ /** Analytics logs for a verification session, from the logs service (a
125
+ * DIFFERENT host than the devtools API — hence its own fetch, not
126
+ * `#request`). Attaches the caller identity when one is set; queried
127
+ * anonymously otherwise. */
128
+ async getSessionAnalyticsLogs(sessionId: string): Promise<unknown> {
129
+ const url = `${this.logsBaseUrl}/api/analytics-logs/session/`
130
+ + encodeURIComponent(sessionId)
131
+ const headers: Record<string, string> = {
132
+ Accept: 'application/json',
133
+ 'User-Agent': `${NAME}/${VERSION}`,
134
+ }
135
+ if(this.#identity) {
136
+ headers[this.#authHeaderName()] = this.#authHeaderValue()
137
+ }
138
+
139
+ const res = await this.#fetch(url, { method: 'GET', headers })
140
+ const json = await this.#parseBody(res)
141
+ if(!res.ok) {
142
+ throw this.#asBoom(res, json)
143
+ }
144
+
145
+ return json
146
+ }
147
+
116
148
  getMyProviders(query: MyProvidersQuery = {}): Promise<unknown> {
117
149
  // Every one of these query params is REQUIRED by the backend
118
150
  // validator (missing/invalid → 400), so default them all here.
@@ -3,12 +3,13 @@ import type { ReclaimOldClient } from '../client.ts'
3
3
  import { authenticateTool } from './authenticate.ts'
4
4
  import { getMeProvidersTool } from './me-providers.ts'
5
5
  import { createVersionFromCaptureTool } from './publish.ts'
6
+ import { sessionAnalyticsLogsTool } from './session-analytics-logs.ts'
6
7
 
7
8
  /**
8
9
  * The backend-facing tools for OLD-devtools mode. Mirrors
9
10
  * `buildCredentialTools` / `buildAgentTools`: returns a flat `RegisteredTool`
10
- * list for `registerToolList`. These three replace the builder credential +
11
- * publish tools and the (disabled) generated provider tools.
11
+ * list for `registerToolList`. These replace the builder credential + publish
12
+ * tools and the (disabled) generated provider tools.
12
13
  */
13
14
  export function buildOldBackendTools(
14
15
  client: ReclaimOldClient,
@@ -17,5 +18,6 @@ export function buildOldBackendTools(
17
18
  authenticateTool(client),
18
19
  createVersionFromCaptureTool(client),
19
20
  getMeProvidersTool(client),
21
+ sessionAnalyticsLogsTool(client),
20
22
  ]
21
23
  }
@@ -0,0 +1,33 @@
1
+ import { defineTool, type RegisteredTool } from '../../mcp/server.ts'
2
+ import type { ReclaimOldClient } from '../client.ts'
3
+
4
+ /**
5
+ * Fetch analytics logs for a verification session from the OLD-devtools logs
6
+ * service (`GET https://logs.reclaimprotocol.org/api/analytics-logs/session/
7
+ * :sessionId`). Old mode only — useful for diagnosing why a session failed or
8
+ * stalled. The logs service is a separate host from the devtools API.
9
+ */
10
+ export function sessionAnalyticsLogsTool(
11
+ client: ReclaimOldClient,
12
+ ): RegisteredTool {
13
+ return defineTool<{ sessionId: string }>(
14
+ {
15
+ name: 'session_analytics_logs',
16
+ description:
17
+ 'Fetch analytics logs for a verification session by id from the '
18
+ + 'old-devtools logs service — use to diagnose what happened in a '
19
+ + 'session (matches, claims, failures). Pass the `sessionId`.',
20
+ inputSchema: {
21
+ type: 'object',
22
+ properties: {
23
+ sessionId: {
24
+ type: 'string',
25
+ description: 'The verification session id to fetch logs for.',
26
+ },
27
+ },
28
+ required: ['sessionId'],
29
+ },
30
+ },
31
+ async(args) => client.getSessionAnalyticsLogs(args.sessionId),
32
+ )
33
+ }