mcp-coordinator 0.6.0 → 0.7.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 (66) hide show
  1. package/README.md +24 -0
  2. package/dist/src/agent-activity.d.ts +13 -9
  3. package/dist/src/agent-activity.js +45 -24
  4. package/dist/src/agent-registry.d.ts +7 -7
  5. package/dist/src/agent-registry.js +19 -18
  6. package/dist/src/announce-workflow.d.ts +1 -0
  7. package/dist/src/announce-workflow.js +13 -12
  8. package/dist/src/auth/providers/registry.d.ts +4 -0
  9. package/dist/src/auth/providers/registry.js +7 -0
  10. package/dist/src/auth/providers/types.d.ts +11 -0
  11. package/dist/src/auth/providers/types.js +1 -0
  12. package/dist/src/auth.d.ts +24 -5
  13. package/dist/src/auth.js +172 -23
  14. package/dist/src/conflict-detector.d.ts +1 -0
  15. package/dist/src/conflict-detector.js +4 -4
  16. package/dist/src/consultation.d.ts +28 -14
  17. package/dist/src/consultation.js +101 -68
  18. package/dist/src/context-provider.d.ts +2 -2
  19. package/dist/src/context-provider.js +3 -4
  20. package/dist/src/database.js +203 -4
  21. package/dist/src/dependency-map.d.ts +25 -4
  22. package/dist/src/dependency-map.js +49 -11
  23. package/dist/src/file-tracker.d.ts +5 -4
  24. package/dist/src/file-tracker.js +16 -14
  25. package/dist/src/git-cochange-builder.d.ts +11 -2
  26. package/dist/src/git-cochange-builder.js +15 -7
  27. package/dist/src/http/handle-health.d.ts +9 -5
  28. package/dist/src/http/handle-health.js +22 -8
  29. package/dist/src/http/handle-rest.d.ts +3 -0
  30. package/dist/src/http/handle-rest.js +86 -57
  31. package/dist/src/http/utils.d.ts +4 -0
  32. package/dist/src/http/utils.js +7 -1
  33. package/dist/src/impact-scorer.d.ts +3 -0
  34. package/dist/src/impact-scorer.js +65 -51
  35. package/dist/src/introspection.d.ts +13 -7
  36. package/dist/src/introspection.js +34 -11
  37. package/dist/src/metrics.js +2 -1
  38. package/dist/src/mqtt-bridge.d.ts +3 -2
  39. package/dist/src/mqtt-bridge.js +33 -23
  40. package/dist/src/mqtt-broker.d.ts +16 -7
  41. package/dist/src/mqtt-broker.js +57 -15
  42. package/dist/src/security/audit.d.ts +11 -0
  43. package/dist/src/security/audit.js +7 -0
  44. package/dist/src/security/encryption.d.ts +17 -0
  45. package/dist/src/security/encryption.js +5 -0
  46. package/dist/src/serve-http.js +136 -57
  47. package/dist/src/server-setup.d.ts +12 -2
  48. package/dist/src/server-setup.js +33 -15
  49. package/dist/src/sse-emitter.d.ts +7 -4
  50. package/dist/src/sse-emitter.js +27 -21
  51. package/dist/src/tools/agents-tools.d.ts +2 -1
  52. package/dist/src/tools/agents-tools.js +36 -12
  53. package/dist/src/tools/consultation-tools.d.ts +2 -1
  54. package/dist/src/tools/consultation-tools.js +106 -40
  55. package/dist/src/tools/dependencies-tools.d.ts +2 -1
  56. package/dist/src/tools/dependencies-tools.js +25 -7
  57. package/dist/src/tools/files-tools.d.ts +2 -1
  58. package/dist/src/tools/files-tools.js +26 -8
  59. package/dist/src/tools/mqtt-tools.d.ts +7 -1
  60. package/dist/src/tools/mqtt-tools.js +27 -4
  61. package/dist/src/tools/status-tools.d.ts +7 -1
  62. package/dist/src/tools/status-tools.js +26 -9
  63. package/dist/src/types.d.ts +2 -0
  64. package/dist/src/working-files-tracker.d.ts +21 -11
  65. package/dist/src/working-files-tracker.js +32 -21
  66. package/package.json +4 -1
@@ -14,12 +14,12 @@ const __dirname = path.dirname(__filename);
14
14
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
15
15
  import { createServices, createMcpServer } from "./server-setup.js";
16
16
  import { createLogger } from "./logger.js";
17
- import { initAuth, authenticateRequest, createToken, refreshToken, revokeAgent, setAuthLogger, verifyToken } from "./auth.js";
17
+ import { initAuth, authenticateRequest, createToken, refreshToken, revokeAgent, setAuthLogger } from "./auth.js";
18
18
  import { safeJoinUnderRoot } from "./path-guard.js";
