@reclaimprotocol/agent 0.1.34 → 0.1.35

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/README.md +3 -3
  2. package/lib/mcp/api/operations.gen.js +340 -150
  3. package/lib/mcp/instructions.js +3 -3
  4. package/lib/mcp/start.js +8 -7
  5. package/lib/mcp/tools/agent/build.js +2 -0
  6. package/lib/mcp/tools/agent/check-attestor.d.ts +5 -0
  7. package/lib/mcp/tools/agent/check-attestor.js +37 -0
  8. package/lib/mcp/tools/agent/live-view.js +42 -18
  9. package/lib/mcp/tools/agent/proof.d.ts +12 -0
  10. package/lib/mcp/tools/agent/proof.js +32 -0
  11. package/lib/mcp/tools/agent/shared.d.ts +2 -1
  12. package/lib/mcp/tools/agent/shared.js +28 -24
  13. package/lib/mcp/tools/agent/start-integration.js +5 -4
  14. package/lib/mcp/tools/verification/build.d.ts +3 -1
  15. package/lib/mcp/tools/verification/build.js +10 -2
  16. package/lib/mcp/tools/verification/callback-listener.d.ts +51 -0
  17. package/lib/mcp/tools/verification/callback-listener.js +163 -0
  18. package/lib/mcp/tools/verification/index.d.ts +2 -0
  19. package/lib/mcp/tools/verification/index.js +2 -0
  20. package/lib/mcp/tools/verification/proxy-callback-events.d.ts +59 -0
  21. package/lib/mcp/tools/verification/proxy-callback-events.js +300 -0
  22. package/lib/mcp/tools/verification/read-callback-events.d.ts +3 -0
  23. package/lib/mcp/tools/verification/read-callback-events.js +135 -0
  24. package/lib/mcp/tools/verification/result.d.ts +6 -0
  25. package/lib/mcp/tools/verification/result.js +1 -1
  26. package/lib/proof/check-attestor.d.ts +67 -0
  27. package/lib/proof/check-attestor.js +156 -0
  28. package/package.json +2 -2
  29. package/skill/SKILL.builder.md +7 -1
  30. package/skill/SKILL.common.md +21 -9
  31. package/skill/builder-mode.md +17 -12
  32. package/skill/start-integration.md +36 -11
  33. package/skill/verification-client-integration.md +15 -9
  34. package/src/mcp/api/operations.gen.ts +344 -150
  35. package/src/mcp/instructions.ts +3 -3
  36. package/src/mcp/start.ts +9 -7
  37. package/src/mcp/tools/agent/build.ts +2 -0
  38. package/src/mcp/tools/agent/check-attestor.ts +49 -0
  39. package/src/mcp/tools/agent/live-view.ts +42 -18
  40. package/src/mcp/tools/agent/proof.ts +55 -0
  41. package/src/mcp/tools/agent/shared.ts +33 -23
  42. package/src/mcp/tools/agent/start-integration.ts +5 -4
  43. package/src/mcp/tools/verification/build.ts +10 -2
  44. package/src/mcp/tools/verification/callback-listener.ts +203 -0
  45. package/src/mcp/tools/verification/index.ts +2 -0
  46. package/src/mcp/tools/verification/proxy-callback-events.ts +374 -0
  47. package/src/mcp/tools/verification/read-callback-events.ts +160 -0
  48. package/src/mcp/tools/verification/result.ts +1 -1
  49. package/src/proof/check-attestor.ts +209 -0
