devglow-mcp 1.0.4 → 1.1.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/build/deeplink.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { execFile } from "child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { getPortInfo } from "./portInfo.js";
2
4
  function openDeepLink(url) {
3
5
  return new Promise((resolve, reject) => {
4
6
  execFile("open", ["-g", url], (error) => {
@@ -12,27 +14,51 @@ function openDeepLink(url) {
12
14
  });
13
15
  }
14
16
  function buildUrl(action, params) {
17
+ // Note: empty-string values are intentionally preserved (used to clear fields like description/tags)
15
18
  const query = Object.entries(params)
16
- .filter(([, v]) => v !== undefined && v !== "")
19
+ .filter(([, v]) => v !== undefined && v !== null)
17
20
  .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
18
21
  .join("&");
19
22
  return `devglow://${action}${query ? "?" + query : ""}`;
20
23
  }
24
+ function genId(prefix) {
25
+ return `${prefix}-${randomUUID().replace(/-/g, "").slice(0, 16)}`;
26
+ }
21
27
  export async function startProject(id) {
22
28
  await openDeepLink(buildUrl("start", { id }));
23
29
  }
24
30
  export async function stopProject(id) {
25
31
  await openDeepLink(buildUrl("stop", { id }));
26
32
  }
33
+ /**
34
+ * Start a new AI process. Returns the id assigned by MCP — devglow uses the supplied id,
35
+ * which lets the caller correlate the response with the resulting AiProcess entry.
36
+ *
37
+ * If `port` is given and currently occupied, this function looks up the occupier
38
+ * (PID, command name, and whether it's a devglow-managed project) and forwards
39
+ * that context via the deeplink so the resulting AI process card can show
40
+ * "occupied by todoglow" instead of an anonymous warning.
41
+ */
27
42
  export async function runProcess(name, path, command, port, source, description) {
28
- const params = { name, path, command };
43
+ const id = genId("ai");
44
+ const params = { id, name, path, command };
29
45
  if (port !== undefined)
30
46
  params.port = String(port);
31
47
  if (source)
32
48
  params.source = source;
33
49
  if (description)
34
50
  params.description = description;
51
+ // Pre-flight port check: if occupied, attach occupier info so the Rust side
52
+ // can populate AiProcess.port_conflict with a human-readable owner.
53
+ if (port !== undefined) {
54
+ const info = await getPortInfo(port);
55
+ if (info.in_use && info.occupier) {
56
+ params.port_conflict_pid = String(info.occupier.pid);
57
+ params.port_conflict_name = info.owned_by_devglow?.project_name ?? info.occupier.name;
58
+ }
59
+ }
35
60
  await openDeepLink(buildUrl("run", params));
61
+ return id;
36
62
  }
37
63
  export async function stopProcess(name) {
38
64
  await openDeepLink(buildUrl("stop-process", { name }));
@@ -40,9 +66,42 @@ export async function stopProcess(name) {
40
66
  export async function exportLogs(id) {
41
67
  await openDeepLink(buildUrl("export-logs", { id }));
42
68
  }
43
- export async function updateProject(id, description) {
69
+ export async function updateProject(id, fields) {
44
70
  const params = { id };
45
- if (description !== undefined)
46
- params.description = description;
71
+ if (fields.name !== undefined)
72
+ params.name = fields.name;
73
+ if (fields.path !== undefined)
74
+ params.path = fields.path;
75
+ if (fields.command !== undefined)
76
+ params.command = fields.command;
77
+ if (fields.port !== undefined)
78
+ params.port = fields.port === null ? "" : String(fields.port);
79
+ if (fields.description !== undefined)
80
+ params.description = fields.description === null ? "" : fields.description;
81
+ if (fields.tags !== undefined)
82
+ params.tags = fields.tags.join(",");
47
83
  await openDeepLink(buildUrl("update-project", params));
48
84
  }
85
+ /**
86
+ * Register a permanent project without running it. Returns the id assigned by MCP.
87
+ */
88
+ export async function createProject(input) {
89
+ const id = genId("proj");
90
+ const params = {
91
+ id,
92
+ name: input.name,
93
+ path: input.path,
94
+ command: input.command,
95
+ };
96
+ if (input.port !== undefined)
97
+ params.port = String(input.port);
98
+ if (input.description)
99
+ params.description = input.description;
100
+ if (input.tags && input.tags.length > 0)
101
+ params.tags = input.tags.join(",");
102
+ await openDeepLink(buildUrl("create-project", params));
103
+ return id;
104
+ }
105
+ export async function deleteProject(id) {
106
+ await openDeepLink(buildUrl("delete-project", { id }));
107
+ }
package/build/index.js CHANGED
@@ -11,7 +11,8 @@ import { fileURLToPath } from "node:url";
11
11
  import { dirname, join } from "node:path";
12
12
  import { z } from "zod";
13
13
  import { loadProjects, loadAiProcesses, loadStatuses, loadExportedLogs, } from "./storage.js";
14
- import { startProject, stopProject, runProcess, stopProcess, exportLogs, updateProject, } from "./deeplink.js";
14
+ import { getPortInfo } from "./portInfo.js";
15
+ import { startProject, stopProject, runProcess, stopProcess, exportLogs, updateProject, createProject, deleteProject, } from "./deeplink.js";
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
17
  const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
17
18
  // --- Orphan process prevention constants ---
@@ -32,6 +33,12 @@ function createMcpServer() {
32
33
  "- If found, use `start_project` (runs the user's registered command as-is).",
33
34
  "- Only use `run_process` when no matching project exists.",
34
35
  "",
36
+ "## Before binding a port (run_process / create_project with port)",
37
+ "- Call `check_port` first. If the port is in use, the response identifies the occupier — including whether it's another devglow-managed project (project_name + project_id).",
38
+ "- If devglow already runs the occupier: tell the user, and offer to stop it via `stop_project` or to pick a different port. Do not silently kill it.",
39
+ "- If an external process holds it: tell the user; let them decide. Do not pick an arbitrary 'next port' — respect the user's port conventions (e.g., 5173 → 5174 makes sense for Vite, but 25006 → 25007 may not).",
40
+ "- If you proceed despite a conflict, `run_process` will register the entry but mark it with `port_conflict` and skip start. The response surfaces this — relay the conflict to the user instead of pretending success.",
41
+ "",
35
42
  "## Reading logs",
36
43
  "- `get_logs` triggers a log export from DevGlow, waits briefly, then reads the file.",
37
44
  "- If logs are empty, DevGlow may not have finished writing. Try again after a moment.",
@@ -133,16 +140,35 @@ function createMcpServer() {
133
140
  // ── Tool: check_port ──
134
141
  server.registerTool("check_port", {
135
142
  title: "Check port availability",
136
- description: "Check if a TCP port is in use on localhost.",
143
+ description: "Check if a TCP port is in use on localhost. When occupied, also reports who holds it — and if it's a devglow-managed project (so the user can recognize their own process). Call this BEFORE run_process / create_project to avoid silent conflicts.",
137
144
  inputSchema: {
138
145
  port: z.number().describe("Port number to check"),
139
146
  },
140
147
  }, async ({ port }) => {
141
- const inUse = await checkPort(port);
142
- const text = inUse
143
- ? `Port ${port} is in use.`
144
- : `Port ${port} is available.`;
145
- return { content: [{ type: "text", text }] };
148
+ const info = await getPortInfo(port);
149
+ if (!info.in_use) {
150
+ return {
151
+ content: [{ type: "text", text: `Port ${port} is available.` }],
152
+ structuredContent: { ...info },
153
+ };
154
+ }
155
+ const lines = [];
156
+ if (info.owned_by_devglow) {
157
+ const tag = info.owned_by_devglow.is_ai_process ? "AI process" : "project";
158
+ lines.push(`Port ${port} is in use by "${info.owned_by_devglow.project_name}" — a devglow-managed ${tag} (PID ${info.occupier?.pid}).`);
159
+ lines.push(`To free it: stop_project with id "${info.owned_by_devglow.project_id}", or pick a different port.`);
160
+ }
161
+ else if (info.occupier) {
162
+ lines.push(`Port ${port} is in use by ${info.occupier.name} (PID ${info.occupier.pid}) — not a devglow-managed process.`);
163
+ lines.push(`To free it: ask the user to stop it manually, or pick a different port.`);
164
+ }
165
+ else {
166
+ lines.push(`Port ${port} is in use, but the occupying process could not be identified.`);
167
+ }
168
+ return {
169
+ content: [{ type: "text", text: lines.join("\n") }],
170
+ structuredContent: { ...info },
171
+ };
146
172
  });
147
173
  // ── Tool: start_project ──
148
174
  server.registerTool("start_project", {
@@ -214,33 +240,200 @@ function createMcpServer() {
214
240
  }, async ({ name, path, command, port, description }) => {
215
241
  // Auto-detect port from command if not explicitly provided
216
242
  const resolvedPort = port ?? detectPortFromCommand(command);
217
- await runProcess(name, path, command, resolvedPort, "claude", description);
218
- // Brief wait for DevGlow to register the process
219
- await new Promise((resolve) => setTimeout(resolve, 300));
220
- const text = `AI process "${name}" started.\nCommand: ${command}\nPath: ${path}${resolvedPort ? `\nPort: ${resolvedPort}` : ""}${description ? `\nDescription: ${description}` : ""}`;
221
- return { content: [{ type: "text", text }] };
243
+ const id = await runProcess(name, path, command, resolvedPort, "claude", description);
244
+ // Poll storage for the registered AI process so we can return actionable info
245
+ let registered;
246
+ let status;
247
+ for (let i = 0; i < 10; i++) {
248
+ await new Promise((resolve) => setTimeout(resolve, 100));
249
+ const aiProcs = await loadAiProcesses();
250
+ registered = aiProcs.find((p) => p.id === id);
251
+ if (registered) {
252
+ const statuses = await loadStatuses();
253
+ status = statuses.find((s) => s.id === id);
254
+ // If we have an exit reason or running status, stop polling
255
+ if (registered.last_exit_reason || status?.running)
256
+ break;
257
+ }
258
+ }
259
+ const persisted = registered !== undefined;
260
+ const running = status?.running ?? false;
261
+ const portConflict = registered?.port_conflict;
262
+ const exitReason = registered?.last_exit_reason;
263
+ let state;
264
+ if (!persisted)
265
+ state = "unknown";
266
+ else if (running)
267
+ state = "running";
268
+ else if (exitReason || portConflict)
269
+ state = "error";
270
+ else
271
+ state = "pending";
272
+ const lines = [];
273
+ if (state === "running") {
274
+ lines.push(`Started "${name}" (id: ${id}, PID: ${status?.pid ?? "?"})`);
275
+ }
276
+ else if (state === "error") {
277
+ if (portConflict) {
278
+ lines.push(`Registered "${name}" (id: ${id}) but NOT started — port ${portConflict.port} is already in use.`);
279
+ lines.push(`The entry persists in DevGlow with port_conflict flag. Free the port and retry, or pick a different one.`);
280
+ }
281
+ else {
282
+ lines.push(`Registered "${name}" (id: ${id}) but exited shortly after start.${exitReason ? ` Last log line: ${exitReason}` : ""}`);
283
+ lines.push(`For full output (likely the real cause), call get_logs with id "${id}".`);
284
+ }
285
+ }
286
+ else if (state === "pending") {
287
+ lines.push(`Registered "${name}" (id: ${id}). Status not yet confirmed; check list_projects.`);
288
+ }
289
+ else {
290
+ lines.push(`Sent run request for "${name}" (id: ${id}) but registration not yet visible in DevGlow.`);
291
+ }
292
+ lines.push(`Command: ${command}`);
293
+ lines.push(`Path: ${path}`);
294
+ if (resolvedPort)
295
+ lines.push(`Port: ${resolvedPort}`);
296
+ if (description)
297
+ lines.push(`Description: ${description}`);
298
+ return {
299
+ content: [{ type: "text", text: lines.join("\n") }],
300
+ structuredContent: {
301
+ id,
302
+ persisted,
303
+ state,
304
+ running,
305
+ port_conflict: portConflict ?? null,
306
+ last_exit_reason: exitReason ?? null,
307
+ },
308
+ };
222
309
  });
223
310
  // ── Tool: update_project ──
224
311
  server.registerTool("update_project", {
225
312
  title: "Update a project's metadata",
226
- description: "Update an existing DevGlow project's metadata (currently only description). Use this to fill in or refresh a project's description after registering it.",
313
+ description: "Update a kept (permanent) DevGlow project's metadata. Pass only the fields you want to change. Does NOT apply to temporary AI processes — for those, set fields at run_process time or have the user keep the process first.",
227
314
  inputSchema: {
228
315
  id: z
229
316
  .string()
230
- .describe("Project id (call list_projects first to get the id)"),
317
+ .describe("Project id (call list_projects first). Must be a permanent project, not an AI process (those have ids starting with 'ai-')."),
318
+ name: z.string().optional().describe("New display name."),
319
+ path: z.string().optional().describe("New working directory (supports ~/)."),
320
+ command: z.string().optional().describe("New shell command."),
321
+ port: z
322
+ .number()
323
+ .nullable()
324
+ .optional()
325
+ .describe("New port number. Pass null to clear the existing port."),
231
326
  description: z
232
327
  .string()
233
328
  .max(280)
234
- .describe("Short summary of what the app does (max 280 chars). Pass an empty string to clear the existing description."),
329
+ .nullable()
330
+ .optional()
331
+ .describe("Short summary (max 280 chars). Pass null or empty string to clear."),
332
+ tags: z
333
+ .array(z.string())
334
+ .optional()
335
+ .describe("Tags as a list of strings. Pass [] to clear all tags."),
235
336
  },
236
- }, async ({ id, description }) => {
237
- await updateProject(id, description);
337
+ }, async ({ id, name, path, command, port, description, tags }) => {
338
+ await updateProject(id, { name, path, command, port, description, tags });
238
339
  await new Promise((resolve) => setTimeout(resolve, 200));
239
- const text = description
240
- ? `Project ${id} description updated.`
241
- : `Project ${id} description cleared.`;
340
+ const changed = [];
341
+ if (name !== undefined)
342
+ changed.push("name");
343
+ if (path !== undefined)
344
+ changed.push("path");
345
+ if (command !== undefined)
346
+ changed.push("command");
347
+ if (port !== undefined)
348
+ changed.push("port");
349
+ if (description !== undefined)
350
+ changed.push("description");
351
+ if (tags !== undefined)
352
+ changed.push("tags");
353
+ const text = changed.length === 0
354
+ ? `Project ${id}: no fields supplied (no-op).`
355
+ : `Project ${id} updated: ${changed.join(", ")}.`;
242
356
  return { content: [{ type: "text", text }] };
243
357
  });
358
+ // ── Tool: create_project ──
359
+ server.registerTool("create_project", {
360
+ title: "Create a permanent project (without running)",
361
+ description: "Register a permanent DevGlow project WITHOUT running it. Use this for bulk registration when you want to record metadata only (no port binding, no browser tab, no dependency install). To start the project later, call start_project. Use run_process instead when you need to actually launch a server for verification or one-off work.",
362
+ inputSchema: {
363
+ name: z.string().describe("Display name."),
364
+ path: z.string().describe("Working directory (supports ~/)."),
365
+ command: z.string().describe("Shell command to run when the user starts the project."),
366
+ port: z
367
+ .number()
368
+ .optional()
369
+ .describe("Port number the command listens on, if any. Used for the Open ↗ button."),
370
+ description: z
371
+ .string()
372
+ .max(280)
373
+ .optional()
374
+ .describe("Short summary of what the app does (max 280 chars)."),
375
+ tags: z
376
+ .array(z.string())
377
+ .optional()
378
+ .describe("Tags. Used as inline filters in the UI."),
379
+ },
380
+ }, async ({ name, path, command, port, description, tags }) => {
381
+ const id = await createProject({ name, path, command, port, description, tags });
382
+ // Poll for confirmation that the project landed in projects.json
383
+ let registered = false;
384
+ for (let i = 0; i < 10; i++) {
385
+ await new Promise((resolve) => setTimeout(resolve, 100));
386
+ const projects = await loadProjects();
387
+ if (projects.some((p) => p.id === id)) {
388
+ registered = true;
389
+ break;
390
+ }
391
+ }
392
+ const lines = [];
393
+ if (registered) {
394
+ lines.push(`Created permanent project "${name}" (id: ${id}). Not running — use start_project to launch.`);
395
+ }
396
+ else {
397
+ lines.push(`Sent create request for "${name}" (id: ${id}) but registration not yet visible. Check list_projects.`);
398
+ }
399
+ lines.push(`Command: ${command}`);
400
+ lines.push(`Path: ${path}`);
401
+ if (port)
402
+ lines.push(`Port: ${port}`);
403
+ return {
404
+ content: [{ type: "text", text: lines.join("\n") }],
405
+ structuredContent: { id, persisted: registered },
406
+ };
407
+ });
408
+ // ── Tool: delete_project ──
409
+ server.registerTool("delete_project", {
410
+ title: "Delete a permanent project",
411
+ description: "Delete a kept (permanent) DevGlow project. Stops the process if running. Does NOT apply to AI processes — those are dismissed via the UI.",
412
+ inputSchema: {
413
+ id: z
414
+ .string()
415
+ .describe("Project id (call list_projects first). Must be a permanent project, not an AI process."),
416
+ },
417
+ }, async ({ id }) => {
418
+ await deleteProject(id);
419
+ // Poll briefly to confirm removal
420
+ let removed = false;
421
+ for (let i = 0; i < 10; i++) {
422
+ await new Promise((resolve) => setTimeout(resolve, 100));
423
+ const projects = await loadProjects();
424
+ if (!projects.some((p) => p.id === id)) {
425
+ removed = true;
426
+ break;
427
+ }
428
+ }
429
+ const text = removed
430
+ ? `Project ${id} deleted.`
431
+ : `Sent delete request for ${id} but it's still listed. The user may need to confirm in the UI.`;
432
+ return {
433
+ content: [{ type: "text", text }],
434
+ structuredContent: { id, removed },
435
+ };
436
+ });
244
437
  // ── Tool: stop_process ──
245
438
  server.registerTool("stop_process", {
246
439
  title: "Stop an AI process",
@@ -0,0 +1,54 @@
1
+ import { execFile } from "child_process";
2
+ import { loadProjects, loadAiProcesses, loadStatuses } from "./storage.js";
3
+ export async function getPortOccupier(port) {
4
+ return new Promise((resolve) => {
5
+ // -i :PORT : restrict to that port
6
+ // -sTCP:LISTEN : only LISTEN sockets (avoid ephemeral connect-side matches)
7
+ // -Fpcn : machine-readable: p<pid> c<command> n<name>
8
+ execFile("lsof", ["-i", `:${port}`, "-sTCP:LISTEN", "-Fpc"], (err, stdout) => {
9
+ if (err || !stdout)
10
+ return resolve(null);
11
+ let pid = null;
12
+ let name = null;
13
+ for (const line of stdout.split("\n")) {
14
+ if (line.startsWith("p"))
15
+ pid = parseInt(line.slice(1), 10);
16
+ else if (line.startsWith("c"))
17
+ name = line.slice(1);
18
+ if (pid !== null && name !== null)
19
+ break;
20
+ }
21
+ if (pid !== null)
22
+ resolve({ pid, name: name ?? "unknown" });
23
+ else
24
+ resolve(null);
25
+ });
26
+ });
27
+ }
28
+ export async function findDevglowOwner(pid) {
29
+ const [projects, aiProcs, statuses] = await Promise.all([
30
+ loadProjects(),
31
+ loadAiProcesses(),
32
+ loadStatuses(),
33
+ ]);
34
+ const ownerStatus = statuses.find((s) => s.pid === pid && s.running);
35
+ if (!ownerStatus)
36
+ return null;
37
+ const ai = aiProcs.find((p) => p.id === ownerStatus.id);
38
+ if (ai) {
39
+ return { project_id: ai.id, project_name: ai.name, is_ai_process: true };
40
+ }
41
+ const proj = projects.find((p) => p.id === ownerStatus.id);
42
+ if (proj) {
43
+ return { project_id: proj.id, project_name: proj.name, is_ai_process: false };
44
+ }
45
+ return null;
46
+ }
47
+ export async function getPortInfo(port) {
48
+ const occupier = await getPortOccupier(port);
49
+ if (!occupier) {
50
+ return { port, in_use: false, occupier: null, owned_by_devglow: null };
51
+ }
52
+ const owned = await findDevglowOwner(occupier.pid);
53
+ return { port, in_use: true, occupier, owned_by_devglow: owned };
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devglow-mcp",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "description": "MCP server for DevGlow — manage local dev processes through AI agents",
5
5
  "type": "module",
6
6
  "bin": {