mcp-coordinator 0.3.0 → 0.4.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.
@@ -18,6 +18,7 @@ import { SseEmitter } from "./sse-emitter.js";
18
18
  import { MqttBridge } from "./mqtt-bridge.js";
19
19
  import { AgentActivityTracker } from "./agent-activity.js";
20
20
  import { QuotaCache } from "./quota/quota-cache.js";
21
+ import { Metrics } from "./metrics.js";
21
22
  import { createLogger } from "./logger.js";
22
23
  import { getVersion } from "../cli/version.js";
23
24
  const VERSION = getVersion();
@@ -36,6 +37,7 @@ export function createServices(config) {
36
37
  const contextProvider = new SummaryContextProvider(registry, consultation, fileTracker);
37
38
  const sseEmitter = new SseEmitter();
38
39
  const mqttBridge = new MqttBridge(logger.child({ component: "mqtt" }));
40
+ const metrics = new Metrics();
39
41
  // Quota cache — macOS-only for now, Linux/Windows stubs return 503 via the
40
42
  // /api/quota handler so raids keep running without a quota guardrail there.
41
43
  // onRefresh fans the new data out to dashboard (SSE) + any live listener (MQTT)
@@ -62,8 +64,9 @@ export function createServices(config) {
62
64
  else if (event.type === "agent_offline")
63
65
  quotaCache.onAgentInactive();
64
66
  });
