devglow-mcp 1.0.0 → 1.0.3

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/build/index.js CHANGED
@@ -4,16 +4,25 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
4
4
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
5
5
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
6
  import { createServer } from "node:http";
7
+ import { createConnection } from "node:net";
7
8
  import { randomUUID } from "node:crypto";
9
+ import { readFileSync } from "node:fs";
10
+ import { fileURLToPath } from "node:url";
11
+ import { dirname, join } from "node:path";
8
12
  import { z } from "zod";
9
13
  import { loadProjects, loadAiProcesses, loadStatuses, loadExportedLogs, } from "./storage.js";
10
14
  import { startProject, stopProject, runProcess, stopProcess, exportLogs, } from "./deeplink.js";
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
17
+ // --- Orphan process prevention constants ---
18
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
19
+ const IDLE_CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
11
20
  // --- Server factory ---
12
21
  // Each transport connection gets its own McpServer instance
13
22
  function createMcpServer() {
14
23
  const server = new McpServer({
15
24
  name: "devglow-mcp",
16
- version: "1.0.0",
25
+ version: pkg.version,
17
26
  }, {
18
27
  instructions: [
19
28
  "devglow is the user's local process manager (macOS menu bar app).",
@@ -34,9 +43,9 @@ function createMcpServer() {
34
43
  description: "List all registered user projects and AI temporary processes with their status.",
35
44
  inputSchema: {},
36
45
  }, async () => {
37
- const projects = loadProjects();
38
- const aiProcesses = loadAiProcesses();
39
- const statuses = loadStatuses();
46
+ const projects = await loadProjects();
47
+ const aiProcesses = await loadAiProcesses();
48
+ const statuses = await loadStatuses();
40
49
  const statusMap = new Map(statuses.map((s) => [s.id, s]));
41
50
  let text = "";
42
51
  if (projects.length > 0) {
@@ -72,7 +81,7 @@ function createMcpServer() {
72
81
  id: z.string().describe("Project or AI process ID"),
73
82
  },
74
83
  }, async ({ id }) => {
75
- const statuses = loadStatuses();
84
+ const statuses = await loadStatuses();
76
85
  const status = statuses.find((s) => s.id === id);
77
86
  if (!status) {
78
87
  return {
@@ -96,11 +105,16 @@ function createMcpServer() {
96
105
  }, async ({ id, lines }) => {
97
106
  // Trigger log export via deep link
98
107
  await exportLogs(id);
99
- // Wait for DevGlow to write the file
100
- await new Promise((resolve) => setTimeout(resolve, 500));
101
- const logs = loadExportedLogs(id);
108
+ // Retry up to 5 times (100ms intervals) to read exported logs
102
109
  const limit = lines ?? 100;
103
- const recent = logs.slice(-limit);
110
+ let recent = [];
111
+ for (let i = 0; i < 5; i++) {
112
+ await new Promise((resolve) => setTimeout(resolve, 100));
113
+ const logs = await loadExportedLogs(id);
114
+ recent = logs.slice(-limit);
115
+ if (recent.length > 0)
116
+ break;
117
+ }
104
118
  if (recent.length === 0) {
105
119
  return {
106
120
  content: [
@@ -138,7 +152,7 @@ function createMcpServer() {
138
152
  id: z.string().describe("Project ID (from list_projects)"),
139
153
  },
140
154
  }, async ({ id }) => {
141
- const projects = loadProjects();
155
+ const projects = await loadProjects();
142
156
  const project = projects.find((p) => p.id === id);
143
157
  if (!project) {
144
158
  return {
@@ -146,10 +160,17 @@ function createMcpServer() {
146
160
  };
147
161
  }
148
162
  await startProject(id);
149
- await new Promise((resolve) => setTimeout(resolve, 1000));
150
- const statuses = loadStatuses();
151
- const status = statuses.find((s) => s.id === id);
152
- const running = status?.running ? "started" : "may still be starting";
163
+ // Retry up to 5 times (200ms intervals) to check if started
164
+ let running = "may still be starting";
165
+ for (let i = 0; i < 5; i++) {
166
+ await new Promise((resolve) => setTimeout(resolve, 200));
167
+ const statuses = await loadStatuses();
168
+ const status = statuses.find((s) => s.id === id);
169
+ if (status?.running) {
170
+ running = "started";
171
+ break;
172
+ }
173
+ }
153
174
  return {
154
175
  content: [
155
176
  {
@@ -177,7 +198,7 @@ function createMcpServer() {
177
198
  title: "Run a new AI process",
178
199
  description: "Create and start a new temporary process. Shows in DevGlow's AI Processes section. User can later [keep] or [dismiss] it.",
179
200
  inputSchema: {
180
- name: z.string().describe("Display name for the process"),
201
+ name: z.string().describe("Display name for the process. Do NOT include port number in the name — use the port parameter instead."),
181
202
  path: z.string().describe("Working directory (supports ~/)"),
182
203
  command: z.string().describe("Shell command to execute"),
183
204
  port: z
@@ -189,7 +210,8 @@ function createMcpServer() {
189
210
  // Auto-detect port from command if not explicitly provided
190
211
  const resolvedPort = port ?? detectPortFromCommand(command);
191
212
  await runProcess(name, path, command, resolvedPort, "claude");
192
- await new Promise((resolve) => setTimeout(resolve, 1000));
213
+ // Brief wait for DevGlow to register the process
214
+ await new Promise((resolve) => setTimeout(resolve, 300));
193
215
  const text = `AI process "${name}" started.\nCommand: ${command}\nPath: ${path}${resolvedPort ? `\nPort: ${resolvedPort}` : ""}`;
194
216
  return { content: [{ type: "text", text }] };
195
217
  });
@@ -220,7 +242,6 @@ function detectPortFromCommand(command) {
220
242
  }
221
243
  function checkPort(port) {
222
244
  return new Promise((resolve) => {
223
- const { createConnection } = require("net");
224
245
  const socket = createConnection({ port, host: "127.0.0.1" });
225
246
  socket.setTimeout(1000);
226
247
  socket.on("connect", () => {
@@ -262,71 +283,80 @@ async function main() {
262
283
  const sseTransports = new Map();
263
284
  const streamableTransports = new Map();
264
285
  const httpServer = createServer(async (req, res) => {
265
- const url = new URL(req.url || "", `http://localhost:${port}`);
266
- // CORS headers
267
- res.setHeader("Access-Control-Allow-Origin", "*");
268
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
269
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id");
270
- if (req.method === "OPTIONS") {
271
- res.writeHead(204);
272
- res.end();
273
- return;
274
- }
275
- // --- Streamable HTTP: /mcp ---
276
- if (url.pathname === "/mcp") {
277
- const sessionId = req.headers["mcp-session-id"];
278
- if (sessionId && streamableTransports.has(sessionId)) {
279
- const transport = streamableTransports.get(sessionId);
280
- await transport.handleRequest(req, res, await parseBody(req));
286
+ try {
287
+ const url = new URL(req.url || "", `http://localhost:${port}`);
288
+ // CORS headers
289
+ res.setHeader("Access-Control-Allow-Origin", "*");
290
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
291
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id");
292
+ if (req.method === "OPTIONS") {
293
+ res.writeHead(204);
294
+ res.end();
295
+ return;
296
+ }
297
+ // --- Streamable HTTP: /mcp ---
298
+ if (url.pathname === "/mcp") {
299
+ const sessionId = req.headers["mcp-session-id"];
300
+ if (sessionId && streamableTransports.has(sessionId)) {
301
+ const transport = streamableTransports.get(sessionId);
302
+ await transport.handleRequest(req, res, await parseBody(req));
303
+ return;
304
+ }
305
+ if (!sessionId && req.method === "POST") {
306
+ const transport = new StreamableHTTPServerTransport({
307
+ sessionIdGenerator: () => randomUUID(),
308
+ onsessioninitialized: (sid) => {
309
+ streamableTransports.set(sid, transport);
310
+ },
311
+ });
312
+ transport.onclose = () => {
313
+ const sid = transport.sessionId;
314
+ if (sid)
315
+ streamableTransports.delete(sid);
316
+ };
317
+ const mcpServer = createMcpServer();
318
+ await mcpServer.connect(transport);
319
+ await transport.handleRequest(req, res, await parseBody(req));
320
+ return;
321
+ }
322
+ res.writeHead(400, { "Content-Type": "application/json" });
323
+ res.end(JSON.stringify({ error: "Bad request" }));
281
324
  return;
282
325
  }
283
- if (!sessionId && req.method === "POST") {
284
- const transport = new StreamableHTTPServerTransport({
285
- sessionIdGenerator: () => randomUUID(),
286
- onsessioninitialized: (sid) => {
287
- streamableTransports.set(sid, transport);
288
- },
326
+ // --- SSE (legacy): /sse + /messages ---
327
+ if (req.method === "GET" && url.pathname === "/sse") {
328
+ const sseTransport = new SSEServerTransport("/messages", res);
329
+ sseTransports.set(sseTransport.sessionId, sseTransport);
330
+ res.on("close", () => {
331
+ sseTransports.delete(sseTransport.sessionId);
289
332
  });
290
- transport.onclose = () => {
291
- const sid = transport.sessionId;
292
- if (sid)
293
- streamableTransports.delete(sid);
294
- };
295
333
  const mcpServer = createMcpServer();
296
- await mcpServer.connect(transport);
297
- await transport.handleRequest(req, res, await parseBody(req));
334
+ await mcpServer.connect(sseTransport);
298
335
  return;
299
336
  }
300
- res.writeHead(400, { "Content-Type": "application/json" });
301
- res.end(JSON.stringify({ error: "Bad request" }));
302
- return;
303
- }
304
- // --- SSE (legacy): /sse + /messages ---
305
- if (req.method === "GET" && url.pathname === "/sse") {
306
- const sseTransport = new SSEServerTransport("/messages", res);
307
- sseTransports.set(sseTransport.sessionId, sseTransport);
308
- res.on("close", () => {
309
- sseTransports.delete(sseTransport.sessionId);
310
- });
311
- const mcpServer = createMcpServer();
312
- await mcpServer.connect(sseTransport);
313
- return;
314
- }
315
- if (req.method === "POST" && url.pathname === "/messages") {
316
- const sessionId = url.searchParams.get("sessionId");
317
- const sseTransport = sessionId
318
- ? sseTransports.get(sessionId)
319
- : undefined;
320
- if (!sseTransport) {
321
- res.writeHead(400, { "Content-Type": "application/json" });
322
- res.end(JSON.stringify({ error: "No transport found for sessionId" }));
337
+ if (req.method === "POST" && url.pathname === "/messages") {
338
+ const sessionId = url.searchParams.get("sessionId");
339
+ const sseTransport = sessionId
340
+ ? sseTransports.get(sessionId)
341
+ : undefined;
342
+ if (!sseTransport) {
343
+ res.writeHead(400, { "Content-Type": "application/json" });
344
+ res.end(JSON.stringify({ error: "No transport found for sessionId" }));
345
+ return;
346
+ }
347
+ await sseTransport.handlePostMessage(req, res, await parseBody(req));
323
348
  return;
324
349
  }
325
- await sseTransport.handlePostMessage(req, res, await parseBody(req));
326
- return;
350
+ res.writeHead(404, { "Content-Type": "text/plain" });
351
+ res.end("Not found");
352
+ }
353
+ catch (err) {
354
+ if (!res.headersSent) {
355
+ const status = err instanceof Error && err.message === "Request body too large" ? 413 : 500;
356
+ res.writeHead(status, { "Content-Type": "application/json" });
357
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Internal server error" }));
358
+ }
327
359
  }
328
- res.writeHead(404);
329
- res.end("Not found");
330
360
  });
331
361
  httpServer.listen(port, () => {
332
362
  console.error(`devglow MCP server running on http://localhost:${port}\n` +
@@ -334,10 +364,18 @@ async function main() {
334
364
  ` SSE (legacy): http://localhost:${port}/sse`);
335
365
  });
336
366
  process.on("SIGINT", async () => {
337
- for (const [, t] of sseTransports)
338
- await t.close();
339
- for (const [, t] of streamableTransports)
340
- await t.close();
367
+ for (const [, t] of sseTransports) {
368
+ try {
369
+ await t.close();
370
+ }
371
+ catch { }
372
+ }
373
+ for (const [, t] of streamableTransports) {
374
+ try {
375
+ await t.close();
376
+ }
377
+ catch { }
378
+ }
341
379
  httpServer.close();
342
380
  process.exit(0);
343
381
  });
@@ -348,12 +386,52 @@ async function main() {
348
386
  const transport = new StdioServerTransport();
349
387
  await mcpServer.connect(transport);
350
388
  console.error("devglow MCP server running on stdio");
389
+ // --- Orphan process prevention (stdio mode) ---
390
+ // When Claude Code exits abnormally, the npx → npm → node chain
391
+ // may not propagate stdin pipe close properly, leaving this process orphaned.
392
+ let lastActivityTime = Date.now();
393
+ let exiting = false;
394
+ let idleTimer;
395
+ function gracefulExit(reason) {
396
+ if (exiting)
397
+ return;
398
+ exiting = true;
399
+ console.error(`devglow-mcp: ${reason}, shutting down`);
400
+ clearInterval(idleTimer);
401
+ try {
402
+ mcpServer.close();
403
+ }
404
+ catch { }
405
+ process.exit(0);
406
+ }
407
+ // Track incoming messages for idle detection
408
+ process.stdin.on("data", () => {
409
+ lastActivityTime = Date.now();
410
+ });
411
+ // 1st defense: detect stdin pipe close (parent process gone)
412
+ process.stdin.resume();
413
+ process.stdin.on("end", () => gracefulExit("stdin ended"));
414
+ process.stdin.on("close", () => gracefulExit("stdin closed"));
415
+ // 2nd defense: idle timeout (no messages for 30 min)
416
+ idleTimer = setInterval(() => {
417
+ if (Date.now() - lastActivityTime >= IDLE_TIMEOUT_MS) {
418
+ gracefulExit(`idle timeout (${IDLE_TIMEOUT_MS / 60_000}m)`);
419
+ }
420
+ }, IDLE_CHECK_INTERVAL_MS);
421
+ idleTimer.unref();
351
422
  }
352
423
  }
424
+ const MAX_BODY_SIZE = 1024 * 1024; // 1MB
353
425
  function parseBody(req) {
354
- return new Promise((resolve) => {
426
+ return new Promise((resolve, reject) => {
355
427
  let data = "";
356
- req.on("data", (chunk) => (data += chunk));
428
+ req.on("data", (chunk) => {
429
+ data += chunk;
430
+ if (data.length > MAX_BODY_SIZE) {
431
+ req.destroy();
432
+ reject(new Error("Request body too large"));
433
+ }
434
+ });
357
435
  req.on("end", () => {
358
436
  try {
359
437
  resolve(JSON.parse(data));
package/build/storage.js CHANGED
@@ -1,41 +1,41 @@
1
- import { readFileSync } from "fs";
1
+ import { readFile } from "fs/promises";
2
2
  import { join } from "path";
3
3
  import { homedir } from "os";
4
4
  const DATA_DIR = join(homedir(), "Library", "Application Support", "app.devglow.macos");
5
5
  const TMP_DIR = join(DATA_DIR, "tmp");
6
6
  // ── File readers ──
7
- function readJsonFile(filename, fallback) {
7
+ async function readJsonFile(filename, fallback) {
8
8
  try {
9
9
  const filePath = join(DATA_DIR, filename);
10
- const content = readFileSync(filePath, "utf-8");
10
+ const content = await readFile(filePath, "utf-8");
11
11
  return JSON.parse(content);
12
12
  }
13
13
  catch {
14
14
  return fallback;
15
15
  }
16
16
  }
17
- export function loadProjects() {
18
- const data = readJsonFile("projects.json", {
17
+ export async function loadProjects() {
18
+ const data = await readJsonFile("projects.json", {
19
19
  projects: [],
20
20
  });
21
21
  return data.projects;
22
22
  }
23
- export function loadAiProcesses() {
24
- const data = readJsonFile("ai_processes.json", {
23
+ export async function loadAiProcesses() {
24
+ const data = await readJsonFile("ai_processes.json", {
25
25
  processes: [],
26
26
  });
27
27
  return data.processes;
28
28
  }
29
- export function loadStatuses() {
30
- const data = readJsonFile("status.json", {
29
+ export async function loadStatuses() {
30
+ const data = await readJsonFile("status.json", {
31
31
  statuses: [],
32
32
  });
33
33
  return data.statuses;
34
34
  }
35
- export function loadExportedLogs(projectId) {
35
+ export async function loadExportedLogs(projectId) {
36
36
  try {
37
37
  const filePath = join(TMP_DIR, `logs-${projectId}.json`);
38
- const content = readFileSync(filePath, "utf-8");
38
+ const content = await readFile(filePath, "utf-8");
39
39
  return JSON.parse(content);
40
40
  }
41
41
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devglow-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
4
4
  "description": "MCP server for DevGlow — manage local dev processes through AI agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "dev": "tsc --watch"
15
15
  },
16
16
  "keywords": ["mcp", "devglow", "ai", "claude", "process-manager"],
17
+ "os": ["darwin"],
17
18
  "license": "MIT",
18
19
  "dependencies": {
19
20
  "@modelcontextprotocol/sdk": "^1.26.0",