@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.
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 +77 -0
  21. package/lib/mcp/tools/verification/proxy-callback-events.js +383 -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 +25 -12
  32. package/skill/start-integration.md +37 -11
  33. package/skill/verification-client-integration.md +81 -18
  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 +484 -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,484 @@
1
+ import { badRequest } from '@hapi/boom'
2
+ import type { ReclaimClient } from '@reclaimprotocol/client/api'
3
+ import assert from 'node:assert'
4
+ import { setTimeout as delay } from 'node:timers/promises'
5
+ import { LOGGER } from '../../../logger.ts'
6
+ import { defineTool, type RegisteredTool } from '../../server.ts'
7
+ import {
8
+ type CallbackDeps,
9
+ type CallbackEvent,
10
+ ensureListener,
11
+ type ListenerInfo,
12
+ orgClient,
13
+ } from './callback-listener.ts'
14
+ import { resolveOrgSecret } from './result.ts'
15
+
16
+ const POLL_MS = 2_000
17
+ const MAX_BACKOFF_MS = 30_000
18
+ const FORWARD_TIMEOUT_MS = 10_000
19
+ const BATCH = 50
20
+ /**
21
+ * Pages read per poll. A page is `BATCH` events, so a poll can look past up to
22
+ * 500 pending events. That is enough to reach other sessions behind several
23
+ * held ones, and still bounds the work of one poll.
24
+ */
25
+ const MAX_PAGES = 10
26
+ const RECENT = 10
27
+ /** Loopback addresses, in the order tried. Dev servers often bind only one. */
28
+ const HOSTS = ['127.0.0.1', '[::1]']
29
+
30
+ interface ProxyArgs {
31
+ port?: number
32
+ path?: string
33
+ stop?: boolean
34
+ orgToken?: string
35
+ }
36
+
37
+ interface Target {
38
+ port: number
39
+ path: string
40
+ }
41
+
42
+ type Recent = {
43
+ id: string
44
+ sessionId: string
45
+ event: string
46
+ status: 'delivered' | 'failed'
47
+ httpStatus?: number
48
+ at: string
49
+ }
50
+
51
+ /** A session whose oldest pending event the app has not accepted. */
52
+ type Blocked = {
53
+ sessionId: string
54
+ event: string
55
+ eventId: string
56
+ httpStatus?: number
57
+ attempts: number
58
+ lastError?: string
59
+ /** When the first attempt failed, as an ISO timestamp. */
60
+ since: string
61
+ }
62
+
63
+ type Held = { blocked: Blocked, retryAt: number, delay: number }
64
+
65
+ type Outcome = { ok: true } | { ok: false, httpStatus?: number, error: string }
66
+
67
+ export interface ProxyDeps extends CallbackDeps {
68
+ /** The clock, in milliseconds. Defaults to `Date.now`. */
69
+ now?: () => number
70
+ /** Delivers an event to the app. Defaults to global `fetch`. */
71
+ forwardFetch?: typeof globalThis.fetch
72
+ /** Waits between polls. Resolves early, without error, on abort. */
73
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>
74
+ registry?: ForwarderRegistry
75
+ }
76
+
77
+ /**
78
+ * Polls the org's pending pull events and POSTs each envelope to a local
79
+ * route, like `stripe listen --forward-to`. 2xx responses are acknowledged;
80
+ * anything else (redirects included) stays pending.
81
+ *
82
+ * Order holds per session. When an event fails, its session is held: its later
83
+ * events are not sent or acknowledged, and the failed event is retried with
84
+ * exponential backoff (2 s, 4 s, ... 30 s). Other sessions keep flowing at the
85
+ * normal poll rate. Held sessions appear in `status().blocked`, and clear when
86
+ * the app accepts the event.
87
+ */
88
+ class Forwarder {
89
+ target: Target
90
+ listener: ListenerInfo
91
+ #client: ReclaimClient
92
+ #deps: ProxyDeps
93
+ #abort = new AbortController()
94
+ #forwarded = 0
95
+ #failed = 0
96
+ #lastError: string | undefined
97
+ #recent: Recent[] = []
98
+ /** Delivered to the app but not yet acknowledged; never POSTed twice. */
99
+ #unacked = new Set<string>()
100
+ /** Held sessions by session id, with the time of their next attempt. */
101
+ #held = new Map<string, Held>()
102
+ #host = HOSTS[0]
103
+ #running: Promise<void>
104
+
105
+ constructor(
106
+ client: ReclaimClient,
107
+ listener: ListenerInfo,
108
+ target: Target,
109
+ deps: ProxyDeps,
110
+ ) {
111
+ this.#client = client
112
+ this.listener = listener
113
+ this.target = target
114
+ this.#deps = deps
115
+ this.#running = this.#loop()
116
+ }
117
+
118
+ status() {
119
+ return {
120
+ forwarding: !this.#abort.signal.aborted,
121
+ target: targetUrl(this.target, HOSTS[0]),
122
+ listener: this.listener,
123
+ forwarded: this.#forwarded,
124
+ failed: this.#failed,
125
+ ...(this.#lastError ? { lastError: this.#lastError } : {}),
126
+ recent: [...this.#recent],
127
+ blocked: [...this.#held.values()].map((h) => h.blocked),
128
+ }
129
+ }
130
+
131
+ async stop() {
132
+ this.#abort.abort()
133
+ await this.#running
134
+ }
135
+
136
+ async #loop() {
137
+ const sleep = this.#deps.sleep || sleepFor
138
+ const signal = this.#abort.signal
139
+ let backoff = 0
140
+ while(!signal.aborted) {
141
+ let wait = POLL_MS
142
+ try {
143
+ const more = await this.#tick()
144
+ backoff = 0
145
+ wait = more ? 0 : POLL_MS
146
+ } catch(err) {
147
+ this.#lastError = err instanceof Error ? err.message : String(err)
148
+ LOGGER.warn({ err }, 'callback proxy poll failed')
149
+ backoff = nextBackoff(backoff)
150
+ wait = backoff
151
+ }
152
+
153
+ if(wait && !signal.aborted) {
154
+ await sleep(wait, signal)
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Forwards one poll's worth of pending events. Returns whether to poll again
161
+ * at once: the page limit was hit with events still unread, and progress was
162
+ * made (so held sessions alone never cause a busy loop).
163
+ */
164
+ async #tick() {
165
+ const now = (this.#deps.now || Date.now)()
166
+ // Sessions whose later events are skipped in this poll.
167
+ const skip = new Set<string>()
168
+ const seen = new Set<string>()
169
+ let after: string | undefined
170
+ let page = 0
171
+ let full = false
172
+ let progress = false
173
+ do {
174
+ const { data } = await this.#client.call('ListCallbackEvents', {
175
+ query: { pending: true, limit: BATCH, ...(after ? { after } : {}) },
176
+ })
177
+ const ack: string[] = []
178
+ for(const item of data.items) {
179
+ if(this.#abort.signal.aborted) {
180
+ break
181
+ }
182
+
183
+ seen.add(item.sessionId)
184
+ const held = this.#held.get(item.sessionId)
185
+ if(
186
+ skip.has(item.sessionId)
187
+ || (held && held.retryAt > now && !this.#unacked.has(item.id))
188
+ ) {
189
+ skip.add(item.sessionId)
190
+ continue
191
+ }
192
+
193
+ if(this.#unacked.has(item.id)) {
194
+ ack.push(item.id)
195
+ continue
196
+ }
197
+
198
+ const outcome = await this.#forward(item)
199
+ if(outcome.ok) {
200
+ this.#held.delete(item.sessionId)
201
+ ack.push(item.id)
202
+ } else {
203
+ // Hold the session so its later events don't overtake this one.
204
+ this.#hold(item, outcome, now)
205
+ skip.add(item.sessionId)
206
+ }
207
+ }
208
+
209
+ if(ack.length) {
210
+ progress = true
211
+ for(const id of ack) {
212
+ this.#unacked.add(id)
213
+ }
214
+
215
+ await this.#client.call('AcknowledgeCallbackEvents', {
216
+ body: { ids: ack },
217
+ })
218
+ for(const id of ack) {
219
+ this.#unacked.delete(id)
220
+ }
221
+ }
222
+
223
+ // `next` skips what this page returned, including held events.
224
+ after = data.next
225
+ full = data.items.length >= BATCH
226
+ page += 1
227
+ } while(full && page < MAX_PAGES && !this.#abort.signal.aborted)
228
+
229
+ if(!full) {
230
+ // The listing was read to its end: forget sessions with nothing pending.
231
+ for(const sessionId of this.#held.keys()) {
232
+ if(!seen.has(sessionId)) {
233
+ this.#held.delete(sessionId)
234
+ }
235
+ }
236
+ }
237
+
238
+ return full && progress
239
+ }
240
+
241
+ #hold(
242
+ item: CallbackEvent,
243
+ outcome: Extract<Outcome, { ok: false }>,
244
+ now: number,
245
+ ) {
246
+ const prev = this.#held.get(item.sessionId)
247
+ const delay = nextBackoff(prev?.delay || 0)
248
+ this.#held.set(item.sessionId, {
249
+ blocked: {
250
+ sessionId: item.sessionId,
251
+ event: item.event,
252
+ eventId: item.id,
253
+ ...(outcome.httpStatus === undefined
254
+ ? {}
255
+ : { httpStatus: outcome.httpStatus }),
256
+ attempts: (prev?.blocked.attempts || 0) + 1,
257
+ lastError: outcome.error,
258
+ since: prev?.blocked.since || new Date(now).toISOString(),
259
+ },
260
+ retryAt: now + delay,
261
+ delay,
262
+ })
263
+ }
264
+
265
+ async #forward(item: CallbackEvent): Promise<Outcome> {
266
+ const doFetch = this.#deps.forwardFetch || globalThis.fetch
267
+ const record = (status: Recent['status'], httpStatus?: number) => {
268
+ this.#recent.push({
269
+ id: item.id,
270
+ sessionId: item.sessionId,
271
+ event: item.event,
272
+ status,
273
+ ...(httpStatus === undefined ? {} : { httpStatus }),
274
+ at: new Date().toISOString(),
275
+ })
276
+ this.#recent.splice(0, this.#recent.length - RECENT)
277
+ }
278
+
279
+ const hosts = [this.#host, ...HOSTS.filter((h) => h !== this.#host)]
280
+ for(const host of hosts) {
281
+ try {
282
+ const res = await doFetch(targetUrl(this.target, host), {
283
+ method: 'POST',
284
+ headers: { 'Content-Type': 'application/json' },
285
+ body: JSON.stringify(item.envelope),
286
+ signal: AbortSignal.timeout(FORWARD_TIMEOUT_MS),
287
+ // Never follow a redirect: it could re-POST the signed result
288
+ // to a host outside this machine.
289
+ redirect: 'manual',
290
+ })
291
+ this.#host = host
292
+ if(res.ok) {
293
+ this.#forwarded += 1
294
+ record('delivered', res.status)
295
+ return { ok: true }
296
+ }
297
+
298
+ this.#failed += 1
299
+ const url = targetUrl(this.target, host)
300
+ this.#lastError = res.status >= 300 && res.status < 400
301
+ ? `${url} redirected (HTTP ${res.status}). Redirects are not `
302
+ + 'followed; call again with the redirect target\'s `path`.'
303
+ : `${url} returned HTTP ${res.status}`
304
+ record('failed', res.status)
305
+ return { ok: false, httpStatus: res.status, error: this.#lastError }
306
+ } catch(err) {
307
+ this.#lastError = err instanceof Error ? err.message : String(err)
308
+ }
309
+ }
310
+
311
+ // No loopback address accepted the connection.
312
+ this.#failed += 1
313
+ record('failed')
314
+ return { ok: false, error: this.#lastError || 'Connection failed' }
315
+ }
316
+ }
317
+
318
+ /** The forwarders of one MCP process, one per org key. */
319
+ export class ForwarderRegistry {
320
+ #forwarders = new Map<string, Forwarder>()
321
+
322
+ get(secret: string) {
323
+ return this.#forwarders.get(secret)
324
+ }
325
+
326
+ set(secret: string, forwarder: Forwarder) {
327
+ this.#forwarders.set(secret, forwarder)
328
+ }
329
+
330
+ async stop(secret: string) {
331
+ const forwarder = this.#forwarders.get(secret)
332
+ this.#forwarders.delete(secret)
333
+ await forwarder?.stop()
334
+ return forwarder
335
+ }
336
+
337
+ async stopAll() {
338
+ const all = [...this.#forwarders.values()]
339
+ this.#forwarders.clear()
340
+ await Promise.all(all.map((f) => f.stop()))
341
+ }
342
+ }
343
+
344
+ const defaultRegistry = new ForwarderRegistry()
345
+
346
+ /** Stops every forwarder. Called when the MCP server shuts down. */
347
+ export function stopAllForwarders() {
348
+ return defaultRegistry.stopAll()
349
+ }
350
+
351
+ export function proxyCallbackEventsTool(deps: ProxyDeps = {}): RegisteredTool {
352
+ const registry = deps.registry || defaultRegistry
353
+ return defineTool<ProxyArgs>(
354
+ {
355
+ name: 'proxy_callback_events',
356
+ description:
357
+ 'Forwards the events Reclaim sends to the org\'s pull listener '
358
+ + 'to a local route, like `stripe listen --forward-to`. Call it '
359
+ + 'when the app\'s callback route runs locally. It POSTs each '
360
+ + 'envelope as JSON to `http://127.0.0.1:<port><path>` (loopback '
361
+ + 'only; also tries `[::1]`) and acknowledges 2xx responses. '
362
+ + 'Events of one session arrive in order. When the app rejects '
363
+ + 'an event, including with a redirect, that session is held and '
364
+ + 'retried with backoff (2 s up to 30 s) while other sessions '
365
+ + 'keep flowing. Creates the listener on first use, so it is not '
366
+ + 'read-only. One forwarder runs per org: call again to read '
367
+ + 'status, or with another `port` or `path` to retarget. '
368
+ + '`stop: true` stops forwarding only. Forwarding lasts only '
369
+ + 'while this MCP server runs; production needs '
370
+ + '`create_org_callback_subscription`. Returns `{ forwarding, '
371
+ + 'target, listener, forwarded, failed, lastError?, recent, '
372
+ + 'blocked }`. `listener.expiresAt` is the lease end, and '
373
+ + '`recent` lists the last events with `status` and '
374
+ + '`httpStatus`. `blocked` lists each held session as '
375
+ + '`{ sessionId, event, eventId, httpStatus?, attempts, '
376
+ + 'lastError?, since }`, and is `[]` when none is held. When '
377
+ + '`blocked` is not empty, check the app\'s response and logs '
378
+ + 'for that session. The app must return 2xx for a result that '
379
+ + 'failed verification, and 5xx only for a temporary error. '
380
+ + 'Fix the app, and the session resumes on the next retry.',
381
+ inputSchema: {
382
+ type: 'object',
383
+ properties: {
384
+ port: {
385
+ type: 'integer',
386
+ minimum: 1,
387
+ maximum: 65535,
388
+ description: 'The local port the app listens on.',
389
+ },
390
+ path: {
391
+ type: 'string',
392
+ pattern: '^/[^\\s#\\\\]*$',
393
+ maxLength: 500,
394
+ description:
395
+ 'The callback route, starting with `/`. Defaults to `/`.',
396
+ },
397
+ stop: {
398
+ type: 'boolean',
399
+ description: 'Stop forwarding. `port` is not needed.',
400
+ },
401
+ orgToken: {
402
+ type: 'string',
403
+ pattern: '^rorg_',
404
+ maxLength: 200,
405
+ description: 'The organization API key (`rorg_…`). Defaults to '
406
+ + 'the server\'s `RECLAIM_ORG_SECRET`, then the key saved by '
407
+ + '`issue_org_token`. Never echo it back to the user.',
408
+ },
409
+ },
410
+ },
411
+ annotations: {
412
+ readOnlyHint: false,
413
+ idempotentHint: true,
414
+ openWorldHint: true,
415
+ },
416
+ },
417
+ async(args) => {
418
+ const secret = resolveOrgSecret(args.orgToken)
419
+ if(args.stop) {
420
+ const stopped = await registry.stop(secret)
421
+ return {
422
+ ...(stopped?.status() || { forwarding: false }),
423
+ _notes: 'Forwarding stopped. The listener stays alive until the '
424
+ + 'MCP server exits, so `read_callback_events` still works.',
425
+ }
426
+ }
427
+
428
+ const existing = registry.get(secret)
429
+ if(args.port === undefined) {
430
+ assert(
431
+ existing,
432
+ badRequest('Pass `port`, the local port the app listens on.'),
433
+ )
434
+ existing.listener = await ensureListener(secret, deps)
435
+ return existing.status()
436
+ }
437
+
438
+ const target = validateTarget(args.port, args.path ?? '/')
439
+ if(existing) {
440
+ existing.target = target
441
+ existing.listener = await ensureListener(secret, deps)
442
+ return existing.status()
443
+ }
444
+
445
+ const client = orgClient(secret, deps)
446
+ const listener = await ensureListener(secret, deps)
447
+ const forwarder = new Forwarder(client, listener, target, deps)
448
+ registry.set(secret, forwarder)
449
+ return forwarder.status()
450
+ },
451
+ )
452
+ }
453
+
454
+ /** Rejects anything that is not a path on a loopback port. */
455
+ export function validateTarget(port: number, path: string): Target {
456
+ assert(
457
+ Number.isInteger(port) && port >= 1 && port <= 65535,
458
+ badRequest('`port` must be an integer from 1 to 65535.'),
459
+ )
460
+ assert(
461
+ path.length <= 500 && /^\/[^\s#\\]*$/.test(path),
462
+ badRequest(
463
+ '`path` must start with "/" and contain no host, space, or "#".',
464
+ ),
465
+ )
466
+ const url = new URL(targetUrl({ port, path }, HOSTS[0]))
467
+ assert(
468
+ url.hostname === HOSTS[0] && url.port === String(port),
469
+ badRequest('`path` must not change the target host.'),
470
+ )
471
+ return { port, path }
472
+ }
473
+
474
+ function targetUrl(target: Target, host: string) {
475
+ return `http://${host}:${target.port}${target.path}`
476
+ }
477
+
478
+ async function sleepFor(ms: number, signal: AbortSignal) {
479
+ await delay(ms, undefined, { signal }).catch(() => undefined)
480
+ }
481
+
482
+ function nextBackoff(current: number) {
483
+ return Math.min(current ? current * 2 : POLL_MS, MAX_BACKOFF_MS)
484
+ }
@@ -0,0 +1,160 @@
1
+ import { decryptCallback, ReclaimVerification } from '@reclaimprotocol/client'
2
+ import { apiUrl } from '../../../consts.ts'
3
+ import { defineTool, type RegisteredTool } from '../../server.ts'
4
+ import { resolveDecryptionKey } from '../authenticate/decrypt-key.ts'
5
+ import {
6
+ type CallbackDeps,
7
+ type CallbackEvent,
8
+ ensureListener,
9
+ orgClient,
10
+ } from './callback-listener.ts'
11
+ import { resolveOrgSecret } from './result.ts'
12
+
13
+ interface ReadArgs {
14
+ sessionId?: string
15
+ after?: string
16
+ limit?: number
17
+ pending?: boolean
18
+ orgToken?: string
19
+ }
20
+
21
+ /** Events whose `data` is the signed result (encrypted or plaintext JWS). */
22
+ const RESULT_EVENTS = new Set([
23
+ 'verification_success',
24
+ 'verification_rejected',
25
+ 'verification_error',
26
+ 'verification_cancelled',
27
+ ])
28
+
29
+ export function readCallbackEventsTool(
30
+ deps: CallbackDeps = {},
31
+ ): RegisteredTool {
32
+ return defineTool<ReadArgs>(
33
+ {
34
+ name: 'read_callback_events',
35
+ description:
36
+ 'Reads the events Reclaim sent to the organization\'s pull '
37
+ + 'listener, and decodes them: each result event is decrypted and '
38
+ + 'signature-verified into `result`, and each lifecycle event '
39
+ + 'returns its `eventData`. Call it to try a provider without an '
40
+ + 'app or to check what Reclaim sent for a session. Creates the '
41
+ + 'listener on first use, so it is not read-only. Filter with '
42
+ + '`sessionId`, or pass the previous `next` as `after` to read on. '
43
+ + 'Items with a lower sequence can appear late, so for a complete '
44
+ + 'read use `pending: true`. Returns `{ listener, items, next }`. '
45
+ + 'Encrypted results need the `RECLAIM_DECRYPT_KEY` that matches '
46
+ + 'the org keypair. To deliver events to your app\'s callback '
47
+ + 'route, call `proxy_callback_events`.',
48
+ inputSchema: {
49
+ type: 'object',
50
+ properties: {
51
+ sessionId: {
52
+ type: 'string',
53
+ format: 'uuid',
54
+ description: 'Only events of this verification session.',
55
+ },
56
+ after: {
57
+ type: 'string',
58
+ pattern: '^[0-9]{1,19}$',
59
+ description: 'The `next` value of a previous call.',
60
+ },
61
+ limit: {
62
+ type: 'integer',
63
+ minimum: 1,
64
+ maximum: 100,
65
+ description: 'Events to return. Defaults to 50.',
66
+ },
67
+ pending: {
68
+ type: 'boolean',
69
+ description:
70
+ 'Only events not yet acknowledged. `proxy_callback_events` '
71
+ + 'acknowledges the events it delivers.',
72
+ },
73
+ orgToken: {
74
+ type: 'string',
75
+ pattern: '^rorg_',
76
+ maxLength: 200,
77
+ description: 'The organization API key (`rorg_…`). Defaults to '
78
+ + 'the server\'s `RECLAIM_ORG_SECRET`, then the key saved by '
79
+ + '`issue_org_token`. Never echo it back to the user.',
80
+ },
81
+ },
82
+ },
83
+ annotations: {
84
+ readOnlyHint: false,
85
+ idempotentHint: true,
86
+ openWorldHint: true,
87
+ },
88
+ },
89
+ async(args) => {
90
+ const secret = resolveOrgSecret(args.orgToken)
91
+ const client = orgClient(secret, deps)
92
+ const listener = await ensureListener(secret, deps)
93
+ const { data } = await client.call('ListCallbackEvents', {
94
+ query: {
95
+ after: args.after,
96
+ limit: args.limit,
97
+ sessionId: args.sessionId,
98
+ pending: args.pending,
99
+ },
100
+ })
101
+
102
+ const hasResults = data.items.some((e) => RESULT_EVENTS.has(e.event))
103
+ const key = hasResults ? await resolveDecryptionKey() : undefined
104
+ const reclaim = ReclaimVerification.create({
105
+ baseUrl: apiUrl(),
106
+ orgSecret: secret,
107
+ fetch: deps.fetch,
108
+ })
109
+ const items = []
110
+ for(const item of data.items) {
111
+ items.push(await decode(item))
112
+ }
113
+
114
+ return {
115
+ listener,
116
+ items,
117
+ next: data.next,
118
+ ...(listener.created
119
+ ? {
120
+ _notes: 'Created the pull listener; only sessions created from '
121
+ + 'now on are recorded. It works only while this MCP server '
122
+ + 'runs.',
123
+ }
124
+ : {}),
125
+ }
126
+
127
+ async function decode(item: CallbackEvent) {
128
+ const base = {
129
+ id: item.id,
130
+ sessionId: item.sessionId,
131
+ event: item.event,
132
+ deliveryStatus: item.deliveryStatus,
133
+ createdAt: item.createdAt,
134
+ }
135
+ try {
136
+ if(!RESULT_EVENTS.has(item.event)) {
137
+ const received = await reclaim.results.receive(item.envelope)
138
+ return received.kind === 'event'
139
+ ? { ...base, eventData: received.eventData }
140
+ : base
141
+ }
142
+
143
+ const jws = await decryptCallback(key, item.envelope)
144
+ const outcome = await reclaim.results.verify(jws, {
145
+ reclaimSessionId: item.sessionId,
146
+ })
147
+ return {
148
+ ...base,
149
+ result: { payload: outcome.payload, proofs: outcome.proofs },
150
+ }
151
+ } catch(err) {
152
+ const message = err instanceof Error ? err.message : String(err)
153
+ return RESULT_EVENTS.has(item.event)
154
+ ? { ...base, resultError: message }
155
+ : { ...base, eventError: message }
156
+ }
157
+ }
158
+ },
159
+ )
160
+ }
@@ -32,7 +32,7 @@ const RESULT_EVENTS = new Set([
32
32
  * single per-org bearer secret (`rorg_…`), not an eth signature: the call's
33
33
  * `orgToken`, else `RECLAIM_ORG_SECRET`.
34
34
  */
35
- function resolveOrgSecret(orgToken?: string): string {
35
+ export function resolveOrgSecret(orgToken?: string): string {
36
36
  const secret = orgToken?.trim() || orgSecret()?.trim() || savedOrgKey()
37
37
  assert(
38
38
  secret,