@reclaimprotocol/agent 0.1.10 → 0.1.12

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 (49) hide show
  1. package/lib/authoring/tools.js +67 -0
  2. package/lib/mcp/tools/agent/find.js +41 -16
  3. package/lib/mcp/tools/agent/propose.js +4 -4
  4. package/lib/old/client.d.ts +31 -5
  5. package/lib/old/client.js +33 -10
  6. package/lib/old/to-register.d.ts +2 -0
  7. package/lib/old/to-register.js +9 -5
  8. package/lib/old/tools/build.js +12 -2
  9. package/lib/old/tools/check-app-status.d.ts +8 -0
  10. package/lib/old/tools/check-app-status.js +27 -0
  11. package/lib/old/tools/get-provider-info.d.ts +10 -7
  12. package/lib/old/tools/get-provider-info.js +21 -15
  13. package/lib/old/tools/issue-app-credentials.d.ts +11 -0
  14. package/lib/old/tools/issue-app-credentials.js +35 -0
  15. package/lib/old/tools/js-sdk-integration-guide.d.ts +2 -0
  16. package/lib/old/tools/js-sdk-integration-guide.js +141 -0
  17. package/lib/old/tools/link-app.d.ts +10 -0
  18. package/lib/old/tools/link-app.js +72 -0
  19. package/lib/old/tools/publish.js +89 -126
  20. package/lib/old/tools/scaffold-demo-server.d.ts +2 -0
  21. package/lib/old/tools/scaffold-demo-server.js +242 -0
  22. package/lib/old/tools/verify-sdk-setup.d.ts +9 -0
  23. package/lib/old/tools/verify-sdk-setup.js +128 -0
  24. package/lib/provider/dom-xpath.js +178 -25
  25. package/lib/provider/draft.d.ts +6 -0
  26. package/lib/provider/draft.js +54 -5
  27. package/lib/provider/matcher.d.ts +4 -0
  28. package/lib/provider/matcher.js +6 -0
  29. package/lib/provider/to-version.d.ts +13 -0
  30. package/package.json +2 -1
  31. package/src/authoring/tools.ts +76 -0
  32. package/src/mcp/tools/agent/find.ts +44 -16
  33. package/src/mcp/tools/agent/propose.ts +4 -4
  34. package/src/old/client.ts +60 -18
  35. package/src/old/to-register.ts +11 -5
  36. package/src/old/tools/build.ts +12 -2
  37. package/src/old/tools/check-app-status.ts +33 -0
  38. package/src/old/tools/get-provider-info.ts +21 -15
  39. package/src/old/tools/issue-app-credentials.ts +42 -0
  40. package/src/old/tools/js-sdk-integration-guide.ts +147 -0
  41. package/src/old/tools/link-app.ts +88 -0
  42. package/src/old/tools/publish.ts +98 -158
  43. package/src/old/tools/scaffold-demo-server.ts +265 -0
  44. package/src/old/tools/verify-sdk-setup.ts +158 -0
  45. package/src/provider/dom-xpath.ts +227 -27
  46. package/src/provider/draft.ts +59 -5
  47. package/src/provider/matcher.ts +7 -0
  48. package/src/provider/to-version.ts +13 -0
  49. package/src/old/tools/get-version-config.ts +0 -56
@@ -1,6 +1,35 @@
1
- import { synthesizeMatcher } from "./matcher.js";
1
+ import { isOprfHashable, OPRF_MAX_BYTES, synthesizeMatcher } from "./matcher.js";
2
2
  import { splitSecrets } from "./secrets.js";
