@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,163 @@
1
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
2
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
3
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
4
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
5
+ };
6
+ var _ListenerKeeper_instances, _ListenerKeeper_entries, _ListenerKeeper_beat;
7
+ import { badGateway } from '@hapi/boom';
8
+ import { ReclaimClient } from '@reclaimprotocol/client/api';
9
+ import assert from 'node:assert';
10
+ import { setTimeout as delay } from 'node:timers/promises';
11
+ import { apiUrl } from "../../../consts.js";
12
+ import { LOGGER } from "../../../logger.js";
13
+ import { NAME, VERSION } from "../../../pkg.js";
14
+ /**
15
+ * Events the org's pull listener subscribes to: the five session-ending
16
+ * events (the first four carry the signed result; `session_expired` has no
17
+ * result) plus the lifecycle milestones an app developer checks while testing.
18
+ * High-volume diagnostics (`network_request_observed`, `provider_script_log`,
19
+ * `user_interaction_*`) are left out, so a forwarded session sends the app at
20
+ * most 11 requests instead of dozens.
21
+ */
22
+ export const LISTENER_EVENTS = [
23
+ 'verification_success',
24
+ 'verification_rejected',
25
+ 'verification_error',
26
+ 'verification_cancelled',
27
+ 'session_expired',
28
+ 'session_pending',
29
+ 'verification_client_opened',
30
+ 'authenticated',
31
+ 'verification_proofs_completed',
32
+ 'verification_result_submitting',
33
+ 'verification_result_submission_failed',
34
+ ];
35
+ /**
36
+ * A client that presents the organization's API key. With a `signal`, every
37
+ * request aborts when it does.
38
+ */
39
+ export function orgClient(secret, deps = {}, signal) {
40
+ const base = deps.fetch || globalThis.fetch;
41
+ return new ReclaimClient({
42
+ baseUrl: apiUrl(),
43
+ token: secret,
44
+ fetch: signal
45
+ ? (input, init) => base(input, { ...init, signal })
46
+ : deps.fetch,
47
+ headers: { 'User-Agent': `${NAME}/${VERSION}` },
48
+ });
49
+ }
50
+ /** Renewal interval. The backend lease lasts 90 seconds. */
51
+ const HEARTBEAT_MS = 30000;
52
+ /** Upper bound for releasing all listeners at shutdown. */
53
+ const RELEASE_TIMEOUT_MS = 3000;
54
+ /**
55
+ * Keeps each org's pull listener alive while this MCP process runs. Creating
56
+ * the subscription is the heartbeat: it renews a lease, and the listener
57
+ * stops receiving events when the lease lapses. {@link release} ends the
58
+ * lease at shutdown.
59
+ */
60
+ export class ListenerKeeper {
61
+ constructor() {
62
+ _ListenerKeeper_instances.add(this);
63
+ _ListenerKeeper_entries.set(this, new Map()
64
+ /** Creates or renews the listener and starts its heartbeat (once). */
65
+ );
66
+ }
67
+ /** Creates or renews the listener and starts its heartbeat (once). */
68
+ async ensure(secret, deps = {}) {
69
+ const existing = __classPrivateFieldGet(this, _ListenerKeeper_entries, "f").get(secret);
70
+ if (existing) {
71
+ const wasSettled = existing.settled;
72
+ const first = await existing.ready;
73
+ // Callers that arrived during creation share its result.
74
+ return wasSettled ? await renew(existing.client) : first;
75
+ }
76
+ const abort = new AbortController();
77
+ const client = orgClient(secret, deps, abort.signal);
78
+ const sleep = deps.sleep || sleepFor;
79
+ const entry = {
80
+ client,
81
+ releaseClient: orgClient(secret, deps),
82
+ abort,
83
+ settled: false,
84
+ ready: renew(client).then((info) => {
85
+ entry.settled = true;
86
+ void __classPrivateFieldGet(this, _ListenerKeeper_instances, "m", _ListenerKeeper_beat).call(this, entry, sleep);
87
+ return info;
88
+ }, (err) => {
89
+ if (__classPrivateFieldGet(this, _ListenerKeeper_entries, "f").get(secret) === entry) {
90
+ __classPrivateFieldGet(this, _ListenerKeeper_entries, "f").delete(secret);
91
+ }
92
+ abort.abort();
93
+ throw err;
94
+ }),
95
+ };
96
+ __classPrivateFieldGet(this, _ListenerKeeper_entries, "f").set(secret, entry);
97
+ return await entry.ready;
98
+ }
99
+ /** Ends every lease and stops the heartbeats, within a short timeout. */
100
+ async release() {
101
+ const entries = [...__classPrivateFieldGet(this, _ListenerKeeper_entries, "f").values()];
102
+ __classPrivateFieldGet(this, _ListenerKeeper_entries, "f").clear();
103
+ const timeout = AbortSignal.timeout(RELEASE_TIMEOUT_MS);
104
+ await Promise.all(entries.map(async ({ releaseClient, abort }) => {
105
+ abort.abort();
106
+ try {
107
+ await Promise.race([
108
+ releaseClient.call('ReleaseCallbackListener', {}),
109
+ new Promise((_, reject) => {
110
+ timeout.addEventListener('abort', () => reject(timeout.reason));
111
+ }),
112
+ ]);
113
+ }
114
+ catch (err) {
115
+ LOGGER.warn({ err }, 'releasing the callback listener failed');
116
+ }
117
+ }));
118
+ }
119
+ }
120
+ _ListenerKeeper_entries = new WeakMap(), _ListenerKeeper_instances = new WeakSet(), _ListenerKeeper_beat = async function _ListenerKeeper_beat(entry, sleep) {
121
+ const { signal } = entry.abort;
122
+ while (!signal.aborted) {
123
+ await sleep(HEARTBEAT_MS, signal);
124
+ if (signal.aborted) {
125
+ return;
126
+ }
127
+ try {
128
+ await renew(entry.client);
129
+ }
130
+ catch (err) {
131
+ if (signal.aborted) {
132
+ return;
133
+ }
134
+ LOGGER.warn({ err }, 'callback listener heartbeat failed; retrying');
135
+ }
136
+ }
137
+ };
138
+ const defaultKeeper = new ListenerKeeper();
139
+ /** Releases every listener this process keeps alive. Runs at MCP shutdown. */
140
+ export function releaseAllListeners() {
141
+ return defaultKeeper.release();
142
+ }
143
+ /**
144
+ * Create the org's pull listener and keep it alive until shutdown. An org has
145
+ * one, so a second call returns the existing subscription (HTTP 200).
146
+ */
147
+ export function ensureListener(secret, deps = {}) {
148
+ return (deps.keeper || defaultKeeper).ensure(secret, deps);
149
+ }
150
+ async function renew(client) {
151
+ const { data, response } = await client.call('CreateCallbackSubscription', {
152
+ body: { deliveryMode: 'pull', events: [...LISTENER_EVENTS] },
153
+ });
154
+ assert(data.listenerExpiresAt, badGateway('Builder returned a pull listener without a lease.'));
155
+ return {
156
+ subscriptionId: data.id,
157
+ created: response.status === 201,
158
+ expiresAt: data.listenerExpiresAt,
159
+ };
160
+ }
161
+ export async function sleepFor(ms, signal) {
162
+ await delay(ms, undefined, { signal }).catch(() => undefined);
163
+ }
@@ -1 +1,3 @@
1
1
  export { buildVerificationTools } from './build.ts';
