@reclaimprotocol/agent 0.1.33 → 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 (117) hide show
  1. package/README.md +3 -3
  2. package/lib/authoring/backend.d.ts +13 -0
  3. package/lib/authoring/index.d.ts +1 -1
  4. package/lib/authoring/tools.js +78 -42
  5. package/lib/config/store.d.ts +10 -0
  6. package/lib/mcp/api/operations.gen.d.ts +238 -4
  7. package/lib/mcp/api/operations.gen.js +6967 -672
  8. package/lib/mcp/api/tools.js +21 -8
  9. package/lib/mcp/instructions.js +12 -12
  10. package/lib/mcp/server.d.ts +1 -1
  11. package/lib/mcp/server.js +3 -0
  12. package/lib/mcp/start.js +8 -7
  13. package/lib/mcp/success-response.js +5 -1
  14. package/lib/mcp/tools/agent/analyze.js +5 -4
  15. package/lib/mcp/tools/agent/attach.js +11 -7
  16. package/lib/mcp/tools/agent/build.js +5 -1
  17. package/lib/mcp/tools/agent/capture.d.ts +2 -2
  18. package/lib/mcp/tools/agent/capture.js +15 -11
  19. package/lib/mcp/tools/agent/check-attestor.d.ts +5 -0
  20. package/lib/mcp/tools/agent/check-attestor.js +37 -0
  21. package/lib/mcp/tools/agent/eval.js +4 -3
  22. package/lib/mcp/tools/agent/find.js +9 -10
  23. package/lib/mcp/tools/agent/how-it-works.js +1 -0
  24. package/lib/mcp/tools/agent/live-view.js +49 -22
  25. package/lib/mcp/tools/agent/pod-proof.js +7 -11
  26. package/lib/mcp/tools/agent/proof.d.ts +12 -0
  27. package/lib/mcp/tools/agent/proof.js +32 -0
  28. package/lib/mcp/tools/agent/propose.js +4 -3
  29. package/lib/mcp/tools/agent/publish.d.ts +2 -1
  30. package/lib/mcp/tools/agent/publish.js +12 -6
  31. package/lib/mcp/tools/agent/replay.js +10 -7
  32. package/lib/mcp/tools/agent/session.js +16 -8
  33. package/lib/mcp/tools/agent/shared.d.ts +2 -1
  34. package/lib/mcp/tools/agent/shared.js +29 -24
  35. package/lib/mcp/tools/agent/start-integration.d.ts +15 -0
  36. package/lib/mcp/tools/agent/start-integration.js +62 -0
  37. package/lib/mcp/tools/authenticate/authenticate.js +9 -11
  38. package/lib/mcp/tools/authenticate/build.js +2 -0
  39. package/lib/mcp/tools/authenticate/builder-access.js +1 -0
  40. package/lib/mcp/tools/authenticate/eth-key.d.ts +8 -0
  41. package/lib/mcp/tools/authenticate/eth-key.js +27 -15
  42. package/lib/mcp/tools/authenticate/import.js +18 -31
  43. package/lib/mcp/tools/authenticate/issue-org-token.d.ts +8 -0
  44. package/lib/mcp/tools/authenticate/issue-org-token.js +60 -0
  45. package/lib/mcp/tools/authenticate/issue.js +9 -9
  46. package/lib/mcp/tools/authenticate/org-key-store.d.ts +10 -0
  47. package/lib/mcp/tools/authenticate/org-key-store.js +23 -0
  48. package/lib/mcp/tools/authenticate/resolve-owner-key.js +10 -12
  49. package/lib/mcp/tools/mode/mode.js +2 -0
  50. package/lib/mcp/tools/verification/build.d.ts +3 -1
  51. package/lib/mcp/tools/verification/build.js +10 -2
  52. package/lib/mcp/tools/verification/callback-listener.d.ts +51 -0
  53. package/lib/mcp/tools/verification/callback-listener.js +163 -0
  54. package/lib/mcp/tools/verification/index.d.ts +2 -0
  55. package/lib/mcp/tools/verification/index.js +2 -0
  56. package/lib/mcp/tools/verification/proxy-callback-events.d.ts +59 -0
  57. package/lib/mcp/tools/verification/proxy-callback-events.js +300 -0
  58. package/lib/mcp/tools/verification/read-callback-events.d.ts +3 -0
  59. package/lib/mcp/tools/verification/read-callback-events.js +135 -0
  60. package/lib/mcp/tools/verification/result.d.ts +6 -0
  61. package/lib/mcp/tools/verification/result.js +11 -8
  62. package/lib/proof/check-attestor.d.ts +67 -0
  63. package/lib/proof/check-attestor.js +156 -0
  64. package/lib/tools/registry.d.ts +12 -0
  65. package/lib/tools/registry.js +1 -0
  66. package/package.json +2 -2
  67. package/skill/SKILL.builder.md +84 -75
  68. package/skill/SKILL.common.md +23 -10
  69. package/skill/builder-mode.md +140 -138
  70. package/skill/start-integration.md +74 -0
  71. package/skill/user-script.md +64 -0
  72. package/skill/verification-client-integration.md +40 -28
  73. package/src/authoring/backend.ts +15 -0
  74. package/src/authoring/index.ts +2 -0
  75. package/src/authoring/tools.ts +86 -42
  76. package/src/config/store.ts +11 -0
  77. package/src/mcp/api/operations.gen.ts +7011 -704
  78. package/src/mcp/api/tools.ts +21 -8
  79. package/src/mcp/instructions.ts +12 -12
  80. package/src/mcp/server.ts +4 -0
  81. package/src/mcp/start.ts +9 -7
  82. package/src/mcp/success-response.ts +5 -1
  83. package/src/mcp/tools/agent/analyze.ts +5 -4
  84. package/src/mcp/tools/agent/attach.ts +11 -7
  85. package/src/mcp/tools/agent/build.ts +7 -1
  86. package/src/mcp/tools/agent/capture.ts +20 -12
  87. package/src/mcp/tools/agent/check-attestor.ts +49 -0
  88. package/src/mcp/tools/agent/eval.ts +4 -3
  89. package/src/mcp/tools/agent/find.ts +9 -10
  90. package/src/mcp/tools/agent/how-it-works.ts +1 -0
  91. package/src/mcp/tools/agent/live-view.ts +49 -22
  92. package/src/mcp/tools/agent/pod-proof.ts +7 -11
  93. package/src/mcp/tools/agent/proof.ts +55 -0
  94. package/src/mcp/tools/agent/propose.ts +4 -3
  95. package/src/mcp/tools/agent/publish.ts +12 -4
  96. package/src/mcp/tools/agent/replay.ts +10 -7
  97. package/src/mcp/tools/agent/session.ts +16 -8
  98. package/src/mcp/tools/agent/shared.ts +39 -24
  99. package/src/mcp/tools/agent/start-integration.ts +84 -0
  100. package/src/mcp/tools/authenticate/authenticate.ts +9 -11
  101. package/src/mcp/tools/authenticate/build.ts +2 -0
  102. package/src/mcp/tools/authenticate/builder-access.ts +1 -0
  103. package/src/mcp/tools/authenticate/eth-key.ts +48 -18
  104. package/src/mcp/tools/authenticate/import.ts +21 -36
  105. package/src/mcp/tools/authenticate/issue-org-token.ts +84 -0
  106. package/src/mcp/tools/authenticate/issue.ts +9 -9
  107. package/src/mcp/tools/authenticate/org-key-store.ts +25 -0
  108. package/src/mcp/tools/authenticate/resolve-owner-key.ts +10 -12
  109. package/src/mcp/tools/mode/mode.ts +2 -0
  110. package/src/mcp/tools/verification/build.ts +10 -2
  111. package/src/mcp/tools/verification/callback-listener.ts +203 -0
  112. package/src/mcp/tools/verification/index.ts +2 -0
  113. package/src/mcp/tools/verification/proxy-callback-events.ts +374 -0
  114. package/src/mcp/tools/verification/read-callback-events.ts +160 -0
  115. package/src/mcp/tools/verification/result.ts +11 -8
  116. package/src/proof/check-attestor.ts +209 -0
  117. package/src/tools/registry.ts +14 -0