65
- // Centralized resolution → SSE + MQTT
67
+ // Centralized resolution → SSE + MQTT + metrics
66
68
  consultation.onResolve((event) => {
69
+ metrics.recordThreadResolved(event.resolution_type);
67
70
  sseEmitter.emit("thread_resolved", {
68
71
  thread_id: event.thread_id,
69
72
  resolution_type: event.resolution_type,
@@ -77,10 +80,15 @@ export function createServices(config) {
77
80
  if (event.resolution_type !== "auto_resolved") {
78
81
  mqttBridge.publishResolution(event.thread_id, "resolved", event.resolution_summary || "");
79
82
  }
83
+ // P1 fix: clear the retained `coordinator/consultations/new` event so a
84
+ // coordinator restart doesn't re-broadcast a consultation that's already
85
+ // been resolved. No-op when the retained slot holds a different (newer)
86
+ // thread.
87
+ mqttBridge.clearRetainedConsultation(event.thread_id);
80
88
  });
81
89
  return {
82
90
  logger, registry, activityTracker, consultation, conflictDetector,
83
- depMap, fileTracker, impactScorer, introspection, contextProvider, sseEmitter, mqttBridge, quotaCache,
91
+ depMap, fileTracker, impactScorer, introspection, contextProvider, sseEmitter, mqttBridge, quotaCache, metrics,
84
92
  };
85
93
  }
86
94
  /** Create a new McpServer bound to the shared services (one per MCP session). */
@@ -1,10 +1,16 @@
1
1
  import type { CoordinatorEvent, EventType } from "./types.js";
2
2
  type EventListener = (event: CoordinatorEvent) => void;
3
+ export declare const MAX_SSE_CLIENTS: number;
3
4
  export declare class SseEmitter {
4
5
  private listeners;
6
+ private rejectedCount;
5
7
  emit(type: EventType, payload: Record<string, unknown>): void;
6
8
  getEventsSince(lastId: number): CoordinatorEvent[];
7
9
  addListener(listener: EventListener): () => void;
8
10
  removeAllListeners(): void;
11
+ /** P3: introspection for tests + ops dashboards. */
12
+ listenerCount(): number;
13
+ /** P3: count of addListener calls refused due to MAX_SSE_CLIENTS. */
14
+ getRejectedCount(): number;
9
15
  }
10
16
  export {};
@@ -1,6 +1,25 @@
1
1
  import { getDb } from "./database.js";
2
+ /**
3
+ * P3: bound the listener array so a runaway client (or DoS attempt) can't
4
+ * grow it without limit. Default 100 covers a small-to-mid swarm — enough
5
+ * headroom for a dashboard + every agent + a handful of CLI tailers, but
6
+ * not so large that a leak would silently exhaust memory. Override via
7
+ * COORDINATOR_MAX_SSE_CLIENTS for larger deployments.
8
+ */
9
+ const DEFAULT_MAX_SSE_CLIENTS = 100;
10
+ export const MAX_SSE_CLIENTS = (() => {
11
+ const raw = process.env.COORDINATOR_MAX_SSE_CLIENTS;
12
+ if (!raw)
13
+ return DEFAULT_MAX_SSE_CLIENTS;
14
+ const n = parseInt(raw, 10);
15
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_SSE_CLIENTS;
16
+ })();
17
+ const NOOP = () => { };
2
18
  export class SseEmitter {
3
19
  listeners = [];
20
+ // P3: track refusals so operators can see when the cap is being hit.
21
+ // Also lets tests assert "we refused without throwing" without scraping logs.
22
+ rejectedCount = 0;
4
23
  emit(type, payload) {
5
24
  const db = getDb();
6
25
  const payloadStr = JSON.stringify(payload);
@@ -13,8 +32,22 @@ export class SseEmitter {
13
32
  payload: payloadStr,
14
33
  created_at: new Date().toISOString(),
15
34
  };
16
- for (const listener of this.listeners) {
17
- listener(event);
35
+ // P3: async fan-out via setImmediate so a slow listener (e.g. a stalled
36
+ // SSE client whose socket buffer is full) cannot block siblings or the
37
+ // emit() caller. Snapshot the array first so a listener that unsubscribes
38
+ // mid-loop doesn't shift indices under us.
39
+ const snapshot = this.listeners.slice();
40
+ for (const listener of snapshot) {
41
+ setImmediate(() => {
42
+ try {
43
+ listener(event);
44
+ }
45
+ catch {
46
+ // Listener errors must not crash the emitter or affect siblings.
47
+ // Drop silently — the SSE response writers swallow their own
48
+ // socket errors via the unsubscribe path on req.on("close").
49
+ }
50
+ });
18
51
  }
19
52
  }
20
53
  getEventsSince(lastId) {
@@ -24,6 +57,13 @@ export class SseEmitter {
24
57
  .all(lastId);
25
58
  }
26
59
  addListener(listener) {
60
+ // P3: refuse-with-no-op when the cap is reached. Returning a no-op
61
+ // keeps the caller's unsubscribe contract intact (no special-casing
62
+ // upstream) while preventing the array from growing past MAX_SSE_CLIENTS.
63
+ if (this.listeners.length >= MAX_SSE_CLIENTS) {
64
+ this.rejectedCount++;
65
+ return NOOP;
66
+ }
27
67
  this.listeners.push(listener);
28
68
  return () => {
29
69
  this.listeners = this.listeners.filter((l) => l !== listener);
@@ -32,4 +72,12 @@ export class SseEmitter {
32
72
  removeAllListeners() {
33
73
  this.listeners = [];
34
74
  }
75
+ /** P3: introspection for tests + ops dashboards. */
76
+ listenerCount() {
77
+ return this.listeners.length;
78
+ }
79
+ /** P3: count of addListener calls refused due to MAX_SSE_CLIENTS. */
80
+ getRejectedCount() {
81
+ return this.rejectedCount;
82
+ }
35
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-coordinator",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "mcpName": "io.github.swoofer/mcp-coordinator",
5
5
  "description": "Embedded MQTT broker + MCP server for multi-agent coordination",
6
6
  "type": "module",
@@ -63,6 +63,8 @@
63
63
  "jose": "^6.2.2",
64
64
  "mqtt": "^5.15.0",
65
65
  "pino": "^10.3.1",
66
+ "prom-client": "^15.1.3",
67
+ "tar": "^7.4.3",
66
68
  "ws": "^8.20.0",
67
69
  "zod": "^3.23.0"
68
70
  },