too-many-cooks 0.2.0 → 0.4.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 NIMBLESITE PTY LTD
3
+ Copyright (c) 2026 NIMBLESITE PTY LTD
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -0,0 +1,306 @@
1
+ /// Entry point for Too Many Cooks MCP server.
2
+ ///
3
+ /// Starts a single Express HTTP server on port 4040 with:
4
+ /// - `/mcp` — MCP Streamable HTTP for agent connections
5
+ /// - `/admin/*` — REST + Streamable HTTP for the VSCode extension
6
+ import crypto from "node:crypto";
7
+ import { execSync } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import express from "express";
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
12
+ import { LogLevel, logLevelName, logTransport, createLoggerWithContext, createLoggingContext, } from "../lib/src/logger.js";
13
+ import { defaultConfig, getServerPort, getWorkspaceFolder, pathJoin } from "../lib/src/data/config.js";
14
+ import { createDb } from "../lib/src/data/db.js";
15
+ import { createAgentEventHub } from "../lib/src/notifications.js";
16
+ import { createAdminEventHub, registerAdminRoutes } from "../lib/src/admin_routes.js";
17
+ import { createMcpServerForDb } from "../lib/src/server.js";
18
+ /** JSON-RPC bad request error response. */
19
+ const BAD_REQUEST_JSON = '{"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request"},"id":null}';
20
+ /** JSON-RPC session-not-found error response (404). */
21
+ const SESSION_NOT_FOUND_JSON = '{"jsonrpc":"2.0","error":{"code":-32001,"message":"Session not found"},"id":null}';
22
+ const main = async () => {
23
+ const log = createLogger();
24
+ log.info("Server starting...");
25
+ try {
26
+ await startServer(log);
27
+ }
28
+ catch (e) {
29
+ log.fatal("Fatal error", { error: String(e) });
30
+ throw e;
31
+ }
32
+ };
33
+ /** Maximum time to wait for port to become free after killing a process. */
34
+ const PORT_FREE_TIMEOUT_MS = 5000;
35
+ /** Check whether a port has any process listening via lsof. */
36
+ const isPortFree = (port) => {
37
+ try {
38
+ const out = execSync(`lsof -ti :${String(port)}`, { encoding: "utf8" }).trim();
39
+ return out.length === 0;
40
+ }
41
+ catch {
42
+ return true;
43
+ }
44
+ };
45
+ /** Kill any existing process listening on the given port and wait for it to be freed. */
46
+ const killExistingProcess = (port, log) => {
47
+ try {
48
+ const output = execSync(`lsof -ti :${String(port)}`, { encoding: "utf8" }).trim();
49
+ if (output.length === 0) {
50
+ return;
51
+ }
52
+ const pids = output.split("\n").map((pid) => pid.trim()).filter((pid) => pid.length > 0);
53
+ for (const pid of pids) {
54
+ log.info("Killing existing process on port", { port, pid });
55
+ execSync(`kill -9 ${pid}`);
56
+ }
57
+ const start = Date.now();
58
+ while (Date.now() - start < PORT_FREE_TIMEOUT_MS) {
59
+ if (isPortFree(port)) {
60
+ log.info("Port is now free", { port });
61
+ return;
62
+ }
63
+ execSync(`sleep 0.1`);
64
+ }
65
+ log.warn("Port still in use after timeout — proceeding anyway", { port });
66
+ }
67
+ catch {
68
+ // lsof exits non-zero when no process found — that's fine
69
+ }
70
+ };
71
+ const startServer = async (log) => {
72
+ log.info("Creating server...");
73
+ const cfg = defaultConfig;
74
+ const dbResult = createDb(cfg);
75
+ if (!dbResult.ok) {
76
+ throw new Error(dbResult.error);
77
+ }
78
+ const db = dbResult.value;
79
+ log.info("Database created.");
80
+ const transports = new Map();
81
+ const agentHub = createAgentEventHub();
82
+ const adminHub = createAdminEventHub();
83
+ const app = express();
84
+ registerAdminRoutes(app, db, adminHub);
85
+ // Admin Streamable HTTP routes (/admin/events)
86
+ app.post("/admin/events", asyncHandler(adminPostHandler(adminHub, log), log));
87
+ app.get("/admin/events", asyncHandler(adminGetDeleteHandler(adminHub), log));
88
+ app.delete("/admin/events", asyncHandler(adminGetDeleteHandler(adminHub), log));
89
+ // MCP Streamable HTTP routes
90
+ const mcpCtx = { transports, db, cfg, log, adminHub, agentHub };
91
+ app.post("/mcp", asyncHandler(mcpPostHandler(mcpCtx), log));
92
+ app.get("/mcp", asyncHandler(mcpGetDeleteHandler(transports, agentHub), log));
93
+ app.delete("/mcp", asyncHandler(mcpGetDeleteHandler(transports, agentHub), log));
94
+ const port = getServerPort();
95
+ killExistingProcess(port, log);
96
+ app.listen(port, () => {
97
+ log.info("Server listening", { port });
98
+ });
99
+ // Keep event loop alive
100
+ const KEEP_ALIVE_INTERVAL_MS = 60000;
101
+ setInterval(() => { }, KEEP_ALIVE_INTERVAL_MS);
102
+ await new Promise(() => { });
103
+ };
104
+ /** Check if a parsed JSON body is an MCP initialize request. */
105
+ const isInitializeRequest = (body) => {
106
+ if (typeof body !== "object" || body === null) {
107
+ return false;
108
+ }
109
+ const { method } = body;
110
+ return method === "initialize";
111
+ };
112
+ /** Wire up transport close handler for agent sessions. */
113
+ const wireAgentTransportClose = (transport, ctx) => {
114
+ transport.onclose = () => {
115
+ const sid = transport.sessionId;
116
+ if (sid !== undefined) {
117
+ ctx.log.info("Session closed", { sessionId: sid });
118
+ ctx.transports.delete(sid);
119
+ ctx.agentHub.servers.delete(sid);
120
+ ctx.agentHub.sessionAgentNames.delete(sid);
121
+ ctx.agentHub.activeStreamSessions.delete(sid);
122
+ }
123
+ };
124
+ };
125
+ /** Initialize an MCP agent session (POST /mcp with initialize body). */
126
+ const initializeMcpSession = async (req, res, ctx) => {
127
+ const { body } = req;
128
+ const transport = new StreamableHTTPServerTransport({
129
+ sessionIdGenerator: () => crypto.randomUUID(),
130
+ onsessioninitialized: (sid) => {
131
+ ctx.log.info("Session init", { sessionId: sid });
132
+ ctx.transports.set(sid, transport);
133
+ },
134
+ });
135
+ wireAgentTransportClose(transport, ctx);
136
+ const serverResult = createMcpServerForDb(ctx.db, ctx.cfg, ctx.log, {
137
+ adminPush: ctx.adminHub.pushEvent,
138
+ agentPush: ctx.agentHub.pushEvent,
139
+ agentPushToAgent: ctx.agentHub.pushToAgent,
140
+ onSessionSet: (agentName) => {
141
+ const sid = transport.sessionId;
142
+ if (sid !== undefined) {
143
+ ctx.agentHub.sessionAgentNames.set(sid, agentName);
144
+ }
145
+ },
146
+ });
147
+ if (!serverResult.ok) {
148
+ throw new Error(serverResult.error);
149
+ }
150
+ const server = serverResult.value;
151
+ await server.connect(transport);
152
+ await transport.handleRequest(req, res, body);
153
+ const sid = transport.sessionId;
154
+ if (sid !== undefined) {
155
+ ctx.agentHub.servers.set(sid, server);
156
+ }
157
+ };
158
+ /** POST /mcp handler. */
159
+ const mcpPostHandler = (ctx) => async (req, res) => {
160
+ const sessionId = req.headers["mcp-session-id"];
161
+ const { body } = req;
162
+ if (sessionId !== undefined && ctx.transports.has(sessionId)) {
163
+ const existing = ctx.transports.get(sessionId);
164
+ if (existing !== undefined) {
165
+ await existing.handleRequest(req, res, body);
166
+ }
167
+ return;
168
+ }
169
+ if (sessionId !== undefined) {
170
+ res.status(404).send(SESSION_NOT_FOUND_JSON);
171
+ return;
172
+ }
173
+ if (isInitializeRequest(body)) {
174
+ await initializeMcpSession(req, res, ctx);
175
+ return;
176
+ }
177
+ res.status(400).send(BAD_REQUEST_JSON);
178
+ };
179
+ /** GET/DELETE /mcp handler. */
180
+ const mcpGetDeleteHandler = (transports, agentHub) => async (req, res) => {
181
+ const sessionId = req.headers["mcp-session-id"];
182
+ if (sessionId === undefined) {
183
+ res.status(400).send("Missing session ID");
184
+ return;
185
+ }
186
+ const transport = transports.get(sessionId);
187
+ if (transport === undefined) {
188
+ res.status(404).send(SESSION_NOT_FOUND_JSON);
189
+ return;
190
+ }
191
+ agentHub.activeStreamSessions.add(sessionId);
192
+ await transport.handleRequest(req, res);
193
+ };
194
+ /** Initialize an admin session (POST /admin/events with initialize body). */
195
+ const initializeAdminSession = async (req, res, hub, log) => {
196
+ const { body } = req;
197
+ const transport = new StreamableHTTPServerTransport({
198
+ sessionIdGenerator: () => crypto.randomUUID(),
199
+ onsessioninitialized: (sid) => {
200
+ log.info("Admin session init", { sessionId: sid });
201
+ hub.transports.set(sid, transport);
202
+ },
203
+ });
204
+ transport.onclose = () => {
205
+ const sid = transport.sessionId;
206
+ if (sid !== undefined) {
207
+ log.info("Admin session closed", { sessionId: sid });
208
+ hub.transports.delete(sid);
209
+ hub.servers.delete(sid);
210
+ }
211
+ };
212
+ const server = new McpServer({ name: "too-many-cooks", version: "0.1.0" }, { capabilities: { logging: {} } });
213
+ await server.connect(transport);
214
+ await transport.handleRequest(req, res, body);
215
+ const sid = transport.sessionId;
216
+ if (sid !== undefined) {
217
+ hub.servers.set(sid, server);
218
+ }
219
+ };
220
+ /** POST /admin/events handler. */
221
+ const adminPostHandler = (hub, log) => async (req, res) => {
222
+ const sessionId = req.headers["mcp-session-id"];
223
+ const { body } = req;
224
+ if (sessionId !== undefined && hub.transports.has(sessionId)) {
225
+ const existing = hub.transports.get(sessionId);
226
+ if (existing !== undefined) {
227
+ await existing.handleRequest(req, res, body);
228
+ }
229
+ return;
230
+ }
231
+ if (sessionId !== undefined) {
232
+ res.status(404).send(SESSION_NOT_FOUND_JSON);
233
+ return;
234
+ }
235
+ if (isInitializeRequest(body)) {
236
+ await initializeAdminSession(req, res, hub, log);
237
+ return;
238
+ }
239
+ res.status(400).send(BAD_REQUEST_JSON);
240
+ };
241
+ /** GET/DELETE /admin/events handler. */
242
+ const adminGetDeleteHandler = (hub) => async (req, res) => {
243
+ const sessionId = req.headers["mcp-session-id"];
244
+ if (sessionId === undefined) {
245
+ res.status(400).send("Missing session ID");
246
+ return;
247
+ }
248
+ const transport = hub.transports.get(sessionId);
249
+ if (transport === undefined) {
250
+ res.status(404).send(SESSION_NOT_FOUND_JSON);
251
+ return;
252
+ }
253
+ await transport.handleRequest(req, res);
254
+ };
255
+ /** Wrap an async handler for Express. */
256
+ const asyncHandler = (fn, log) => (req, res) => {
257
+ fn(req, res).catch((e) => {
258
+ log.error("Request error", { error: String(e) });
259
+ });
260
+ };
261
+ const resolveLogFilePath = () => {
262
+ const logsDir = pathJoin([getWorkspaceFolder(), "logs"]);
263
+ if (!fs.existsSync(logsDir)) {
264
+ fs.mkdirSync(logsDir, { recursive: true });
265
+ }
266
+ const timestamp = new Date()
267
+ .toISOString()
268
+ .replaceAll(":", "-")
269
+ .replaceAll(".", "-");
270
+ return pathJoin([logsDir, `mcp-server-${timestamp}.log`]);
271
+ };
272
+ const createLogger = () => {
273
+ const logFilePath = resolveLogFilePath();
274
+ return createLoggerWithContext(createLoggingContext({
275
+ transports: [
276
+ logTransport(createConsoleTransport()),
277
+ logTransport(createFileTransport(logFilePath)),
278
+ ],
279
+ minimumLogLevel: LogLevel.DEBUG,
280
+ }));
281
+ };
282
+ const formatLogLine = (message) => {
283
+ const level = logLevelName(message.logLevel);
284
+ const data = message.structuredData;
285
+ const dataStr = data !== undefined && Object.keys(data).length > 0
286
+ ? ` ${JSON.stringify(data)}`
287
+ : "";
288
+ return `[TMC] [${message.timestamp.toISOString()}] [${level}] ${message.message}${dataStr}\n`;
289
+ };
290
+ const createConsoleTransport = () => (message, minimumLogLevel) => {
291
+ if (message.logLevel < minimumLogLevel) {
292
+ return;
293
+ }
294
+ console.error(formatLogLine(message).trimEnd());
295
+ };
296
+ const createFileTransport = (filePath) => (message, minimumLogLevel) => {
297
+ if (message.logLevel < minimumLogLevel) {
298
+ return;
299
+ }
300
+ fs.appendFileSync(filePath, formatLogLine(message));
301
+ };
302
+ main().catch((e) => {
303
+ console.error("Fatal:", e);
304
+ process.exit(1);
305
+ });
306
+ //# sourceMappingURL=server.js.map
package/package.json CHANGED
@@ -1,20 +1,24 @@
1
1
  {
2
2
  "name": "too-many-cooks",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Multi-agent Git coordination MCP server - enables multiple AI agents to safely edit a git repository simultaneously with file locking, messaging, and plan visibility",
5
- "main": "build/bin/server_node.js",
5
+ "type": "module",
6
+ "main": "build/bin/server.js",
6
7
  "bin": {
7
- "too-many-cooks": "build/bin/server_node.js"
8
+ "too-many-cooks": "build/bin/server.js"
8
9
  },
9
10
  "scripts": {
10
- "start": "node build/bin/server_node.js"
11
+ "build": "tsc",
12
+ "start": "node build/bin/server.js",
13
+ "lint": "eslint .",
14
+ "test": "node --import tsx --test --test-concurrency=1 test/*.ts"
11
15
  },
12
16
  "keywords": [
13
17
  "mcp",
14
18
  "model-context-protocol",
15
19
  "ai-agents",
16
20
  "multi-agent",
17
- "dart"
21
+ "typescript"
18
22
  ],
19
23
  "author": "Christian Findlay",
20
24
  "license": "MIT",
@@ -25,16 +29,29 @@
25
29
  "bugs": {
26
30
  "url": "https://github.com/MelbourneDeveloper/dart_node/issues"
27
31
  },
28
- "homepage": "https://github.com/MelbourneDeveloper/dart_node/tree/main/examples/too_many_cooks#readme",
32
+ "homepage": "https://github.com/MelbourneDeveloper/dart_node/tree/main/examples/too-many-cooks#readme",
29
33
  "engines": {
30
34
  "node": ">=18.0.0"
31
35
  },
32
36
  "dependencies": {
33
37
  "@modelcontextprotocol/sdk": "^1.0.0",
34
- "better-sqlite3": "^12.5.0"
38
+ "better-sqlite3": "^12.5.0",
39
+ "express": "^4.21.0"
40
+ },
41
+ "devDependencies": {
42
+ "@eslint/js": "^10.0.1",
43
+ "@types/better-sqlite3": "^7.6.12",
44
+ "@types/express": "^5.0.0",
45
+ "@types/node": "^22.0.0",
46
+ "@typescript-eslint/eslint-plugin": "^8.56.1",
47
+ "@typescript-eslint/parser": "^8.56.1",
48
+ "eslint": "^10.0.3",
49
+ "tsx": "^4.19.0",
50
+ "typescript": "^5.7.0",
51
+ "typescript-eslint": "^8.56.1"
35
52
  },
36
53
  "files": [
37
- "build/bin/server_node.js",
54
+ "build/bin/server.js",
38
55
  "README.md",
39
56
  "LICENSE"
40
57
  ]