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 +157 -79
- package/build/storage.js +11 -11
- package/package.json +2 -1
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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(
|
|
297
|
-
await transport.handleRequest(req, res, await parseBody(req));
|
|
334
|
+
await mcpServer.connect(sseTransport);
|
|
298
335
|
return;
|
|
299
336
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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) =>
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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.
|
|
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",
|