@reclaimprotocol/agent 0.1.35 → 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/lib/mcp/api/operations.gen.js +1 -1
- package/lib/mcp/tools/verification/proxy-callback-events.d.ts +21 -3
- package/lib/mcp/tools/verification/proxy-callback-events.js +128 -45
- package/package.json +2 -2
- package/skill/builder-mode.md +9 -1
- package/skill/start-integration.md +6 -5
- package/skill/verification-client-integration.md +66 -9
- package/src/mcp/api/operations.gen.ts +1 -1
- package/src/mcp/tools/verification/proxy-callback-events.ts +151 -41
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// AUTO-GENERATED by packages/app/scripts/mcp-tools-gen.ts
|
|
2
|
-
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:
|
|
2
|
+
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:96339e8dff91).
|
|
3
3
|
// The hash catches drift the version misses (a spec can change without a
|
|
4
4
|
// version bump) — do not remove it as "redundant" with the version.
|
|
5
5
|
// Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
|
|
@@ -13,7 +13,20 @@ type Recent = {
|
|
|
13
13
|
httpStatus?: number;
|
|
14
14
|
at: string;
|
|
15
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
|
+
};
|
|
16
27
|
export interface ProxyDeps extends CallbackDeps {
|
|
28
|
+
/** The clock, in milliseconds. Defaults to `Date.now`. */
|
|
29
|
+
now?: () => number;
|
|
17
30
|
/** Delivers an event to the app. Defaults to global `fetch`. */
|
|
18
31
|
forwardFetch?: typeof globalThis.fetch;
|
|
19
32
|
/** Waits between polls. Resolves early, without error, on abort. */
|
|
@@ -23,9 +36,13 @@ export interface ProxyDeps extends CallbackDeps {
|
|
|
23
36
|
/**
|
|
24
37
|
* Polls the org's pending pull events and POSTs each envelope to a local
|
|
25
38
|
* route, like `stripe listen --forward-to`. 2xx responses are acknowledged;
|
|
26
|
-
* anything else (redirects included) stays pending
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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.
|
|
29
46
|
*/
|
|
30
47
|
declare class Forwarder {
|
|
31
48
|
#private;
|
|
@@ -34,6 +51,7 @@ declare class Forwarder {
|
|
|
34
51
|
constructor(client: ReclaimClient, listener: ListenerInfo, target: Target, deps: ProxyDeps);
|
|
35
52
|
status(): {
|
|
36
53
|
recent: Recent[];
|
|
54
|
+
blocked: Blocked[];
|
|
37
55
|
lastError?: string | undefined;
|
|
38
56
|
forwarding: boolean;
|
|
39
57
|
target: string;
|
|
@@ -9,7 +9,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
|
|
|
9
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
10
|
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
11
11
|
};
|
|
12
|
-
var _Forwarder_instances, _Forwarder_client, _Forwarder_deps, _Forwarder_abort, _Forwarder_forwarded, _Forwarder_failed, _Forwarder_lastError, _Forwarder_recent, _Forwarder_unacked, _Forwarder_host, _Forwarder_running, _Forwarder_loop, _Forwarder_tick, _Forwarder_forward, _ForwarderRegistry_forwarders;
|
|
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
13
|
import { badRequest } from '@hapi/boom';
|
|
14
14
|
import assert from 'node:assert';
|
|
15
15
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
@@ -21,15 +21,25 @@ const POLL_MS = 2000;
|
|
|
21
21
|
const MAX_BACKOFF_MS = 30000;
|
|
22
22
|
const FORWARD_TIMEOUT_MS = 10000;
|
|
23
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;
|
|
24
30
|
const RECENT = 10;
|
|
25
31
|
/** Loopback addresses, in the order tried. Dev servers often bind only one. */
|
|
26
32
|
const HOSTS = ['127.0.0.1', '[::1]'];
|
|
27
33
|
/**
|
|
28
34
|
* Polls the org's pending pull events and POSTs each envelope to a local
|
|
29
35
|
* route, like `stripe listen --forward-to`. 2xx responses are acknowledged;
|
|
30
|
-
* anything else (redirects included) stays pending
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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.
|
|
33
43
|
*/
|
|
34
44
|
class Forwarder {
|
|
35
45
|
constructor(client, listener, target, deps) {
|
|
@@ -44,7 +54,11 @@ class Forwarder {
|
|
|
44
54
|
/** Delivered to the app but not yet acknowledged; never POSTed twice. */
|
|
45
55
|
);
|
|
46
56
|
/** Delivered to the app but not yet acknowledged; never POSTed twice. */
|
|
47
|
-
_Forwarder_unacked.set(this, new Set()
|
|
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());
|
|
48
62
|
_Forwarder_host.set(this, HOSTS[0]);
|
|
49
63
|
_Forwarder_running.set(this, void 0);
|
|
50
64
|
__classPrivateFieldSet(this, _Forwarder_client, client, "f");
|
|
@@ -62,6 +76,7 @@ class Forwarder {
|
|
|
62
76
|
failed: __classPrivateFieldGet(this, _Forwarder_failed, "f"),
|
|
63
77
|
...(__classPrivateFieldGet(this, _Forwarder_lastError, "f") ? { lastError: __classPrivateFieldGet(this, _Forwarder_lastError, "f") } : {}),
|
|
64
78
|
recent: [...__classPrivateFieldGet(this, _Forwarder_recent, "f")],
|
|
79
|
+
blocked: [...__classPrivateFieldGet(this, _Forwarder_held, "f").values()].map((h) => h.blocked),
|
|
65
80
|
};
|
|
66
81
|
}
|
|
67
82
|
async stop() {
|
|
@@ -69,16 +84,16 @@ class Forwarder {
|
|
|
69
84
|
await __classPrivateFieldGet(this, _Forwarder_running, "f");
|
|
70
85
|
}
|
|
71
86
|
}
|
|
72
|
-
_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_host = new WeakMap(), _Forwarder_running = new WeakMap(), _Forwarder_instances = new WeakSet(), _Forwarder_loop = async function _Forwarder_loop() {
|
|
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() {
|
|
73
88
|
const sleep = __classPrivateFieldGet(this, _Forwarder_deps, "f").sleep || sleepFor;
|
|
74
89
|
const signal = __classPrivateFieldGet(this, _Forwarder_abort, "f").signal;
|
|
75
90
|
let backoff = 0;
|
|
76
91
|
while (!signal.aborted) {
|
|
77
92
|
let wait = POLL_MS;
|
|
78
93
|
try {
|
|
79
|
-
const
|
|
80
|
-
backoff =
|
|
81
|
-
wait =
|
|
94
|
+
const more = await __classPrivateFieldGet(this, _Forwarder_instances, "m", _Forwarder_tick).call(this);
|
|
95
|
+
backoff = 0;
|
|
96
|
+
wait = more ? 0 : POLL_MS;
|
|
82
97
|
}
|
|
83
98
|
catch (err) {
|
|
84
99
|
__classPrivateFieldSet(this, _Forwarder_lastError, err instanceof Error ? err.message : String(err), "f");
|
|
@@ -90,37 +105,96 @@ _Forwarder_client = new WeakMap(), _Forwarder_deps = new WeakMap(), _Forwarder_a
|
|
|
90
105
|
await sleep(wait, signal);
|
|
91
106
|
}
|
|
92
107
|
}
|
|
93
|
-
}, _Forwarder_tick =
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
+
}
|
|
110
153
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
}
|
|
115
165
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
}
|
|
121
177
|
}
|
|
122
178
|
}
|
|
123
|
-
return
|
|
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
|
+
});
|
|
124
198
|
}, _Forwarder_forward = async function _Forwarder_forward(item) {
|
|
125
199
|
const doFetch = __classPrivateFieldGet(this, _Forwarder_deps, "f").forwardFetch || globalThis.fetch;
|
|
126
200
|
const record = (status, httpStatus) => {
|
|
@@ -150,7 +224,7 @@ _Forwarder_client = new WeakMap(), _Forwarder_deps = new WeakMap(), _Forwarder_a
|
|
|
150
224
|
if (res.ok) {
|
|
151
225
|
__classPrivateFieldSet(this, _Forwarder_forwarded, __classPrivateFieldGet(this, _Forwarder_forwarded, "f") + 1, "f");
|
|
152
226
|
record('delivered', res.status);
|
|
153
|
-
return true;
|
|
227
|
+
return { ok: true };
|
|
154
228
|
}
|
|
155
229
|
__classPrivateFieldSet(this, _Forwarder_failed, __classPrivateFieldGet(this, _Forwarder_failed, "f") + 1, "f");
|
|
156
230
|
const url = targetUrl(this.target, host);
|
|
@@ -159,7 +233,7 @@ _Forwarder_client = new WeakMap(), _Forwarder_deps = new WeakMap(), _Forwarder_a
|
|
|
159
233
|
+ 'followed; call again with the redirect target\'s `path`.'
|
|
160
234
|
: `${url} returned HTTP ${res.status}`, "f");
|
|
161
235
|
record('failed', res.status);
|
|
162
|
-
return false;
|
|
236
|
+
return { ok: false, httpStatus: res.status, error: __classPrivateFieldGet(this, _Forwarder_lastError, "f") };
|
|
163
237
|
}
|
|
164
238
|
catch (err) {
|
|
165
239
|
__classPrivateFieldSet(this, _Forwarder_lastError, err instanceof Error ? err.message : String(err), "f");
|
|
@@ -168,7 +242,7 @@ _Forwarder_client = new WeakMap(), _Forwarder_deps = new WeakMap(), _Forwarder_a
|
|
|
168
242
|
// No loopback address accepted the connection.
|
|
169
243
|
__classPrivateFieldSet(this, _Forwarder_failed, __classPrivateFieldGet(this, _Forwarder_failed, "f") + 1, "f");
|
|
170
244
|
record('failed');
|
|
171
|
-
return false;
|
|
245
|
+
return { ok: false, error: __classPrivateFieldGet(this, _Forwarder_lastError, "f") || 'Connection failed' };
|
|
172
246
|
};
|
|
173
247
|
/** The forwarders of one MCP process, one per org key. */
|
|
174
248
|
export class ForwarderRegistry {
|
|
@@ -207,17 +281,26 @@ export function proxyCallbackEventsTool(deps = {}) {
|
|
|
207
281
|
+ 'to a local route, like `stripe listen --forward-to`. Call it '
|
|
208
282
|
+ 'when the app\'s callback route runs locally. It POSTs each '
|
|
209
283
|
+ 'envelope as JSON to `http://127.0.0.1:<port><path>` (loopback '
|
|
210
|
-
+ 'only; also tries `[::1]`)
|
|
211
|
-
+ '
|
|
212
|
-
+ '
|
|
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 '
|
|
213
289
|
+ 'read-only. One forwarder runs per org: call again to read '
|
|
214
290
|
+ 'status, or with another `port` or `path` to retarget. '
|
|
215
291
|
+ '`stop: true` stops forwarding only. Forwarding lasts only '
|
|
216
292
|
+ 'while this MCP server runs; production needs '
|
|
217
293
|
+ '`create_org_callback_subscription`. Returns `{ forwarding, '
|
|
218
|
-
+ 'target, listener, forwarded, failed, lastError?, recent
|
|
219
|
-
+ '`listener.expiresAt` is the lease end, and
|
|
220
|
-
+ 'last events with `status` and
|
|
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.',
|
|
221
304
|
inputSchema: {
|
|
222
305
|
type: 'object',
|
|
223
306
|
properties: {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reclaimprotocol/agent",
|
|
3
3
|
"title": "Reclaim Agent",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.36",
|
|
5
5
|
"description": "Reclaim Protocol tools for AI coding agents. Reclaim lets your app verify data points about its users, such as a follower count, an account balance, or an order history. This package is the Reclaim MCP server: it gives your agent the tools to find, build, test, and fix data verification providers by capturing and replaying browser requests, drafting provider recipes, running zkTLS proofs, and publishing provider versions.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./lib/index.js",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
84
84
|
"@noble/hashes": "^2.2.0",
|
|
85
85
|
"@reclaimprotocol/attestor-core": "^5.1.5",
|
|
86
|
-
"@reclaimprotocol/client": "0.1.
|
|
86
|
+
"@reclaimprotocol/client": "0.1.36",
|
|
87
87
|
"@reclaimprotocol/tls": "^0.1.3",
|
|
88
88
|
"chrome-remote-interface": "^0.33.0",
|
|
89
89
|
"domhandler": "^6.0.0",
|
package/skill/builder-mode.md
CHANGED
|
@@ -75,9 +75,17 @@ TEE binding. Edit the app with your file tools. Use the HTTP API
|
|
|
75
75
|
`@reclaimprotocol/client`. The only exception is `results.receive` on the
|
|
76
76
|
callback, or when the developer asks for the client package.
|
|
77
77
|
|
|
78
|
+
The app's frontend renders every state and every failure, never only a
|
|
79
|
+
spinner. Follow "UI states" in `how_it_works({ topic: "integration" })`. The
|
|
80
|
+
callback route responds `error.retryable ? 503 : 200` for a thrown
|
|
81
|
+
`ResultVerificationError`; a result that failed verification gets 2xx.
|
|
82
|
+
|
|
78
83
|
For local development, call `proxy_callback_events({ port, path })` to forward
|
|
79
84
|
events to the app's callback route on localhost. The local listener works only
|
|
80
|
-
while this MCP server runs.
|
|
85
|
+
while this MCP server runs. It keeps events in order per session and retries
|
|
86
|
+
a session the app rejects with backoff, so other sessions keep flowing. When
|
|
87
|
+
its status lists `blocked` sessions, read that session's callback route
|
|
88
|
+
response and logs, and fix the app. Production needs a webhook:
|
|
81
89
|
`create_org_callback_subscription` with the deployed app's public URL. Without
|
|
82
90
|
one, session creation returns 409.
|
|
83
91
|
|
|
@@ -32,8 +32,9 @@ Without subagents, do step 2 first.
|
|
|
32
32
|
- [ ] Detect the app's framework and package manager from the repository, and use them. Read `how_it_works({ topic: "integration" })`.
|
|
33
33
|
- [ ] Call `issue_org_token` with the organization, this project's absolute path, and the server env file. It writes the key to that file and never returns it. Never print or commit the key. Confirm the env file is in `.gitignore`.
|
|
34
34
|
- [ ] Add a backend route that creates a verification session (`POST /verifications/sessions`) and returns `verificationUrl`. Read the provider ID from server config (an env variable such as `RECLAIM_PROVIDER_ID`), so the route works before the provider exists.
|
|
35
|
-
- [ ] Add a callback route that verifies each result with `results.receive` from `@reclaimprotocol/client`, then stores only the requested data points.
|
|
36
|
-
- [ ] Add a frontend
|
|
35
|
+
- [ ] Add a callback route that verifies each result with `results.receive` from `@reclaimprotocol/client`, then stores only the requested data points. Store every failure on the verification session record and log it server-side: a failure event (`verification_rejected`, `verification_error`, `verification_cancelled`, `session_expired`), or a thrown `ResultVerificationError` (`error.reason` plus each failing proof's `reason`). Respond `error.retryable ? 503 : 200`. A result that failed verification gets 2xx, because retrying can't fix it. Read the exact pattern in `how_it_works({ topic: "integration" })`.
|
|
36
|
+
- [ ] Add a status route the frontend polls. It returns the stored status, data points, and failure reason. While the app has no result, it also reads `GET /verifications/sessions/{sessionId}`, so a session that ended in Builder never stays pending.
|
|
37
|
+
- [ ] Add a frontend Verify button that opens `verificationUrl`, and render every state in "UI states" in `how_it_works({ topic: "integration" })`: idle, waiting in the other tab, verifying, verified (with the data points), failed (plain-language reason and **Try again**), and expired or cancelled (**Try again**). Never leave a spinner as the only feedback.
|
|
37
38
|
- [ ] Start the app. Call `proxy_callback_events({ port, path })` with the callback route's port and path. It forwards the org's verification events to that route on localhost, with no public URL.
|
|
38
39
|
<!-- ifnot:providerIds -->
|
|
39
40
|
- [ ] Call `check_attestor` once. If it fails, tell the developer before they spend time on the provider build: this machine cannot reach the attestor.
|
|
@@ -67,8 +68,8 @@ Build a new organization-private provider for exactly "{{goal}}". Do not search
|
|
|
67
68
|
## 4. Wire the provider and test end to end
|
|
68
69
|
|
|
69
70
|
- [ ] Set the provider ID in the app's server config.
|
|
70
|
-
- [ ] Confirm the callback route is forwarding: `proxy_callback_events` with no `stop` returns its status.
|
|
71
|
-
- [ ] Tell the developer exactly how to test: open the app, click Verify, sign in, and see the stored result.
|
|
72
|
-
- [ ] After they click Verify, read the delivered events from `proxy_callback_events` status (`recent`) or `read_callback_events`. If the result did not arrive, call `list_verification_deliveries` and fix the cause. Repeat until the app shows the stored result.
|
|
71
|
+
- [ ] Confirm the callback route is forwarding: `proxy_callback_events` with no `stop` returns its status. If `blocked` is not empty, the app rejected that session's event: read the callback route's response and logs, fix the app, and check again.
|
|
72
|
+
- [ ] Tell the developer exactly how to test: open the app, click Verify, sign in, and see the stored result. Also test one failure, such as cancelling in the verification tab, and confirm the app shows the failed state with **Try again**.
|
|
73
|
+
- [ ] After they click Verify, read the delivered events from `proxy_callback_events` status (`recent`) or `read_callback_events`. If the result did not arrive, or the app shows an unexpected failure, call `list_verification_deliveries` and fix the cause. Repeat until the app shows the stored result.
|
|
73
74
|
- [ ] Ask the developer to set up the production callback. The local listener works only while this coding agent's MCP server runs; once it stops, the deployed app receives no results. Call `create_org_callback_subscription` with the deployed app's public callback route URL for all five session-ending events: `verification_success`, `verification_rejected`, `verification_error`, `verification_cancelled`, and `session_expired`.
|
|
74
75
|
- [ ] If they have no deployed URL yet, tell them: "When you deploy, ask me to run `create_org_callback_subscription` with your public callback URL, or results will not reach the app."
|
|
@@ -47,12 +47,22 @@ server), or migrate to the session flow below. When migrating, remove
|
|
|
47
47
|
is not proof of success. Use `@reclaimprotocol/client`
|
|
48
48
|
(`ReclaimVerification.create({ orgSecret })`, `sessions.create`) only when
|
|
49
49
|
the developer asks for it.
|
|
50
|
-
5. Add a backend callback
|
|
50
|
+
5. Add a backend callback route. Persist each delivery idempotently and load
|
|
51
51
|
the expected session ID and org ID from app storage. To verify the result on
|
|
52
|
-
your callback, install `@reclaimprotocol/client` in the backend and call
|
|
52
|
+
your callback route, install `@reclaimprotocol/client` in the backend and call
|
|
53
53
|
`reclaim.results.receive(body, { reclaimSessionId, expectedAud,
|
|
54
54
|
orgEthPrivateKey })`. Use the verified proof data to update app state.
|
|
55
|
-
|
|
55
|
+
Store every failure on the verification session record: failure events, and
|
|
56
|
+
a thrown `ResultVerificationError` (`error.reason` and each
|
|
57
|
+
`error.outcome.proofs[].reason`). Respond `error.retryable ? 503 : 200`.
|
|
58
|
+
A result that failed verification gets 2xx: retrying can't fix it, and an
|
|
59
|
+
unacknowledged delivery holds back that session's later events. Respond 5xx
|
|
60
|
+
for any other temporary problem too, such as a database outage.
|
|
61
|
+
Add a status route that returns the stored status, data points, and failure
|
|
62
|
+
reason. While the app has no result, it also reads
|
|
63
|
+
`GET /verifications/sessions/{sessionId}`, so a session Builder has ended
|
|
64
|
+
never stays pending in the app. Render the frontend per
|
|
65
|
+
[UI states](#ui-states).
|
|
56
66
|
6. Exercise one session through the selected registered Verification Client,
|
|
57
67
|
inspect its Builder events, and test the app's success and failure paths.
|
|
58
68
|
Omit `verificationClientUrl` to use the Portal, or select another client
|
|
@@ -125,16 +135,37 @@ Receive and verify the result on the callback route. This is the one place the
|
|
|
125
135
|
client package is needed:
|
|
126
136
|
|
|
127
137
|
```js
|
|
128
|
-
import { ReclaimVerification } from '@reclaimprotocol/client'
|
|
138
|
+
import { ReclaimVerification, ResultVerificationError } from '@reclaimprotocol/client'
|
|
129
139
|
|
|
130
140
|
const reclaim = ReclaimVerification.create({
|
|
131
141
|
orgSecret: process.env.RECLAIM_ORG_SECRET,
|
|
132
142
|
})
|
|
133
143
|
|
|
134
144
|
app.post('/reclaim/callback', async(req, res) => {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
145
|
+
let outcome
|
|
146
|
+
try {
|
|
147
|
+
outcome = await reclaim.results.receive(req.body, {
|
|
148
|
+
reclaimSessionId: sessionId, // the id you stored
|
|
149
|
+
})
|
|
150
|
+
} catch(error) {
|
|
151
|
+
if(!(error instanceof ResultVerificationError)) {
|
|
152
|
+
throw error
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if(error.retryable) {
|
|
156
|
+
return res.sendStatus(503) // temporary: Builder retries the delivery
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Retrying can't fix a result that failed verification. Store the failure
|
|
160
|
+
// where the status route returns it, then acknowledge the delivery.
|
|
161
|
+
const proofReasons = (error.outcome.proofs ?? [])
|
|
162
|
+
.filter((proof) => !proof.proofValid)
|
|
163
|
+
.map((proof) => proof.reason)
|
|
164
|
+
console.warn('result failed verification', error.reason, proofReasons)
|
|
165
|
+
await markSessionFailed(sessionId, error.reason) // your app's storage
|
|
166
|
+
return res.sendStatus(200)
|
|
167
|
+
}
|
|
168
|
+
|
|
138
169
|
if(outcome.kind === 'result') {
|
|
139
170
|
console.log(outcome.result.payload) // verified
|
|
140
171
|
}
|
|
@@ -143,8 +174,34 @@ app.post('/reclaim/callback', async(req, res) => {
|
|
|
143
174
|
})
|
|
144
175
|
```
|
|
145
176
|
|
|
146
|
-
|
|
147
|
-
|
|
177
|
+
### UI states
|
|
178
|
+
|
|
179
|
+
Apply these rules when you write the frontend. Test each state, including one
|
|
180
|
+
failure.
|
|
181
|
+
|
|
182
|
+
| State | Render |
|
|
183
|
+
| --- | --- |
|
|
184
|
+
| Idle | The **Verify** button. |
|
|
185
|
+
| Waiting in the other tab | The current step ("Finish verifying in the new tab") and a way to reopen `verificationUrl`. After about 30 seconds, say what is happening and offer **Cancel** or **Try again**. |
|
|
186
|
+
| Verifying | "Checking your result". |
|
|
187
|
+
| Verified | The requested data points. |
|
|
188
|
+
| Failed | A plain-language reason and what to do next, near the **Verify** button, with **Try again**. |
|
|
189
|
+
| Expired or cancelled | Say which, with **Try again**. |
|
|
190
|
+
|
|
191
|
+
- Show the current step, never an indefinite spinner (Nielsen heuristic 1,
|
|
192
|
+
visibility of system status).
|
|
193
|
+
- Write errors in plain language that says what happened and what to do next,
|
|
194
|
+
visible next to the action, with a recovery action (Nielsen Norman Group
|
|
195
|
+
error-message guidelines).
|
|
196
|
+
- Send reason codes (`error.reason`, proof `reason`) to logs or an expandable
|
|
197
|
+
**Details** section, not the headline.
|
|
198
|
+
- Announce changes: `role="status"` (or `aria-live="polite"`) for progress,
|
|
199
|
+
`role="alert"` for failures (WCAG 2.2 SC 4.1.3).
|
|
200
|
+
- Pair color with text or an icon (WCAG 2.2 SC 1.4.1).
|
|
201
|
+
- Stop the waiting state when the status route reports a terminal status.
|
|
202
|
+
|
|
203
|
+
Check or investigate a session from the status route or by hand with `curl`.
|
|
204
|
+
All routes take the org key:
|
|
148
205
|
|
|
149
206
|
| Route | Use |
|
|
150
207
|
| --- | --- |
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// AUTO-GENERATED by packages/app/scripts/mcp-tools-gen.ts
|
|
2
|
-
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:
|
|
2
|
+
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:96339e8dff91).
|
|
3
3
|
// The hash catches drift the version misses (a spec can change without a
|
|
4
4
|
// version bump) — do not remove it as "redundant" with the version.
|
|
5
5
|
// Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
|
|
@@ -17,6 +17,12 @@ const POLL_MS = 2_000
|
|
|
17
17
|
const MAX_BACKOFF_MS = 30_000
|
|
18
18
|
const FORWARD_TIMEOUT_MS = 10_000
|
|
19
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
|
|
20
26
|
const RECENT = 10
|
|
21
27
|
/** Loopback addresses, in the order tried. Dev servers often bind only one. */
|
|
22
28
|
const HOSTS = ['127.0.0.1', '[::1]']
|
|
@@ -42,7 +48,25 @@ type Recent = {
|
|
|
42
48
|
at: string
|
|
43
49
|
}
|
|
44
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
|
+
|
|
45
67
|
export interface ProxyDeps extends CallbackDeps {
|
|
68
|
+
/** The clock, in milliseconds. Defaults to `Date.now`. */
|
|
69
|
+
now?: () => number
|
|
46
70
|
/** Delivers an event to the app. Defaults to global `fetch`. */
|
|
47
71
|
forwardFetch?: typeof globalThis.fetch
|
|
48
72
|
/** Waits between polls. Resolves early, without error, on abort. */
|
|
@@ -53,9 +77,13 @@ export interface ProxyDeps extends CallbackDeps {
|
|
|
53
77
|
/**
|
|
54
78
|
* Polls the org's pending pull events and POSTs each envelope to a local
|
|
55
79
|
* route, like `stripe listen --forward-to`. 2xx responses are acknowledged;
|
|
56
|
-
* anything else (redirects included) stays pending
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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.
|
|
59
87
|
*/
|
|
60
88
|
class Forwarder {
|
|
61
89
|
target: Target
|
|
@@ -69,6 +97,8 @@ class Forwarder {
|
|
|
69
97
|
#recent: Recent[] = []
|
|
70
98
|
/** Delivered to the app but not yet acknowledged; never POSTed twice. */
|
|
71
99
|
#unacked = new Set<string>()
|
|
100
|
+
/** Held sessions by session id, with the time of their next attempt. */
|
|
101
|
+
#held = new Map<string, Held>()
|
|
72
102
|
#host = HOSTS[0]
|
|
73
103
|
#running: Promise<void>
|
|
74
104
|
|
|
@@ -94,6 +124,7 @@ class Forwarder {
|
|
|
94
124
|
failed: this.#failed,
|
|
95
125
|
...(this.#lastError ? { lastError: this.#lastError } : {}),
|
|
96
126
|
recent: [...this.#recent],
|
|
127
|
+
blocked: [...this.#held.values()].map((h) => h.blocked),
|
|
97
128
|
}
|
|
98
129
|
}
|
|
99
130
|
|
|
@@ -109,9 +140,9 @@ class Forwarder {
|
|
|
109
140
|
while(!signal.aborted) {
|
|
110
141
|
let wait = POLL_MS
|
|
111
142
|
try {
|
|
112
|
-
const
|
|
113
|
-
backoff =
|
|
114
|
-
wait =
|
|
143
|
+
const more = await this.#tick()
|
|
144
|
+
backoff = 0
|
|
145
|
+
wait = more ? 0 : POLL_MS
|
|
115
146
|
} catch(err) {
|
|
116
147
|
this.#lastError = err instanceof Error ? err.message : String(err)
|
|
117
148
|
LOGGER.warn({ err }, 'callback proxy poll failed')
|
|
@@ -125,43 +156,113 @@ class Forwarder {
|
|
|
125
156
|
}
|
|
126
157
|
}
|
|
127
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
|
+
*/
|
|
128
164
|
async #tick() {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
let
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
}
|
|
138
197
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
}
|
|
145
207
|
}
|
|
146
|
-
}
|
|
147
208
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
+
}
|
|
151
221
|
}
|
|
152
222
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
}
|
|
158
235
|
}
|
|
159
236
|
}
|
|
160
237
|
|
|
161
|
-
return
|
|
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
|
+
})
|
|
162
263
|
}
|
|
163
264
|
|
|
164
|
-
async #forward(item: CallbackEvent) {
|
|
265
|
+
async #forward(item: CallbackEvent): Promise<Outcome> {
|
|
165
266
|
const doFetch = this.#deps.forwardFetch || globalThis.fetch
|
|
166
267
|
const record = (status: Recent['status'], httpStatus?: number) => {
|
|
167
268
|
this.#recent.push({
|
|
@@ -191,7 +292,7 @@ class Forwarder {
|
|
|
191
292
|
if(res.ok) {
|
|
192
293
|
this.#forwarded += 1
|
|
193
294
|
record('delivered', res.status)
|
|
194
|
-
return true
|
|
295
|
+
return { ok: true }
|
|
195
296
|
}
|
|
196
297
|
|
|
197
298
|
this.#failed += 1
|
|
@@ -201,7 +302,7 @@ class Forwarder {
|
|
|
201
302
|
+ 'followed; call again with the redirect target\'s `path`.'
|
|
202
303
|
: `${url} returned HTTP ${res.status}`
|
|
203
304
|
record('failed', res.status)
|
|
204
|
-
return false
|
|
305
|
+
return { ok: false, httpStatus: res.status, error: this.#lastError }
|
|
205
306
|
} catch(err) {
|
|
206
307
|
this.#lastError = err instanceof Error ? err.message : String(err)
|
|
207
308
|
}
|
|
@@ -210,7 +311,7 @@ class Forwarder {
|
|
|
210
311
|
// No loopback address accepted the connection.
|
|
211
312
|
this.#failed += 1
|
|
212
313
|
record('failed')
|
|
213
|
-
return false
|
|
314
|
+
return { ok: false, error: this.#lastError || 'Connection failed' }
|
|
214
315
|
}
|
|
215
316
|
}
|
|
216
317
|
|
|
@@ -257,17 +358,26 @@ export function proxyCallbackEventsTool(deps: ProxyDeps = {}): RegisteredTool {
|
|
|
257
358
|
+ 'to a local route, like `stripe listen --forward-to`. Call it '
|
|
258
359
|
+ 'when the app\'s callback route runs locally. It POSTs each '
|
|
259
360
|
+ 'envelope as JSON to `http://127.0.0.1:<port><path>` (loopback '
|
|
260
|
-
+ 'only; also tries `[::1]`)
|
|
261
|
-
+ '
|
|
262
|
-
+ '
|
|
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 '
|
|
263
366
|
+ 'read-only. One forwarder runs per org: call again to read '
|
|
264
367
|
+ 'status, or with another `port` or `path` to retarget. '
|
|
265
368
|
+ '`stop: true` stops forwarding only. Forwarding lasts only '
|
|
266
369
|
+ 'while this MCP server runs; production needs '
|
|
267
370
|
+ '`create_org_callback_subscription`. Returns `{ forwarding, '
|
|
268
|
-
+ 'target, listener, forwarded, failed, lastError?, recent
|
|
269
|
-
+ '`listener.expiresAt` is the lease end, and
|
|
270
|
-
+ 'last events with `status` and
|
|
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.',
|
|
271
381
|
inputSchema: {
|
|
272
382
|
type: 'object',
|
|
273
383
|
properties: {
|