19
19
  import { handleRest as handleRestExt } from "./http/handle-rest.js";
20
20
  import { handleLivez, handleReadyz, handleHealth } from "./http/handle-health.js";
21
21
  import { serveMetrics } from "./metrics.js";
22
- import { parseBody as parseBodyShared, json as jsonShared } from "./http/utils.js";
22
+ import { parseBody as parseBodyShared, json as jsonShared, jsonAuthError as jsonAuthErrorShared } from "./http/utils.js";
23
23
  import { getVersion } from "../cli/version.js";
24
24
  const VERSION = getVersion();
25
25
  import { startEmbeddedMqttBroker } from "./mqtt-broker.js";
@@ -44,7 +44,14 @@ const MQTT_TCP_PORT = parseInt(process.env.COORDINATOR_MQTT_TCP_PORT || "1883");
44
44
  const MQTT_WS_PATH = process.env.COORDINATOR_MQTT_WS_PATH || "/mqtt";
45
45
  const AUTH_ENABLED = process.env.COORDINATOR_AUTH_ENABLED === "true";
46
46
  const JWT_SECRET = process.env.COORDINATOR_JWT_SECRET || "";
47
+ // Capture whether the env var was EXPLICITLY set at startup. Do not derive this
48
+ // from the runtime signing key — `initAuth()` may have generated a random
49
+ // per-boot secret, in which case the key's length is > 0 but the operator did
50
+ // not provide one. `/healthz` must report the operator's intent, not the
51
+ // random fallback.
52
+ const JWT_SECRET_EXPLICITLY_SET = !!process.env.COORDINATOR_JWT_SECRET;
47
53
  const JWT_EXPIRY = process.env.COORDINATOR_JWT_EXPIRY || "24h";
54
+ const JWT_PREV_SECRET = process.env.COORDINATOR_JWT_PREV_SECRET || "";
48
55
  const REGISTRATION_SECRET = process.env.COORDINATOR_REGISTRATION_SECRET || "";
49
56
  const ADMIN_SECRET = process.env.COORDINATOR_ADMIN_SECRET || "";
50
57
  let services;
@@ -57,6 +64,7 @@ let currentRunConfig = null;
57
64
  // startServer) can keep using `parseBody` / `json` without changes.
58
65
  const parseBody = parseBodyShared;
59
66
  const json = jsonShared;