3
- /** Request headers that must NOT survive into the provider params. Two kinds:
3
+ /** OPRF mode for the auto-hash floor — TEE-native (attestor rejects oprf-raw,
4
+ * isn't set up for gnark oprf). */
5
+ const DEFAULT_SENSITIVE_HASH = 'oprf-mpc';
6
+ // The deterministic ANTI-LEAK FLOOR: values whose SHAPE is unambiguously
7
+ // sensitive PII, force-hashed no matter the field name or the model's judgment.
8
+ // Deliberately only FIXED-FORMAT identifiers — OPRF is byte-exact, so a
9
+ // self-mutable value (a display name re-cased) would be grindable and pointless
10
+ // to hash; and a bare number is ambiguous (a private account id vs a PUBLIC
11
+ // user id / follower count that's meant to be proven in the clear). Those
12
+ // contextual calls are the model's, via an explicit per-target `hash`. This
13
+ // floor is universal (not domain-specific) and high-precision.
14
+ const EMAIL_VALUE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
15
+ const SSN_VALUE = /^\d{3}-\d{2}-\d{4}$/;
16
+ const IBAN_VALUE = /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/;
17
+ function isCanonicalPii(value) {
18
+ return EMAIL_VALUE.test(value)
19
+ || SSN_VALUE.test(value) || IBAN_VALUE.test(value);
20
+ }
21
+ /** OPRF mode to apply when the caller passed none: the anti-leak floor —
22
+ * force-hash a value whose SHAPE is unambiguous PII, but ONLY when it's
23
+ * OPRF-hashable AND within the 62-byte MPC-circuit cap (UTF-8). Undefined =
24
+ * leave plaintext (a longer value can't be OPRF'd; contextual PII + the proven
25
+ * identity are the model's/plaintext call). */
26
+ export function defaultSensitiveHash(value) {
27
+ const hashable = isCanonicalPii(value)
28
+ && isOprfHashable(value)
29
+ && Buffer.byteLength(value, 'utf8') <= OPRF_MAX_BYTES;
30
+ return hashable ? DEFAULT_SENSITIVE_HASH : undefined;
31
+ }
32
+ /** Request headers that must NOT survive into the provider params. Three kinds:
4
33
  * - Conditional headers (`if-none-match`/`if-modified-since`/…): a captured
5
34
  * cached ETag/date makes the origin answer 304/412 when the attestor
6
35
  * re-issues, and the attestor treats non-2xx as failure. We always want the
@@ -8,7 +37,14 @@ import { splitSecrets } from "./secrets.js";
8
37
  * - Transfer headers the ATTESTOR sets itself (`connection: close`,
9
38
  * `accept-encoding: identity`). Passing our own duplicates/collides with the
10
39
  * request the attestor builds, so leave them out — the attestor already
11
- * forces `identity` (uncompressed, so its byte-matching works). */
40
+ * forces `identity` (uncompressed, so its byte-matching works).
41
+ * - Host / framing headers the TRANSPORT owns (`host`, `content-length`,
42
+ * `transfer-encoding`) and HTTP/2 pseudo-headers (`:authority`/`:method`/…,
43
+ * which CDP surfaces on h2 captures). The attestor derives `Host` from the
44
+ * URL and sets its own; a baked `Host` produces a DUPLICATE
45
+ * (`found: h,h`) that fails the receipt check / 400s the origin. A stale
46
+ * `content-length` breaks a re-serialized body. Pseudo-headers aren't valid
47
+ * real headers at all. */
12
48
  const STRIP_REQUEST_HEADERS = new Set([
13
49
  'if-none-match',
14
50
  'if-modified-since',
@@ -17,14 +53,22 @@ const STRIP_REQUEST_HEADERS = new Set([
17
53
  'if-range',
18
54
  'connection',
19
55
  'accept-encoding',
56
+ 'host',
57
+ 'content-length',
58
+ 'transfer-encoding',
20
59
  ]);
60
+ /** True for an HTTP/2 pseudo-header (`:authority`, `:method`, `:path`,
61
+ * `:scheme`) — never a valid request header the attestor can replay. */
62
+ function isPseudoHeader(name) {
63
+ return name.startsWith(':');
64
+ }
21
65
  /** Drop the headers the attestor manages or that would 304 at proof time
22
66
  * (see {@link STRIP_REQUEST_HEADERS}); keep every other captured header so the
23
67
  * attestor's replay looks like the browser's request. */
24
68
  function sanitizeRequestHeaders(headers) {
25
69
  const out = {};
26
70
  for (const [k, v] of Object.entries(headers)) {
27
- if (STRIP_REQUEST_HEADERS.has(k.toLowerCase())) {
71
+ if (STRIP_REQUEST_HEADERS.has(k.toLowerCase()) || isPseudoHeader(k)) {
28
72
  continue;
29
73
  }
30
74
  out[k] = v;
@@ -103,7 +147,12 @@ export function draftProvider(req, targets, name, hash) {
103
147
  + 'give them distinct names');
104
148
  }
105
149
  seenParams.add(paramName);
106
- const synthesis = synthesizeMatcher(req.responseBody, req.contentType, t.value, paramName, t.hash ?? hash);
150
+ // Precedence: explicit per-target hash → explicit request-wide hash →
151
+ // the anti-leak floor (force-OPRF values whose SHAPE is unambiguous PII).
152
+ // The model makes the contextual calls (which named fields are sensitive)
153
+ // via an explicit per-target hash; this floor only guarantees the obvious.
154
+ const effectiveHash = t.hash ?? hash ?? defaultSensitiveHash(t.value);
155
+ const synthesis = synthesizeMatcher(req.responseBody, req.contentType, t.value, paramName, effectiveHash);
107
156
  if (!synthesis) {
108
157
  throw new Error(`target value "${t.value}" not found in`
109
158
  + ` response body for request ${req.requestId}`);
@@ -31,3 +31,7 @@ export interface MatcherSynthesis {
31
31
  * proof time. Throws when `hash` is set on a free-form value (no safe class).
32
32
  */
33
33
  export declare function synthesizeMatcher(body: string, contentType: string, target: string, paramName: string, hash?: OprfMode): MatcherSynthesis | undefined;
34
+ /** True when `target` has a character class safe to OPRF-hash. Free-form values
35
+ * (names with spaces, arbitrary unicode/punctuation) return false — the caller
36
+ * must NOT default them to a hash (it would throw in synthesizeMatcher). */
37
+ export declare function isOprfHashable(target: string): boolean;
@@ -548,3 +548,9 @@ function inferValueClass(target) {
548
548
  }
549
549
  return undefined;
550
550
  }
551
+ /** True when `target` has a character class safe to OPRF-hash. Free-form values
552
+ * (names with spaces, arbitrary unicode/punctuation) return false — the caller
553
+ * must NOT default them to a hash (it would throw in synthesizeMatcher). */
554
+ export function isOprfHashable(target) {
555
+ return inferValueClass(target) !== undefined;
556
+ }
@@ -25,6 +25,19 @@ export interface AllowedJsRequest extends DraftRequest {
25
25
  multiple?: boolean;
26
26
  /** Fail validation if no request matches this template. Default true. */
27
27
  required?: boolean;
28
+ /** Names of `${var}` placeholders in this template's `responseMatches`/
29
+ * `responseRedactions` that get substituted at verify time from the
30
+ * submitted proof's own witness params (e.g. an index list a
31
+ * `Reclaim.requestClaim` call attached alongside its `{{var}}`
32
+ * extractions). Omit (or leave empty) when the template has no such
33
+ * placeholders — verification then matches it literally as-is. */
34
+ templateParams?: string[];
35
+ /** How multiple values for `templateParams` expand into request specs.
36
+ * `'separate'` (default): one independent spec per value, each expected
37
+ * to match its own proof. `'merge'`: all values folded into ONE spec
38
+ * with one `responseMatches`/`responseRedactions` entry per value,
39
+ * matching a single proof that bundles them all into one claim. */
40
+ templateParamsMode?: 'separate' | 'merge';
28
41
  }
29
42
  /** Optional extras for a create-version body beyond the request list. */
30
43
  export interface CreateVersionOptions {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reclaimprotocol/agent",
3
3
  "title": "Reclaim Agent",
4
- "version": "0.1.10",
4
+ "version": "0.1.12",
5
5
  "description": "Reclaim Protocol's tools for agents — Reclaim Protocol lets your app verify **data points** about its users — a follower count, an account balance, an order history. This package gives your AI coding agent the tools to **find, build, test and fix the _data verification providers_** (the recipes that verify a data point) by inspecting and replaying captured requests, checking sessions, evaluate proofs, publishing updates, and much more.",
6
6
  "type": "module",
7
7
  "main": "./lib/index.js",
@@ -88,6 +88,7 @@
88
88
  "@reclaimprotocol/tls": "^0.1.3",
89
89
  "chrome-remote-interface": "^0.33.0",
90
90
  "domhandler": "^6.0.0",
91
+ "ethers": "^6.17.0",
91
92
  "parse5": "^8.0.0",
92
93
  "parse5-htmlparser2-tree-adapter": "^8.0.0",
93
94
  "pino": "^9.14.0",
@@ -205,6 +205,82 @@ export function authoringTools(
205
205
  }))
206
206
  },
207
207
  ),
208
+ defineTool<Record<string, unknown>>(
209
+ {
210
+ name: 'wait_for_login',
211
+ description:
212
+ 'Wait for the user to finish logging in. Polls the browser\'s '
213
+ + 'REAL cookies over CDP (httpOnly INCLUDED) until a NEW session '
214
+ + 'cookie appears, ignoring analytics/bot cookies. The ONLY '
215
+ + 'correct login wait — NEVER use wait_for_page for it: page JS '
216
+ + 'can\'t see the httpOnly session cookie (hangs the whole '
217
+ + 'timeout), yet DOES see pre-login tracking cookies (a '
218
+ + 'length/substring check fires instantly, before the user '
219
+ + 'types). Snapshots the baseline itself — call right after '
220
+ + 'navigating to the login page. Does NOT navigate (never yanks '
221
+ + 'the user off login/2FA). Returns { loggedIn, newCookies }.',
222
+ inputSchema: objectSchema({ timeoutMs: { type: 'number' } }),
223
+ },
224
+ async(args) => {
225
+ const backend = resolveBackend(args)
226
+ const deadline = Date.now() + (Number(args.timeoutMs) || 180_000)
227
+ const cookies = async() => {
228
+ const res = (await backend.sendCdp('Network.getCookies')) as {
229
+ cookies?: { name: string, httpOnly?: boolean }[]
230
+ }
231
+ return res.cookies ?? []
232
+ }
233
+
234
+ // Analytics / bot-protection / pre-auth infra cookies appear (and
235
+ // rotate) without any login — never the signal, even when new.
236
+ const JUNK = new RegExp('^(' + [
237
+ '_ga', '_gid', '_gat', '_ym', '__ddg', '__gads', '__gpi', '__eoi',
238
+ '__utm', '_fbp', 'fbp', 'fr', 'FCCDCF', 'pinLogger',
239
+ 'userFirstVisit', 'cf_', '_dd_', 'bcookie', 'bscookie', 'lidc',
240
+ ].join('|') + ')', 'i')
241
+ // A real session/auth cookie name. Deliberately NOT bare `token` — a
242
+ // challenge/CSRF token (e.g. LinkedIn `chp_token`) contains it and is
243
+ // set BEFORE auth completes. Any new httpOnly cookie is also a
244
+ // candidate (server-set session cookies are usually httpOnly).
245
+ const SESSION = /sess|sid|jwt|_session|auth|logged.?in|remember|li_at/i
246
+ // Auth/challenge URL paths — while the page is still on one, login is
247
+ // NOT done (a mid-flow cookie must not count). Cross-site-stable, far
248
+ // more so than cookie names.
249
+ const AUTH_URL = new RegExp([
250
+ 'login', 'signin', 'sign-in', 'challenge', 'checkpoint', '\\buas\\b',
251
+ 'authorize', '\\bsso\\b', 'mfa', '2fa', 'two-factor', 'otp', 'verify',
252
+ 'password',
253
+ ].join('|'), 'i')
254
+ const offAuthUrl = async() => {
255
+ const { value } = await evalInPage(backend, 'location.href')
256
+ const href = typeof value === 'string' ? value : ''
257
+ try {
258
+ const u = new URL(href)
259
+ return !AUTH_URL.test(u.pathname + u.search)
260
+ } catch{
261
+ return false
262
+ }
263
+ }
264
+
265
+ const initial = await cookies()
266
+ const baseline = new Set(initial.map((c) => c.name))
267
+ while(Date.now() < deadline) {
268
+ const current = await cookies()
269
+ const fresh = current
270
+ .filter((c) => !baseline.has(c.name) && !JUNK.test(c.name))
271
+ const hit = fresh.filter((c) => c.httpOnly || SESSION.test(c.name))
272
+ // BOTH required: a new session cookie AND the page has left the auth
273
+ // flow. Either alone false-positives (mid-flow token; stray nav).
274
+ if(hit.length && await offAuthUrl()) {
275
+ return { loggedIn: true, newCookies: hit.map((c) => c.name) }
276
+ }
277
+
278
+ await sleep(1000)
279
+ }
280
+
281
+ return { loggedIn: false, newCookies: [] }
282
+ },
283
+ ),
208
284
  defineTool<Record<string, unknown>>(
209
285
  {
210
286
  name: 'list_requests',
@@ -21,7 +21,12 @@ export function findTool(resolveBackend: ResolveBackend): RegisteredTool {
21
21
  'Search captured responses for any encoding of `value` (raw, '
22
22
  + 'URL/JSON/HTML-escaped, …). Returns a ranked candidate list with '
23
23
  + 'the matched variant, score, and a short snippet — used to locate '
24
- + 'the request that returns a target. Server-side; bodies stay here.',
24
+ + 'the request that returns a target. Also finds SPLIT matches: when '
25
+ + '`value` is multi-word (e.g. a full name), a response that carries '
26
+ + 'every word SEPARATELY (a JSON profile API with first/last name in '
27
+ + 'distinct fields) is returned with `splitMatch:true` — prove those '
28
+ + 'per-field (a redaction per part) rather than as one string. '
29
+ + 'Server-side; bodies stay here.',
25
30
  inputSchema: objectSchema(
26
31
  { value: { type: 'string' }, limit: { type: 'number' } },
27
32
  ['value'],
@@ -29,37 +34,60 @@ export function findTool(resolveBackend: ResolveBackend): RegisteredTool {
29
34
  },
30
35
  async(args) => {
31
36
  const backend = resolveBackend(args)
32
- const variants = normalizeCandidates(String(args.value))
37
+ const value = String(args.value)
38
+ const variants = normalizeCandidates(value)
39
+ // Name-part tokens (≥3 chars) for split-field detection — a JSON API
40
+ // often carries a full name as separate first/last fields, so the whole
41
+ // string never matches even though the value IS there.
42
+ const tokens = value.split(/\s+/).filter((t) => t.length >= 3)
43
+ const snip = (body: string, idx: number, len: number) => {
44
+ const s = Math.max(0, idx - 40)
45
+
46
+ return body.slice(s, Math.min(body.length, idx + len + 40))
47
+ }
48
+
33
49
  const ranked = []
34
50
  for(const r of backend.capture.requests.values()) {
35
51
  if(!r.responseBody) {
36
52
  continue
37
53
  }
38
54
 
55
+ const body = r.responseBody
56
+ // Prefer JSON responses + smaller bodies (likely API endpoints).
57
+ const base = (r.contentType.includes('json') ? 10 : 0)
58
+ + Math.max(0, 5 - Math.log10(Math.max(r.size, 1)))
59
+
39
60
  let hit: { snippet: string, variant: string } | undefined
40
61
  for(const v of variants) {
41
- const idx = r.responseBody.indexOf(v)
62
+ const idx = body.indexOf(v)
42
63
  if(idx >= 0) {
43
- const start = Math.max(0, idx - 40)
44
- const end = Math.min(r.responseBody.length, idx + v.length + 40)
45
- hit = { snippet: r.responseBody.slice(start, end), variant: v }
64
+ hit = { snippet: snip(body, idx, v.length), variant: v }
46
65
  break
47
66
  }
48
67
  }
49
68
 
50
- if(!hit) {
69
+ if(hit) {
70
+ // Whole-value match — ranked above split matches (+5).
71
+ ranked.push({
72
+ ...publicRequest(r),
73
+ matchedVariant: hit.variant,
74
+ snippet: hit.snippet,
75
+ score: base + 5,
76
+ })
51
77
  continue
52
78
  }
53
79
 
54
- // Prefer JSON responses + smaller bodies (likely API endpoints).
55
- const score = (r.contentType.includes('json') ? 10 : 0)
56
- + Math.max(0, 5 - Math.log10(Math.max(r.size, 1)))
57
- ranked.push({
58
- ...publicRequest(r),
59
- matchedVariant: hit.variant,
60
- snippet: hit.snippet,
61
- score,
62
- })
80
+ // Split match: every word present separately (e.g. first/last name
81
+ // in distinct JSON fields). Requires ≥2 tokens so it's specific.
82
+ if(tokens.length >= 2 && tokens.every((t) => body.includes(t))) {
83
+ ranked.push({
84
+ ...publicRequest(r),
85
+ matchedVariant: `split:${tokens.join('+')}`,
86
+ splitMatch: true,
87
+ snippet: snip(body, body.indexOf(tokens[0]), tokens[0].length),
88
+ score: base,
89
+ })
90
+ }
63
91
  }
64
92
 
65
93
  ranked.sort((a, b) => b.score - a.score)
@@ -83,12 +83,12 @@ export function proposeTool(resolveBackend: ResolveBackend): RegisteredTool {
83
83
  }
84
84
 
85
85
  for(const t of targets) {
86
- if((t.hash ?? defaultHash) !== undefined
87
- && t.value.length > OPRF_MAX_BYTES) {
86
+ const bytes = Buffer.byteLength(t.value, 'utf8')
87
+ if((t.hash ?? defaultHash) !== undefined && bytes > OPRF_MAX_BYTES) {
88
88
  throw new Error(
89
89
  `OPRF caps hashed values at ${OPRF_MAX_BYTES} bytes; got `
90
- + `${t.value.length}-byte "${t.value}". Use a tighter `
91
- + 'target or drop `hash`.',
90
+ + `${bytes}-byte "${t.value}". Use a tighter target or drop `
91
+ + '`hash`.',
92
92
  )
93
93
  }
94
94
  }
package/src/old/client.ts CHANGED
@@ -74,6 +74,22 @@ export interface MyProvidersQuery {
74
74
  searchQuery?: string
75
75
  }
76
76
 
77
+ /** `POST /api/applications/issue-credentials` response — a brand-new,
78
+ * unassociated (sandboxed) app. `appSecret` is the raw eth private key;
79
+ * it's returned once and never stored server-side in recoverable form. */
80
+ export interface IssuedAppCredentials {
81
+ appId: string
82
+ appSecret: string
83
+ }
84
+
85
+ /** `GET /api/applications/link/nonce` response — a one-time, 5-minute-TTL
86
+ * challenge the caller signs with the app's private key to prove ownership
87
+ * in `linkAppToAccount`. */
88
+ export interface LinkNonce {
89
+ nonce: string
90
+ expiresInSeconds: number
91
+ }
92
+
77
93
  interface RequestOpts {
78
94
  query?: Record<string, string | number | undefined>
79
95
  body?: unknown
@@ -147,24 +163,6 @@ export class ReclaimOldClient {
147
163
  )
148
164
  }
149
165
 
150
- /** Full `providerConfig` (loginUrl, customInjection, requestData, ...) for
151
- * one version — the latest when `version` is omitted. Readable for ANY
152
- * non-archived provider (not owner-restricted), same as the dashboard's
153
- * edit-form prefill fetch. */
154
- getProviderConfig(
155
- providerId: string,
156
- version?: string,
157
- ): Promise<unknown> {
158
- return this.#request(
159
- 'GET',
160
- `/providers/${encodeURIComponent(providerId)}/config`,
161
- {
162
- auth: true,
163
- query: version ? { versionNumber: version } : undefined,
164
- },
165
- )
166
- }
167
-
168
166
  /** Partial update on the PROVIDER document itself — name/description/
169
167
  * providerType/tags/isActive, after creation. The backend 403s if you
170
168
  * don't own the provider (and aren't admin); other providerConfig-style
@@ -205,6 +203,50 @@ export class ReclaimOldClient {
205
203
  return json
206
204
  }
207
205
 
206
+ /** Mint a brand-new, unassociated (sandboxed) app — no auth required. Link
207
+ * it to an account afterwards with `getLinkNonce` + `linkAppToAccount`, or
208
+ * use it as-is within the sandbox session limit. */
209
+ async issueCredentials(): Promise<IssuedAppCredentials> {
210
+ const res = await this.#request('POST', '/applications/issue-credentials')
211
+ return (res as { data: IssuedAppCredentials }).data
212
+ }
213
+
214
+ /** One-time, 5-minute challenge for `linkAppToAccount`. Requires auth —
215
+ * the nonce is scoped to the authenticated user's uid. */
216
+ async getLinkNonce(): Promise<LinkNonce> {
217
+ const res = await this.#request('GET', '/applications/link/nonce', {
218
+ auth: true,
219
+ })
220
+ return (res as { data: LinkNonce }).data
221
+ }
222
+
223
+ /** Link an unassociated (`issueCredentials`-minted) app to the
224
+ * authenticated account. `signature` must be an EIP-191 signature, made
225
+ * with the app's private key, over the exact string
226
+ * `` `Link ${appId} to account ${userId} | nonce: ${nonce}` `` — see
227
+ * `buildLinkMessage` in the backend's `application.controller.ts`. Fails
228
+ * with `APP_ALREADY_LINKED` (400) if the app already belongs to someone. */
229
+ linkAppToAccount(
230
+ appId: string,
231
+ signature: string,
232
+ nonce: string,
233
+ ): Promise<unknown> {
234
+ return this.#request('POST', '/applications/link', {
235
+ body: { appId, signature, nonce },
236
+ auth: true,
237
+ })
238
+ }
239
+
240
+ /** Public, unauthenticated app status — `isLinked`, sandbox/quota info.
241
+ * Works for any appId, owned or not. */
242
+ async getApplicationStatus(appId: string): Promise<unknown> {
243
+ const res = await this.#request(
244
+ 'GET',
245
+ `/applications/status/${encodeURIComponent(appId)}`,
246
+ )
247
+ return (res as { data: unknown }).data
248
+ }
249
+
208
250
  getMyProviders(query: MyProvidersQuery = {}): Promise<unknown> {
209
251
  // Every one of these query params is REQUIRED by the backend
210
252
  // validator (missing/invalid → 400), so default them all here.
@@ -93,6 +93,8 @@ interface OldRequestData {
93
93
  export interface OldInjectedRequestData extends OldRequestData {
94
94
  required?: boolean
95
95
  multiple?: boolean
96
+ templateParams?: string[]
97
+ templateParamsMode?: 'separate' | 'merge'
96
98
  }
97
99
 
98
100
  /** Build the single `requestData` entry shared by register + update. Takes
@@ -200,11 +202,15 @@ function requestDataFrom(providers: ReclaimProvider[]): OldRequestData[] {
200
202
  export function injectedRequestDataFrom(
201
203
  requests: AllowedJsRequest[],
202
204
  ): OldInjectedRequestData[] {
203
- return requests.map((req) => ({
204
- ...requestDataFromProvider(req),
205
- required: req.required ?? true,
206
- multiple: req.multiple ?? true,
207
- }))
205
+ return requests.map((req) => {
206
+ return {
207
+ ...requestDataFromProvider(req),
208
+ required: req.required ?? true,
209
+ multiple: req.multiple ?? true,
210
+ templateParams: req.templateParams,
211
+ templateParamsMode: req.templateParamsMode,
212
+ }
213
+ })
208
214
  }
209
215
 
210
216
  /** Body for `POST /api/providers/register` — new provider + v1.0.0. Every
@@ -2,12 +2,17 @@ import type { RegisteredTool } from '../../mcp/server.ts'
2
2
  import type { ReclaimOldClient } from '../client.ts'
3
3
  import type { ReclaimSdkClient } from '../sdk-client.ts'
4
4
  import { authenticateTool } from './authenticate.ts'
5
+ import { checkAppStatusTool } from './check-app-status.ts'
5
6
  import { getProviderInfoTool } from './get-provider-info.ts'
6
- import { getProviderVersionConfigTool } from './get-version-config.ts'
7
+ import { issueAppCredentialsTool } from './issue-app-credentials.ts'
8
+ import { getJsSdkIntegrationGuideTool } from './js-sdk-integration-guide.ts'
9
+ import { linkAppTool } from './link-app.ts'
7
10
  import { getMeProvidersTool } from './me-providers.ts'
8
11
  import { createVersionFromCaptureTool } from './publish.ts'
12
+ import { scaffoldDemoServerTool } from './scaffold-demo-server.ts'
9
13
  import { sessionAnalyticsLogsTool } from './session-analytics-logs.ts'
10
14
  import { updateProviderMetadataTool } from './update-provider-metadata.ts'
15
+ import { verifySdkSetupTool } from './verify-sdk-setup.ts'
11
16
 
12
17
  /**
13
18
  * The backend-facing tools for OLD-devtools mode. Mirrors
@@ -27,9 +32,14 @@ export function buildOldBackendTools(
27
32
  authenticateTool(client),
28
33
  createVersionFromCaptureTool(client),
29
34
  getMeProvidersTool(client),
30
- getProviderVersionConfigTool(client),
31
35
  getProviderInfoTool(sdkClient),
32
36
  updateProviderMetadataTool(client),
33
37
  sessionAnalyticsLogsTool(client),
38
+ issueAppCredentialsTool(client),
39
+ linkAppTool(client),
40
+ checkAppStatusTool(client),
41
+ getJsSdkIntegrationGuideTool(),
42
+ scaffoldDemoServerTool(),
43
+ verifySdkSetupTool(client),
34
44
  ]
35
45
  }
@@ -0,0 +1,33 @@
1
+ import { defineTool, type RegisteredTool } from '../../mcp/server.ts'
2
+ import type { ReclaimOldClient } from '../client.ts'
3
+
4
+ /**
5
+ * Public, unauthenticated app status lookup — works for any `appId`, owned
6
+ * or not, linked or sandboxed. Mirrors the backend's
7
+ * `GET /api/applications/status/:appId`.
8
+ */
9
+ export function checkAppStatusTool(client: ReclaimOldClient): RegisteredTool {
10
+ return defineTool<{ appId: string }>(
11
+ {
12
+ name: 'check_app_status',
13
+ description:
14
+ 'Check a Reclaim app\'s link/sandbox status by appId — no ' +
15
+ 'authentication required. Returns whether the app is linked to a ' +
16
+ 'dashboard account (`isLinked`), and for linked apps, remaining ' +
17
+ 'session quota. A 404 means the appId has never been used yet — ' +
18
+ 'it is created lazily on the first ReclaimProofRequest.init() call ' +
19
+ 'or via issue_app_credentials.',
20
+ inputSchema: {
21
+ type: 'object',
22
+ properties: {
23
+ appId: {
24
+ type: 'string',
25
+ description: 'The eth-address appId to check.',
26
+ },
27
+ },
28
+ required: ['appId'],
29
+ },
30
+ },
31
+ async({ appId }) => client.getApplicationStatus(appId),
32
+ )
33
+ }
@@ -18,14 +18,17 @@ async function attempt(
18
18
  }
19
19
 
20
20
  /**
21
- * Read provider info from the PUBLIC production SDK backend
22
- * (`api.reclaimprotocol.org`) — the service the verification SDK/attestor
23
- * itself calls at runtime, NOT the old-devtools dashboard backend
24
- * (`get_provider_version_config`, `devapi.reclaimprotocol.org`, requires
25
- * auth). Both underlying reads are unauthenticated.
21
+ * The single provider-read tool for old-devtools mode. Reads from the PUBLIC
22
+ * production SDK backend (`api.reclaimprotocol.org`) — the service the
23
+ * verification SDK/attestor itself calls at runtime. No auth required, works
24
+ * for ANY provider.
26
25
  *
27
- * Fetches BOTH shapes in one call, since they're complementary:
28
- * - `provider`: full provider + providerConfig, `customInjection` included.
26
+ * Fetches BOTH shapes in one call, since they're complementary and together
27
+ * cover everything a caller needs before re-publishing a new version:
28
+ * - `provider`: the full provider + providerConfig — `customInjection`,
29
+ * `userAgent`, `allowedInjectedRequestData`, `requestData`, everything.
30
+ * This is what you read before create_provider_version_from_capture so
31
+ * you can pass the whole config back (that tool carries nothing forward).
29
32
  * - `configs`: the trimmed runtime recipe the attestor actually consumes —
30
33
  * `customInjection` deliberately omitted, `isScriptRequestingClaim`
31
34
  * computed instead.
@@ -38,15 +41,18 @@ export function getProviderInfoTool(client: ReclaimSdkClient): RegisteredTool {
38
41
  'Read a PUBLISHED provider from the public production SDK backend ' +
39
42
  '(api.reclaimprotocol.org — what the verification SDK/attestor ' +
40
43
  'itself fetches at runtime), for ANY provider, no auth required. ' +
41
- 'Different from get_provider_version_config, which reads the old ' +
42
- 'devtools DASHBOARD backend and requires auth. \n\n' +
44
+ 'This is the provider-read tool for old-devtools mode. \n\n' +
43
45
  'Returns two complementary reads for the given version (latest ' +
44
- 'when `versionNumber` is omitted): `provider` (the full provider ' +
45
- '+ providerConfig, including `customInjection`) and `configs` (the ' +
46
- 'trimmed recipe the attestor actually consumes — `customInjection` ' +
47
- 'is deliberately OMITTED there, replaced by a computed ' +
48
- '`isScriptRequestingClaim` flag). One endpoint failing (e.g. an ' +
49
- 'invalid `versionNumber`) is reported under `errors` without ' +
46
+ 'when `versionNumber` is omitted): `provider` (the FULL provider ' +
47
+ '+ providerConfig — `customInjection`, `userAgent`, ' +
48
+ '`allowedInjectedRequestData`, `requestData`, everything) and ' +
49
+ '`configs` (the trimmed recipe the attestor actually consumes — ' +
50
+ '`customInjection` is deliberately OMITTED there, replaced by a ' +
51
+ 'computed `isScriptRequestingClaim` flag). Read `provider` here ' +
52
+ 'BEFORE create_provider_version_from_capture so you can pass the ' +
53
+ 'whole existing config back — that tool always creates a new ' +
54
+ 'version and carries nothing forward. One endpoint failing (e.g. ' +
55
+ 'an invalid `versionNumber`) is reported under `errors` without ' +
50
56
  'hiding a result the other endpoint did return.',
51
57
  inputSchema: {
52
58
  type: 'object',
@@ -0,0 +1,42 @@
1
+ import { defineTool, type RegisteredTool } from '../../mcp/server.ts'
2
+ import type { ReclaimOldClient } from '../client.ts'
3
+
4
+ /**
5
+ * Mint a brand-new eth-keypair APP (as opposed to authenticating as a
6
+ * dashboard user). `appId` is the eth address, `appSecret` the raw private
7
+ * key — this is the pair `@reclaimprotocol/js-sdk`'s `ReclaimProofRequest.init`
8
+ * expects. No auth required: the app starts unassociated (`creatorUid: null`)
9
+ * and sandboxed (`sandboxLimit: 100` sessions). Use `link_app_to_account` to
10
+ * attach it to your dashboard account, or `check_app_status` to inspect it.
11
+ */
12
+ export function issueAppCredentialsTool(client: ReclaimOldClient): RegisteredTool {
13
+ return defineTool<Record<string, never>>(
14
+ {
15
+ name: 'issue_app_credentials',
16
+ description:
17
+ 'Mint a new Reclaim APP (eth keypair) on the old devtools backend — ' +
18
+ 'no authentication required. Returns `{ appId, appSecret }`: ' +
19
+ '`appId` is the eth address, `appSecret` the raw private key. This ' +
20
+ 'is the appId/appSecret pair for @reclaimprotocol/js-sdk ' +
21
+ '(`ReclaimProofRequest.init(appId, appSecret, providerId)`). The app ' +
22
+ 'starts UNLINKED and sandboxed (100 sessions) — call ' +
23
+ 'link_app_to_account to attach it to your dashboard account and ' +
24
+ 'check_app_status to check its state. Store `appSecret` like a ' +
25
+ 'password (e.g. in a gitignored .env as RECLAIM_APP_SECRET) — it ' +
26
+ 'is returned once and cannot be recovered from the server.',
27
+ inputSchema: { type: 'object', properties: {} },
28
+ },
29
+ async() => {
30
+ const { appId, appSecret } = await client.issueCredentials()
31
+ return {
32
+ appId,
33
+ appSecret,
34
+ note:
35
+ 'Unlinked, sandboxed app (100 session limit). Set RECLAIM_APP_ID ' +
36
+ 'and RECLAIM_APP_SECRET in your .env (gitignored). Run ' +
37
+ 'link_app_to_account to attach it to your dashboard account and ' +
38
+ 'lift the sandbox limit.',
39
+ }
40
+ },
41
+ )
42
+ }