@@ -5,68 +5,53 @@ import { writeEthKey } from './eth-key.ts'
5
5
 
6
6
  export function importTool(): RegisteredTool {
7
7
  return defineTool<{
8
- privateKey?: string
9
- keyFile?: string
8
+ keyFile: string
10
9
  projectDir?: string
11
10
  }>(
12
11
  {
13
12
  name: 'import_credentials',
14
13
  description:
15
- 'Import an eth proof-owner key the dev already holds and persist it ' +
16
- 'to the project `.env` as RECLAIM_PRIVATE_KEY=0x… (mode 0600; `.env` ' +
17
- 'added to .gitignore). Provide EITHER `privateKey` (raw 0x-hex, 64 ' +
18
- 'chars) OR `keyFile` (path to a file holding the raw 0x-hex key). ' +
19
- 'The address is derived locally; the private key is never ' +
20
- 'returned. This is a local signing key only (not registered ' +
21
- 'with any server, ' +
22
- 'unrelated to org auth or encryption).',
14
+ 'Imports an eth proof-owner key from a file the developer already '
15
+ + 'holds. Reads the raw 0x-hex key from `keyFile` and writes it to '
16
+ + 'the project `.env` as RECLAIM_PRIVATE_KEY (mode 0600; `.env` '
17
+ + 'added to .gitignore). Returns the derived address and the `.env` '
18
+ + 'path; never the key. The key is a local signing key only: it is '
19
+ + 'not registered with any server and is unrelated to org auth or '
20
+ + 'encryption. To create a new key instead, use `issue_credentials`.',
23
21
  inputSchema: {
24
22
  type: 'object',
23
+ required: ['keyFile'],
25
24
  properties: {
26
- privateKey: {
27
- type: 'string',
28
- pattern: '^0x?[0-9a-fA-F]{64}$',
29
- description:
30
- 'Raw Ethereum private key, 0x-prefixed hex (64 hex chars).',
31
- },
32
25
  keyFile: {
33
26
  type: 'string',
34
27
  description:
35
- 'Path to a file containing the raw 0x-hex private key ' +
36
- '(absolute or project-relative).',
28
+ 'Path to a file containing the raw 0x-hex private key '
29
+ + '(absolute or relative to the working directory).',
37
30
  },
38
31
  projectDir: {
39
32
  type: 'string',
40
33
  description:
41
- 'Project directory whose `.env` the key is written to. ' +
42
- 'Defaults to the closest ancestor of cwd containing .git or ' +
43
- 'package.json.',
34
+ 'Project directory whose `.env` the key is written to. '
35
+ + 'Defaults to the closest ancestor of cwd containing .git or '
36
+ + 'package.json.',
44
37
  },
45
38
  },
46
39
  },
40
+ annotations: { readOnlyHint: false, destructiveHint: false },
47
41
  },
48
42
  async(args) => {
49
- if(!args.privateKey && !args.keyFile) {
50
- throw new Error('Provide either `privateKey` (0x-hex) or `keyFile`.')
51
- }
52
-
53
- if(args.privateKey && args.keyFile) {
54
- throw new Error('Provide only one of `privateKey` or `keyFile`.')
55
- }
56
-
57
43
  const projectDir = findProjectRoot(args.projectDir || process.cwd())
58
- const privateKeyHex = args.keyFile
59
- ? readFileSync(args.keyFile, 'utf8')
60
- : args.privateKey!
61
- const { path, address } = writeEthKey(privateKeyHex, projectDir)
44
+ const { path, address } = writeEthKey(
45
+ readFileSync(args.keyFile, 'utf8'), projectDir,
46
+ )
62
47
 
63
48
  return {
64
49
  address,
65
50
  path,
66
51
  note:
67
- 'Eth proof-owner key written to .env as RECLAIM_PRIVATE_KEY ' +
68
- '(mode 0600). resolve_owner_key resolves it from .env. Keep .env ' +
69
- 'secret — it has been added to .gitignore.',
52
+ 'Eth proof-owner key written to .env as RECLAIM_PRIVATE_KEY '
53
+ + '(mode 0600). resolve_owner_key resolves it from .env. Keep .env '
54
+ + 'secret: it has been added to .gitignore.',
70
55
  }
71
56
  },
72
57
  )
@@ -0,0 +1,84 @@
1
+ import type { ReclaimClient } from '@reclaimprotocol/client/api'
2
+ import assert from 'node:assert'
3
+ import { isAbsolute, relative, resolve } from 'node:path'
4
+ import { defineTool, type RegisteredTool } from '../../server.ts'
5
+ import { upsertEnvVar } from './eth-key.ts'
6
+ import { saveOrgKey } from './org-key-store.ts'
7
+
8
+ /**
9
+ * Issues an organization API key and stores it on disk, so the key never
10
+ * passes through the agent's transcript. Replaces the generated
11
+ * `issue_org_token` operation, which returned the key in its response.
12
+ */
13
+ export function issueOrgTokenTool(client: ReclaimClient): RegisteredTool {
14
+ return defineTool<{
15
+ orgId: string
16
+ projectDir: string
17
+ envFile?: string
18
+ envVar?: string
19
+ }>(
20
+ {
21
+ name: 'issue_org_token',
22
+ description:
23
+ 'Issues an organization API key (owner only) and writes it to a '
24
+ + 'server env file in the project, so the app can read it at runtime. '
25
+ + 'Also saves it on this machine for the org-key tools. Returns the '
26
+ + 'org, file, variable, and last four characters; never the key. '
27
+ + 'Replaces an existing variable of the same name in the file. '
28
+ + 'Adds the file to .gitignore. Use when the app needs a key, or '
29
+ + 'before `create_verification_session`.',
30
+ inputSchema: {
31
+ type: 'object',
32
+ required: ['orgId', 'projectDir'],
33
+ properties: {
34
+ orgId: { type: 'string', format: 'uuid' },
35
+ projectDir: {
36
+ type: 'string',
37
+ description: 'Absolute path of the app\'s project directory.',
38
+ },
39
+ envFile: {
40
+ type: 'string',
41
+ description:
42
+ 'Env file to write, relative to `projectDir`. Default `.env`.',
43
+ },
44
+ envVar: {
45
+ type: 'string',
46
+ pattern: '^[A-Za-z_][A-Za-z0-9_]*$',
47
+ description:
48
+ 'Variable name to set. Default `RECLAIM_ORG_SECRET`.',
49
+ },
50
+ },
51
+ },
52
+ annotations: { openWorldHint: true, destructiveHint: false },
53
+ },
54
+ async(args) => {
55
+ assert(
56
+ isAbsolute(args.projectDir),
57
+ new Error('`projectDir` must be an absolute path.'),
58
+ )
59
+ const envFile = args.envFile || '.env'
60
+ const envVar = args.envVar || 'RECLAIM_ORG_SECRET'
61
+ const inside = relative(
62
+ args.projectDir, resolve(args.projectDir, envFile),
63
+ )
64
+ assert(
65
+ inside && !inside.startsWith('..') && !isAbsolute(inside),
66
+ new Error('`envFile` must be inside `projectDir`.'),
67
+ )
68
+
69
+ const { data } = await client.call('IssueOrgToken', {
70
+ params: { orgId: args.orgId },
71
+ })
72
+ const path = upsertEnvVar(
73
+ args.projectDir, envFile, envVar, data.orgSecret,
74
+ )
75
+ saveOrgKey(args.orgId, data.orgSecret)
76
+ return {
77
+ orgId: args.orgId,
78
+ envFile: path,
79
+ envVar,
80
+ last4: data.last4,
81
+ }
82
+ },
83
+ )
84
+ }
@@ -8,15 +8,14 @@ export function issueTool(): RegisteredTool {
8
8
  {
9
9
  name: 'issue_credentials',
10
10
  description:
11
- 'Generate a fresh secp256k1 eth proof-owner key-pair (the key ' +
12
- 'that signs local claim creation) and write the raw private key to ' +
13
- 'the project `.env` as RECLAIM_PRIVATE_KEY=0x… (mode 0600; `.env` ' +
14
- 'added to .gitignore). This is a LOCAL signing key only — it is NOT ' +
15
- 'registered with any server and is unrelated to org auth (the org ' +
16
- 'secret) or encryption (the eth decrypt key). ALWAYS confirm with ' +
17
- 'the dev before generating a new key. After writing, run_proof picks ' +
18
- 'it up automatically via resolve_owner_key. The private key is never ' +
19
- 'returned in the response.',
11
+ 'Generates a new secp256k1 eth proof-owner key and writes the '
12
+ + 'private key to the project `.env` as RECLAIM_PRIVATE_KEY (mode '
13
+ + '0600; `.env` added to .gitignore). Returns the address and the '
14
+ + '`.env` path; never the key. The key is a local signing key only: '
15
+ + 'it is not registered with any server and is unrelated to org auth '
16
+ + 'or encryption. Confirm with the developer before replacing an '
17
+ + 'existing key. `run_proof` then picks it up through '
18
+ + '`resolve_owner_key`.',
20
19
  inputSchema: {
21
20
  type: 'object',
22
21
  properties: {
@@ -29,6 +28,7 @@ export function issueTool(): RegisteredTool {
29
28
  },
30
29
  },
31
30
  },
31
+ annotations: { destructiveHint: false },
32
32
  },
33
33
  async(args) => {
34
34
  const projectDir = findProjectRoot(args.projectDir || process.cwd())
@@ -0,0 +1,25 @@
1
+ import { ConfigStore } from '../../../config/store.ts'
2
+
3
+ /**
4
+ * Save an organization API key (`rorg_…`) in the local config (mode 0600), so
5
+ * the org-key tools can use it without the key passing through a transcript.
6
+ */
7
+ export function saveOrgKey(orgId: string, secret: string) {
8
+ const saved = new ConfigStore().read()?.org_keys
9
+ new ConfigStore().write({
10
+ org_keys: {
11
+ byOrg: { ...saved?.byOrg, [orgId]: secret },
12
+ recent: orgId,
13
+ },
14
+ })
15
+ }
16
+
17
+ /**
18
+ * The saved key for `orgId`, or the most recently saved key when no org is
19
+ * given. Returns `undefined` when none is saved.
20
+ */
21
+ export function savedOrgKey(orgId?: string): string | undefined {
22
+ const saved = new ConfigStore().read()?.org_keys
23
+ const id = orgId || saved?.recent
24
+ return id ? saved?.byOrg[id] : undefined
25
+ }
@@ -19,18 +19,16 @@ export function resolveOwnerKeyTool(): RegisteredTool {
19
19
  {
20
20
  name: 'resolve_owner_key',
21
21
  description:
22
- 'Resolve which eth proof-owner key run_proof should sign with — ' +
23
- 'call this BEFORE run_proof. Checks, in order: RECLAIM_PRIVATE_KEY ' +
24
- 'in the MCP env, a RECLAIM_PRIVATE_KEY=0x… line in the project .env, ' +
25
- 'then a file at RECLAIM_PRIVATE_KEY_FILE holding the raw hex. ' +
26
- 'Returns { ready: true, address, source } when one is ' +
27
- 'available — pass `address` to run_proof as `ownerAddress`. ' +
28
- 'Returns { ready: false } ' +
29
- 'when none exists: then ask the dev to generate one ' +
30
- '(issue_credentials) or import one they already hold ' +
31
- '(import_credentials, accepts 0x-hex or a key-file path), and call ' +
32
- 'resolve_owner_key again. Never exposes the private key.',
22
+ 'Finds the eth proof-owner key `run_proof` signs with. Call it '
23
+ + 'before `run_proof`. Checks RECLAIM_PRIVATE_KEY in the MCP env, a '
24
+ + 'RECLAIM_PRIVATE_KEY line in the project .env, then the file at '
25
+ + 'RECLAIM_PRIVATE_KEY_FILE. Returns { ready: true, address, source '
26
+ + '}: pass `address` to `run_proof` as `ownerAddress`. Returns { '
27
+ + 'ready: false } when none exists: ask the developer to create one '
28
+ + 'with `issue_credentials` or import one with `import_credentials` '
29
+ + '(a key file), then call it again. Never returns the key.',
33
30
  inputSchema: { type: 'object', properties: {} },
31
+ annotations: { readOnlyHint: true, openWorldHint: false },
34
32
  },
35
33
  async() => {
36
34
  const projectDir = findProjectRoot(process.cwd())
@@ -51,7 +49,7 @@ export function resolveOwnerKeyTool(): RegisteredTool {
51
49
  `env, no RECLAIM_PRIVATE_KEY in ${join(projectDir, ENV_FILE)}, and ` +
52
50
  'no RECLAIM_PRIVATE_KEY_FILE. Ask the dev to generate one via ' +
53
51
  'issue_credentials, or import one they already hold via ' +
54
- 'import_credentials (0x-hex or a key-file path). Then call ' +
52
+ 'import_credentials (a key file). Then call ' +
55
53
  'resolve_owner_key again and pass its address to run_proof as ' +
56
54
  'ownerAddress.',
57
55
  }
@@ -53,6 +53,7 @@ export function buildModeTools(
53
53
  },
54
54
  required: ['mode'],
55
55
  },
56
+ annotations: { openWorldHint: false, idempotentHint: true },
56
57
  },
57
58
  async(parsed) => {
58
59
  config.write({ mode: parsed.mode })
@@ -84,6 +85,7 @@ export function buildModeTools(
84
85
  + 'the staleness with an error naming the tool to re-run. Which '
85
86
  + 'login authorizes what: how_it_works({ topic: "auth" }).',
86
87
  inputSchema: { type: 'object', properties: {} },
88
+ annotations: { readOnlyHint: true, openWorldHint: false },
87
89
  },
88
90
  async() => {
89
91
  const persisted = config.read()?.mode
@@ -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'