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.
- package/README.md +24 -0
- package/dist/src/agent-activity.d.ts +13 -9
- package/dist/src/agent-activity.js +45 -24
- package/dist/src/agent-registry.d.ts +7 -7
- package/dist/src/agent-registry.js +19 -18
- package/dist/src/announce-workflow.d.ts +1 -0
- package/dist/src/announce-workflow.js +13 -12
- package/dist/src/auth/providers/registry.d.ts +4 -0
- package/dist/src/auth/providers/registry.js +7 -0
- package/dist/src/auth/providers/types.d.ts +11 -0
- package/dist/src/auth/providers/types.js +1 -0
- package/dist/src/auth.d.ts +24 -5
- package/dist/src/auth.js +172 -23
- package/dist/src/conflict-detector.d.ts +1 -0
- package/dist/src/conflict-detector.js +4 -4
- package/dist/src/consultation.d.ts +28 -14
- package/dist/src/consultation.js +101 -68
- package/dist/src/context-provider.d.ts +2 -2
- package/dist/src/context-provider.js +3 -4
- package/dist/src/database.js +203 -4
- package/dist/src/dependency-map.d.ts +25 -4
- package/dist/src/dependency-map.js +49 -11
- package/dist/src/file-tracker.d.ts +5 -4
- package/dist/src/file-tracker.js +16 -14
- package/dist/src/git-cochange-builder.d.ts +11 -2
- package/dist/src/git-cochange-builder.js +15 -7
- package/dist/src/http/handle-health.d.ts +9 -5
- package/dist/src/http/handle-health.js +22 -8
- package/dist/src/http/handle-rest.d.ts +3 -0
- package/dist/src/http/handle-rest.js +86 -57
- package/dist/src/http/utils.d.ts +4 -0
- package/dist/src/http/utils.js +7 -1
- package/dist/src/impact-scorer.d.ts +3 -0
- package/dist/src/impact-scorer.js +65 -51
- package/dist/src/introspection.d.ts +13 -7
- package/dist/src/introspection.js +34 -11
- package/dist/src/metrics.js +2 -1
- package/dist/src/mqtt-bridge.d.ts +3 -2
- package/dist/src/mqtt-bridge.js +33 -23
- package/dist/src/mqtt-broker.d.ts +16 -7
- package/dist/src/mqtt-broker.js +57 -15
- package/dist/src/security/audit.d.ts +11 -0
- package/dist/src/security/audit.js +7 -0
- package/dist/src/security/encryption.d.ts +17 -0
- package/dist/src/security/encryption.js +5 -0
- package/dist/src/serve-http.js +136 -57
- package/dist/src/server-setup.d.ts +12 -2
- package/dist/src/server-setup.js +33 -15
- package/dist/src/sse-emitter.d.ts +7 -4
- package/dist/src/sse-emitter.js +27 -21
- package/dist/src/tools/agents-tools.d.ts +2 -1
- package/dist/src/tools/agents-tools.js +36 -12
- package/dist/src/tools/consultation-tools.d.ts +2 -1
- package/dist/src/tools/consultation-tools.js +106 -40
- package/dist/src/tools/dependencies-tools.d.ts +2 -1
- package/dist/src/tools/dependencies-tools.js +25 -7
- package/dist/src/tools/files-tools.d.ts +2 -1
- package/dist/src/tools/files-tools.js +26 -8
- package/dist/src/tools/mqtt-tools.d.ts +7 -1
- package/dist/src/tools/mqtt-tools.js +27 -4
- package/dist/src/tools/status-tools.d.ts +7 -1
- package/dist/src/tools/status-tools.js +26 -9
- package/dist/src/types.d.ts +2 -0
- package/dist/src/working-files-tracker.d.ts +21 -11
- package/dist/src/working-files-tracker.js +32 -21
- package/package.json +4 -1
package/dist/src/serve-http.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
354
|
-
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/src/server-setup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
124
|
-
// its tool group
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
8
|
+
private entries;
|
|
6
9
|
private rejectedCount;
|
|
7
|
-
emit(type: EventType, payload: Record<string, unknown
|
|
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;
|
package/dist/src/sse-emitter.js
CHANGED
|
@@ -16,31 +16,36 @@ export const MAX_SSE_CLIENTS = (() => {
|
|
|
16
16
|
})();
|
|
17
17
|
const NOOP = () => { };
|
|
18
18
|
export class SseEmitter {
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
36
|
-
// SSE client whose socket buffer is full) cannot
|
|
37
|
-
// emit() caller. Snapshot the array first so a
|
|
38
|
-
// mid-loop doesn't shift indices under us.
|
|
39
|
-
const snapshot = this.
|
|
40
|
-
for (const
|
|
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.
|
|
68
|
+
if (this.entries.length >= MAX_SSE_CLIENTS) {
|
|
64
69
|
this.rejectedCount++;
|
|
65
70
|
return NOOP;
|
|
66
71
|
}
|
|
67
|
-
|
|
72
|
+
const entry = { orgId, listener };
|
|
73
|
+
this.entries.push(entry);
|
|
68
74
|
return () => {
|
|
69
|
-
this.
|
|
75
|
+
this.entries = this.entries.filter((e) => e !== entry);
|
|
70
76
|
};
|
|
71
77
|
}
|
|
72
78
|
removeAllListeners() {
|
|
73
|
-
this.
|
|
79
|
+
this.entries = [];
|
|
74
80
|
}
|
|
75
81
|
/** P3: introspection for tests + ops dashboards. */
|
|
76
82
|
listenerCount() {
|
|
77
|
-
return this.
|
|
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;
|