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.
- package/README.md +779 -36
- package/dashboard/Dockerfile +19 -19
- package/dashboard/public/index.html +1178 -1178
- package/dist/cli/doctor.d.ts +2 -0
- package/dist/cli/doctor.js +220 -0
- package/dist/cli/index.js +6 -0
- package/dist/cli/init.d.ts +2 -0
- package/dist/cli/init.js +199 -0
- package/dist/cli/server/index.js +2 -0
- package/dist/cli/server/logs.d.ts +2 -0
- package/dist/cli/server/logs.js +83 -0
- package/dist/cli/uninstall.d.ts +2 -0
- package/dist/cli/uninstall.js +146 -0
- package/dist/src/agent-activity.js +6 -6
- package/dist/src/agent-registry.js +6 -6
- package/dist/src/consultation.js +20 -20
- package/dist/src/database.js +126 -126
- package/dist/src/dependency-map.js +3 -3
- package/dist/src/file-tracker.js +8 -8
- package/dist/src/introspection.js +1 -1
- package/package.json +1 -1
|
@@ -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();
|
package/dist/cli/init.js
ADDED
|
@@ -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
|
+
}
|
package/dist/cli/server/index.js
CHANGED
|
@@ -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,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
|
+
}
|