@@ -0,0 +1,49 @@
1
+ import {
2
+ checkAttestor,
3
+ type CheckAttestorOptions,
4
+ } from '../../../proof/check-attestor.ts'
5
+ import { defineTool, type RegisteredTool } from '../../server.ts'
6
+
7
+ /** `check_attestor`: proves a public request to show whether the attestor
8
+ * works from this machine. `check` is injectable so tests need no network. */
9
+ export function checkAttestorTool(
10
+ check: (opts: CheckAttestorOptions) => ReturnType<typeof checkAttestor>
11
+ = checkAttestor,
12
+ ): RegisteredTool {
13
+ return defineTool<{ attestorUrl?: string }>(
14
+ {
15
+ name: 'check_attestor',
16
+ description:
17
+ 'Checks that the Reclaim attestor works from this machine by proving '
18
+ + 'a public CoinGecko price request with a throwaway key. It uses no '
19
+ + 'credential, provider, or logged-in site, and takes up to 45 '
20
+ + 'seconds (about 8 when healthy). Call it once when you set up a '
21
+ + 'new provider build, and after `run_proof` fails with a network '
22
+ + 'error. Returns `{ ok, attestorUrl, durationMs, '
23
+ + 'extractedParameters, error, errorKind }`. If `ok` is true, the '
24
+ + 'attestor is fine and a failing proof points to the target site. '
25
+ + 'If `errorKind` is `attestor-unreachable`, this machine cannot '
26
+ + 'reach the attestor (network, VPN, firewall, or proxy): tell the '
27
+ + 'developer. If it is `setup-failed`, the ZK file download failed '
28
+ + 'before the attestor was contacted: tell the developer to check '
29
+ + 'network access to the circuit download. If it is '
30
+ + '`attestor-error`, the attestor rejected the claim: report '
31
+ + '`error`.',
32
+ inputSchema: {
33
+ type: 'object',
34
+ properties: {
35
+ attestorUrl: {
36
+ type: 'string',
37
+ description:
38
+ 'Attestor websocket URL. Set it only if the developer '
39
+ + 'supplied one; the default is the public attestor.',
40
+ },
41
+ },
42
+ },
43
+ annotations: { openWorldHint: true, readOnlyHint: true },
44
+ },
45
+ async({ attestorUrl }) => {
46
+ return check(attestorUrl ? { attestorUrl } : {})
47
+ },
48
+ )
49
+ }
@@ -3,9 +3,10 @@
3
3
  * attached container browser's built-in viewer behind a public URL, so someone
4
4
  * who is not at this machine can watch it and optionally drive it.
5
5
  *
6
- * The server never runs, downloads or resolves cloudflared: the agent runs it
7
- * itself and passes the resulting `trycloudflare.com` URL back to get the link
8
- * to hand out.
6
+ * The server never runs, downloads or resolves cloudflared. The developer
7
+ * chooses who runs it: they run it themselves (recommended) or approve the
8
+ * agent running it. Either way the `trycloudflare.com` URL it prints comes
9
+ * back as `publicUrl` to get the link to hand out.
9
10
  *
10
11
  * Scoped to `container` mode on purpose. A `builder`-mode browser already has
11
12
  * a hosted live view at `attach_browser`'s `liveViewUrl`.
@@ -30,9 +31,13 @@ export function liveViewTools(
30
31
  'Returns a link that lets someone who is not at this machine watch '
31
32
  + 'and drive the attached `container` browser, for example to sign '
32
33
  + 'in. This server opens no tunnel itself. Step 1: call with no '
33
- + 'arguments to get the local viewer URL and the `cloudflared` '
34
- + 'command for the developer to run. Step 2: after cloudflared '
35
- + 'prints its `https://*.trycloudflare.com` URL, call again with it '
34
+ + 'arguments. It returns the local viewer URL, the `cloudflared` '
35
+ + 'command, and two `choices`. Ask the developer to pick one before '
36
+ + 'any tunnel runs: (a, recommended) they run `! cloudflared tunnel '
37
+ + '--url http://localhost:<port>` themselves and paste the printed '
38
+ + '`https://*.trycloudflare.com` URL; (b) they explicitly approve '
39
+ + 'you running the command in the background. Never install '
40
+ + '`cloudflared` without approval. Step 2: call again with the URL '
36
41
  + 'as `publicUrl` to get the link to relay. The link grants access '
37
42
  + 'to that browser session only, so share it only with the person '
38
43
  + 'who needs it. For `builder`, use the `liveViewUrl` from '
@@ -90,28 +95,47 @@ export function liveViewTools(
90
95
  + 'separate window is visible too.',
91
96
  'Opening it on a phone lays the page out for that phone, with '
92
97
  + 'touch, pinch-zoom and the soft keyboard.',
93
- 'When they are done, stop the cloudflared process you started. '
94
- + 'The browser keeps running until `dispose_browser`.',
98
+ 'When they are done, stop the cloudflared process: yourself if '
99
+ + 'you started it, otherwise ask the developer. The browser '
100
+ + 'keeps running until `dispose_browser`.',
95
101
  ],
96
102
  }
