mcp-coordinator 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function createDoctorCommand(): Command;
@@ -0,0 +1,220 @@
1
+ import { Command } from "commander";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { createConnection } from "net";
5
+ import { request } from "http";
6
+ import { getConfigDir, loadConfig } from "./config.js";
7
+ async function tcpReachable(host, port, timeoutMs = 1500) {
8
+ return new Promise((resolveP) => {
9
+ const sock = createConnection({ host, port });
10
+ const settle = (ok) => {
11
+ try {
12
+ sock.destroy();
13
+ }
14
+ catch { }
15
+ resolveP(ok);
16
+ };
17
+ sock.setTimeout(timeoutMs, () => settle(false));
18
+ sock.on("connect", () => settle(true));
19
+ sock.on("error", () => settle(false));
20
+ });
21
+ }
22
+ async function httpGet(host, port, path, timeoutMs = 1500) {
23
+ return new Promise((resolveP) => {
24
+ const req = request({ host, port, path, method: "GET", timeout: timeoutMs }, (res) => {
25
+ const chunks = [];
26
+ res.on("data", (c) => chunks.push(c));
27
+ res.on("end", () => {
28
+ resolveP({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf-8").slice(0, 200) });
29
+ });
30
+ });
31
+ req.on("timeout", () => {
32
+ req.destroy();
33
+ resolveP(null);
34
+ });
35
+ req.on("error", () => resolveP(null));
36
+ req.end();
37
+ });
38
+ }
39
+ async function mcpInitialize(host, port, timeoutMs = 2500) {
40
+ return new Promise((resolveP) => {
41
+ const payload = JSON.stringify({
42
+ jsonrpc: "2.0",
43
+ id: 1,
44
+ method: "initialize",
45
+ params: {
46
+ protocolVersion: "2024-11-05",
47
+ capabilities: {},
48
+ clientInfo: { name: "mcp-coordinator-doctor", version: "1.0.0" },
49
+ },
50
+ });
51
+ const req = request({
52
+ host,
53
+ port,
54
+ path: "/mcp",
55
+ method: "POST",
56
+ timeout: timeoutMs,
57
+ headers: {
58
+ "Content-Type": "application/json",
59
+ "Accept": "application/json, text/event-stream",
60
+ "Content-Length": Buffer.byteLength(payload).toString(),
61
+ },
62
+ }, (res) => {
63
+ const chunks = [];
64
+ res.on("data", (c) => chunks.push(c));
65
+ res.on("end", () => {
66
+ const body = Buffer.concat(chunks).toString("utf-8");
67
+ // streaming MCP responses prefix with `data: { ... }`
68
+ resolveP(res.statusCode === 200 && body.includes('"protocolVersion"'));
69
+ });
70
+ });
71
+ req.on("timeout", () => {
72
+ req.destroy();
73
+ resolveP(false);
74
+ });
75
+ req.on("error", () => resolveP(false));
76
+ req.write(payload);
77
+ req.end();
78
+ });
79
+ }
80
+ export function createDoctorCommand() {
81
+ return new Command("doctor")
82
+ .description("Run a health check: config, server liveness, MCP endpoint, MQTT broker, dashboard")
83
+ .option("--host <host>", "Hostname to probe", "127.0.0.1")
84
+ .option("--port <port>", "HTTP port", "")
85
+ .option("--mqtt-port <port>", "MQTT TCP port", "")
86
+ .action(async (opts) => {
87
+ const results = [];
88
+ const host = opts.host;
89
+ // 1. Config dir
90
+ const configDir = getConfigDir();
91
+ results.push({
92
+ name: "config-dir",
93
+ ok: existsSync(configDir),
94
+ detail: existsSync(configDir) ? configDir : `missing — run 'mcp-coordinator init'`,
95
+ hint: existsSync(configDir) ? undefined : "Run: mcp-coordinator init",
96
+ });
97
+ // 2. config.json
98
+ const configFile = join(configDir, "config.json");
99
+ let parsedConfig = null;
100
+ if (existsSync(configFile)) {
101
+ try {
102
+ parsedConfig = loadConfig();
103
+ results.push({
104
+ name: "config.json",
105
+ ok: true,
106
+ detail: `valid — port ${parsedConfig.server.port}, data_dir ${parsedConfig.server.data_dir}`,
107
+ });
108
+ }
109
+ catch (e) {
110
+ results.push({
111
+ name: "config.json",
112
+ ok: false,
113
+ detail: `invalid: ${e.message}`,
114
+ hint: "Re-run 'mcp-coordinator init' to restore defaults",
115
+ });
116
+ }
117
+ }
118
+ else {
119
+ results.push({
120
+ name: "config.json",
121
+ ok: false,
122
+ detail: "missing — defaults will be used",
123
+ hint: "Run: mcp-coordinator init",
124
+ });
125
+ }
126
+ const port = parseInt(opts.port || String(parsedConfig?.server.port ?? 3100), 10);
127
+ const mqttPort = parseInt(opts.mqttPort || process.env.COORDINATOR_MQTT_TCP_PORT || "1883", 10);
128
+ // 3. Server PID file
129
+ const pidPath = join(configDir, "server.pid");
130
+ let pidFromFile = null;
131
+ if (existsSync(pidPath)) {
132
+ try {
133
+ pidFromFile = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
134
+ results.push({
135
+ name: "pid-file",
136
+ ok: !isNaN(pidFromFile) && pidFromFile > 0,
137
+ detail: `PID ${pidFromFile} (this is just the PID file; check 'tcp-${port}' below to confirm the server is actually listening)`,
138
+ });
139
+ }
140
+ catch {
141
+ results.push({
142
+ name: "pid-file",
143
+ ok: false,
144
+ detail: "exists but unreadable",
145
+ hint: "Stale state — run 'mcp-coordinator server stop' or delete ~/.mcp-coordinator/server.pid",
146
+ });
147
+ }
148
+ }
149
+ else {
150
+ results.push({
151
+ name: "pid-file",
152
+ ok: false,
153
+ detail: "absent (server not running in daemon mode)",
154
+ hint: "Start the server: mcp-coordinator server start --daemon",
155
+ });
156
+ }
157
+ // 4. HTTP TCP reachable
158
+ const httpUp = await tcpReachable(host, port);
159
+ results.push({
160
+ name: `tcp-${port}`,
161
+ ok: httpUp,
162
+ detail: httpUp ? `${host}:${port} accepts connections` : `${host}:${port} unreachable`,
163
+ hint: httpUp ? undefined : `Start the server: mcp-coordinator server start --daemon (or check the configured port)`,
164
+ });
165
+ // 5. /health endpoint
166
+ if (httpUp) {
167
+ const health = await httpGet(host, port, "/health");
168
+ results.push({
169
+ name: "/health",
170
+ ok: !!health && health.status === 200,
171
+ detail: health ? `HTTP ${health.status}: ${health.body}` : "no response",
172
+ hint: !!health && health.status === 200 ? undefined : "Server is reachable but /health failed; check server logs",
173
+ });
174
+ // 6. /mcp initialize
175
+ const mcpOk = await mcpInitialize(host, port);
176
+ results.push({
177
+ name: "/mcp initialize",
178
+ ok: mcpOk,
179
+ detail: mcpOk ? "JSON-RPC 2.0 initialize succeeded" : "no valid MCP response",
180
+ hint: mcpOk ? undefined : "MCP HTTP transport not responding; check server logs and version compatibility",
181
+ });
182
+ // 7. Dashboard
183
+ const dash = await httpGet(host, port, "/dashboard/");
184
+ results.push({
185
+ name: "/dashboard",
186
+ ok: !!dash && dash.status === 200,
187
+ detail: dash ? `HTTP ${dash.status}` : "no response",
188
+ hint: !!dash && dash.status === 200 ? undefined : "Dashboard files not found; verify package install or rerun init",
189
+ });
190
+ }
191
+ // 8. MQTT broker
192
+ const mqttUp = await tcpReachable(host, mqttPort);
193
+ results.push({
194
+ name: `mqtt-${mqttPort}`,
195
+ ok: mqttUp,
196
+ detail: mqttUp ? `${host}:${mqttPort} accepts connections` : `${host}:${mqttPort} unreachable`,
197
+ hint: mqttUp ? undefined : `MQTT broker not listening on port ${mqttPort}; check COORDINATOR_MQTT_TCP_PORT and server logs`,
198
+ });
199
+ // Print
200
+ let allOk = true;
201
+ console.log("");
202
+ for (const r of results) {
203
+ const prefix = r.ok ? "[ OK ]" : "[FAIL]";
204
+ console.log(`${prefix} ${r.name.padEnd(20)} ${r.detail}`);
205
+ if (!r.ok) {
206
+ allOk = false;
207
+ if (r.hint)
208
+ console.log(` hint: ${r.hint}`);
209
+ }
210
+ }
211
+ console.log("");
212
+ if (allOk) {
213
+ console.log("All checks passed. Coordinator is healthy.");
214
+ }
215
+ else {
216
+ console.log("Some checks failed. See hints above.");
217
+ process.exit(1);
218
+ }
219
+ });
220
+ }
package/dist/cli/index.js CHANGED
@@ -2,12 +2,18 @@
2
2
  import { Command } from "commander";
3
3
  import { createServerProgram } from "./server/index.js";
4
4
  import { createDashboardCommand } from "./dashboard.js";
5
+ import { createInitCommand } from "./init.js";
6
+ import { createDoctorCommand } from "./doctor.js";
7
+ import { createUninstallCommand } from "./uninstall.js";
5
8
  import { getVersion } from "./version.js";
6
9
  const program = new Command();
7
10
  program
8
11
  .name("mcp-coordinator")
9
12
  .description("Embedded MQTT broker + MCP server for multi-agent coordination")
10
13
  .version(getVersion());
14
+ program.addCommand(createInitCommand());
11
15
  program.addCommand(createServerProgram());
12
16
  program.addCommand(createDashboardCommand());
17
+ program.addCommand(createDoctorCommand());
18
+ program.addCommand(createUninstallCommand());
13
19
  program.parse();
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function createInitCommand(): Command;
@@ -0,0 +1,199 @@
1
+ import { Command } from "commander";
2
+ import { writeFileSync, existsSync, readFileSync, statSync } from "fs";
3
+ import { join, resolve } from "path";
4
+ import { ensureConfigDir, loadConfig, saveConfig } from "./config.js";
5
+ const CLAUDE_MD_TEMPLATE = `## Coordination via mcp-coordinator
6
+
7
+ You share a coordinator with other Claude Code sessions on this repo. Use the
8
+ \`coordinator\` MCP tools to announce work and resolve conflicts before writing
9
+ code.
10
+
11
+ ### How coordination flows (important — read first)
12
+
13
+ You communicate with the coordinator over MCP (request/response). You do NOT
14
+ receive automatic push notifications. To stay aware of what other agents are
15
+ doing, you must **poll** at the right moments:
16
+
17
+ - **Session start** — call \`register_agent\` once.
18
+ - **Before any source-file change** — call \`announce_work\`. The response tells
19
+ you immediately if a thread was opened (conflict detected). If yes, react.
20
+ - **Before resuming work after a non-trivial pause** (e.g., before a new
21
+ feature, between phases, after returning from a sub-task) — call
22
+ \`coordinator_status\` to see if anyone has posted to threads you're a
23
+ participant in. New posts may need your reply or vote.
24
+ - **Anytime you suspect activity** — call \`list_threads\` or
25
+ \`coordinator_status\` to scan for open threads.
26
+
27
+ If you skip the polling step, you can still write code, but you may miss a
28
+ question another agent posted on a thread you opened. The dashboard
29
+ (\`http://localhost:3100/dashboard\`) is the human's view of all activity if
30
+ you want a quick visual.
31
+
32
+ ### Before any source-file change
33
+
34
+ 1. (Once per session) Call \`register_agent\` with your name and the modules
35
+ you plan to touch.
36
+ 2. Call \`announce_work\` with:
37
+ - \`subject\`: short description of the change
38
+ - \`target_files\`: files you will modify
39
+ - \`depends_on_files\` (optional): files whose interface you depend on
40
+ - \`target_modules\`: bounded contexts you'll touch
41
+ 3. **Read the response carefully**. If \`thread_id\` is present, a conflict
42
+ was detected — DO NOT proceed to code. Instead:
43
+ - Call \`get_thread(thread_id)\` to see what other agents have said.
44
+ - Call \`post_to_thread\` with \`type: "context"\` to share your plan and
45
+ constraints.
46
+ - Wait for the other agent to acknowledge. Poll \`get_thread_updates\` or
47
+ \`coordinator_status\` until the thread reaches \`resolving\`.
48
+ - When a resolution is proposed, call \`approve_resolution\` or
49
+ \`contest_resolution\` (with reason).
50
+ - Only proceed to code once the thread is \`resolved\`.
51
+ 4. After completing a meaningful change, call \`log_action_summary\` to update
52
+ the dashboard timeline.
53
+
54
+ ### Polling for thread updates (the most-missed step)
55
+
56
+ Whenever you've opened a thread or are a participant in one, the other agents
57
+ may post new context. They cannot push to you — you must check. A reasonable
58
+ cadence:
59
+
60
+ - **Whenever you call any other coordinator tool** (announce_work,
61
+ log_action_summary, etc.), spend one extra call on
62
+ \`coordinator_status\` and scan for open threads where the latest message is
63
+ from someone else.
64
+ - **Before each major task transition** (finishing one feature, starting the
65
+ next), call \`list_threads\` or \`coordinator_status\` once.
66
+
67
+ A useful pattern: after every 3-5 file edits, run
68
+ \`coordinator_status\` once to confirm no thread you opened is still waiting on
69
+ your input.
70
+
71
+ ### Tools you'll reach for most
72
+
73
+ - \`coordinator_status\` — full system snapshot (agents, threads, files, quota)
74
+ - \`announce_work\` / \`post_to_thread\` / \`approve_resolution\` /
75
+ \`contest_resolution\` — consultation flow
76
+ - \`get_thread_updates\` — fetch only new posts since a timestamp
77
+ - \`hot_files\` — files multiple agents are editing
78
+ - \`check_file_conflict\` — quick check before opening a file
79
+
80
+ If you want push-based coordination (real-time interrupts between agent
81
+ turns instead of polling), see [essaim](https://github.com/swoofer/essaim) —
82
+ it ships an agent-loop wrapper that subscribes to the coordinator's MQTT
83
+ broker and injects events into your turn flow automatically.
84
+ `;
85
+ export function createInitCommand() {
86
+ return new Command("init")
87
+ .description("First-time setup: create the config dir, write a default config.json, and print a .mcp.json snippet for your MCP client")
88
+ .option("--url <url>", "Coordinator URL to use in the printed .mcp.json snippet (defaults to http://localhost:<port>/mcp)")
89
+ .option("--write-mcp-config <path>", "Write the .mcp.json snippet into <path>/.mcp.json (merges if the file already exists). <path> must be an existing directory.")
90
+ .option("--write-claude-md <path>", "Write a sample CLAUDE.md (system instructions for your coordinator-aware agent) into <path>/CLAUDE.md (merges with existing — appends a clearly-marked section). <path> must be an existing directory.")
91
+ .action((opts) => {
92
+ const dir = ensureConfigDir();
93
+ console.log(`Config directory: ${dir}`);
94
+ const configPath = join(dir, "config.json");
95
+ if (!existsSync(configPath)) {
96
+ saveConfig(loadConfig());
97
+ console.log(`Wrote default config: ${configPath}`);
98
+ }
99
+ else {
100
+ console.log(`Config already exists: ${configPath} (untouched)`);
101
+ }
102
+ const config = loadConfig();
103
+ const url = opts.url ?? `http://localhost:${config.server.port}/mcp`;
104
+ const snippet = {
105
+ mcpServers: {
106
+ coordinator: {
107
+ type: "http",
108
+ url,
109
+ },
110
+ },
111
+ };
112
+ const validateDir = (p, label) => {
113
+ const abs = resolve(p);
114
+ if (!existsSync(abs)) {
115
+ console.error(`Error: ${label} path ${abs} does not exist.`);
116
+ return null;
117
+ }
118
+ const st = statSync(abs);
119
+ if (!st.isDirectory()) {
120
+ console.error(`Error: ${label} path ${abs} is not a directory.`);
121
+ return null;
122
+ }
123
+ return abs;
124
+ };
125
+ let exitCode = 0;
126
+ if (opts.writeMcpConfig) {
127
+ const dirAbs = validateDir(opts.writeMcpConfig, "--write-mcp-config");
128
+ if (!dirAbs) {
129
+ exitCode = 1;
130
+ }
131
+ else {
132
+ const target = resolve(dirAbs, ".mcp.json");
133
+ let merged = snippet;
134
+ if (existsSync(target)) {
135
+ try {
136
+ const existing = JSON.parse(readFileSync(target, "utf-8"));
137
+ merged = {
138
+ ...existing,
139
+ mcpServers: {
140
+ ...(existing.mcpServers ?? {}),
141
+ coordinator: snippet.mcpServers.coordinator,
142
+ },
143
+ };
144
+ }
145
+ catch {
146
+ console.warn(`Warning: ${target} is not valid JSON; overwriting`);
147
+ }
148
+ }
149
+ writeFileSync(target, JSON.stringify(merged, null, 2) + "\n");
150
+ console.log(`Wrote MCP config: ${target}`);
151
+ }
152
+ }
153
+ if (opts.writeClaudeMd) {
154
+ const dirAbs = validateDir(opts.writeClaudeMd, "--write-claude-md");
155
+ if (!dirAbs) {
156
+ exitCode = 1;
157
+ }
158
+ else {
159
+ const target = resolve(dirAbs, "CLAUDE.md");
160
+ const SENTINEL = "<!-- mcp-coordinator:coordination-section -->";
161
+ const sectionBody = SENTINEL + "\n" + CLAUDE_MD_TEMPLATE + SENTINEL + "\n";
162
+ let final;
163
+ if (existsSync(target)) {
164
+ const existing = readFileSync(target, "utf-8");
165
+ if (existing.includes(SENTINEL)) {
166
+ const re = new RegExp(`${SENTINEL}[\\s\\S]*?${SENTINEL}\\n?`, "g");
167
+ final = existing.replace(re, sectionBody);
168
+ console.log(`Updated CLAUDE.md (replaced existing coordinator section): ${target}`);
169
+ }
170
+ else {
171
+ const sep = existing.endsWith("\n") ? "\n" : "\n\n";
172
+ final = existing + sep + sectionBody;
173
+ console.log(`Appended coordinator section to CLAUDE.md: ${target}`);
174
+ }
175
+ }
176
+ else {
177
+ final = "# CLAUDE.md\n\n" + sectionBody;
178
+ console.log(`Wrote CLAUDE.md: ${target}`);
179
+ }
180
+ writeFileSync(target, final);
181
+ }
182
+ }
183
+ if (!opts.writeMcpConfig && !opts.writeClaudeMd) {
184
+ console.log("");
185
+ console.log("Add this to your MCP client (e.g., ~/.claude/.mcp.json):");
186
+ console.log("");
187
+ console.log(JSON.stringify(snippet, null, 2));
188
+ }
189
+ console.log("");
190
+ console.log("Next steps:");
191
+ console.log(" 1. Start the coordinator: mcp-coordinator server start --daemon");
192
+ console.log(" 2. Open the dashboard: mcp-coordinator dashboard");
193
+ console.log(" 3. Connect any MCP client (Claude Code, Cursor, Cline, ...) using the snippet above");
194
+ console.log(" 4. Health check: mcp-coordinator doctor");
195
+ if (exitCode !== 0) {
196
+ process.exit(exitCode);
197
+ }
198
+ });
199
+ }
@@ -2,10 +2,12 @@ import { Command } from "commander";
2
2
  import { createServerStartCommand } from "./start.js";
3
3
  import { createServerStopCommand } from "./stop.js";
4
4
  import { createServerStatusCommand } from "./status.js";
5
+ import { createServerLogsCommand } from "./logs.js";
5
6
  export function createServerProgram() {
6
7
  const server = new Command("server").description("Manage the coordination server");
7
8
  server.addCommand(createServerStartCommand());
8
9
  server.addCommand(createServerStopCommand());
9
10
  server.addCommand(createServerStatusCommand());
11
+ server.addCommand(createServerLogsCommand());
10
12
  return server;
11
13
  }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function createServerLogsCommand(): Command;
@@ -0,0 +1,83 @@
1
+ import { Command } from "commander";
2
+ import { existsSync, statSync, openSync, readSync, closeSync, watchFile, unwatchFile } from "fs";
3
+ import { join } from "path";
4
+ import { getConfigDir } from "../config.js";
5
+ export function createServerLogsCommand() {
6
+ return new Command("logs")
7
+ .description("Tail the daemon server log at ~/.mcp-coordinator/logs/server.log")
8
+ .option("-n, --lines <n>", "Print the last N lines and exit (default: 50)", "50")
9
+ .option("-f, --follow", "After printing the tail, follow the file for new lines")
10
+ .action((opts) => {
11
+ const logPath = join(getConfigDir(), "logs", "server.log");
12
+ if (!existsSync(logPath)) {
13
+ console.error(`No log file at ${logPath}.`);
14
+ console.error("The server has never been started in daemon mode (foreground runs print to stdout).");
15
+ console.error("Start a daemon: mcp-coordinator server start --daemon");
16
+ process.exit(1);
17
+ }
18
+ const n = Math.max(1, parseInt(opts.lines, 10) || 50);
19
+ // Print the last N lines by reading from the end of the file in chunks.
20
+ const fd = openSync(logPath, "r");
21
+ try {
22
+ const size = statSync(logPath).size;
23
+ const chunkSize = 65536;
24
+ let pos = size;
25
+ let collected = "";
26
+ let newlines = 0;
27
+ while (pos > 0 && newlines <= n) {
28
+ const readLen = Math.min(chunkSize, pos);
29
+ pos -= readLen;
30
+ const buf = Buffer.alloc(readLen);
31
+ readSync(fd, buf, 0, readLen, pos);
32
+ const piece = buf.toString("utf-8");
33
+ collected = piece + collected;
34
+ newlines = (collected.match(/\n/g) ?? []).length;
35
+ }
36
+ const lines = collected.split("\n");
37
+ const tail = lines.slice(Math.max(0, lines.length - n - 1));
38
+ process.stdout.write(tail.join("\n"));
39
+ if (!collected.endsWith("\n"))
40
+ process.stdout.write("\n");
41
+ }
42
+ finally {
43
+ closeSync(fd);
44
+ }
45
+ if (!opts.follow)
46
+ return;
47
+ // Follow mode: poll the file for size changes and print appended bytes.
48
+ let lastSize = statSync(logPath).size;
49
+ const onChange = () => {
50
+ try {
51
+ const cur = statSync(logPath).size;
52
+ if (cur < lastSize) {
53
+ // file truncated/rotated — reset
54
+ lastSize = 0;
55
+ }
56
+ if (cur > lastSize) {
57
+ const fd2 = openSync(logPath, "r");
58
+ try {
59
+ const buf = Buffer.alloc(cur - lastSize);
60
+ readSync(fd2, buf, 0, buf.length, lastSize);
61
+ process.stdout.write(buf.toString("utf-8"));
62
+ lastSize = cur;
63
+ }
64
+ finally {
65
+ closeSync(fd2);
66
+ }
67
+ }
68
+ }
69
+ catch {
70
+ // ignore transient errors during rotation
71
+ }
72
+ };
73
+ console.log("");
74
+ console.log("[following — Ctrl+C to stop]");
75
+ watchFile(logPath, { interval: 500 }, onChange);
76
+ const cleanup = () => {
77
+ unwatchFile(logPath, onChange);
78
+ process.exit(0);
79
+ };
80
+ process.on("SIGINT", cleanup);
81
+ process.on("SIGTERM", cleanup);
82
+ });
83
+ }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function createUninstallCommand(): Command;