/** * Build the shared authoring tools for the MCP, bound to a per-capture LOCAL * backend: a Chrome tab over CDP + the in-process attestor SDK. The tool logic * (navigate/inspect/propose/replay/prove) is authored once in `../../../ * authoring` with the shared `defineTool` primitive; this only supplies the * MCP host — resolve the backend from `captureId` (the session handle from * `start_capture`, since one capture bundles the tab, its requests, and its * drafts) and the rich, credential-aware `run_proof` schema. */ import type { JSONSchema } from 'zod/v4/core' import { type AuthoringBackend, authoringTools, extendTool, } from '../../../authoring/index.ts' import { runAttestorProof } from '../../../proof/attestor.ts' import { replayProviderInPage } from '../../../proof/page-replay.ts' import type { OprfMode, ReclaimProvider } from '../../../provider/schema.ts' import type { RegisteredTool } from '../../server.ts' import type { CaptureEntry, CaptureStore } from './capture.ts' import { shapeRichProofResult } from './proof.ts' /** * OPRF mode this LOCAL (non-TEE) attestor can actually run. Recipes carry * `oprf-mpc` — the canonical/publish mode a TEE expects — but the local dev * attestor can't do MPC. gnark `oprf` (Linux/Mac, needs ZK circuits) is the * strongest it supports; Windows falls back to server-side `oprf-raw` (no * circuits). Proving with the wrong mode fails, which is what pushed the agent * to hand-craft a plaintext workaround. */ const LOCAL_OPRF_MODE: OprfMode = process.platform === 'win32' ? 'oprf-raw' : 'oprf' /** * Clone `provider` with every `oprf-mpc` redaction remapped to the mode this * local attestor can run, so the PROOF validates locally. The draft itself is * untouched — it keeps `oprf-mpc`, so the PUBLISHED recipe is always the * TEE-canonical mode regardless of where it was proved. */ function withLocalOprfMode(provider: ReclaimProvider): ReclaimProvider { const reds = provider.responseRedactions if(!reds?.some((r) => r.hash === 'oprf-mpc')) { return provider } return { ...provider, responseRedactions: reds.map((r) => { return r.hash === 'oprf-mpc' ? { ...r, hash: LOCAL_OPRF_MODE } : r }), } } /** The local backend for one capture: drive its tab over CDP, read its * captured requests, prove via the in-process attestor, replay by re-fetching * FROM INSIDE the tab (real cookies/session — see proof/page-replay.ts). */ function localBackend(entry: CaptureEntry): AuthoringBackend { const { tabClient, session, drafts } = entry const sendCdp = (method: string, params?: Record) => { return tabClient.send(method, params ?? {}) } return { capture: session, drafts, sendCdp, waitForEvent: (method, timeoutMs) => new Promise((resolve) => { let stopListening = () => {} const onEvent = () => { cleanup() resolve('fired') } const timer = setTimeout(() => { cleanup() resolve('timeout') }, timeoutMs) const cleanup = () => { clearTimeout(timer) stopListening() } stopListening = tabClient.subscribeOnce(method, onEvent) }), replay: async(draft, opts) => { // Replayed FROM INSIDE the attached tab (real cookies/session), not // a detached Node fetch — see proof/page-replay.ts. const result = await replayProviderInPage( { sendCdp }, draft.provider, draft.secrets, opts, ) return { status: result.status, matchesOriginal: result.matchesOriginal, bodySnippet: result.body.slice(0, 256), ...(result.hints !== undefined ? { hints: result.hints } : {}), } }, prove: async(draft, opts) => { // Prove with the locally-runnable OPRF mode; the draft (→ published // recipe) keeps oprf-mpc. const result = await runAttestorProof( withLocalOprfMode(draft.provider), draft.secrets, { ...(opts?.ownerAddress ? { ownerAddress: opts.ownerAddress } : {}), ...(opts?.attestorUrl ? { attestorUrl: opts.attestorUrl } : {}), }, ) return shapeRichProofResult(result, { provider: draft.provider, hasSecrets: Object.keys(draft.secrets).length > 0, ...(opts?.attestorUrl ? { attestorUrl: opts.attestorUrl } : {}), }) }, } } /** The MCP's rich, credential-aware `run_proof` schema, merged onto the shared * tool by the host below (the cloud's TEE signs in-enclave, so it needs none * of this). */ const RUN_PROOF_EXTRAS = { description: 'Run a full zkTLS proof of the drafted recipe through the Reclaim ' + 'attestor SDK. Use only after replay_request looks right — attestor ' + 'calls are expensive. Writes the full claim to ' + '~/.reclaim/claims/.json and returns identifier + ' + 'extractedParameters + claimPath + a prettified `claim` (request ' + 'dropped, byte arrays as base64). With RECLAIM_AGENT_TRACE=1, also ' + 'writes a per-call diagnostic dump (input provider, sentParams, full ' + 'attestor log stream, result) and returns it as `runLogPath`. ' + '`attestorUrl` overrides the default only when the dev supplies a ' + 'specific alternative — do NOT invent backup URLs. `ownerAddress` ' + '(the credential to sign with) is REQUIRED — call resolve_owner_key ' + 'first. errorKind="missing-owner-key" means that address has no known ' + 'private key; re-run resolve_owner_key or issue/import a credential. ' + 'NOTE: unlike replay_request (which re-issues from inside the live ' + 'attached browser tab), the ATTESTOR calls the target from its OWN ' + 'network/IP, separate from the browser\'s. A request that replayed ' + 'fine can still fail here for geo/IP-bound content (country-locked ' + "pages, IP-allowlisted APIs) — if that happens, it's a property of " + 'the site, not a bug in the draft.', properties: { ownerAddress: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$', description: 'ETH address of the credential to sign the claim with. Get it ' + 'from resolve_owner_key first. The private key is resolved ' + 'locally (cache / env / .env) — never passed here.', }, attestorUrl: { type: 'string' }, }, required: ['ownerAddress'], } satisfies { description: string properties: JSONSchema.ObjectSchema['properties'] required: string[] } /** The shared authoring tools mapped to the MCP's shape: resolve each call's * local backend from `args.captureId`, and widen the input schema — every tool * takes `captureId`; `run_proof` also gets the credential-aware contract. The * tools are authored plain in `../../../authoring`; this only wraps them with * `extendTool` (handlers untouched — loose arg parse keeps `captureId`). */ export function sharedAuthoringTools(captures: CaptureStore): RegisteredTool[] { const resolveBackend = (args: Record): AuthoringBackend => { const captureId = String(args.captureId) const entry = captures.sessions.get(captureId) if(!entry) { throw new Error( `no capture ${captureId} — call start_capture first ` + '(it has been reset or never started).', ) } return localBackend(entry) } return authoringTools(resolveBackend).map((tool) => { if(tool.definition.name === 'run_proof') { return extendTool(tool, { description: RUN_PROOF_EXTRAS.description, properties: { captureId: { type: 'string' }, ...RUN_PROOF_EXTRAS.properties, }, required: ['captureId', ...RUN_PROOF_EXTRAS.required], }) } return extendTool(tool, { properties: { captureId: { type: 'string' } }, required: ['captureId'], }) }) }