qyl-mcp-server 7.1.0 → 7.2.0

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 (75) hide show
  1. package/README.md +118 -15
  2. package/dist/auth-extensions.d.ts +20 -0
  3. package/dist/auth-extensions.js +81 -0
  4. package/dist/auth-extensions.js.map +1 -0
  5. package/dist/auth.d.ts +4 -0
  6. package/dist/auth.js +5 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/authorization.d.ts +14 -0
  9. package/dist/authorization.js +82 -0
  10. package/dist/authorization.js.map +1 -0
  11. package/dist/ci.d.ts +5 -5
  12. package/dist/ci.js +25 -19
  13. package/dist/ci.js.map +1 -1
  14. package/dist/collector-access.d.ts +17 -0
  15. package/dist/collector-access.js +61 -0
  16. package/dist/collector-access.js.map +1 -0
  17. package/dist/collector.d.ts +4 -1
  18. package/dist/collector.js +6 -2
  19. package/dist/collector.js.map +1 -1
  20. package/dist/config.d.ts +4 -3
  21. package/dist/config.js +5 -5
  22. package/dist/config.js.map +1 -1
  23. package/dist/contract-operations.d.ts +1 -1
  24. package/dist/contract-operations.js +1 -1
  25. package/dist/contract-operations.js.map +1 -1
  26. package/dist/contract-validation.d.ts +1 -1
  27. package/dist/contract-validation.js +45 -1
  28. package/dist/contract-validation.js.map +1 -1
  29. package/dist/events-authorization.d.ts +9 -0
  30. package/dist/events-authorization.js +160 -0
  31. package/dist/events-authorization.js.map +1 -0
  32. package/dist/events.d.ts +235 -0
  33. package/dist/events.js +647 -0
  34. package/dist/events.js.map +1 -0
  35. package/dist/main.d.ts +14 -23
  36. package/dist/main.js +99 -36
  37. package/dist/main.js.map +1 -1
  38. package/dist/mcp-app.html +1 -1
  39. package/dist/mcp-dashboard.html +1 -1
  40. package/dist/mcp-home.html +6 -6
  41. package/dist/metrics-tools.js +16 -13
  42. package/dist/metrics-tools.js.map +1 -1
  43. package/dist/native-execution.d.ts +8 -52
  44. package/dist/native-execution.js +51 -222
  45. package/dist/native-execution.js.map +1 -1
  46. package/dist/oauth-metadata.d.ts +8 -0
  47. package/dist/oauth-metadata.js +89 -0
  48. package/dist/oauth-metadata.js.map +1 -0
  49. package/dist/oauth.d.ts +7 -17
  50. package/dist/oauth.js +16 -68
  51. package/dist/oauth.js.map +1 -1
  52. package/dist/request-scope.d.ts +4 -12
  53. package/dist/request-scope.js +9 -22
  54. package/dist/request-scope.js.map +1 -1
  55. package/dist/server.d.ts +7 -1
  56. package/dist/server.js +83 -33
  57. package/dist/server.js.map +1 -1
  58. package/dist/summaries.d.ts +1 -1
  59. package/dist/summaries.js +2 -2
  60. package/dist/summaries.js.map +1 -1
  61. package/dist/telemetry-redaction.js +13 -0
  62. package/dist/telemetry-redaction.js.map +1 -1
  63. package/dist/tools.d.ts +10 -0
  64. package/dist/tools.js +77 -18
  65. package/dist/tools.js.map +1 -1
  66. package/dist/trace-query.d.ts +5 -0
  67. package/dist/trace-query.js +8 -0
  68. package/dist/trace-query.js.map +1 -0
  69. package/dist/ui-domain.d.ts +4 -0
  70. package/dist/ui-domain.js +28 -0
  71. package/dist/ui-domain.js.map +1 -0
  72. package/dist/webhook.d.ts +45 -0
  73. package/dist/webhook.js +198 -0
  74. package/dist/webhook.js.map +1 -0
  75. package/package.json +27 -22