97
103
  }
98
104
 
99
105
  const localUrl = `http://localhost:${viewerPort}`
106
+ const command = `cloudflared tunnel --url ${localUrl}`
100
107
  return {
101
108
  localViewerUrl: ensureLiveViewUrl(localUrl),
102
109
  port: viewerPort,
103
- command: `cloudflared tunnel --url ${localUrl}`,
110
+ command,
111
+ choices: [
112
+ {
113
+ id: 'developer-runs',
114
+ recommended: true,
115
+ action: `Run \`! ${command}\` in the prompt, then paste the `
116
+ + 'https://*.trycloudflare.com URL it prints.',
117
+ },
118
+ {
119
+ id: 'agent-runs',
120
+ recommended: false,
121
+ action: 'Approve the agent to run the command in the '
122
+ + 'background and read the URL from its output.',
123
+ },
124
+ ],
104
125
  _notes: [
105
- 'Run `command` yourself in a background process. If `cloudflared` '
106
- + 'is not installed, tell the developer and ask them to install '
107
- + 'it (https://developers.cloudflare.com/cloudflare-one/'
108
- + 'connections/connect-networks/downloads/) — do not install it '
126
+ 'Ask the developer to choose before any tunnel runs. Do not run '
127
+ + '`command` until they pick `agent-runs`.',
128
+ 'If they pick `developer-runs`, wait for the pasted URL. If they '
129
+ + 'pick `agent-runs`, run `command` in a background process and '
130
+ + 'read its output for a line containing '
131
+ + '`https://<words>.trycloudflare.com`.',
132
+ 'If `cloudflared` is not installed, tell the developer and ask '
133
+ + 'them to install it '
134
+ + '(https://developers.cloudflare.com/cloudflare-one/'
135
+ + 'connections/connect-networks/downloads/). Never install it '
109
136
  + 'without their approval.',
110
- 'Read its output for a line containing '
111
- + '`https://<words>.trycloudflare.com`. That is the public URL.',
112
- 'Call share_browser_view again with that value as `publicUrl` '
113
- + 'to get the link to relay. Never guess or reconstruct the '
114
- + 'hostname.',
137
+ 'Call share_browser_view again with the URL as `publicUrl` to get '
138
+ + 'the link to relay. Never guess or reconstruct the hostname.',
115
139
  'Expose only this port. Never tunnel the browser\'s CDP port.',
116
140
  ],
117
141
  }
@@ -7,6 +7,11 @@ import {
7
7
  tailLogs,
8
8
  warnsAndErrors,
9
9
  } from '../../../proof/attestor.ts'
10
+ import {
11
+ type AttestorCheckResult,
12
+ type CheckAttestorOptions,
13
+ isNetworkError,
14
+ } from '../../../proof/check-attestor.ts'
10
15
 
