mcp-coordinator 0.4.0 → 0.6.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 (36) hide show
  1. package/README.md +938 -846
  2. package/dashboard/Dockerfile +19 -19
  3. package/dashboard/public/index.html +1201 -1178
  4. package/dist/cli/server/start.js +33 -0
  5. package/dist/src/agent-activity.js +6 -6
  6. package/dist/src/agent-registry.js +6 -6
  7. package/dist/src/announce-workflow.d.ts +1 -0
  8. package/dist/src/announce-workflow.js +28 -0
  9. package/dist/src/consultation.js +20 -20
  10. package/dist/src/database.js +191 -126
  11. package/dist/src/dependency-map.js +3 -3
  12. package/dist/src/file-tracker.d.ts +2 -0
  13. package/dist/src/file-tracker.js +13 -12
  14. package/dist/src/git-cochange-builder.d.ts +32 -0
  15. package/dist/src/git-cochange-builder.js +238 -0
  16. package/dist/src/http/handle-health.d.ts +1 -1
  17. package/dist/src/http/handle-health.js +26 -0
  18. package/dist/src/http/handle-rest.js +98 -2
  19. package/dist/src/http/utils.d.ts +0 -4
  20. package/dist/src/http/utils.js +16 -2
  21. package/dist/src/impact-scorer.d.ts +5 -1
  22. package/dist/src/impact-scorer.js +98 -8
  23. package/dist/src/introspection.js +1 -1
  24. package/dist/src/metrics.d.ts +5 -0
  25. package/dist/src/metrics.js +33 -0
  26. package/dist/src/path-normalize.d.ts +17 -0
  27. package/dist/src/path-normalize.js +38 -0
  28. package/dist/src/serve-http.js +41 -2
  29. package/dist/src/server-setup.d.ts +6 -0
  30. package/dist/src/server-setup.js +23 -3
  31. package/dist/src/tools/consultation-tools.js +4 -2
  32. package/dist/src/tree-sitter-extractor.d.ts +36 -0
  33. package/dist/src/tree-sitter-extractor.js +354 -0
  34. package/dist/src/working-files-tracker.d.ts +42 -0
  35. package/dist/src/working-files-tracker.js +111 -0
  36. package/package.json +100 -83