package/dist/events.js ADDED
@@ -0,0 +1,647 @@
1
+ /**
2
+ * MCP Events for ChatGPT: `events/list`, `events/subscribe` and
3
+ * `events/unsubscribe` on the authenticated MCP endpoint, with signed webhook
4
+ * delivery of one event type, `trace.error`.
5
+ *
6
+ * Contract: developers.openai.com/plugins/build/mcp-events (ChatGPT supports
7
+ * webhook delivery with callback verification only) and the draft MCP Events
8
+ * design sketch in modelcontextprotocol/experimental-ext-triggers-events.
9
+ * `@modelcontextprotocol/server` 2.3.1 ships no `events/*` methods, so the
10
+ * three are custom request handlers and the capability is added by hand.
11
+ *
12
+ * Delivery source: the collector's recent-trace list, polled only while a
13
+ * subscription is live. The first poll sets a baseline, so a subscription
14
+ * never receives a backlog. Events carry `cursor: null` — there is no replay;
15
+ * traces that arrive while the server is down are not delivered.
16
+ *
17
+ * Access: subscribing, refreshing and unsubscribing each pass the same OAuth
18
+ * gate as every tool call. The required authorization callback rechecks the
19
+ * owner's current access while polling and before delivery, including retries.
20
+ * Authorization outages suspend delivery; revoked access removes the record.
21
+ */
22
+ import { createHash, timingSafeEqual } from "node:crypto";
23
+ import { setTimeout as delay } from "node:timers/promises";
24
+ import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server";
25
+ import * as z from "zod/v4";
26
+ import { AtomicJsonStore } from "./atomic-json-store.js";
27
+ import { fetchTraces } from "./data.js";
28
+ import { CollectorAccessError, collectorAccessForSubject } from "./collector-access.js";
29
+ import { collectorUrl } from "./config.js";
30
+ import { rootSpanName } from "./summaries.js";
31
+ import { CallbackAddressError, WEBHOOK_MAX_BODY_BYTES, WebhookSecretError, decodeWebhookSecret, parseCallbackUrl, postWebhook, randomId, signWebhook, } from "./webhook.js";
32
+ export const TRACE_ERROR_EVENT_NAME = "trace.error";
33
+ /** JSON-RPC error for a callback that failed validation or verification. */
34
+ export const CALLBACK_ENDPOINT_ERROR = -32015;
35
+ const MINUTE_MS = 60_000;
36
+ export const DEFAULT_TTL_MS = 60 * MINUTE_MS;
37
+ export const MIN_TTL_MS = 5 * MINUTE_MS;
38
+ export const MAX_TTL_MS = 24 * 60 * MINUTE_MS;
39
+ const VERIFICATION_CACHE_MS = 60 * MINUTE_MS;
40
+ const SECRET_ROTATION_WINDOW_MS = 10 * MINUTE_MS;
41
+ const CALLBACK_TIMEOUT_MS = 10_000;
42
+ const DEFAULT_POLL_INTERVAL_MS = 30_000;
43
+ const TRACES_PER_POLL = 100;
44
+ const MAX_SEEN_TRACES = 5_000;
45
+ const DELIVERY_ATTEMPTS = 5;
46
+ const RETRY_BASE_MS = 1_000;
47
+ /**
48
+ * Live subscriptions one caller may hold. Every subscription is a stored
49
+ * entry, a delivery fan-out target and, for a new callback, an outbound
50
+ * verification POST; without a cap one account grows all three at will.
51
+ */
52
+ export const MAX_SUBSCRIPTIONS_PER_PRINCIPAL = 20;
53
+ /** The one event definition `events/list` returns. */
54
+ export const TRACE_ERROR_EVENT = {
55
+ name: TRACE_ERROR_EVENT_NAME,
56
+ description: "A trace containing at least one error span reached the qyl collector. " +
57
+ "Pass service_name to watch one service; omit it to watch every service.",
58
+ delivery: ["webhook"],
59
+ // mcp-v2-allow TS-RAW-SHAPE: events/list publishes JSON Schema, not a Zod tool-registration shape.
60
+ inputSchema: {
61
+ type: "object",
62
+ properties: {
63
+ service_name: {
64
+ type: "string",
65
+ minLength: 1,
66
+ maxLength: 128,
67
+ description: "Only traces that involve this service.",
68
+ },
69
+ },
70
+ additionalProperties: false,
71
+ },
72
+ payloadSchema: {
73
+ type: "object",
74
+ properties: {
75
+ trace_id: { type: "string", description: "Trace identifier; pass it to get_trace or display_traces." },
76
+ root_span: { type: "string", description: "Name of the trace's root span." },
77
+ services: { type: "array", items: { type: "string" }, description: "Services involved in the trace." },
78
+ span_count: { type: "integer", description: "Total span count." },
79
+ duration_ms: { type: "number", description: "Trace duration in milliseconds." },
80
+ start_time: { type: "string", format: "date-time", description: "Trace start time." },
81
+ },
82
+ required: ["trace_id", "root_span", "services", "span_count", "duration_ms", "start_time"],
83
+ additionalProperties: false,
84
+ },
85
+ };
86
+ const TraceErrorArgumentsSchema = z
87
+ .object({ service_name: z.string().min(1).max(128).optional() })
88
+ .strict();
89
+ const ListParamsSchema = z.object({ cursor: z.string().optional() }).passthrough();
90
+ const SubscribeParamsSchema = z
91
+ .object({
92
+ name: z.string(),
93
+ arguments: z.record(z.string(), z.unknown()).optional(),
94
+ delivery: z
95
+ .object({ mode: z.string(), url: z.string(), secret: z.string().optional() })
96
+ .passthrough(),
97
+ cursor: z.string().nullable().optional(),
98
+ ttlMs: z.number().int().nonnegative().nullable().optional(),
99
+ })
100
+ .passthrough();
101
+ const UnsubscribeParamsSchema = z
102
+ .object({
103
+ name: z.string(),
104
+ arguments: z.record(z.string(), z.unknown()).optional(),
105
+ delivery: z.object({ mode: z.string().optional(), url: z.string() }).passthrough(),
106
+ })
107
+ .passthrough();
108
+ const StoredSubscriptionSchema = z.object({
109
+ id: z.string(),
110
+ subject: z.string(),
111
+ clientId: z.string(),
112
+ name: z.literal(TRACE_ERROR_EVENT_NAME),
113
+ arguments: TraceErrorArgumentsSchema,
114
+ url: z.string(),
115
+ secret: z.string(),
116
+ previousSecret: z.string().optional(),
117
+ previousSecretUntil: z.string().optional(),
118
+ refreshBefore: z.string(),
119
+ updatedAt: z.string(),
120
+ });
121
+ const EventStoreStateSchema = z.object({
122
+ version: z.literal(1),
123
+ subscriptions: z.array(StoredSubscriptionSchema),
124
+ });
125
+ /** Subscription storage that survives restarts: an atomic JSON file (0600). */
126
+ export function createEventStore(filePath) {
127
+ return new AtomicJsonStore(filePath, {
128
+ initial: () => ({ version: 1, subscriptions: [] }),
129
+ parse: (value) => EventStoreStateSchema.parse(value),
130
+ prepareForWrite: (value) => value,
131
+ });
132
+ }
133
+ /** Key-order-independent JSON, so `{a,b}` and `{b,a}` name one subscription. */
134
+ export function canonicalJson(value) {
135
+ if (Array.isArray(value))
136
+ return `[${value.map(canonicalJson).join(",")}]`;
137
+ if (value !== null && typeof value === "object") {
138
+ const entries = Object.entries(value)
139
+ .filter(([, entry]) => entry !== undefined)
140
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
141
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
142
+ }
143
+ return JSON.stringify(value);
144
+ }
145
+ /** Deterministic ID over principal, callback URL, event name and arguments. */
146
+ export function subscriptionId(principal, url, name, args) {
147
+ const digest = createHash("sha256")
148
+ .update(canonicalJson([principal.clientId, principal.subject, url, name, args ?? {}]))
149
+ .digest("base64url");
150
+ return `sub_${digest.slice(0, 32)}`;
151
+ }
152
+ /** The lifetime granted for a requested `ttlMs`: bounded, and never unbounded. */
153
+ export function grantedTtlMs(ttlMs) {
154
+ if (ttlMs === undefined)
155
+ return DEFAULT_TTL_MS;
156
+ if (ttlMs === null)
157
+ return MAX_TTL_MS;
158
+ return Math.min(Math.max(ttlMs, MIN_TTL_MS), MAX_TTL_MS);
159
+ }
160
+ /** The principal of a verified request, or a JSON-RPC error without one. */
161
+ export function principalOf(ctx) {
162
+ const authInfo = ctx.http?.authInfo;
163
+ const subject = authInfo?.extra?.["subject"];
164
+ if (authInfo === undefined || typeof subject !== "string" || subject.length === 0) {
165
+ throw new ProtocolError(ProtocolErrorCode.InvalidRequest, "MCP Events requires an authenticated caller");
166
+ }
167
+ return { subject, clientId: authInfo.clientId };
168
+ }
169
+ function invalidParams(message) {
170
+ return new ProtocolError(ProtocolErrorCode.InvalidParams, message);
171
+ }
172
+ function callbackError(message, reason) {
173
+ return new ProtocolError(CALLBACK_ENDPOINT_ERROR, message, { reason });
174
+ }
175
+ function parseArguments(name, args) {
176
+ if (name !== TRACE_ERROR_EVENT_NAME)
177
+ throw invalidParams(`unknown event "${name}"`);
178
+ const parsed = TraceErrorArgumentsSchema.safeParse(args ?? {});
179
+ if (!parsed.success) {
180
+ throw invalidParams(`invalid arguments for ${name}: ${parsed.error.issues[0]?.message ?? "rejected"}`);
181
+ }
182
+ return parsed.data;
183
+ }
184
+ function checkedCallbackUrl(value) {
185
+ try {
186
+ return parseCallbackUrl(value);
187
+ }
188
+ catch (error) {
189
+ if (error instanceof CallbackAddressError)
190
+ throw callbackError(error.message, "invalid_url");
191
+ throw error;
192
+ }
193
+ }
194
+ function sameText(a, b) {
195
+ const left = Buffer.from(a);
196
+ const right = Buffer.from(b);
197
+ return left.length === right.length && timingSafeEqual(left, right);
198
+ }
199
+ /** The `data` of a `trace.error` delivery; matches `payloadSchema`. */
200
+ export function traceErrorPayload(trace) {
201
+ return {
202
+ trace_id: trace.trace_id,
203
+ root_span: rootSpanName(trace),
204
+ services: [...trace.services],
205
+ span_count: trace.span_count,
206
+ duration_ms: Math.round(Number(trace.duration_ns) / 1_000) / 1_000,
207
+ start_time: new Date(trace.start_time).toISOString(),
208
+ };
209
+ }
210
+ function matches(subscription, trace) {
211
+ const service = subscription.arguments.service_name;
212
+ return service === undefined || trace.services.includes(service);
213
+ }
214
+ /**
215
+ * One per process: owns the subscription store, the verification cache and the
216
+ * poller. `register` wires the three methods into each per-request McpServer.
217
+ */
218
+ export class EventsRuntime {
219
+ store;
220
+ isAuthorized;
221
+ post;
222
+ recentTraces;
223
+ pollIntervalMs;
224
+ now;
225
+ sleep;
226
+ log;
227
+ verified = new Map();
228
+ seen = new Map();
229
+ ready;
230
+ timer;
231
+ polling;
232
+ stopped = false;
233
+ deliveries = new Map();
234
+ constructor(options) {
235
+ this.store = options.store;
236
+ this.isAuthorized = options.isAuthorized;
237
+ this.post = options.post ?? postWebhook;
238
+ this.recentTraces = options.recentTraces
239
+ ?? (async (principal) => {
240
+ const access = collectorAccessForSubject(principal.subject);
241
+ return (await fetchTraces(TRACES_PER_POLL, access === undefined ? {} : { access })).traces;
242
+ });
243
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
244
+ this.now = options.now ?? Date.now;
245
+ this.sleep = options.sleep ?? ((ms, signal) => delay(ms, undefined, { signal }));
246
+ this.log = options.log ?? ((message) => process.stderr.write(`qyl.mcp events: ${message}\n`));
247
+ }
248
+ /** Advertise `events` and answer the three methods on this server instance. */
249
+ register(server) {
250
+ // ServerCapabilities has no `events` key yet; `server/discover` passes it through.
251
+ server.server.registerCapabilities({ events: {} });
252
+ server.server.setRequestHandler("events/list", { params: ListParamsSchema }, () => this.list());
253
+ server.server.setRequestHandler("events/subscribe", { params: SubscribeParamsSchema }, (params, ctx) => this.subscribe(params, principalOf(ctx)));
254
+ server.server.setRequestHandler("events/unsubscribe", { params: UnsubscribeParamsSchema }, (params, ctx) => this.unsubscribe(params, principalOf(ctx)));
255
+ }
256
+ list() {
257
+ return { events: [TRACE_ERROR_EVENT] };
258
+ }
259
+ async subscribe(params, principal) {
260
+ const args = parseArguments(params.name, params.arguments);
261
+ if (params.delivery.mode !== "webhook") {
262
+ throw invalidParams(`delivery mode "${params.delivery.mode}" is not supported; use "webhook"`);
263
+ }
264
+ const secret = params.delivery.secret;
265
+ if (secret === undefined)
266
+ throw invalidParams("webhook delivery requires a whsec_ secret");
267
+ try {
268
+ decodeWebhookSecret(secret);
269
+ }
270
+ catch (error) {
271
+ if (error instanceof WebhookSecretError)
272
+ throw invalidParams(error.message);
273
+ throw error;
274
+ }
275
+ const url = checkedCallbackUrl(params.delivery.url);
276
+ const id = subscriptionId(principal, url.href, params.name, args);
277
+ let allowed;
278
+ try {
279
+ collectorAccessForSubject(principal.subject);
280
+ allowed = await this.isAuthorized(principal);
281
+ }
282
+ catch (error) {
283
+ if (error instanceof CollectorAccessError) {
284
+ throw new ProtocolError(ProtocolErrorCode.InvalidRequest, error.message);
285
+ }
286
+ throw new ProtocolError(ProtocolErrorCode.InternalError, "Event authorization is temporarily unavailable");
287
+ }
288
+ if (!allowed) {
289
+ throw new ProtocolError(ProtocolErrorCode.InvalidRequest, "Event access has been revoked");
290
+ }
291
+ // Checked before the verification POST, and again atomically below.
292
+ await this.initialized();
293
+ this.assertCapacity(await this.store.read(), principal, id);
294
+ await this.verifyCallback(principal, url, id, secret);
295
+ const now = this.now();
296
+ const refreshBefore = new Date(now + grantedTtlMs(params.ttlMs)).toISOString();
297
+ await this.store.transact((state) => {
298
+ this.pruneState(state);
299
+ this.assertCapacity(state, principal, id);
300
+ const existing = state.subscriptions.find((entry) => entry.id === id);
301
+ const rotated = existing !== undefined && existing.secret !== secret;
302
+ const next = {
303
+ id,
304
+ subject: principal.subject,
305
+ clientId: principal.clientId,
306
+ name: TRACE_ERROR_EVENT_NAME,
307
+ arguments: args,
308
+ url: url.href,
309
+ secret,
310
+ ...(rotated
311
+ ? {
312
+ previousSecret: existing.secret,
313
+ previousSecretUntil: new Date(now + SECRET_ROTATION_WINDOW_MS).toISOString(),
314
+ }
315
+ : existing?.previousSecret !== undefined && existing.previousSecretUntil !== undefined
316
+ ? { previousSecret: existing.previousSecret, previousSecretUntil: existing.previousSecretUntil }
317
+ : {}),
318
+ refreshBefore,
319
+ updatedAt: new Date(now).toISOString(),
320
+ };
321
+ state.subscriptions = [...state.subscriptions.filter((entry) => entry.id !== id), next];
322
+ });
323
+ this.ensurePolling();
324
+ return { id, refreshBefore, cursor: null, truncated: false };
325
+ }
326
+ async unsubscribe(params, principal) {
327
+ const args = parseArguments(params.name, params.arguments);
328
+ const url = checkedCallbackUrl(params.delivery.url);
329
+ const id = subscriptionId(principal, url.href, params.name, args);
330
+ await this.initialized();
331
+ await this.store.transact((state) => {
332
+ state.subscriptions = state.subscriptions.filter((entry) => entry.id !== id);
333
+ });
334
+ const active = this.deliveries.get(id);
335
+ for (const delivery of active ?? [])
336
+ delivery.controller.abort();
337
+ await Promise.allSettled([...(active ?? [])].map((delivery) => delivery.done));
338
+ return {};
339
+ }
340
+ /** A refresh always passes; a new subscription needs a free slot for its caller. */
341
+ assertCapacity(state, principal, id) {
342
+ if (state.subscriptions.some((entry) => entry.id === id))
343
+ return;
344
+ const held = state.subscriptions.filter((entry) => entry.subject === principal.subject
345
+ && entry.clientId === principal.clientId
346
+ && this.isLive(entry)).length;
347
+ if (held >= MAX_SUBSCRIPTIONS_PER_PRINCIPAL) {
348
+ throw invalidParams(`at most ${MAX_SUBSCRIPTIONS_PER_PRINCIPAL} live subscriptions per caller; unsubscribe one first`);
349
+ }
350
+ }
351
+ /**
352
+ * A signed, single-use challenge before any application data; a success is
353
+ * cached per principal and callback URL for a bounded period.
354
+ */
355
+ async verifyCallback(principal, url, id, secret) {
356
+ const key = canonicalJson([principal.clientId, principal.subject, url.href]);
357
+ const cachedUntil = this.verified.get(key);
358
+ if (cachedUntil !== undefined && cachedUntil > this.now())
359
+ return;
360
+ const challenge = randomId("chl");
361
+ const body = JSON.stringify({ type: "verification", challenge });
362
+ const msgId = randomId("msg_verification");
363
+ const timestamp = Math.floor(this.now() / 1000);
364
+ let response;
365
+ try {
366
+ response = await this.post(url, body, {
367
+ "content-type": "application/json",
368
+ "webhook-id": msgId,
369
+ "webhook-timestamp": String(timestamp),
370
+ "webhook-signature": signWebhook([secret], msgId, timestamp, body),
371
+ "x-mcp-subscription-id": id,
372
+ }, CALLBACK_TIMEOUT_MS);
373
+ }
374
+ catch (error) {
375
+ if (error instanceof CallbackAddressError)
376
+ throw callbackError(error.message, "invalid_url");
377
+ const timedOut = error instanceof Error && /timed out/u.test(error.message);
378
+ throw callbackError("callback verification request failed", timedOut ? "timeout" : "unreachable");
379
+ }
380
+ let echoed;
381
+ try {
382
+ echoed = JSON.parse(response.body).challenge;
383
+ }
384
+ catch {
385
+ echoed = undefined;
386
+ }
387
+ if (response.status < 200 || response.status > 299 || typeof echoed !== "string" || !sameText(echoed, challenge)) {
388
+ throw callbackError("callback did not echo the verification challenge", "challenge_failed");
389
+ }
390
+ if (this.verified.size >= 10_000)
391
+ this.verified.clear();
392
+ this.verified.set(key, this.now() + VERIFICATION_CACHE_MS);
393
+ }
394
+ /** Loads the store once; every store access waits for it. */
395
+ initialized() {
396
+ this.ready ??= this.store.initialize();
397
+ return this.ready;
398
+ }
399
+ /** Start the poller if a live subscription exists; `stop` ends it. */
400
+ async start() {
401
+ this.stopped = false;
402
+ await this.initialized();
403
+ const { state } = await this.store.transact((draft) => this.pruneState(draft));
404
+ if (state.subscriptions.some((entry) => this.isLive(entry)))
405
+ this.ensurePolling();
406
+ }
407
+ async stop() {
408
+ this.stopped = true;
409
+ if (this.timer !== undefined)
410
+ clearInterval(this.timer);
411
+ this.timer = undefined;
412
+ for (const active of this.deliveries.values()) {
413
+ for (const delivery of active)
414
+ delivery.controller.abort();
415
+ }
416
+ await this.polling;
417
+ }
418
+ ensurePolling() {
419
+ if (this.stopped || this.timer !== undefined)
420
+ return;
421
+ // A failed store write rejects; an unhandled rejection would end the process.
422
+ this.timer = setInterval(() => {
423
+ this.poll().catch((error) => {
424
+ this.log(`poll failed: ${error instanceof Error ? error.message : String(error)}`);
425
+ });
426
+ }, this.pollIntervalMs);
427
+ this.timer.unref();
428
+ }
429
+ isLive(entry) {
430
+ return Date.parse(entry.refreshBefore) > this.now();
431
+ }
432
+ pruneState(state) {
433
+ state.subscriptions = state.subscriptions.filter((entry) => this.isLive(entry));
434
+ for (const entry of state.subscriptions) {
435
+ if (entry.previousSecretUntil === undefined || Date.parse(entry.previousSecretUntil) <= this.now()) {
436
+ delete entry.previousSecret;
437
+ delete entry.previousSecretUntil;
438
+ }
439
+ }
440
+ }
441
+ async authorized(subscription) {
442
+ let allowed;
443
+ try {
444
+ collectorAccessForSubject(subscription.subject);
445
+ allowed = await this.isAuthorized(subscription);
446
+ }
447
+ catch (error) {
448
+ if (error instanceof CollectorAccessError) {
449
+ allowed = false;
450
+ }
451
+ else {
452
+ this.log(`authorization unavailable for ${subscription.id}; delivery suspended`);
453
+ return false;
454
+ }
455
+ }
456
+ if (!allowed) {
457
+ await this.store.transact((state) => {
458
+ state.subscriptions = state.subscriptions.filter((entry) => entry.id !== subscription.id);
459
+ });
460
+ }
461
+ return allowed;
462
+ }
463
+ /** One poll: drop lapsed subscriptions, then deliver each new error trace. */
464
+ poll() {
465
+ this.polling ??= this.pollOnce().finally(() => {
466
+ this.polling = undefined;
467
+ });
468
+ return this.polling;
469
+ }
470
+ async pollOnce() {
471
+ if (this.stopped)
472
+ return;
473
+ await this.initialized();
474
+ const { state } = await this.store.transact((draft) => {
475
+ this.pruneState(draft);
476
+ });
477
+ if (state.subscriptions.length === 0) {
478
+ if (this.timer !== undefined)
479
+ clearInterval(this.timer);
480
+ this.timer = undefined;
481
+ this.seen.clear();
482
+ return;
483
+ }
484
+ const subscriptions = [];
485
+ for (const subscription of state.subscriptions) {
486
+ if (await this.authorized(subscription))
487
+ subscriptions.push(subscription);
488
+ }
489
+ const groups = new Map();
490
+ for (const subscription of subscriptions) {
491
+ const key = this.sourceKey(subscription);
492
+ const group = groups.get(key) ?? [];
493
+ group.push(subscription);
494
+ groups.set(key, group);
495
+ }
496
+ // An authorization outage suspends a live subscription; retain its baseline
497
+ // so recovery can still deliver errors that arrived during the outage.
498
+ const retainedKeys = new Set();
499
+ for (const subscription of state.subscriptions) {
500
+ try {
501
+ this.sourceKey(subscription);
502
+ retainedKeys.add(subscription.id);
503
+ }
504
+ catch (error) {
505
+ if (!(error instanceof CollectorAccessError))
506
+ throw error;
507
+ }
508
+ }
509
+ for (const key of this.seen.keys()) {
510
+ if (!retainedKeys.has(key))
511
+ this.seen.delete(key);
512
+ }
513
+ const results = (await Promise.all([...groups].map(async ([key, group]) => {
514
+ let traces;
515
+ try {
516
+ traces = await this.recentTraces(group[0]);
517
+ }
518
+ catch {
519
+ this.log("collector poll failed; delivery suspended for this account");
520
+ return [];
521
+ }
522
+ const deliveries = [];
523
+ for (const subscription of group) {
524
+ const previous = this.seen.get(subscription.id);
525
+ if (previous === undefined || previous.source !== key) {
526
+ this.seen.set(subscription.id, {
527
+ source: key,
528
+ traces: new Set(traces.filter((trace) => trace.has_error).map((trace) => trace.trace_id)),
529
+ });
530
+ continue;
531
+ }
532
+ // Fetch once per project; authorization outages must not advance the
533
+ // delivery baseline belonging to a different subscription.
534
+ const fresh = traces.filter((trace) => trace.has_error && !previous.traces.has(trace.trace_id));
535
+ for (const trace of fresh)
536
+ previous.traces.add(trace.trace_id);
537
+ if (previous.traces.size > MAX_SEEN_TRACES) {
538
+ previous.traces = new Set([...previous.traces].slice(-MAX_SEEN_TRACES));
539
+ }
540
+ for (const trace of fresh) {
541
+ if (matches(subscription, trace))
542
+ deliveries.push(this.deliver(subscription, trace, key));
543
+ }
544
+ }
545
+ return Promise.allSettled(deliveries);
546
+ }))).flat();
547
+ for (const result of results) {
548
+ if (result.status === "rejected")
549
+ this.log("event delivery failed");
550
+ }
551
+ }
552
+ sourceKey(principal) {
553
+ const project = collectorAccessForSubject(principal.subject)?.project
554
+ ?? (process.env.QYL_PROJECT?.trim() || "default");
555
+ return canonicalJson([collectorUrl(), project]);
556
+ }
557
+ deliver(subscription, trace, sourceKey) {
558
+ const controller = new AbortController();
559
+ const done = this.deliverWhileActive(subscription, trace, sourceKey, controller.signal);
560
+ const active = this.deliveries.get(subscription.id) ?? new Set();
561
+ this.deliveries.set(subscription.id, active);
562
+ const delivery = { controller, done };
563
+ active.add(delivery);
564
+ return done.finally(() => {
565
+ active.delete(delivery);
566
+ if (active.size === 0)
567
+ this.deliveries.delete(subscription.id);
568
+ });
569
+ }
570
+ /** POST one signed event, retrying transient failures with backoff. */
571
+ async deliverWhileActive(subscription, trace, sourceKey, signal) {
572
+ const event = {
573
+ eventId: `evt_${trace.trace_id}`,
574
+ name: subscription.name,
575
+ timestamp: new Date(trace.start_time).toISOString(),
576
+ data: traceErrorPayload(trace),
577
+ cursor: null,
578
+ };
579
+ const body = JSON.stringify(event);
580
+ if (Buffer.byteLength(body, "utf8") > WEBHOOK_MAX_BODY_BYTES) {
581
+ this.log(`dropped ${event.eventId}: body exceeds 256 KiB`);
582
+ return;
583
+ }
584
+ for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt++) {
585
+ if (this.stopped || signal.aborted || !await this.authorized(subscription))
586
+ return;
587
+ // Re-read after the async access check: unsubscribe, expiry and key
588
+ // rotation must also take effect during retries and in-flight polls.
589
+ const current = (await this.store.read()).subscriptions.find((entry) => entry.id === subscription.id);
590
+ if (this.stopped || signal.aborted || current === undefined || !this.isLive(current))
591
+ return;
592
+ subscription = current;
593
+ if (this.sourceKey(subscription) !== sourceKey)
594
+ return;
595
+ const timestamp = Math.floor(this.now() / 1000);
596
+ const secrets = subscription.previousSecret !== undefined
597
+ && subscription.previousSecretUntil !== undefined
598
+ && Date.parse(subscription.previousSecretUntil) > this.now()
599
+ ? [subscription.secret, subscription.previousSecret]
600
+ : [subscription.secret];
601
+ let status;
602
+ try {
603
+ status = (await this.post(new URL(subscription.url), body, {
604
+ "content-type": "application/json",
605
+ "webhook-id": event.eventId,
606
+ "webhook-timestamp": String(timestamp),
607
+ "webhook-signature": signWebhook(secrets, event.eventId, timestamp, body),
608
+ "x-mcp-subscription-id": subscription.id,
609
+ }, CALLBACK_TIMEOUT_MS, signal)).status;
610
+ }
611
+ catch (error) {
612
+ if (signal.aborted)
613
+ return;
614
+ if (error instanceof CallbackAddressError) {
615
+ this.log(`dropped ${event.eventId} for ${subscription.id}: ${error.message}`);
616
+ return;
617
+ }
618
+ }
619
+ if (status !== undefined && status >= 200 && status <= 299)
620
+ return;
621
+ if (status === 410) {
622
+ // Standard Webhooks: 410 Gone disables the endpoint.
623
+ await this.store.transact((state) => {
624
+ state.subscriptions = state.subscriptions.filter((entry) => entry.id !== subscription.id);
625
+ });
626
+ return;
627
+ }
628
+ const transient = status === undefined || status === 429 || status >= 500;
629
+ if (!transient) {
630
+ this.log(`dropped ${event.eventId} for ${subscription.id}: HTTP ${status}`);
631
+ return;
632
+ }
633
+ if (attempt < DELIVERY_ATTEMPTS) {
634
+ try {
635
+ await this.sleep(RETRY_BASE_MS * 2 ** (attempt - 1), signal);
636
+ }
637
+ catch (error) {
638
+ if (signal.aborted)
639
+ return;
640
+ throw error;
641
+ }
642
+ }
643
+ }
644
+ this.log(`gave up on ${event.eventId} for ${subscription.id} after ${DELIVERY_ATTEMPTS} attempts`);
645
+ }
646
+ }
647
+ //# sourceMappingURL=events.js.map