11
16
  function claimsDir(): string {
12
17
  return join(reclaimHomeDirPath(), 'claims')
@@ -192,3 +197,53 @@ export function shapeRichProofResult(
192
197
 
193
198
  return out
194
199
  }
200
+
201
+ export interface ProofDiagnosis {
202
+ attestor: AttestorCheckResult
203
+ likelyCause: string
204
+ next: string
205
+ }
206
+
207
+ /**
208
+ * When an unverified proof failed with a network-like error, runs the
209
+ * attestor check once and says whether the attestor or the target site is the
210
+ * likely cause. Returns `undefined` for verified proofs and other errors.
211
+ */
212
+ export async function diagnoseNetworkFailure(
213
+ result: Pick<ProofResult, 'verified' | 'error' | 'errorKind'>,
214
+ check: (opts: CheckAttestorOptions) => Promise<AttestorCheckResult>,
215
+ attestorUrl?: string,
216
+ ): Promise<ProofDiagnosis | undefined> {
217
+ if(
218
+ result.verified
219
+ || result.errorKind !== undefined
220
+ || !isNetworkError(result.error)
221
+ ) {
222
+ return undefined
223
+ }
224
+
225
+ const attestor = await check(attestorUrl ? { attestorUrl } : {})
226
+ if(attestor.ok) {
227
+ return {
228
+ attestor,
229
+ likelyCause:
230
+ 'The attestor works from this machine, so the target site likely '
231
+ + 'blocks or drops the attestor\'s network (geo or IP restriction).',
232
+ next:
233
+ 'If the browser attach mode is "builder", call `run_proof_via_pod`. '
234
+ + 'Otherwise tell the developer and suggest asking the Reclaim team. '
235
+ + 'Do not retry the same proof.',
236
+ }
237
+ }
238
+
239
+ return {
240
+ attestor,
241
+ likelyCause:
242
+ 'The attestor check also failed, so this machine cannot reach the '
243
+ + 'attestor (network, VPN, firewall, or proxy).',
244
+ next:
245
+ 'Tell the developer. To see the connection details, suggest '
246
+ + 'restarting the MCP server with `RECLAIM_AGENT_TRACE=1`. Do not '
247
+ + 'retry the same proof.',
248
+ }
249
+ }
@@ -15,11 +15,12 @@ import {
15
15
  type KeptScriptRef,
16
16
  } from '../../../authoring/index.ts'
17
17
  import { runAttestorProof } from '../../../proof/attestor.ts'
18
+ import { checkAttestor } from '../../../proof/check-attestor.ts'
18
19
  import { replayProviderInPage } from '../../../proof/page-replay.ts'
19
20
  import type { OprfMode, ReclaimProvider } from '../../../provider/schema.ts'
20
21
  import type { RegisteredTool } from '../../server.ts'
21
22
  import type { CaptureEntry, CaptureStore } from './capture.ts'
22
- import { shapeRichProofResult } from './proof.ts'
23
+ import { diagnoseNetworkFailure, shapeRichProofResult } from './proof.ts'
23
24
 
24
25
  /**
25
26
  * OPRF mode this LOCAL (non-TEE) attestor can actually run. Recipes carry
@@ -58,6 +59,7 @@ function withLocalOprfMode(provider: ReclaimProvider): ReclaimProvider {
58
59
  function localBackend(
59
60
  entry: CaptureEntry,
60
61
  kept: KeptScriptRef,
62
+ check: typeof checkAttestor,
61
63
  ): AuthoringBackend {
62
64
  const { tabClient, session, drafts } = entry
63
65
  const sendCdp = (method: string, params?: Record<string, unknown>) => {
@@ -112,11 +114,15 @@ function localBackend(
112
114
  ...(opts?.attestorUrl ? { attestorUrl: opts.attestorUrl } : {}),
113
115
  },
114
116
  )
115
- return shapeRichProofResult(result, {
117
+ const shaped = shapeRichProofResult(result, {
116
118
  provider: draft.provider,
117
119
  hasSecrets: Object.keys(draft.secrets).length > 0,
118
120
  ...(opts?.attestorUrl ? { attestorUrl: opts.attestorUrl } : {}),
119
121
  })
122
+ const diagnosis = await diagnoseNetworkFailure(
123
+ result, check, opts?.attestorUrl,
124
+ )
125
+ return diagnosis ? { ...shaped, diagnosis } : shaped
120
126
  },
121
127
  }
122
128
  }
@@ -126,25 +132,26 @@ function localBackend(
126
132
  * of this). */
127
133
  const RUN_PROOF_EXTRAS = {
128
134
  description:
129
- 'Run a full zkTLS proof of the drafted recipe through the Reclaim '
130
- + 'attestor SDK. Use only after replay_request looks right — attestor '
131
- + 'calls are expensive. Writes the full claim to '
132
- + '~/.reclaim/claims/<timestamp>.json and returns identifier + '
133
- + 'extractedParameters + claimPath + a prettified `claim` (request '
134
- + 'dropped, byte arrays as base64). With RECLAIM_AGENT_TRACE=1, also '
135
- + 'writes a per-call diagnostic dump (input provider, sentParams, full '
136
- + 'attestor log stream, result) and returns it as `runLogPath`. '
137
- + '`attestorUrl` overrides the default only when the dev supplies a '
138
- + 'specific alternative — do NOT invent backup URLs. `ownerAddress` '
139
- + '(the credential to sign with) is REQUIRED — call resolve_owner_key '
140
- + 'first. errorKind="missing-owner-key" means that address has no known '
141
- + 'private key; re-run resolve_owner_key or issue/import a credential. '
142
- + 'NOTE: unlike replay_request (which re-issues from inside the live '
143
- + 'attached browser tab), the ATTESTOR calls the target from its OWN '
144
- + 'network/IP, separate from the browser\'s. A request that replayed '
145
- + 'fine can still fail here for geo/IP-bound content (country-locked '
146
- + "pages, IP-allowlisted APIs) — if that happens, it's a property of "
147
- + 'the site, not a bug in the draft.',
135
+ 'Proves the drafted recipe with a full zkTLS proof through the attestor '
136
+ + 'SDK. Call it after `replay_request` looks right; attestor calls are '
137
+ + 'expensive. Call `resolve_owner_key` first and pass its address as '
138
+ + '`ownerAddress`. Writes the full claim to '
139
+ + '`~/.reclaim/claims/<timestamp>.json`. Returns `identifier`, '
140
+ + '`extractedParameters`, `claimPath`, and a readable `claim` (request '
141
+ + 'removed, byte arrays as base64). Set `RECLAIM_AGENT_TRACE=1` to also '
142
+ + 'get `runLogPath`, a diagnostic dump with the attestor log stream. '
143
+ + 'Set `attestorUrl` only if the developer supplied one; never invent '
144
+ + 'backup URLs. If `errorKind` is `missing-owner-key`, the address has '
145
+ + 'no known private key: run `resolve_owner_key` again, or issue or '
146
+ + 'import a credential. Unlike `replay_request`, which re-sends from '
147
+ + 'inside the attached browser tab, the attestor calls the target from '
148
+ + 'its own network and IP. A request that replayed can still fail here '
149
+ + 'for geo- or IP-bound content, such as country-locked pages or '
150
+ + 'IP-allowlisted APIs. That is a property of the site, not a draft '
151
+ + 'bug. After a network-like failure ("connection closed", '
152
+ + '`ERROR_NETWORK_ERROR`, timeout), the result includes `diagnosis` '
153
+ + 'with `attestor` (a `check_attestor` result), `likelyCause`, and '
154
+ + '`next`. Follow `next`; do not retry the same proof.',
148
155
  properties: {
149
156
  ownerAddress: {
150
157
  type: 'string',
@@ -168,7 +175,10 @@ const RUN_PROOF_EXTRAS = {
168
175
  * takes `captureId`; `run_proof` also gets the credential-aware contract. The
169
176
  * tools are authored plain in `../../../authoring`; this only wraps them with
170
177
  * `extendTool` (handlers untouched — loose arg parse keeps `captureId`). */
171
- export function sharedAuthoringTools(captures: CaptureStore): RegisteredTool[] {
178
+ export function sharedAuthoringTools(
179
+ captures: CaptureStore,
180
+ check: typeof checkAttestor = checkAttestor,
181
+ ): RegisteredTool[] {
172
182
  const resolveBackend = (args: Record<string, unknown>): AuthoringBackend => {
173
183
  const captureId = String(args.captureId)
174
184
  const entry = captures.sessions.get(captureId)
@@ -179,7 +189,7 @@ export function sharedAuthoringTools(captures: CaptureStore): RegisteredTool[] {
179
189
  )
180
190
  }
181
191
 
182
- return localBackend(entry, captures)
192
+ return localBackend(entry, captures, check)
183
193
  }
184
194
 
185
195
  return authoringTools(resolveBackend).map((tool) => {
@@ -19,10 +19,11 @@ export function startIntegrationTool(): RegisteredTool {
19
19
  name: 'start_integration',
20
20
  description:
21
21
  'Returns a step-by-step plan for adding Reclaim verification to the '
22
- + 'current codebase: sign in, build or choose a provider, then add the '
23
- + 'session route, callback, and Verify button. Call it first when a '
24
- + 'developer wants their users to prove something, and follow the '
25
- + 'plan to the end. Changes nothing itself.',
22
+ + 'current codebase: sign in, set up the app (session route, callback, '
23
+ + 'Verify button, local event proxy) in parallel with building or '
24
+ + 'choosing the provider, then wire the provider and test. Call it '
25
+ + 'first when a developer wants their users to prove something, and '
26
+ + 'follow the plan to the end. Changes nothing itself.',
26
27
  inputSchema: {
27
28
  type: 'object',
28
29
  required: ['goal'],
@@ -1,4 +1,6 @@
1
1
  import type { RegisteredTool } from '../../server.ts'
2
+ import { proxyCallbackEventsTool } from './proxy-callback-events.ts'
3
+ import { readCallbackEventsTool } from './read-callback-events.ts'
2
4
  import { verificationResultTool } from './result.ts'
3
5
 
4
6
  /**
@@ -6,11 +8,17 @@ import { verificationResultTool } from './result.ts'
6
8
  * generated API surface. `get_verification_result` authenticates with the org
7
9
  * token, checks status, pulls the stored payload, and decrypts it locally with
8
10
  * the optional eth key — a flow the generated raw-API tools can't express
9
- * (the decryption key never leaves the consumer). Diagnosis is left to the
11
+ * (the decryption key never leaves the consumer). `read_callback_events` and
12
+ * `proxy_callback_events` read and forward the org's pull-listener events.
13
+ * Diagnosis is left to the
10
14
  * agent over the generated tools (`get_verification_session`,
11
15
  * `list_verification_events`, `list_verification_deliveries`), whose
12
16
  * descriptions carry the lifecycle and delivery semantics.
13
17
  */
14
18
  export function buildVerificationTools(): RegisteredTool[] {
15
- return [verificationResultTool()]
19
+ return [
20
+ verificationResultTool(),
21
+ readCallbackEventsTool(),
22
+ proxyCallbackEventsTool(),
23
+ ]
16
24
  }
@@ -0,0 +1,203 @@
1
+ import { badGateway } from '@hapi/boom'
2
+ import { ReclaimClient } from '@reclaimprotocol/client/api'
3
+ import type { IOperationResponse } from '@reclaimprotocol/client/openapi'
4
+ import assert from 'node:assert'
5
+ import { setTimeout as delay } from 'node:timers/promises'
6
+ import { apiUrl } from '../../../consts.ts'
7
+ import { LOGGER } from '../../../logger.ts'
8
+ import { NAME, VERSION } from '../../../pkg.ts'
9
+
10
+ /** Runtime seams. Production passes none; tests pass a stub `fetch`. */
11
+ export interface CallbackDeps {
12
+ fetch?: typeof globalThis.fetch
13
+ /** Waits between heartbeats and polls. Resolves, without error, on abort. */
14
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>
15
+ keeper?: ListenerKeeper
16
+ }
17
+
18
+ /**
19
+ * Events the org's pull listener subscribes to: the five session-ending
20
+ * events (the first four carry the signed result; `session_expired` has no
21
+ * result) plus the lifecycle milestones an app developer checks while testing.
22
+ * High-volume diagnostics (`network_request_observed`, `provider_script_log`,
23
+ * `user_interaction_*`) are left out, so a forwarded session sends the app at
24
+ * most 11 requests instead of dozens.
25
+ */
26
+ export const LISTENER_EVENTS = [
27
+ 'verification_success',
28
+ 'verification_rejected',
29
+ 'verification_error',
30
+ 'verification_cancelled',
31
+ 'session_expired',
32
+ 'session_pending',
33
+ 'verification_client_opened',
34
+ 'authenticated',
35
+ 'verification_proofs_completed',
36
+ 'verification_result_submitting',
37
+ 'verification_result_submission_failed',
38
+ ] as const
39
+
40
+ export type ListenerInfo = {
41
+ subscriptionId: string
42
+ created: boolean
43
+ /** When the lease lapses unless the heartbeat renews it. */
44
+ expiresAt: string
45
+ }
46
+
47
+ export type CallbackEvent
48
+ = IOperationResponse<'ListCallbackEvents'>['items'][number]
49
+
50
+ /**
51
+ * A client that presents the organization's API key. With a `signal`, every
52
+ * request aborts when it does.
53
+ */
54
+ export function orgClient(
55
+ secret: string,
56
+ deps: CallbackDeps = {},
57
+ signal?: AbortSignal,
58
+ ) {
59
+ const base = deps.fetch || globalThis.fetch
60
+ return new ReclaimClient({
61
+ baseUrl: apiUrl(),
62
+ token: secret,
63
+ fetch: signal
64
+ ? (input, init) => base(input, { ...init, signal })
65
+ : deps.fetch,
66
+ headers: { 'User-Agent': `${NAME}/${VERSION}` },
67
+ })
68
+ }
69
+
70
+ /** Renewal interval. The backend lease lasts 90 seconds. */
71
+ const HEARTBEAT_MS = 30_000
72
+ /** Upper bound for releasing all listeners at shutdown. */
73
+ const RELEASE_TIMEOUT_MS = 3_000
74
+
75
+ type Entry = {
76
+ /** Aborts with the entry, so a renew in flight cannot outlive a release. */
77
+ client: ReclaimClient
78
+ /** Not bound to the abort signal: it must still send the release. */
79
+ releaseClient: ReclaimClient
80
+ abort: AbortController
81
+ /** The first lease; concurrent callers share it. */
82
+ ready: Promise<ListenerInfo>
83
+ settled: boolean
84
+ }
85
+
86
+ /**
87
+ * Keeps each org's pull listener alive while this MCP process runs. Creating
88
+ * the subscription is the heartbeat: it renews a lease, and the listener
89
+ * stops receiving events when the lease lapses. {@link release} ends the
90
+ * lease at shutdown.
91
+ */
92
+ export class ListenerKeeper {
93
+ #entries = new Map<string, Entry>()
94
+
95
+ /** Creates or renews the listener and starts its heartbeat (once). */
96
+ async ensure(secret: string, deps: CallbackDeps = {}): Promise<ListenerInfo> {
97
+ const existing = this.#entries.get(secret)
98
+ if(existing) {
99
+ const wasSettled = existing.settled
100
+ const first = await existing.ready
101
+ // Callers that arrived during creation share its result.
102
+ return wasSettled ? await renew(existing.client) : first
103
+ }
104
+
105
+ const abort = new AbortController()
106
+ const client = orgClient(secret, deps, abort.signal)
107
+ const sleep = deps.sleep || sleepFor
108
+ const entry: Entry = {
109
+ client,
110
+ releaseClient: orgClient(secret, deps),
111
+ abort,
112
+ settled: false,
113
+ ready: renew(client).then((info) => {
114
+ entry.settled = true
115
+ void this.#beat(entry, sleep)
116
+ return info
117
+ }, (err: unknown) => {
118
+ if(this.#entries.get(secret) === entry) {
119
+ this.#entries.delete(secret)
120
+ }
121
+
122
+ abort.abort()
123
+ throw err
124
+ }),
125
+ }
126
+ this.#entries.set(secret, entry)
127
+ return await entry.ready
128
+ }
129
+
130
+ /** Ends every lease and stops the heartbeats, within a short timeout. */
131
+ async release() {
132
+ const entries = [...this.#entries.values()]
133
+ this.#entries.clear()
134
+ const timeout = AbortSignal.timeout(RELEASE_TIMEOUT_MS)
135
+ await Promise.all(entries.map(async({ releaseClient, abort }) => {
136
+ abort.abort()
137
+ try {
138
+ await Promise.race([
139
+ releaseClient.call('ReleaseCallbackListener', {}),
140
+ new Promise((_, reject) => {
141
+ timeout.addEventListener('abort', () => reject(timeout.reason))
142
+ }),
143
+ ])
144
+ } catch(err) {
145
+ LOGGER.warn({ err }, 'releasing the callback listener failed')
146
+ }
147
+ }))
148
+ }
149
+
150
+ async #beat(entry: Entry, sleep: NonNullable<CallbackDeps['sleep']>) {
151
+ const { signal } = entry.abort
152
+ while(!signal.aborted) {
153
+ await sleep(HEARTBEAT_MS, signal)
154
+ if(signal.aborted) {
155
+ return
156
+ }
157
+
158
+ try {
159
+ await renew(entry.client)
160
+ } catch(err) {
161
+ if(signal.aborted) {
162
+ return
163
+ }
164
+
165
+ LOGGER.warn({ err }, 'callback listener heartbeat failed; retrying')
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ const defaultKeeper = new ListenerKeeper()
172
+
173
+ /** Releases every listener this process keeps alive. Runs at MCP shutdown. */
174
+ export function releaseAllListeners() {
175
+ return defaultKeeper.release()
176
+ }
177
+
178
+ /**
179
+ * Create the org's pull listener and keep it alive until shutdown. An org has
180
+ * one, so a second call returns the existing subscription (HTTP 200).
181
+ */
182
+ export function ensureListener(secret: string, deps: CallbackDeps = {}) {
183
+ return (deps.keeper || defaultKeeper).ensure(secret, deps)
184
+ }
185
+
186
+ async function renew(client: ReclaimClient): Promise<ListenerInfo> {
187
+ const { data, response } = await client.call('CreateCallbackSubscription', {
188
+ body: { deliveryMode: 'pull', events: [...LISTENER_EVENTS] },
189
+ })
190
+ assert(
191
+ data.listenerExpiresAt,
192
+ badGateway('Builder returned a pull listener without a lease.'),
193
+ )
194
+ return {
195
+ subscriptionId: data.id,
196
+ created: response.status === 201,
197
+ expiresAt: data.listenerExpiresAt,
198
+ }
199
+ }
200
+
201
+ export async function sleepFor(ms: number, signal: AbortSignal) {
202
+ await delay(ms, undefined, { signal }).catch(() => undefined)
203
+ }
@@ -1,2 +1,4 @@
1
1
  // Barrel for the verification tool group (result retrieval + decryption).
2
2
  export { buildVerificationTools } from './build.ts'
3
+ export { stopAllForwarders } from './proxy-callback-events.ts'
4
+ export { releaseAllListeners } from './callback-listener.ts'