@@ -42,6 +42,11 @@ export declare class Metrics {
42
42
  readonly threadsResolving: Gauge<string>;
43
43
  readonly mqttListenersActive: Gauge<string>;
44
44
  readonly sseClientsActive: Gauge<string>;
45
+ readonly workingFilesActive: Gauge<string>;
46
+ readonly gitCochangePairs: Gauge<string>;
47
+ readonly workingFilesStarts: Counter<"result">;
48
+ readonly treeSitterParseFailures: Counter<string>;
49
+ readonly gitCochangeBuilds: Counter<"outcome">;
45
50
  constructor(opts?: MetricsOptions);
46
51
  recordAnnounce(result: AnnounceResult): void;
47
52
  recordThreadResolved(type: ResolutionType): void;
@@ -14,6 +14,12 @@ export class Metrics {
14
14
  threadsResolving;
15
15
  mqttListenersActive;
16
16
  sseClientsActive;
17
+ workingFilesActive;
18
+ gitCochangePairs;
19
+ // v0.6 counters
20
+ workingFilesStarts;
21
+ treeSitterParseFailures;
22
+ gitCochangeBuilds;
17
23
  constructor(opts = {}) {
18
24
  this.registry = new Registry();
19
25
  if (opts.collectDefault !== false) {
@@ -72,6 +78,33 @@ export class Metrics {
72
78
  help: "Current number of connected SSE clients",
73
79
  registers: [this.registry],
74
80
  });
81
+ this.workingFilesActive = new Gauge({
82
+ name: "mcp_coordinator_working_files_active",
83
+ help: "Current working_files row count",
84
+ registers: [this.registry],
85
+ });
86
+ this.workingFilesStarts = new Counter({
87
+ name: "mcp_coordinator_working_files_starts_total",
88
+ help: "Total working_files start calls",
89
+ labelNames: ["result"],
90
+ registers: [this.registry],
91
+ });
92
+ this.treeSitterParseFailures = new Counter({
93
+ name: "mcp_coordinator_tree_sitter_parse_failures_total",
94
+ help: "Tree-sitter parse failures",
95
+ registers: [this.registry],
96
+ });
97
+ this.gitCochangeBuilds = new Counter({
98
+ name: "mcp_coordinator_git_cochange_builds_total",
99
+ help: "git_cochange build attempts",
100
+ labelNames: ["outcome"],
101
+ registers: [this.registry],
102
+ });
103
+ this.gitCochangePairs = new Gauge({
104
+ name: "mcp_coordinator_git_cochange_pairs_total",
105
+ help: "Current git_cochange row count",
106
+ registers: [this.registry],
107
+ });
75
108
  }
76
109
  // ── Counter helpers (named methods make hook points obvious) ──
77
110
  recordAnnounce(result) {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Normalize a file path for matching/correctness — NOT security.
3
+ *
4
+ * Returns POSIX (forward slash), repo-relative when repoRoot is provided,
5
+ * lower-cased when the path is Windows-style (drive letter prefix in repoRoot
6
+ * or input, or backslash in input). Collapses ./ and .. segments via
7
+ * path.posix.normalize.
8
+ *
9
+ * The lowercase pass is anchored to path SHAPE rather than `process.platform`
10
+ * so a Linux coordinator processing paths from a Windows agent (or a CI run
11
+ * exercising Windows-shaped fixtures) still produces consistent canonical
12
+ * forms.
13
+ *
14
+ * Throws when an absolute path falls outside repoRoot. Security path
15
+ * traversal checks are separate (see path-guard.ts:safeJoinUnderRoot).
16
+ */
17
+ export declare function normalizePath(repoRoot: string | null, input: string): string;
@@ -0,0 +1,38 @@
1
+ import path from "path";
2
+ /**
3
+ * Normalize a file path for matching/correctness — NOT security.
4
+ *
5
+ * Returns POSIX (forward slash), repo-relative when repoRoot is provided,
6
+ * lower-cased when the path is Windows-style (drive letter prefix in repoRoot
7
+ * or input, or backslash in input). Collapses ./ and .. segments via
8
+ * path.posix.normalize.
9
+ *
10
+ * The lowercase pass is anchored to path SHAPE rather than `process.platform`
11
+ * so a Linux coordinator processing paths from a Windows agent (or a CI run
12
+ * exercising Windows-shaped fixtures) still produces consistent canonical
13
+ * forms.
14
+ *
15
+ * Throws when an absolute path falls outside repoRoot. Security path
16
+ * traversal checks are separate (see path-guard.ts:safeJoinUnderRoot).
17
+ */
18
+ export function normalizePath(repoRoot, input) {
19
+ const isWindowsStyle = (repoRoot != null && (/^[a-zA-Z]:/.test(repoRoot) || repoRoot.includes("\\"))) ||
20
+ /^[a-zA-Z]:/.test(input) ||
21
+ input.includes("\\");
22
+ let p = input.replace(/\\/g, "/");
23
+ if (repoRoot) {
24
+ const root = repoRoot.replace(/\\/g, "/").replace(/\/+$/, "");
25
+ if (path.isAbsolute(input) || /^[a-zA-Z]:/.test(input)) {
26
+ const lowerP = isWindowsStyle ? p.toLowerCase() : p;
27
+ const lowerRoot = isWindowsStyle ? root.toLowerCase() : root;
28
+ if (!lowerP.startsWith(lowerRoot + "/") && lowerP !== lowerRoot) {
29
+ throw new Error(`path is outside repoRoot: ${input}`);
30
+ }
31
+ p = p.slice(root.length).replace(/^\/+/, "");
32
+ }
33
+ }
34
+ p = path.posix.normalize(p).replace(/^\.\//, "");
35
+ if (isWindowsStyle)
36
+ p = p.toLowerCase();
37
+ return p;
38
+ }
@@ -17,6 +17,8 @@ import { createLogger } from "./logger.js";
17
17
  import { initAuth, authenticateRequest, createToken, refreshToken, revokeAgent, setAuthLogger, verifyToken } from "./auth.js";
18
18
  import { safeJoinUnderRoot } from "./path-guard.js";
19
19
  import { handleRest as handleRestExt } from "./http/handle-rest.js";
20
+ import { handleLivez, handleReadyz, handleHealth } from "./http/handle-health.js";
21
+ import { serveMetrics } from "./metrics.js";
20
22
  import { parseBody as parseBodyShared, json as jsonShared } from "./http/utils.js";
21
23
  import { getVersion } from "../cli/version.js";
22
24
  const VERSION = getVersion();
@@ -83,7 +85,15 @@ async function handleRest(req, res) {
83
85
  }
84
86
  async function handleAuth(req, res) {
85
87
  const url = req.url || "";
86
- const body = await parseBody(req);
88
+ let body;
89
+ try {
90
+ body = await parseBody(req);
91
+ }
92
+ catch (err) {
93
+ const e = err;
94
+ json(res, { error: e.message || "Invalid request" }, e.statusCode || 400);
95
+ return;
96
+ }
87
97
  if (url === "/api/auth/register" && req.method === "POST") {
88
98
  const { agent_name, registration_secret } = body;
89
99
  if (!agent_name || !registration_secret) {
@@ -184,6 +194,8 @@ function handleSse(req, res) {
184
194
  Connection: "keep-alive",
185
195
  "Access-Control-Allow-Origin": "*",
186
196
  });
197
+ services.metrics.incSseClients();
198
+ services.metrics.recordHttpRequest("/api/events", 200);
187
199
  // Use Last-Event-ID for resumption, otherwise send last 50
188
200
  const lastEventId = parseInt(req.headers["last-event-id"] || "0", 10);
189
201
  const events = lastEventId > 0
@@ -217,6 +229,7 @@ function handleSse(req, res) {
217
229
  // fires between close and unsubscribe can't write to a dead socket.
218
230
  clearInterval(heartbeat);
219
231
  unsubscribe();
232
+ services.metrics.decSseClients();
220
233
  });
221
234
  }
222
235
  export async function startServer(opts) {
@@ -303,8 +316,21 @@ export async function startServer(opts) {
303
316
  }
304
317
  return;
305
318
  }
319
+ else if (url === "/livez") {
320
+ handleLivez(req, res);
321
+ services.metrics.recordHttpRequest("/livez", 200);
322
+ }
323
+ else if (url === "/readyz") {
324
+ handleReadyz(req, res, services);
325
+ services.metrics.recordHttpRequest("/readyz", res.statusCode || 0);
326
+ }
306
327
  else if (url === "/health") {
307
- json(res, { status: "ok", version: VERSION });
328
+ handleHealth(req, res);
329
+ services.metrics.recordHttpRequest("/health", 200);
330
+ }
331
+ else if (url === "/metrics" && req.method === "GET") {
332
+ await serveMetrics(req, res, services, services.metrics);
333
+ services.metrics.recordHttpRequest("/metrics", 200);
308
334
  }
309
335
  else if (url === "/api/events" && req.method === "GET") {
310
336
  handleSse(req, res);
@@ -361,15 +387,18 @@ export async function startServer(opts) {
361
387
  const authResult = await authenticateRequest(req);
362
388
  if (!authResult.ok) {
363
389
  authLog.warn({ reason: authResult.error, url, ip: req.socket.remoteAddress }, "Auth rejected");
390
+ services.metrics.recordAuthRejected();
364
391
  json(res, { error: authResult.error }, authResult.status);
365
392
  return;
366
393
  }
367
394
  }
368
395
  if (url.startsWith("/api/") && (req.method === "POST" || req.method === "GET")) {
369
396
  await handleRest(req, res);
397
+ services.metrics.recordHttpRequest((url.split("?")[0] || ""), res.statusCode || 0);
370
398
  }
371
399
  else {
372
400
  json(res, { error: "not found" }, 404);
401
+ services.metrics.recordHttpRequest((url.split("?")[0] || ""), 404);
373
402
  }
374
403
  }
375
404
  }
@@ -418,6 +447,10 @@ export async function startServer(opts) {
418
447
  services.mqttBridge.onOffline((agentId) => {
419
448
  services.registry.setOffline(agentId);
420
449
  services.consultation.handleAgentDeparture(agentId);
450
+ // Clear in-flight working_files AFTER consultation cleanup so any future
451
+ // consultation logic that might inspect working_files state for this agent
452
+ // sees the pre-cleanup view.
453
+ services.workingFiles.clearForAgent(agentId);
421
454
  services.sseEmitter.emit("agent_offline", { agent_id: agentId });
422
455
  });
423
456
  // Wait for the HTTP server to be actually listening before resolving the
@@ -483,6 +516,12 @@ export async function startServer(opts) {
483
516
  catch (err) {
484
517
  log.warn({ err }, "Error stopping timeout sweeper");
485
518
  }
519
+ try {
520
+ services.workingFiles.stopSweeper();
521
+ }
522
+ catch (err) {
523
+ log.warn({ err }, "Error stopping working-files sweeper");
524
+ }
486
525
  try {
487
526
  const { closeDb } = await import("./database.js");
488
527
  closeDb?.();
@@ -11,7 +11,10 @@ import { SseEmitter } from "./sse-emitter.js";
11
11
  import { MqttBridge } from "./mqtt-bridge.js";
12
12
  import { AgentActivityTracker } from "./agent-activity.js";
13
13
  import { QuotaCache } from "./quota/quota-cache.js";
14
+ import { WorkingFilesTracker } from "./working-files-tracker.js";
14
15
  import { Metrics } from "./metrics.js";
16
+ import { TreeSitterExtractor } from "./tree-sitter-extractor.js";
17
+ import { GitCochangeBuilder } from "./git-cochange-builder.js";
15
18
  import type { CoordinatorConfig } from "./types.js";
16
19
  import { type Logger } from "./logger.js";
17
20
  export interface CoordinatorServices {
@@ -23,12 +26,15 @@ export interface CoordinatorServices {
23
26
  depMap: DependencyMapper;
24
27
  fileTracker: FileTracker;
25
28
  impactScorer: ImpactScorer;
29
+ workingFiles: WorkingFilesTracker;
26
30
  introspection: IntrospectionManager;
27
31
  contextProvider: SummaryContextProvider;
28
32
  sseEmitter: SseEmitter;
29
33
  mqttBridge: MqttBridge;
30
34
  quotaCache: QuotaCache;
31
35
  metrics: Metrics;
36
+ treeSitter: TreeSitterExtractor;
37
+ gitCochange: GitCochangeBuilder | null;
32
38
  }
33
39
  /** Create shared services (once at startup). */
34
40
  export declare function createServices(config: CoordinatorConfig): CoordinatorServices;
@@ -18,7 +18,10 @@ 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 { WorkingFilesTracker } from "./working-files-tracker.js";
21
22
  import { Metrics } from "./metrics.js";
23
+ import { TreeSitterExtractor } from "./tree-sitter-extractor.js";
24
+ import { GitCochangeBuilder } from "./git-cochange-builder.js";
22
25
  import { createLogger } from "./logger.js";
23
26
  import { getVersion } from "../cli/version.js";
24
27
  const VERSION = getVersion();
@@ -26,18 +29,35 @@ const VERSION = getVersion();
26
29
  export function createServices(config) {
27
30
  initDatabase(config.dataDir);
28
31
  const logger = createLogger();
32
+ const metrics = new Metrics();
29
33
  const registry = new AgentRegistry();
30
34
  const activityTracker = new AgentActivityTracker(registry);
31
35
  const consultation = new Consultation(logger.child({ component: "consultation" }));
32
36
  const depMap = new DependencyMapper();
33
37
  const fileTracker = new FileTracker();
34
- const impactScorer = new ImpactScorer(registry, fileTracker, consultation);
38
+ const workingFiles = new WorkingFilesTracker(logger.child({ component: "working-files" }), metrics);
39
+ workingFiles.startSweeper(parseInt(process.env.COORDINATOR_WORKING_FILES_SWEEP_INTERVAL_MS || "60000", 10));
40
+ const impactScorer = new ImpactScorer(registry, fileTracker, consultation, workingFiles);
35
41
  const introspection = new IntrospectionManager();
36
42
  const conflictDetector = new ConflictDetector(consultation, depMap, fileTracker, logger.child({ component: "conflict" }));
37
43
  const contextProvider = new SummaryContextProvider(registry, consultation, fileTracker);
38
44
  const sseEmitter = new SseEmitter();
39
45
  const mqttBridge = new MqttBridge(logger.child({ component: "mqtt" }));
40
- const metrics = new Metrics();
46
+ const treeSitter = new TreeSitterExtractor(metrics);
47
+ treeSitter.load().catch(() => { });
48
+ const repoRoot = process.env.COORDINATOR_REPO_ROOT;
49
+ const gitCochange = repoRoot
50
+ ? new GitCochangeBuilder({
51
+ repoRoot,
52
+ logger: logger.child({ component: "gitcc" }),
53
+ metrics,
54
+ sinceDays: parseInt(process.env.COORDINATOR_LAYER4_SINCE_DAYS || "7", 10),
55
+ maxCount: parseInt(process.env.COORDINATOR_LAYER4_MAX_COMMITS || "2000", 10),
56
+ refreshMs: parseInt(process.env.COORDINATOR_LAYER4_REFRESH_INTERVAL_MS || "1800000", 10),
57
+ retryMs: parseInt(process.env.COORDINATOR_LAYER4_RETRY_MS || "300000", 10),
58
+ })
59
+ : null;
60
+ gitCochange?.startScheduler();
41
61
  // Quota cache — macOS-only for now, Linux/Windows stubs return 503 via the
42
62
  // /api/quota handler so raids keep running without a quota guardrail there.
43
63
  // onRefresh fans the new data out to dashboard (SSE) + any live listener (MQTT)
@@ -88,7 +108,7 @@ export function createServices(config) {
88
108
  });
89
109
  return {
90
110
  logger, registry, activityTracker, consultation, conflictDetector,
91
- depMap, fileTracker, impactScorer, introspection, contextProvider, sseEmitter, mqttBridge, quotaCache, metrics,
111
+ depMap, fileTracker, impactScorer, workingFiles, introspection, contextProvider, sseEmitter, mqttBridge, quotaCache, metrics, treeSitter, gitCochange,
92
112
  };
93
113
  }
94
114
  /** Create a new McpServer bound to the shared services (one per MCP session). */
@@ -30,7 +30,9 @@ export function registerConsultationTools(server, services, mcpLog) {
30
30
  exports_affected: z.array(z.string()).optional(),
31
31
  keep_open: z.boolean().optional().describe("Keep thread open even if no agents are concerned (for manual coordination like games or debates)"),
32
32
  assigned_to: z.string().optional().describe("Directed-dispatch: only this agent_id will be allowed to claim the thread. Use for lead→worker handoffs in maitre/chaine/relais presets. Implies keep_open=true."),
33
- }, async ({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to }) => {
33
+ target_symbols: z.array(z.string().max(256)).max(200).optional()
34
+ .describe("Qualified symbol names you intend to touch (e.g. 'UserService.getById'). Used by Layer 0.5 to annotate same-file overlaps."),
35
+ }, async ({ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, assigned_to, target_symbols }) => {
34
36
  mcpLog.info({ tool: "announce_work", agent_id, subject, target_modules, target_files, assigned_to }, "Tool called");
35
37
  const conflicts = conflictDetector.detect({ agent_id, target_modules, target_files });
36
38
  const thread = consultation.announceWork({
@@ -41,7 +43,7 @@ export function registerConsultationTools(server, services, mcpLog) {
41
43
  .run(JSON.stringify(conflicts), thread.id);
42
44
  }
43
45
  const { updated, categorized, respondents, planQuality } = runCommonAnnounceFlow(services, thread.id, {
44
- agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open,
46
+ agent_id, subject, plan, target_modules, target_files, depends_on_files, exports_affected, keep_open, target_symbols,
45
47
  });
46
48
  sseEmitter.emit("thread_opened", {
47
49
  thread_id: thread.id, initiator: agent_id, subject, target_modules, conflicts,
@@ -0,0 +1,36 @@
1
+ import type { Metrics } from "./metrics.js";
2
+ /**
3
+ * Tree-sitter symbol extractor.
4
+ *
5
+ * Loads grammars asynchronously at boot. extract() runs synchronously per call
6
+ * so it slots into the existing synchronous file_activity ingest path.
7
+ *
8
+ * Naming table per language documented in the v0.6 spec:
9
+ * - top-level fn / arrow assigned to const → `name`
10
+ * - class member → `Class.method`
11
+ * - anonymous default export → `<file_basename>:default`
12
+ * - re-exports, anonymous IIFE → not emitted
13
+ */
14
+ export declare class TreeSitterExtractor {
15
+ private grammars;
16
+ private ready;
17
+ private grammarsLoaded;
18
+ private totalGrammars;
19
+ private metrics?;
20
+ constructor(metrics?: Metrics);
21
+ load(): Promise<void>;
22
+ status(): {
23
+ ok: boolean;
24
+ grammars_loaded: number;
25
+ total_grammars: number;
26
+ optional: true;
27
+ };
28
+ /**
29
+ * Extract qualified symbol names from `content`. Returns null on parse
30
+ * failure, unsupported extension, or grammar not loaded.
31
+ * Caps output at 200 entries (per spec).
32
+ */
33
+ extract(filePath: string, content: string, _changedRanges: Array<[number, number]> | null): string[] | null;
34
+ private extToKey;
35
+ private walk;
36
+ }