2
+ export { stopAllForwarders } from './proxy-callback-events.ts';
3
+ export { releaseAllListeners } from './callback-listener.ts';
@@ -1,2 +1,4 @@
1
1
  // Barrel for the verification tool group (result retrieval + decryption).
2
2
  export { buildVerificationTools } from "./build.js";
3
+ export { stopAllForwarders } from "./proxy-callback-events.js";
4
+ export { releaseAllListeners } from "./callback-listener.js";
@@ -0,0 +1,77 @@
1
+ import type { ReclaimClient } from '@reclaimprotocol/client/api';
2
+ import { type RegisteredTool } from '../../server.ts';
3
+ import { type CallbackDeps, type ListenerInfo } from './callback-listener.ts';
4
+ interface Target {
5
+ port: number;
6
+ path: string;
7
+ }
8
+ type Recent = {
9
+ id: string;
10
+ sessionId: string;
11
+ event: string;
12
+ status: 'delivered' | 'failed';
13
+ httpStatus?: number;
14
+ at: string;
15
+ };
16
+ /** A session whose oldest pending event the app has not accepted. */
17
+ type Blocked = {
18
+ sessionId: string;
19
+ event: string;
20
+ eventId: string;
21
+ httpStatus?: number;
22
+ attempts: number;
23
+ lastError?: string;
24
+ /** When the first attempt failed, as an ISO timestamp. */
25
+ since: string;
26
+ };
27
+ export interface ProxyDeps extends CallbackDeps {
28
+ /** The clock, in milliseconds. Defaults to `Date.now`. */
29
+ now?: () => number;
30
+ /** Delivers an event to the app. Defaults to global `fetch`. */
31
+ forwardFetch?: typeof globalThis.fetch;
32
+ /** Waits between polls. Resolves early, without error, on abort. */
33
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
34
+ registry?: ForwarderRegistry;
35
+ }
36
+ /**
37
+ * Polls the org's pending pull events and POSTs each envelope to a local
38
+ * route, like `stripe listen --forward-to`. 2xx responses are acknowledged;
39
+ * anything else (redirects included) stays pending.
40
+ *
41
+ * Order holds per session. When an event fails, its session is held: its later
42
+ * events are not sent or acknowledged, and the failed event is retried with
43
+ * exponential backoff (2 s, 4 s, ... 30 s). Other sessions keep flowing at the
44
+ * normal poll rate. Held sessions appear in `status().blocked`, and clear when
45
+ * the app accepts the event.
46
+ */
47
+ declare class Forwarder {
48
+ #private;
49
+ target: Target;
50
+ listener: ListenerInfo;
51
+ constructor(client: ReclaimClient, listener: ListenerInfo, target: Target, deps: ProxyDeps);
52
+ status(): {
53
+ recent: Recent[];
54
+ blocked: Blocked[];
55
+ lastError?: string | undefined;
56
+ forwarding: boolean;
57
+ target: string;
58
+ listener: ListenerInfo;
59
+ forwarded: number;
60
+ failed: number;
61
+ };
62
+ stop(): Promise<void>;
63
+ }
64
+ /** The forwarders of one MCP process, one per org key. */
65
+ export declare class ForwarderRegistry {
66
+ #private;
67
+ get(secret: string): Forwarder | undefined;
68
+ set(secret: string, forwarder: Forwarder): void;
69
+ stop(secret: string): Promise<Forwarder | undefined>;
70
+ stopAll(): Promise<void>;
71
+ }
72
+ /** Stops every forwarder. Called when the MCP server shuts down. */
73
+ export declare function stopAllForwarders(): Promise<void>;
74
+ export declare function proxyCallbackEventsTool(deps?: ProxyDeps): RegisteredTool;
75
+ /** Rejects anything that is not a path on a loopback port. */
76
+ export declare function validateTarget(port: number, path: string): Target;
77
+ export {};
@@ -0,0 +1,383 @@
1
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
2
+ if (kind === "m") throw new TypeError("Private method is not writable");
3
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
4
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
5
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
6
+ };
7
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
8
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
9
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
+ };
12
+ var _Forwarder_instances, _Forwarder_client, _Forwarder_deps, _Forwarder_abort, _Forwarder_forwarded, _Forwarder_failed, _Forwarder_lastError, _Forwarder_recent, _Forwarder_unacked, _Forwarder_held, _Forwarder_host, _Forwarder_running, _Forwarder_loop, _Forwarder_tick, _Forwarder_hold, _Forwarder_forward, _ForwarderRegistry_forwarders;
13
+ import { badRequest } from '@hapi/boom';
14
+ import assert from 'node:assert';
15
+ import { setTimeout as delay } from 'node:timers/promises';
16
+ import { LOGGER } from "../../../logger.js";
17
+ import { defineTool } from "../../server.js";
18
+ import { ensureListener, orgClient, } from "./callback-listener.js";
19
+ import { resolveOrgSecret } from "./result.js";
20
+ const POLL_MS = 2000;
21
+ const MAX_BACKOFF_MS = 30000;
22
+ const FORWARD_TIMEOUT_MS = 10000;
23
+ const BATCH = 50;
24
+ /**
25
+ * Pages read per poll. A page is `BATCH` events, so a poll can look past up to
26
+ * 500 pending events. That is enough to reach other sessions behind several
27
+ * held ones, and still bounds the work of one poll.
28
+ */
29
+ const MAX_PAGES = 10;
30
+ const RECENT = 10;
31
+ /** Loopback addresses, in the order tried. Dev servers often bind only one. */
32
+ const HOSTS = ['127.0.0.1', '[::1]'];
33
+ /**
34
+ * Polls the org's pending pull events and POSTs each envelope to a local
35
+ * route, like `stripe listen --forward-to`. 2xx responses are acknowledged;
36
+ * anything else (redirects included) stays pending.
37
+ *
38
+ * Order holds per session. When an event fails, its session is held: its later
39
+ * events are not sent or acknowledged, and the failed event is retried with
40
+ * exponential backoff (2 s, 4 s, ... 30 s). Other sessions keep flowing at the
41
+ * normal poll rate. Held sessions appear in `status().blocked`, and clear when
42
+ * the app accepts the event.
43
+ */
44
+ class Forwarder {
45
+ constructor(client, listener, target, deps) {
46
+ _Forwarder_instances.add(this);
47
+ _Forwarder_client.set(this, void 0);
48
+ _Forwarder_deps.set(this, void 0);
49
+ _Forwarder_abort.set(this, new AbortController());
50
+ _Forwarder_forwarded.set(this, 0);
51
+ _Forwarder_failed.set(this, 0);
52
+ _Forwarder_lastError.set(this, void 0);
53
+ _Forwarder_recent.set(this, []
54
+ /** Delivered to the app but not yet acknowledged; never POSTed twice. */
55
+ );
56
+ /** Delivered to the app but not yet acknowledged; never POSTed twice. */
57
+ _Forwarder_unacked.set(this, new Set()
58
+ /** Held sessions by session id, with the time of their next attempt. */
59
+ );
60
+ /** Held sessions by session id, with the time of their next attempt. */
61
+ _Forwarder_held.set(this, new Map());
62
+ _Forwarder_host.set(this, HOSTS[0]);
63
+ _Forwarder_running.set(this, void 0);
64
+ __classPrivateFieldSet(this, _Forwarder_client, client, "f");
65
+ this.listener = listener;
66
+ this.target = target;
67
+ __classPrivateFieldSet(this, _Forwarder_deps, deps, "f");
68
+ __classPrivateFieldSet(this, _Forwarder_running, __classPrivateFieldGet(this, _Forwarder_instances, "m", _Forwarder_loop).call(this), "f");
69
+ }
70
+ status() {
71
+ return {
72
+ forwarding: !__classPrivateFieldGet(this, _Forwarder_abort, "f").signal.aborted,
73
+ target: targetUrl(this.target, HOSTS[0]),
74
+ listener: this.listener,
75
+ forwarded: __classPrivateFieldGet(this, _Forwarder_forwarded, "f"),
76
+ failed: __classPrivateFieldGet(this, _Forwarder_failed, "f"),
77
+ ...(__classPrivateFieldGet(this, _Forwarder_lastError, "f") ? { lastError: __classPrivateFieldGet(this, _Forwarder_lastError, "f") } : {}),
78
+ recent: [...__classPrivateFieldGet(this, _Forwarder_recent, "f")],
79
+ blocked: [...__classPrivateFieldGet(this, _Forwarder_held, "f").values()].map((h) => h.blocked),
80
+ };
81
+ }
82
+ async stop() {
83
+ __classPrivateFieldGet(this, _Forwarder_abort, "f").abort();
84
+ await __classPrivateFieldGet(this, _Forwarder_running, "f");
85
+ }
86
+ }
87
+ _Forwarder_client = new WeakMap(), _Forwarder_deps = new WeakMap(), _Forwarder_abort = new WeakMap(), _Forwarder_forwarded = new WeakMap(), _Forwarder_failed = new WeakMap(), _Forwarder_lastError = new WeakMap(), _Forwarder_recent = new WeakMap(), _Forwarder_unacked = new WeakMap(), _Forwarder_held = new WeakMap(), _Forwarder_host = new WeakMap(), _Forwarder_running = new WeakMap(), _Forwarder_instances = new WeakSet(), _Forwarder_loop = async function _Forwarder_loop() {
88
+ const sleep = __classPrivateFieldGet(this, _Forwarder_deps, "f").sleep || sleepFor;
89
+ const signal = __classPrivateFieldGet(this, _Forwarder_abort, "f").signal;
90
+ let backoff = 0;
91
+ while (!signal.aborted) {
92
+ let wait = POLL_MS;
93
+ try {
94
+ const more = await __classPrivateFieldGet(this, _Forwarder_instances, "m", _Forwarder_tick).call(this);
95
+ backoff = 0;
96
+ wait = more ? 0 : POLL_MS;
97
+ }
98
+ catch (err) {
99
+ __classPrivateFieldSet(this, _Forwarder_lastError, err instanceof Error ? err.message : String(err), "f");
100
+ LOGGER.warn({ err }, 'callback proxy poll failed');
101
+ backoff = nextBackoff(backoff);
102
+ wait = backoff;
103
+ }
104
+ if (wait && !signal.aborted) {
105
+ await sleep(wait, signal);
106
+ }
107
+ }
108
+ }, _Forwarder_tick =
109
+ /**
110
+ * Forwards one poll's worth of pending events. Returns whether to poll again
111
+ * at once: the page limit was hit with events still unread, and progress was
112
+ * made (so held sessions alone never cause a busy loop).
113
+ */
114
+ async function _Forwarder_tick() {
115
+ const now = (__classPrivateFieldGet(this, _Forwarder_deps, "f").now || Date.now)();
116
+ // Sessions whose later events are skipped in this poll.
117
+ const skip = new Set();
118
+ const seen = new Set();
119
+ let after;
120
+ let page = 0;
121
+ let full = false;
122
+ let progress = false;
123
+ do {
124
+ const { data } = await __classPrivateFieldGet(this, _Forwarder_client, "f").call('ListCallbackEvents', {
125
+ query: { pending: true, limit: BATCH, ...(after ? { after } : {}) },
126
+ });
127
+ const ack = [];
128
+ for (const item of data.items) {
129
+ if (__classPrivateFieldGet(this, _Forwarder_abort, "f").signal.aborted) {
130
+ break;
131
+ }
132
+ seen.add(item.sessionId);
133
+ const held = __classPrivateFieldGet(this, _Forwarder_held, "f").get(item.sessionId);
134
+ if (skip.has(item.sessionId)
135
+ || (held && held.retryAt > now && !__classPrivateFieldGet(this, _Forwarder_unacked, "f").has(item.id))) {
136
+ skip.add(item.sessionId);
137
+ continue;
138
+ }
139
+ if (__classPrivateFieldGet(this, _Forwarder_unacked, "f").has(item.id)) {
140
+ ack.push(item.id);
141
+ continue;
142
+ }
143
+ const outcome = await __classPrivateFieldGet(this, _Forwarder_instances, "m", _Forwarder_forward).call(this, item);
144
+ if (outcome.ok) {
145
+ __classPrivateFieldGet(this, _Forwarder_held, "f").delete(item.sessionId);
146
+ ack.push(item.id);
147
+ }
148
+ else {
149
+ // Hold the session so its later events don't overtake this one.
150
+ __classPrivateFieldGet(this, _Forwarder_instances, "m", _Forwarder_hold).call(this, item, outcome, now);
151
+ skip.add(item.sessionId);
152
+ }
153
+ }
154
+ if (ack.length) {
155
+ progress = true;
156
+ for (const id of ack) {
157
+ __classPrivateFieldGet(this, _Forwarder_unacked, "f").add(id);
158
+ }
159
+ await __classPrivateFieldGet(this, _Forwarder_client, "f").call('AcknowledgeCallbackEvents', {
160
+ body: { ids: ack },
161
+ });
162
+ for (const id of ack) {
163
+ __classPrivateFieldGet(this, _Forwarder_unacked, "f").delete(id);
164
+ }
165
+ }
166
+ // `next` skips what this page returned, including held events.
167
+ after = data.next;
168
+ full = data.items.length >= BATCH;
169
+ page += 1;
170
+ } while (full && page < MAX_PAGES && !__classPrivateFieldGet(this, _Forwarder_abort, "f").signal.aborted);
171
+ if (!full) {
172
+ // The listing was read to its end: forget sessions with nothing pending.
173
+ for (const sessionId of __classPrivateFieldGet(this, _Forwarder_held, "f").keys()) {
174
+ if (!seen.has(sessionId)) {
175
+ __classPrivateFieldGet(this, _Forwarder_held, "f").delete(sessionId);
176
+ }
177
+ }
178
+ }
179
+ return full && progress;
180
+ }, _Forwarder_hold = function _Forwarder_hold(item, outcome, now) {
181
+ const prev = __classPrivateFieldGet(this, _Forwarder_held, "f").get(item.sessionId);
182
+ const delay = nextBackoff(prev?.delay || 0);
183
+ __classPrivateFieldGet(this, _Forwarder_held, "f").set(item.sessionId, {
184
+ blocked: {
185
+ sessionId: item.sessionId,
186
+ event: item.event,
187
+ eventId: item.id,
188
+ ...(outcome.httpStatus === undefined
189
+ ? {}
190
+ : { httpStatus: outcome.httpStatus }),
191
+ attempts: (prev?.blocked.attempts || 0) + 1,
192
+ lastError: outcome.error,
193
+ since: prev?.blocked.since || new Date(now).toISOString(),
194
+ },
195
+ retryAt: now + delay,
196
+ delay,
197
+ });
198
+ }, _Forwarder_forward = async function _Forwarder_forward(item) {
199
+ const doFetch = __classPrivateFieldGet(this, _Forwarder_deps, "f").forwardFetch || globalThis.fetch;
200
+ const record = (status, httpStatus) => {
201
+ __classPrivateFieldGet(this, _Forwarder_recent, "f").push({
202
+ id: item.id,
203
+ sessionId: item.sessionId,
204
+ event: item.event,
205
+ status,
206
+ ...(httpStatus === undefined ? {} : { httpStatus }),
207
+ at: new Date().toISOString(),
208
+ });
209
+ __classPrivateFieldGet(this, _Forwarder_recent, "f").splice(0, __classPrivateFieldGet(this, _Forwarder_recent, "f").length - RECENT);
210
+ };
211
+ const hosts = [__classPrivateFieldGet(this, _Forwarder_host, "f"), ...HOSTS.filter((h) => h !== __classPrivateFieldGet(this, _Forwarder_host, "f"))];
212
+ for (const host of hosts) {
213
+ try {
214
+ const res = await doFetch(targetUrl(this.target, host), {
215
+ method: 'POST',
216
+ headers: { 'Content-Type': 'application/json' },
217
+ body: JSON.stringify(item.envelope),
218
+ signal: AbortSignal.timeout(FORWARD_TIMEOUT_MS),
219
+ // Never follow a redirect: it could re-POST the signed result
220
+ // to a host outside this machine.
221
+ redirect: 'manual',
222
+ });
223
+ __classPrivateFieldSet(this, _Forwarder_host, host, "f");
224
+ if (res.ok) {
225
+ __classPrivateFieldSet(this, _Forwarder_forwarded, __classPrivateFieldGet(this, _Forwarder_forwarded, "f") + 1, "f");
226
+ record('delivered', res.status);
227
+ return { ok: true };
228
+ }
229
+ __classPrivateFieldSet(this, _Forwarder_failed, __classPrivateFieldGet(this, _Forwarder_failed, "f") + 1, "f");
230
+ const url = targetUrl(this.target, host);
231
+ __classPrivateFieldSet(this, _Forwarder_lastError, res.status >= 300 && res.status < 400
232
+ ? `${url} redirected (HTTP ${res.status}). Redirects are not `
233
+ + 'followed; call again with the redirect target\'s `path`.'
234
+ : `${url} returned HTTP ${res.status}`, "f");
235
+ record('failed', res.status);
236
+ return { ok: false, httpStatus: res.status, error: __classPrivateFieldGet(this, _Forwarder_lastError, "f") };
237
+ }
238
+ catch (err) {
239
+ __classPrivateFieldSet(this, _Forwarder_lastError, err instanceof Error ? err.message : String(err), "f");
240
+ }
241
+ }
242
+ // No loopback address accepted the connection.
243
+ __classPrivateFieldSet(this, _Forwarder_failed, __classPrivateFieldGet(this, _Forwarder_failed, "f") + 1, "f");
244
+ record('failed');
245
+ return { ok: false, error: __classPrivateFieldGet(this, _Forwarder_lastError, "f") || 'Connection failed' };
246
+ };
247
+ /** The forwarders of one MCP process, one per org key. */
248
+ export class ForwarderRegistry {
249
+ constructor() {
250
+ _ForwarderRegistry_forwarders.set(this, new Map());
251
+ }
252
+ get(secret) {
253
+ return __classPrivateFieldGet(this, _ForwarderRegistry_forwarders, "f").get(secret);
254
+ }
255
+ set(secret, forwarder) {
256
+ __classPrivateFieldGet(this, _ForwarderRegistry_forwarders, "f").set(secret, forwarder);
257
+ }
258
+ async stop(secret) {
259
+ const forwarder = __classPrivateFieldGet(this, _ForwarderRegistry_forwarders, "f").get(secret);
260
+ __classPrivateFieldGet(this, _ForwarderRegistry_forwarders, "f").delete(secret);
261
+ await forwarder?.stop();
262
+ return forwarder;
263
+ }
264
+ async stopAll() {
265
+ const all = [...__classPrivateFieldGet(this, _ForwarderRegistry_forwarders, "f").values()];
266
+ __classPrivateFieldGet(this, _ForwarderRegistry_forwarders, "f").clear();
267
+ await Promise.all(all.map((f) => f.stop()));
268
+ }
269
+ }
270
+ _ForwarderRegistry_forwarders = new WeakMap();
271
+ const defaultRegistry = new ForwarderRegistry();
272
+ /** Stops every forwarder. Called when the MCP server shuts down. */
273
+ export function stopAllForwarders() {
274
+ return defaultRegistry.stopAll();
275
+ }
276
+ export function proxyCallbackEventsTool(deps = {}) {
277
+ const registry = deps.registry || defaultRegistry;
278
+ return defineTool({
279
+ name: 'proxy_callback_events',
280
+ description: 'Forwards the events Reclaim sends to the org\'s pull listener '
281
+ + 'to a local route, like `stripe listen --forward-to`. Call it '
282
+ + 'when the app\'s callback route runs locally. It POSTs each '
283
+ + 'envelope as JSON to `http://127.0.0.1:<port><path>` (loopback '
284
+ + 'only; also tries `[::1]`) and acknowledges 2xx responses. '
285
+ + 'Events of one session arrive in order. When the app rejects '
286
+ + 'an event, including with a redirect, that session is held and '
287
+ + 'retried with backoff (2 s up to 30 s) while other sessions '
288
+ + 'keep flowing. Creates the listener on first use, so it is not '
289
+ + 'read-only. One forwarder runs per org: call again to read '
290
+ + 'status, or with another `port` or `path` to retarget. '
291
+ + '`stop: true` stops forwarding only. Forwarding lasts only '
292
+ + 'while this MCP server runs; production needs '
293
+ + '`create_org_callback_subscription`. Returns `{ forwarding, '
294
+ + 'target, listener, forwarded, failed, lastError?, recent, '
295
+ + 'blocked }`. `listener.expiresAt` is the lease end, and '
296
+ + '`recent` lists the last events with `status` and '
297
+ + '`httpStatus`. `blocked` lists each held session as '
298
+ + '`{ sessionId, event, eventId, httpStatus?, attempts, '
299
+ + 'lastError?, since }`, and is `[]` when none is held. When '
300
+ + '`blocked` is not empty, check the app\'s response and logs '
301
+ + 'for that session. The app must return 2xx for a result that '
302
+ + 'failed verification, and 5xx only for a temporary error. '
303
+ + 'Fix the app, and the session resumes on the next retry.',
304
+ inputSchema: {
305
+ type: 'object',
306
+ properties: {
307
+ port: {
308
+ type: 'integer',
309
+ minimum: 1,
310
+ maximum: 65535,
311
+ description: 'The local port the app listens on.',
312
+ },
313
+ path: {
314
+ type: 'string',
315
+ pattern: '^/[^\\s#\\\\]*$',
316
+ maxLength: 500,
317
+ description: 'The callback route, starting with `/`. Defaults to `/`.',
318
+ },
319
+ stop: {
320
+ type: 'boolean',
321
+ description: 'Stop forwarding. `port` is not needed.',
322
+ },
323
+ orgToken: {
324
+ type: 'string',
325
+ pattern: '^rorg_',
326
+ maxLength: 200,
327
+ description: 'The organization API key (`rorg_…`). Defaults to '
328
+ + 'the server\'s `RECLAIM_ORG_SECRET`, then the key saved by '
329
+ + '`issue_org_token`. Never echo it back to the user.',
330
+ },
331
+ },
332
+ },
333
+ annotations: {
334
+ readOnlyHint: false,
335
+ idempotentHint: true,
336
+ openWorldHint: true,
337
+ },
338
+ }, async (args) => {
339
+ const secret = resolveOrgSecret(args.orgToken);
340
+ if (args.stop) {
341
+ const stopped = await registry.stop(secret);
342
+ return {
343
+ ...(stopped?.status() || { forwarding: false }),
344
+ _notes: 'Forwarding stopped. The listener stays alive until the '
345
+ + 'MCP server exits, so `read_callback_events` still works.',
346
+ };
347
+ }
348
+ const existing = registry.get(secret);
349
+ if (args.port === undefined) {
350
+ assert(existing, badRequest('Pass `port`, the local port the app listens on.'));
351
+ existing.listener = await ensureListener(secret, deps);
352
+ return existing.status();
353
+ }
354
+ const target = validateTarget(args.port, args.path ?? '/');
355
+ if (existing) {
356
+ existing.target = target;
357
+ existing.listener = await ensureListener(secret, deps);
358
+ return existing.status();
359
+ }
360
+ const client = orgClient(secret, deps);
361
+ const listener = await ensureListener(secret, deps);
362
+ const forwarder = new Forwarder(client, listener, target, deps);
363
+ registry.set(secret, forwarder);
364
+ return forwarder.status();
365
+ });
366
+ }
367
+ /** Rejects anything that is not a path on a loopback port. */
368
+ export function validateTarget(port, path) {
369
+ assert(Number.isInteger(port) && port >= 1 && port <= 65535, badRequest('`port` must be an integer from 1 to 65535.'));
370
+ assert(path.length <= 500 && /^\/[^\s#\\]*$/.test(path), badRequest('`path` must start with "/" and contain no host, space, or "#".'));
371
+ const url = new URL(targetUrl({ port, path }, HOSTS[0]));
372
+ assert(url.hostname === HOSTS[0] && url.port === String(port), badRequest('`path` must not change the target host.'));
373
+ return { port, path };
374
+ }
375
+ function targetUrl(target, host) {
376
+ return `http://${host}:${target.port}${target.path}`;
377
+ }
378
+ async function sleepFor(ms, signal) {
379
+ await delay(ms, undefined, { signal }).catch(() => undefined);
380
+ }
381
+ function nextBackoff(current) {
382
+ return Math.min(current ? current * 2 : POLL_MS, MAX_BACKOFF_MS);
383
+ }
@@ -0,0 +1,3 @@
1
+ import { type RegisteredTool } from '../../server.ts';
2
+ import { type CallbackDeps } from './callback-listener.ts';
3
+ export declare function readCallbackEventsTool(deps?: CallbackDeps): RegisteredTool;