67
+ const jsonAuthError = jsonAuthErrorShared;
60
68
  function decodeJwtPayload(token) {
61
69
  // Used only on tokens we just minted ourselves (to read the `exp` claim
62
70
  // before returning it to the client). Real verification of inbound tokens
@@ -73,11 +81,13 @@ function safeEqual(a, b) {
73
81
  // startServer's call site stable while the 382-line REST router lives in its
74
82
  // own module. currentRunConfig stays here as the single mutable owner; the
75
83
  // extracted function reads/writes via getRunConfig/setRunConfig accessors.
76
- async function handleRest(req, res) {
84
+ // claims: always populated by the caller (synthetic legacy claims when AUTH_ENABLED=false).
85
+ async function handleRest(req, res, claims) {
77
86
  const ctx = {
78
87
  services,
79
88
  httpLog,
80
89
  authEnabled: AUTH_ENABLED,
90
+ claims,
81
91
  getRunConfig: () => currentRunConfig,
82
92
  setRunConfig: (cfg) => { currentRunConfig = cfg; },
83
93
  };
@@ -119,24 +129,30 @@ async function handleAuth(req, res) {
119
129
  else if (url === "/api/auth/refresh" && req.method === "POST") {
120
130
  const authHeader = req.headers.authorization;
121
131
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
132
+ // RFC 6750 §3: 401 from a Bearer-protected endpoint must include
133
+ // WWW-Authenticate. /api/auth/refresh doesn't go through
134
+ // authenticateRequest (it consumes the prior token, doesn't enforce
135
+ // current validity), so we set the header manually here.
136
+ res.setHeader("WWW-Authenticate", 'Bearer realm="mcp-coordinator", error="invalid_token"');
122
137
  json(res, { error: "Bearer token required" }, 401);
123
138
  return;
124
139
  }
125
140
  try {
126
- const newToken = await refreshToken(authHeader.slice(7));
141
+ const newToken = await refreshToken(authHeader.slice(7), { authEnabled: AUTH_ENABLED });
127
142
  const payload = decodeJwtPayload(newToken);
128
143
  const expiresAt = new Date(payload.exp * 1000).toISOString();
129
144
  authLog.info({ agent_id: payload.sub }, "Token refreshed");
130
145
  json(res, { token: newToken, expires_at: expiresAt });
131
146
  }
132
147
  catch {
148
+ res.setHeader("WWW-Authenticate", 'Bearer realm="mcp-coordinator", error="invalid_token"');
133
149
  json(res, { error: "Invalid or expired token (beyond grace period)" }, 401);
134
150
  }
135
151
  }
136
152
  else if (url === "/api/auth/revoke" && req.method === "POST") {
137
- const authResult = await authenticateRequest(req);
153
+ const authResult = await authenticateRequest(req, { authEnabled: AUTH_ENABLED });
138
154
  if (!authResult.ok) {
139
- json(res, { error: authResult.error }, authResult.status);
155
+ jsonAuthError(res, authResult);
140
156
  return;
141
157
  }
142
158
  const { agent_id } = body;
@@ -187,7 +203,26 @@ const SSE_HEARTBEAT_MS = (() => {
187
203
  const n = parseInt(raw, 10);
188
204
  return Number.isFinite(n) && n > 0 ? n : 30_000;
189
205
  })();
190
- function handleSse(req, res) {
206
+ async function handleSse(req, res) {
207
+ // Task 21.5: authenticate SSE GET requests. EventSource does not send
208
+ // Authorization headers, so we also accept a ?token= query param on GET.
209
+ // The ?token= fallback is handled inside authenticateRequest (GET-only,
210
+ // Authorization header takes precedence when both are present).
211
+ const authResult = await authenticateRequest(req, { authEnabled: AUTH_ENABLED });
212
+ if (!authResult.ok) {
213
+ const headers = {
214
+ "Content-Type": "application/json",
215
+ "Access-Control-Allow-Origin": "*",
216
+ };
217
+ if (authResult.wwwAuthenticate) {
218
+ headers["WWW-Authenticate"] = authResult.wwwAuthenticate;
219
+ }
220
+ res.writeHead(authResult.status, headers);
221
+ res.end(JSON.stringify({ error: authResult.error }));
222
+ services.metrics.recordHttpRequest("/api/events", authResult.status);
223
+ return;
224
+ }
225
+ const orgId = authResult.claims.org;
191
226
  res.writeHead(200, {
192
227
  "Content-Type": "text/event-stream",
193
228
  "Cache-Control": "no-cache",
@@ -199,13 +234,13 @@ function handleSse(req, res) {
199
234
  // Use Last-Event-ID for resumption, otherwise send last 50
200
235
  const lastEventId = parseInt(req.headers["last-event-id"] || "0", 10);
201
236
  const events = lastEventId > 0
202
- ? services.sseEmitter.getEventsSince(lastEventId)
203
- : services.sseEmitter.getEventsSince(0).slice(-50);
237
+ ? services.sseEmitter.getEventsSince(orgId, lastEventId)
238
+ : services.sseEmitter.getEventsSince(orgId, 0).slice(-50);
204
239
  for (const event of events) {
205
240
  writeSseEvent(res, event);
206
241
  }
207
242
  // Listen for new events
208
- const unsubscribe = services.sseEmitter.addListener((event) => {
243
+ const unsubscribe = services.sseEmitter.addListener(orgId, (event) => {
209
244
  writeSseEvent(res, event);
210
245
  });
211
246
  // P3: heartbeat. Browsers ignore the `:` comment line per the SSE spec,
@@ -232,6 +267,31 @@ function handleSse(req, res) {
232
267
  services.metrics.decSseClients();
233
268
  });
234
269
  }
270
+ /**
271
+ * MCP per-request auth gate (Task 23).
272
+ *
273
+ * Called on EVERY POST /mcp — both new-session and existing-session branches.
274
+ * Returns claims on success, or writes 401 + returns null so the caller can
275
+ * immediately `return` without further processing.
276
+ *
277
+ * When AUTH_ENABLED=false (open-coordinator mode) returns synthetic legacy
278
+ * claims so that tool handlers (Task 23.5+) always have a claims object keyed
279
+ * by org='default'. Does NOT stash claims on `req` — RequestHandlerExtra in
280
+ * the MCP SDK does not expose IncomingMessage to tool handlers. Task 23.5
281
+ * will populate a sessionClaims Map<sessionId, AuthClaims> instead.
282
+ */
283
+ async function authenticateMcpRequest(req, res) {
284
+ if (!AUTH_ENABLED) {
285
+ // Open-coordinator mode: synthetic legacy claims.
286
+ return { sub: "legacy", user_id: "legacy", org: "default", role: "admin", jti: "legacy" };
287
+ }
288
+ const authResult = await authenticateRequest(req, { authEnabled: AUTH_ENABLED });
289
+ if (!authResult.ok) {
290
+ jsonAuthError(res, authResult);
291
+ return null;
292
+ }
293
+ return authResult.claims;
294
+ }
235
295
  export async function startServer(opts) {
236
296
  const port = opts?.port ?? PORT;
237
297
  const dataDir = opts?.dataDir ?? DATA_DIR;
@@ -257,11 +317,14 @@ export async function startServer(opts) {
257
317
  log.fatal("COORDINATOR_ADMIN_SECRET is required when auth is enabled");
258
318
  process.exit(1);
259
319
  }
260
- initAuth(JWT_SECRET, JWT_EXPIRY);
320
+ initAuth(JWT_SECRET, JWT_EXPIRY, JWT_PREV_SECRET ? { prevSecret: JWT_PREV_SECRET } : {});
261
321
  log.info("Auth enabled (JWT HS256)");
262
322
  }
263
323
  // Multi-session: one transport+server per MCP client session
264
324
  const sessions = new Map();
325
+ // Task 23.5: per-session claims map. Populated when a session is opened or
326
+ // re-verified (mid-session JWT rotation); evicted when the transport closes.
327
+ const sessionClaims = new Map();
265
328
  const httpServer = createServer(async (req, res) => {
266
329
  const url = req.url || "";
267
330
  // CORS preflight
@@ -325,7 +388,10 @@ export async function startServer(opts) {
325
388
  services.metrics.recordHttpRequest("/readyz", res.statusCode || 0);
326
389
  }
327
390
  else if (url === "/health") {
328
- handleHealth(req, res);
391
+ await handleHealth(req, res, {
392
+ authEnabled: AUTH_ENABLED,
393
+ jwtSecretSet: JWT_SECRET_EXPLICITLY_SET,
394
+ });
329
395
  services.metrics.recordHttpRequest("/health", 200);
330
396
  }
331
397
  else if (url === "/metrics" && req.method === "GET") {
@@ -333,10 +399,10 @@ export async function startServer(opts) {
333
399
  services.metrics.recordHttpRequest("/metrics", 200);
334
400
  }
335
401
  else if (url === "/api/events" && req.method === "GET") {
336
- handleSse(req, res);
402
+ await handleSse(req, res);
337
403
  }
338
404
  else if (url.startsWith("/api/auth/")) {
339
- if (!AUTH_ENABLED) {
405
+ if (!AUTH_ENABLED && url !== "/api/auth/refresh") {
340
406
  json(res, { error: "Authentication is not enabled on this coordinator" }, 501);
341
407
  }
342
408
  else {
@@ -346,35 +412,40 @@ export async function startServer(opts) {
346
412
  else if (url === "/mcp") {
347
413
  const sessionId = req.headers["mcp-session-id"];
348
414
  if (sessionId && sessions.has(sessionId)) {
349
- // Existing session — already authenticated, route directly
415
+ // Existing-session branch AUTH-GATED ON EVERY REQUEST per spec §J.
416
+ const claims = await authenticateMcpRequest(req, res);
417
+ if (!claims)
418
+ return; // 401 already written
419
+ // Task 23.5: update stored claims to support mid-session JWT rotation.
420
+ // The latest verified claims always win.
421
+ sessionClaims.set(sessionId, claims);
350
422
  await sessions.get(sessionId).handleRequest(req, res);
351
423
  }
352
424
  else if (req.method === "POST" && !sessionId) {
353
- // New session — auth guard required
354
- let authenticatedAgent;
355
- if (AUTH_ENABLED) {
356
- const authResult = await authenticateRequest(req);
357
- if (!authResult.ok) {
358
- authLog.warn({ reason: authResult.error, url, ip: req.socket.remoteAddress }, "Auth rejected");
359
- json(res, { error: authResult.error }, authResult.status);
360
- return;
361
- }
362
- authenticatedAgent = authResult.claims.sub;
363
- }
425
+ // New-session branch also gated.
426
+ const claims = await authenticateMcpRequest(req, res);
427
+ if (!claims)
428
+ return; // 401 already written
429
+ const authenticatedAgent = claims.sub;
364
430
  // Create transport + server
365
431
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
366
- const mcpServer = createMcpServer(services);
432
+ // Task 23.5: pass a getter so tool handlers can look up per-session claims.
433
+ const mcpServer = createMcpServer(services, (sid) => sessionClaims.get(sid) ?? null);
367
434
  await mcpServer.connect(transport);
368
435
  transport.onclose = () => {
369
436
  const sid = transport.sessionId;
370
- if (sid)
437
+ if (sid) {
371
438
  sessions.delete(sid);
439
+ sessionClaims.delete(sid); // Task 23.5: evict claims on session close
440
+ }
372
441
  mcpLog.info({ session_id: sid, remaining: sessions.size }, "MCP session closed");
373
442
  };
374
443
  await transport.handleRequest(req, res);
375
- if (transport.sessionId) {
376
- sessions.set(transport.sessionId, transport);
377
- mcpLog.info({ session_id: transport.sessionId, total: sessions.size, agent_id: authenticatedAgent }, "MCP session opened");
444
+ const sid = transport.sessionId;
445
+ if (sid) {
446
+ sessions.set(sid, transport);
447
+ sessionClaims.set(sid, claims); // Task 23.5: stash claims after sessionId is assigned
448
+ mcpLog.info({ session_id: sid, total: sessions.size, agent_id: authenticatedAgent }, "MCP session opened");
378
449
  }
379
450
  }
380
451
  else {
@@ -382,18 +453,18 @@ export async function startServer(opts) {
382
453
  }
383
454
  }
384
455
  else {
385
- // Auth guard for protected routes
386
- if (AUTH_ENABLED) {
387
- const authResult = await authenticateRequest(req);
388
- if (!authResult.ok) {
389
- authLog.warn({ reason: authResult.error, url, ip: req.socket.remoteAddress }, "Auth rejected");
390
- services.metrics.recordAuthRejected();
391
- json(res, { error: authResult.error }, authResult.status);
392
- return;
393
- }
456
+ // Always authenticate under AUTH_ENABLED=false this returns synthetic legacy claims
457
+ // so handlers can always read claims.org. Required for Tasks 15-19 which scope every
458
+ // database query by claims.org.
459
+ const authResult = await authenticateRequest(req, { authEnabled: AUTH_ENABLED });
460
+ if (!authResult.ok) {
461
+ authLog.warn({ reason: authResult.error, url, ip: req.socket.remoteAddress }, "Auth rejected");
462
+ services.metrics.recordAuthRejected();
463
+ jsonAuthError(res, authResult);
464
+ return;
394
465
  }
395
466
  if (url.startsWith("/api/") && (req.method === "POST" || req.method === "GET")) {
396
- await handleRest(req, res);
467
+ await handleRest(req, res, authResult.claims);
397
468
  services.metrics.recordHttpRequest((url.split("?")[0] || ""), res.statusCode || 0);
398
469
  }
399
470
  else {
@@ -413,25 +484,28 @@ export async function startServer(opts) {
413
484
  // B3 fix: when AUTH_ENABLED, gate every MQTT CONNECT by JWT in the password
414
485
  // field. Anonymous connections are rejected. Default off (essaim and any
415
486
  // client without auth keep working unchanged).
416
- const mqttAuth = AUTH_ENABLED
417
- ? async (_username, password) => {
418
- if (!password)
419
- return false;
420
- try {
421
- await verifyToken(password.toString("utf-8"));
422
- return true;
423
- }
424
- catch {
425
- return false;
426
- }
427
- }
428
- : undefined;
487
+ // Only install the verifier (and therefore the ACL hooks) when auth is enabled.
488
+ // Without this guard, AUTH_ENABLED=false would still reject anonymous v0.6
489
+ // MQTT clients at the authenticate hook, breaking backward compatibility.
429
490
  const broker = await startEmbeddedMqttBroker({
430
491
  tcpPort: mqttTcpPort,
431
492
  httpServer,
432
493
  wsPath: mqttWsPath,
433
494
  logger: log.child({ component: "mqtt-broker" }),
434
- authenticate: mqttAuth,
495
+ ...(AUTH_ENABLED ? {
496
+ authenticate: async (_username, password) => {
497
+ if (!password)
498
+ return { ok: false };
499
+ try {
500
+ const { verifyTokenStrict } = await import("./auth.js");
501
+ const { claims } = await verifyTokenStrict(password.toString("utf-8"));
502
+ return { ok: true, org: claims.org };
503
+ }
504
+ catch {
505
+ return { ok: false };
506
+ }
507
+ },
508
+ } : {}),
435
509
  });
436
510
  // B3: when AUTH_ENABLED, the internal coordinator client must authenticate
437
511
  // too. Mint a short-lived admin token for the bridge.
@@ -445,13 +519,18 @@ export async function startServer(opts) {
445
519
  agentId: "coordinator-internal",
446
520
  });
447
521
  services.mqttBridge.onOffline((agentId) => {
448
- services.registry.setOffline(agentId);
522
+ // TODO(Task 22): MQTT topics carry no org_id today, so setOffline("default", id) silently
523
+ // no-ops for any agent registered under a non-default org. Acceptable in single-tenant Phase 1
524
+ // (everything is "default"); becomes a correctness bug the moment multi-org goes live. Task 22
525
+ // (MQTT topic scoping + Aedes ACL hook) will thread the real org from the topic prefix.
526
+ services.registry.setOffline("default", agentId);
449
527
  services.consultation.handleAgentDeparture(agentId);
450
528
  // Clear in-flight working_files AFTER consultation cleanup so any future
451
529
  // consultation logic that might inspect working_files state for this agent
452
530
  // sees the pre-cleanup view.
453
531
  services.workingFiles.clearForAgent(agentId);
454
- services.sseEmitter.emit("agent_offline", { agent_id: agentId });
532
+ // TODO(Task 22): MQTT offline path has no org_id context; using "default" for single-tenant Phase 1.
533
+ services.sseEmitter.emit("agent_offline", { agent_id: agentId }, { org_id: "default" });
455
534
  });
456
535
  // Wait for the HTTP server to be actually listening before resolving the
457
536
  // returned handle. Otherwise callers (tests, essaim) may try to connect
@@ -1,4 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { AuthClaims } from "./auth.js";
2
3
  import { AgentRegistry } from "./agent-registry.js";
3
4
  import { Consultation } from "./consultation.js";
4
5
  import { ConflictDetector } from "./conflict-detector.js";
@@ -38,5 +39,14 @@ export interface CoordinatorServices {
38
39
  }
39
40
  /** Create shared services (once at startup). */
40
41
  export declare function createServices(config: CoordinatorConfig): CoordinatorServices;
41
- /** Create a new McpServer bound to the shared services (one per MCP session). */
42
- export declare function createMcpServer(services: CoordinatorServices): McpServer;
42
+ /** Create a new McpServer bound to the shared services (one per MCP session).
43
+ *
44
+ * @param getSessionClaims - Looks up per-session claims by sessionId. In STDIO
45
+ * mode there are no sessions so this defaults to a no-op that always returns
46
+ * null, which causes tool handlers to throw "Session has no captured claims"
47
+ * (expected — STDIO mode is single-tenant and unauthenticated; tool callers
48
+ * in that mode should rely on the AUTH_ENABLED=false synthetic-claims path
49
+ * added to authenticateMcpRequest, which is not invoked for STDIO).
50
+ * Pass a real getter in serve-http.ts's streamable-HTTP path.
51
+ */
52
+ export declare function createMcpServer(services: CoordinatorServices, getSessionClaims?: (sessionId: string) => AuthClaims | null): McpServer;
@@ -42,7 +42,7 @@ export function createServices(config) {
42
42
  const conflictDetector = new ConflictDetector(consultation, depMap, fileTracker, logger.child({ component: "conflict" }));
43
43
  const contextProvider = new SummaryContextProvider(registry, consultation, fileTracker);
44
44
  const sseEmitter = new SseEmitter();
45
- const mqttBridge = new MqttBridge(logger.child({ component: "mqtt" }));
45
+ const mqttBridge = new MqttBridge("default", logger.child({ component: "mqtt" }));
46
46
  const treeSitter = new TreeSitterExtractor(metrics);
47
47
  treeSitter.load().catch(() => { });
48
48
  const repoRoot = process.env.COORDINATOR_REPO_ROOT;
@@ -57,7 +57,8 @@ export function createServices(config) {
57
57
  retryMs: parseInt(process.env.COORDINATOR_LAYER4_RETRY_MS || "300000", 10),
58
58
  })
59
59
  : null;
60
- gitCochange?.startScheduler();
60
+ // TODO(Task 22): boot-time builder uses 'default' org because no auth context exists at startup; multi-org cochange (Phase 5) will require per-org boot or on-demand build.
61
+ gitCochange?.startScheduler("default");
61
62
  // Quota cache — macOS-only for now, Linux/Windows stubs return 503 via the
62
63
  // /api/quota handler so raids keep running without a quota guardrail there.
63
64
  // onRefresh fans the new data out to dashboard (SSE) + any live listener (MQTT)
@@ -65,12 +66,14 @@ export function createServices(config) {
65
66
  const quotaCache = new QuotaCache({
66
67
  logger: logger.child({ component: "quota" }),
67
68
  onRefresh: (info) => {
69
+ // TODO(Task 22): quota_update has no org context at the quota-cache callback level;
70
+ // using "default" (single-tenant Phase 1). Multi-org Phase 5 will require per-org quota.
68
71
  sseEmitter.emit("quota_update", {
69
72
  five_hour: info.fiveHour,
70
73
  seven_day: info.sevenDay,
71
74
  seven_day_sonnet: info.sevenDaySonnet,
72
75
  fetched_at: info.fetchedAt,
73
- });
76
+ }, { org_id: "default" });
74
77
  mqttBridge.publishQuotaUpdate(info);
75
78
  },
76
79
  });
@@ -78,7 +81,9 @@ export function createServices(config) {
78
81
  // SSE events for agent_online/agent_offline since they're already emitted by
79
82
  // the REST handler on /api/register and the offline hook. Avoids plumbing a
80
83
  // dedicated observer through AgentRegistry.
81
- sseEmitter.addListener((event) => {
84
+ // TODO(Task 22): quota observer uses "default" org — single-tenant Phase 1 only.
85
+ // Multi-org Phase 5 will require per-org quota listeners.
86
+ sseEmitter.addListener("default", (event) => {
82
87
  if (event.type === "agent_online")
83
88
  quotaCache.onAgentActive();
84
89
  else if (event.type === "agent_offline")
@@ -87,6 +92,8 @@ export function createServices(config) {
87
92
  // Centralized resolution → SSE + MQTT + metrics
88
93
  consultation.onResolve((event) => {
89
94
  metrics.recordThreadResolved(event.resolution_type);
95
+ // Approach (a): event.org_id is threaded from emitResolution via getThreadCrossOrg,
96
+ // so the correct org is always available here without an extra DB lookup.
90
97
  sseEmitter.emit("thread_resolved", {
91
98
  thread_id: event.thread_id,
92
99
  resolution_type: event.resolution_type,
@@ -96,7 +103,7 @@ export function createServices(config) {
96
103
  created_at: event.created_at,
97
104
  resolved_at: event.resolved_at,
98
105
  had_messages: event.had_messages,
99
- });
106
+ }, { org_id: event.org_id });
100
107
  if (event.resolution_type !== "auto_resolved") {
101
108
  mqttBridge.publishResolution(event.thread_id, "resolved", event.resolution_summary || "");
102
109
  }
@@ -111,8 +118,17 @@ export function createServices(config) {
111
118
  depMap, fileTracker, impactScorer, workingFiles, introspection, contextProvider, sseEmitter, mqttBridge, quotaCache, metrics, treeSitter, gitCochange,
112
119
  };
113
120
  }
114
- /** Create a new McpServer bound to the shared services (one per MCP session). */
115
- export function createMcpServer(services) {
121
+ /** Create a new McpServer bound to the shared services (one per MCP session).
122
+ *
123
+ * @param getSessionClaims - Looks up per-session claims by sessionId. In STDIO
124
+ * mode there are no sessions so this defaults to a no-op that always returns
125
+ * null, which causes tool handlers to throw "Session has no captured claims"
126
+ * (expected — STDIO mode is single-tenant and unauthenticated; tool callers
127
+ * in that mode should rely on the AUTH_ENABLED=false synthetic-claims path
128
+ * added to authenticateMcpRequest, which is not invoked for STDIO).
129
+ * Pass a real getter in serve-http.ts's streamable-HTTP path.
130
+ */
131
+ export function createMcpServer(services, getSessionClaims = () => null) {
116
132
  const { registry, activityTracker, consultation, conflictDetector, depMap, fileTracker, impactScorer, introspection, contextProvider, sseEmitter, mqttBridge } = services;
117
133
  const mcpLog = services.logger.child({ component: "mcp" });
118
134
  const server = new McpServer({
@@ -120,13 +136,15 @@ export function createMcpServer(services) {
120
136
  version: VERSION,
121
137
  });
122
138
  // S1: all 23 MCP tools registered via per-domain modules under src/tools/.
123
- // Each register*Tools function takes (server, services, mcpLog) and wires
124
- // its tool group; nothing else lives here. See src/tools/*.ts for behavior.
125
- registerAgentTools(server, services, mcpLog);
126
- registerConsultationTools(server, services, mcpLog);
127
- registerFilesTools(server, services, mcpLog);
128
- registerDependenciesTools(server, services, mcpLog);
129
- registerStatusTools(server, services, mcpLog);
130
- registerMqttTools(server, services, mcpLog);
139
+ // Each register*Tools function takes (server, services, mcpLog, getSessionClaims)
140
+ // and wires its tool group. See src/tools/*.ts for behavior.
141
+ // Task 23.5: getSessionClaims is threaded into each tool registration so
142
+ // handlers can scope DB queries to claims.org instead of the literal "default".
143
+ registerAgentTools(server, services, mcpLog, getSessionClaims);
144
+ registerConsultationTools(server, services, mcpLog, getSessionClaims);
145
+ registerFilesTools(server, services, mcpLog, getSessionClaims);
146
+ registerDependenciesTools(server, services, mcpLog, getSessionClaims);
147
+ registerStatusTools(server, services, mcpLog, getSessionClaims);
148
+ registerMqttTools(server, services, mcpLog, getSessionClaims);
131
149
  return server;
132
150
  }
@@ -1,12 +1,15 @@
1
1
  import type { CoordinatorEvent, EventType } from "./types.js";
2
2
  type EventListener = (event: CoordinatorEvent) => void;
3
+ interface EmitOptions {
4
+ org_id: string;
5
+ }
3
6
  export declare const MAX_SSE_CLIENTS: number;
4
7
  export declare class SseEmitter {
5
- private listeners;
8
+ private entries;
6
9
  private rejectedCount;
7
- emit(type: EventType, payload: Record<string, unknown>): void;
8
- getEventsSince(lastId: number): CoordinatorEvent[];
9
- addListener(listener: EventListener): () => void;
10
+ emit(type: EventType, payload: Record<string, unknown>, options: EmitOptions): void;
11
+ getEventsSince(orgId: string, lastId: number): CoordinatorEvent[];
12
+ addListener(orgId: string, listener: EventListener): () => void;
10
13
  removeAllListeners(): void;
11
14
  /** P3: introspection for tests + ops dashboards. */
12
15
  listenerCount(): number;
@@ -16,31 +16,36 @@ export const MAX_SSE_CLIENTS = (() => {
16
16
  })();
17
17
  const NOOP = () => { };
18
18
  export class SseEmitter {
19
- listeners = [];
19
+ entries = [];
20
20
  // P3: track refusals so operators can see when the cap is being hit.
21
21
  // Also lets tests assert "we refused without throwing" without scraping logs.
22
22
  rejectedCount = 0;
23
- emit(type, payload) {
23
+ emit(type, payload, options) {
24
24
  const db = getDb();
25
- const payloadStr = JSON.stringify(payload);
25
+ // The spread `{ ...payload, org_id: options.org_id }` deliberately OVERWRITES any
26
+ // caller-supplied `org_id` field in `payload`. The authoritative source is
27
+ // `options.org_id` (derived from `claims.org` in the handler), so callers cannot
28
+ // smuggle a different org through the payload. This is intentional.
29
+ const payloadWithOrg = { ...payload, org_id: options.org_id };
30
+ const payloadStr = JSON.stringify(payloadWithOrg);
26
31
  const result = db
27
- .prepare("INSERT INTO events (type, payload) VALUES (?, ?)")
28
- .run(type, payloadStr);
32
+ .prepare("INSERT INTO events (org_id, type, payload) VALUES (?, ?, ?)")
33
+ .run(options.org_id, type, payloadStr);
29
34
  const event = {
30
35
  id: result.lastInsertRowid,
31
36
  type,
32
37
  payload: payloadStr,
33
38
  created_at: new Date().toISOString(),
34
39
  };
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) {
40
+ // Snapshot + filter by org_id, then fan-out via setImmediate so a slow
41
+ // listener (e.g. a stalled SSE client whose socket buffer is full) cannot
42
+ // block siblings or the emit() caller. Snapshot the array first so a
43
+ // listener that unsubscribes mid-loop doesn't shift indices under us.
44
+ const snapshot = this.entries.filter((e) => e.orgId === options.org_id);
45
+ for (const entry of snapshot) {
41
46
  setImmediate(() => {
42
47
  try {
43
- listener(event);
48
+ entry.listener(event);
44
49
  }
45
50
  catch {
46
51
  // Listener errors must not crash the emitter or affect siblings.
@@ -50,31 +55,32 @@ export class SseEmitter {
50
55
  });
51
56
  }
52
57
  }
53
- getEventsSince(lastId) {
58
+ getEventsSince(orgId, lastId) {
54
59
  const db = getDb();
55
60
  return db
56
- .prepare("SELECT * FROM events WHERE id > ? ORDER BY id")
57
- .all(lastId);
61
+ .prepare("SELECT * FROM events WHERE org_id = ? AND id > ? ORDER BY id")
62
+ .all(orgId, lastId);
58
63
  }
59
- addListener(listener) {
64
+ addListener(orgId, listener) {
60
65
  // P3: refuse-with-no-op when the cap is reached. Returning a no-op
61
66
  // keeps the caller's unsubscribe contract intact (no special-casing
62
67
  // upstream) while preventing the array from growing past MAX_SSE_CLIENTS.
63
- if (this.listeners.length >= MAX_SSE_CLIENTS) {
68
+ if (this.entries.length >= MAX_SSE_CLIENTS) {
64
69
  this.rejectedCount++;
65
70
  return NOOP;
66
71
  }
67
- this.listeners.push(listener);
72
+ const entry = { orgId, listener };
73
+ this.entries.push(entry);
68
74
  return () => {
69
- this.listeners = this.listeners.filter((l) => l !== listener);
75
+ this.entries = this.entries.filter((e) => e !== entry);
70
76
  };
71
77
  }
72
78
  removeAllListeners() {
73
- this.listeners = [];
79
+ this.entries = [];
74
80
  }
75
81
  /** P3: introspection for tests + ops dashboards. */
76
82
  listenerCount() {
77
- return this.listeners.length;
83
+ return this.entries.length;
78
84
  }
79
85
  /** P3: count of addListener calls refused due to MAX_SSE_CLIENTS. */
80
86
  getRejectedCount() {
@@ -1,8 +1,9 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { CoordinatorServices } from "../server-setup.js";
3
3
  import type { Logger } from "../logger.js";
4
+ import type { AuthClaims } from "../auth.js";
4
5
  /**
5
6
  * S1: agent registry MCP tools (4 tools).
6
7
  * register_agent, list_agents, heartbeat, agent_activity.
7
8
  */
8
- export declare function registerAgentTools(server: McpServer, services: CoordinatorServices, mcpLog: Logger): void;
9
+ export declare function registerAgentTools(server: McpServer, services: CoordinatorServices, mcpLog: Logger, getSessionClaims: (sessionId: string) => AuthClaims | null): void;