@reclaimprotocol/agent 0.1.34 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/lib/mcp/api/operations.gen.js +340 -150
- package/lib/mcp/instructions.js +3 -3
- package/lib/mcp/start.js +8 -7
- package/lib/mcp/tools/agent/build.js +2 -0
- package/lib/mcp/tools/agent/check-attestor.d.ts +5 -0
- package/lib/mcp/tools/agent/check-attestor.js +37 -0
- package/lib/mcp/tools/agent/live-view.js +42 -18
- package/lib/mcp/tools/agent/proof.d.ts +12 -0
- package/lib/mcp/tools/agent/proof.js +32 -0
- package/lib/mcp/tools/agent/shared.d.ts +2 -1
- package/lib/mcp/tools/agent/shared.js +28 -24
- package/lib/mcp/tools/agent/start-integration.js +5 -4
- package/lib/mcp/tools/verification/build.d.ts +3 -1
- package/lib/mcp/tools/verification/build.js +10 -2
- package/lib/mcp/tools/verification/callback-listener.d.ts +51 -0
- package/lib/mcp/tools/verification/callback-listener.js +163 -0
- package/lib/mcp/tools/verification/index.d.ts +2 -0
- package/lib/mcp/tools/verification/index.js +2 -0
- package/lib/mcp/tools/verification/proxy-callback-events.d.ts +77 -0
- package/lib/mcp/tools/verification/proxy-callback-events.js +383 -0
- package/lib/mcp/tools/verification/read-callback-events.d.ts +3 -0
- package/lib/mcp/tools/verification/read-callback-events.js +135 -0
- package/lib/mcp/tools/verification/result.d.ts +6 -0
- package/lib/mcp/tools/verification/result.js +1 -1
- package/lib/proof/check-attestor.d.ts +67 -0
- package/lib/proof/check-attestor.js +156 -0
- package/package.json +2 -2
- package/skill/SKILL.builder.md +7 -1
- package/skill/SKILL.common.md +21 -9
- package/skill/builder-mode.md +25 -12
- package/skill/start-integration.md +37 -11
- package/skill/verification-client-integration.md +81 -18
- package/src/mcp/api/operations.gen.ts +344 -150
- package/src/mcp/instructions.ts +3 -3
- package/src/mcp/start.ts +9 -7
- package/src/mcp/tools/agent/build.ts +2 -0
- package/src/mcp/tools/agent/check-attestor.ts +49 -0
- package/src/mcp/tools/agent/live-view.ts +42 -18
- package/src/mcp/tools/agent/proof.ts +55 -0
- package/src/mcp/tools/agent/shared.ts +33 -23
- package/src/mcp/tools/agent/start-integration.ts +5 -4
- package/src/mcp/tools/verification/build.ts +10 -2
- package/src/mcp/tools/verification/callback-listener.ts +203 -0
- package/src/mcp/tools/verification/index.ts +2 -0
- package/src/mcp/tools/verification/proxy-callback-events.ts +484 -0
- package/src/mcp/tools/verification/read-callback-events.ts +160 -0
- package/src/mcp/tools/verification/result.ts +1 -1
- 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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
34
|
-
+ 'command
|
|
35
|
-
+ '
|
|
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
|
|
94
|
-
+ '
|
|
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
|
|
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
|
-
'
|
|
106
|
-
+ '
|
|
107
|
-
|
|
108
|
-
+ '
|
|
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
|
-
'
|
|
111
|
-
+ '
|
|
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
|
-
|
|
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
|
-
'
|
|
130
|
-
+ '
|
|
131
|
-
+ '
|
|
132
|
-
+ '
|
|
133
|
-
+ '
|
|
134
|
-
+ '
|
|
135
|
-
+ '
|
|
136
|
-
+ '
|
|
137
|
-
+ '`attestorUrl`
|
|
138
|
-
+ '
|
|
139
|
-
+ '
|
|
140
|
-
+ '
|
|
141
|
-
+ '
|
|
142
|
-
+ '
|
|
143
|
-
+ '
|
|
144
|
-
+ '
|
|
145
|
-
+ '
|
|
146
|
-
+
|
|
147
|
-
+ '
|
|
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(
|
|
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,
|
|
23
|
-
+ '
|
|
24
|
-
+ '
|
|
25
|
-
+ '
|
|
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).
|
|
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 [
|
|
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
|
+
}
|