devglow-mcp 0.2.1 → 1.0.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,7 +1,7 @@
1
1
  import { execFile } from "child_process";
2
2
  function openDeepLink(url) {
3
3
  return new Promise((resolve, reject) => {
4
- execFile("open", [url], (error) => {
4
+ execFile("open", ["-g", url], (error) => {
5
5
  if (error) {
6
6
  reject(new Error(`Failed to open deep link: ${error.message}`));
7
7
  }
package/build/index.js CHANGED
@@ -1,206 +1,215 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
5
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6
+ import { createServer } from "node:http";
7
+ import { randomUUID } from "node:crypto";
4
8
  import { z } from "zod";
5
9
  import { loadProjects, loadAiProcesses, loadStatuses, loadExportedLogs, } from "./storage.js";
6
10
  import { startProject, stopProject, runProcess, stopProcess, exportLogs, } from "./deeplink.js";
7
- const server = new McpServer({
8
- name: "devglow-mcp",
9
- version: "0.1.0",
10
- }, {
11
- instructions: [
12
- "devglow is the user's local process manager (macOS menu bar app).",
13
- "",
14
- "## When starting a process",
15
- "- Call `list_projects` first to check if the user already has a registered project.",
16
- "- If found, use `start_project` (runs the user's registered command as-is).",
17
- "- Only use `run_process` when no matching project exists.",
18
- "",
19
- "## Reading logs",
20
- "- `get_logs` triggers a log export from DevGlow, waits briefly, then reads the file.",
21
- "- If logs are empty, DevGlow may not have finished writing. Try again after a moment.",
22
- ].join("\n"),
23
- });
24
- // ── Tool: list_projects ──
25
- server.registerTool("list_projects", {
26
- title: "List all projects",
27
- description: "List all registered user projects and AI temporary processes with their status.",
28
- inputSchema: {},
29
- }, async () => {
30
- const projects = loadProjects();
31
- const aiProcesses = loadAiProcesses();
32
- const statuses = loadStatuses();
33
- const statusMap = new Map(statuses.map((s) => [s.id, s]));
34
- let text = "";
35
- if (projects.length > 0) {
36
- text += "## Projects\n";
37
- for (const p of projects) {
38
- const status = statusMap.get(p.id);
39
- const running = status?.running ? "running" : "stopped";
40
- const pid = status?.pid ? ` (PID: ${status.pid})` : "";
41
- const port = p.port ? ` :${p.port}` : "";
42
- text += `- [${running}] ${p.name}${port}${pid} ${p.command} (id: ${p.id})\n`;
11
+ // --- Server factory ---
12
+ // Each transport connection gets its own McpServer instance
13
+ function createMcpServer() {
14
+ const server = new McpServer({
15
+ name: "devglow-mcp",
16
+ version: "1.0.0",
17
+ }, {
18
+ instructions: [
19
+ "devglow is the user's local process manager (macOS menu bar app).",
20
+ "",
21
+ "## When starting a process",
22
+ "- Call `list_projects` first to check if the user already has a registered project.",
23
+ "- If found, use `start_project` (runs the user's registered command as-is).",
24
+ "- Only use `run_process` when no matching project exists.",
25
+ "",
26
+ "## Reading logs",
27
+ "- `get_logs` triggers a log export from DevGlow, waits briefly, then reads the file.",
28
+ "- If logs are empty, DevGlow may not have finished writing. Try again after a moment.",
29
+ ].join("\n"),
30
+ });
31
+ // ── Tool: list_projects ──
32
+ server.registerTool("list_projects", {
33
+ title: "List all projects",
34
+ description: "List all registered user projects and AI temporary processes with their status.",
35
+ inputSchema: {},
36
+ }, async () => {
37
+ const projects = loadProjects();
38
+ const aiProcesses = loadAiProcesses();
39
+ const statuses = loadStatuses();
40
+ const statusMap = new Map(statuses.map((s) => [s.id, s]));
41
+ let text = "";
42
+ if (projects.length > 0) {
43
+ text += "## Projects\n";
44
+ for (const p of projects) {
45
+ const status = statusMap.get(p.id);
46
+ const running = status?.running ? "running" : "stopped";
47
+ const pid = status?.pid ? ` (PID: ${status.pid})` : "";
48
+ const port = p.port ? ` :${p.port}` : "";
49
+ text += `- [${running}] ${p.name}${port}${pid} — ${p.command} (id: ${p.id})\n`;
50
+ }
43
51
  }
44
- }
45
- if (aiProcesses.length > 0) {
46
- text += "\n## AI Processes\n";
47
- for (const p of aiProcesses) {
48
- const status = statusMap.get(p.id);
49
- const running = status?.running ? "running" : "stopped";
50
- const pid = status?.pid ? ` (PID: ${status.pid})` : "";
51
- const port = p.port ? ` :${p.port}` : "";
52
- text += `- [${running}] ${p.name}${port}${pid} — ${p.command} [${p.source}] (id: ${p.id})\n`;
52
+ if (aiProcesses.length > 0) {
53
+ text += "\n## AI Processes\n";
54
+ for (const p of aiProcesses) {
55
+ const status = statusMap.get(p.id);
56
+ const running = status?.running ? "running" : "stopped";
57
+ const pid = status?.pid ? ` (PID: ${status.pid})` : "";
58
+ const port = p.port ? ` :${p.port}` : "";
59
+ text += `- [${running}] ${p.name}${port}${pid} ${p.command} [${p.source}] (id: ${p.id})\n`;
60
+ }
53
61
  }
54
- }
55
- if (!text) {
56
- text = "No projects or AI processes registered.";
57
- }
58
- return { content: [{ type: "text", text }] };
59
- });
60
- // ── Tool: get_status ──
61
- server.registerTool("get_status", {
62
- title: "Get project status",
63
- description: "Get the running status of a specific project or AI process.",
64
- inputSchema: {
65
- id: z.string().describe("Project or AI process ID"),
66
- },
67
- }, async ({ id }) => {
68
- const statuses = loadStatuses();
69
- const status = statuses.find((s) => s.id === id);
70
- if (!status) {
71
- return {
72
- content: [{ type: "text", text: `No status found for ID: ${id}` }],
73
- };
74
- }
75
- const text = `ID: ${status.id}\nRunning: ${status.running}\nPID: ${status.pid ?? "N/A"}\nSource: ${status.source}`;
76
- return { content: [{ type: "text", text }] };
77
- });
78
- // ── Tool: get_logs ──
79
- server.registerTool("get_logs", {
80
- title: "Get process logs",
81
- description: "Export and retrieve recent logs from a running or stopped process.",
82
- inputSchema: {
83
- id: z.string().describe("Project or AI process ID"),
84
- lines: z
85
- .number()
86
- .optional()
87
- .describe("Number of recent lines to return (default: 100)"),
88
- },
89
- }, async ({ id, lines }) => {
90
- // Trigger log export via deep link
91
- await exportLogs(id);
92
- // Wait for DevGlow to write the file
93
- await new Promise((resolve) => setTimeout(resolve, 500));
94
- const logs = loadExportedLogs(id);
95
- const limit = lines ?? 100;
96
- const recent = logs.slice(-limit);
97
- if (recent.length === 0) {
62
+ if (!text) {
63
+ text = "No projects or AI processes registered.";
64
+ }
65
+ return { content: [{ type: "text", text }] };
66
+ });
67
+ // ── Tool: get_status ──
68
+ server.registerTool("get_status", {
69
+ title: "Get project status",
70
+ description: "Get the running status of a specific project or AI process.",
71
+ inputSchema: {
72
+ id: z.string().describe("Project or AI process ID"),
73
+ },
74
+ }, async ({ id }) => {
75
+ const statuses = loadStatuses();
76
+ const status = statuses.find((s) => s.id === id);
77
+ if (!status) {
78
+ return {
79
+ content: [{ type: "text", text: `No status found for ID: ${id}` }],
80
+ };
81
+ }
82
+ const text = `ID: ${status.id}\nRunning: ${status.running}\nPID: ${status.pid ?? "N/A"}\nSource: ${status.source}`;
83
+ return { content: [{ type: "text", text }] };
84
+ });
85
+ // ── Tool: get_logs ──
86
+ server.registerTool("get_logs", {
87
+ title: "Get process logs",
88
+ description: "Export and retrieve recent logs from a running or stopped process.",
89
+ inputSchema: {
90
+ id: z.string().describe("Project or AI process ID"),
91
+ lines: z
92
+ .number()
93
+ .optional()
94
+ .describe("Number of recent lines to return (default: 100)"),
95
+ },
96
+ }, async ({ id, lines }) => {
97
+ // Trigger log export via deep link
98
+ await exportLogs(id);
99
+ // Wait for DevGlow to write the file
100
+ await new Promise((resolve) => setTimeout(resolve, 500));
101
+ const logs = loadExportedLogs(id);
102
+ const limit = lines ?? 100;
103
+ const recent = logs.slice(-limit);
104
+ if (recent.length === 0) {
105
+ return {
106
+ content: [
107
+ {
108
+ type: "text",
109
+ text: `No logs found for ID: ${id}. The process may not have produced output yet.`,
110
+ },
111
+ ],
112
+ };
113
+ }
114
+ const text = recent
115
+ .map((l) => `[${l.timestamp}] [${l.level}] ${l.message}`)
116
+ .join("\n");
117
+ return { content: [{ type: "text", text }] };
118
+ });
119
+ // ── Tool: check_port ──
120
+ server.registerTool("check_port", {
121
+ title: "Check port availability",
122
+ description: "Check if a TCP port is in use on localhost.",
123
+ inputSchema: {
124
+ port: z.number().describe("Port number to check"),
125
+ },
126
+ }, async ({ port }) => {
127
+ const inUse = await checkPort(port);
128
+ const text = inUse
129
+ ? `Port ${port} is in use.`
130
+ : `Port ${port} is available.`;
131
+ return { content: [{ type: "text", text }] };
132
+ });
133
+ // ── Tool: start_project ──
134
+ server.registerTool("start_project", {
135
+ title: "Start a registered project",
136
+ description: "Start a user-registered project using its configured command. Cannot modify the command.",
137
+ inputSchema: {
138
+ id: z.string().describe("Project ID (from list_projects)"),
139
+ },
140
+ }, async ({ id }) => {
141
+ const projects = loadProjects();
142
+ const project = projects.find((p) => p.id === id);
143
+ if (!project) {
144
+ return {
145
+ content: [{ type: "text", text: `Project not found: ${id}` }],
146
+ };
147
+ }
148
+ 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";
98
153
  return {
99
154
  content: [
100
155
  {
101
156
  type: "text",
102
- text: `No logs found for ID: ${id}. The process may not have produced output yet.`,
157
+ text: `Project "${project.name}" ${running}. Command: ${project.command}`,
103
158
  },
104
159
  ],
105
160
  };
106
- }
107
- const text = recent
108
- .map((l) => `[${l.timestamp}] [${l.level}] ${l.message}`)
109
- .join("\n");
110
- return { content: [{ type: "text", text }] };
111
- });
112
- // ── Tool: check_port ──
113
- server.registerTool("check_port", {
114
- title: "Check port availability",
115
- description: "Check if a TCP port is in use on localhost.",
116
- inputSchema: {
117
- port: z.number().describe("Port number to check"),
118
- },
119
- }, async ({ port }) => {
120
- const inUse = await checkPort(port);
121
- const text = inUse
122
- ? `Port ${port} is in use.`
123
- : `Port ${port} is available.`;
124
- return { content: [{ type: "text", text }] };
125
- });
126
- // ── Tool: start_project ──
127
- server.registerTool("start_project", {
128
- title: "Start a registered project",
129
- description: "Start a user-registered project using its configured command. Cannot modify the command.",
130
- inputSchema: {
131
- id: z.string().describe("Project ID (from list_projects)"),
132
- },
133
- }, async ({ id }) => {
134
- const projects = loadProjects();
135
- const project = projects.find((p) => p.id === id);
136
- if (!project) {
161
+ });
162
+ // ── Tool: stop_project ──
163
+ server.registerTool("stop_project", {
164
+ title: "Stop a project",
165
+ description: "Stop a running project or AI process.",
166
+ inputSchema: {
167
+ id: z.string().describe("Project or AI process ID"),
168
+ },
169
+ }, async ({ id }) => {
170
+ await stopProject(id);
137
171
  return {
138
- content: [{ type: "text", text: `Project not found: ${id}` }],
172
+ content: [{ type: "text", text: `Stop signal sent for: ${id}` }],
139
173
  };
140
- }
141
- await startProject(id);
142
- await new Promise((resolve) => setTimeout(resolve, 1000));
143
- const statuses = loadStatuses();
144
- const status = statuses.find((s) => s.id === id);
145
- const running = status?.running ? "started" : "may still be starting";
146
- return {
147
- content: [
148
- {
149
- type: "text",
150
- text: `Project "${project.name}" ${running}. Command: ${project.command}`,
151
- },
152
- ],
153
- };
154
- });
155
- // ── Tool: stop_project ──
156
- server.registerTool("stop_project", {
157
- title: "Stop a project",
158
- description: "Stop a running project or AI process.",
159
- inputSchema: {
160
- id: z.string().describe("Project or AI process ID"),
161
- },
162
- }, async ({ id }) => {
163
- await stopProject(id);
164
- return {
165
- content: [{ type: "text", text: `Stop signal sent for: ${id}` }],
166
- };
167
- });
168
- // ── Tool: run_process ──
169
- server.registerTool("run_process", {
170
- title: "Run a new AI process",
171
- description: "Create and start a new temporary process. Shows in DevGlow's AI Processes section. User can later [keep] or [dismiss] it.",
172
- inputSchema: {
173
- name: z.string().describe("Display name for the process"),
174
- path: z.string().describe("Working directory (supports ~/)"),
175
- command: z.string().describe("Shell command to execute"),
176
- port: z
177
- .number()
178
- .optional()
179
- .describe("Port number the process will listen on. Check package.json scripts or config files for --port flags. Important for DevGlow to show the correct port badge."),
180
- },
181
- }, async ({ name, path, command, port }) => {
182
- // Auto-detect port from command if not explicitly provided
183
- const resolvedPort = port ?? detectPortFromCommand(command);
184
- await runProcess(name, path, command, resolvedPort, "claude");
185
- await new Promise((resolve) => setTimeout(resolve, 1000));
186
- const text = `AI process "${name}" started.\nCommand: ${command}\nPath: ${path}${resolvedPort ? `\nPort: ${resolvedPort}` : ""}`;
187
- return { content: [{ type: "text", text }] };
188
- });
189
- // ── Tool: stop_process ──
190
- server.registerTool("stop_process", {
191
- title: "Stop an AI process",
192
- description: "Stop a running AI temporary process by name.",
193
- inputSchema: {
194
- name: z.string().describe("AI process name"),
195
- },
196
- }, async ({ name }) => {
197
- await stopProcess(name);
198
- return {
199
- content: [
200
- { type: "text", text: `Stop signal sent for AI process: "${name}"` },
201
- ],
202
- };
203
- });
174
+ });
175
+ // ── Tool: run_process ──
176
+ server.registerTool("run_process", {
177
+ title: "Run a new AI process",
178
+ description: "Create and start a new temporary process. Shows in DevGlow's AI Processes section. User can later [keep] or [dismiss] it.",
179
+ inputSchema: {
180
+ name: z.string().describe("Display name for the process"),
181
+ path: z.string().describe("Working directory (supports ~/)"),
182
+ command: z.string().describe("Shell command to execute"),
183
+ port: z
184
+ .number()
185
+ .optional()
186
+ .describe("Port number the process will listen on. Check package.json scripts or config files for --port flags. Important for DevGlow to show the correct port badge."),
187
+ },
188
+ }, async ({ name, path, command, port }) => {
189
+ // Auto-detect port from command if not explicitly provided
190
+ const resolvedPort = port ?? detectPortFromCommand(command);
191
+ await runProcess(name, path, command, resolvedPort, "claude");
192
+ await new Promise((resolve) => setTimeout(resolve, 1000));
193
+ const text = `AI process "${name}" started.\nCommand: ${command}\nPath: ${path}${resolvedPort ? `\nPort: ${resolvedPort}` : ""}`;
194
+ return { content: [{ type: "text", text }] };
195
+ });
196
+ // ── Tool: stop_process ──
197
+ server.registerTool("stop_process", {
198
+ title: "Stop an AI process",
199
+ description: "Stop a running AI temporary process by name.",
200
+ inputSchema: {
201
+ name: z.string().describe("AI process name"),
202
+ },
203
+ }, async ({ name }) => {
204
+ await stopProcess(name);
205
+ return {
206
+ content: [
207
+ { type: "text", text: `Stop signal sent for AI process: "${name}"` },
208
+ ],
209
+ };
210
+ });
211
+ return server;
212
+ }
204
213
  // ── Utility ──
205
214
  function detectPortFromCommand(command) {
206
215
  // Match patterns: --port 3000, --port=3000, -p 3000, -p=3000
@@ -227,11 +236,133 @@ function checkPort(port) {
227
236
  });
228
237
  });
229
238
  }
239
+ // ── Args ──
240
+ function parseArgs() {
241
+ const args = process.argv.slice(2);
242
+ let transport = "stdio";
243
+ let port = 26215;
244
+ for (let i = 0; i < args.length; i++) {
245
+ if (args[i] === "--transport" && args[i + 1]) {
246
+ const value = args[i + 1];
247
+ // "sse" is an alias for "http" (backwards compat)
248
+ transport = value === "sse" || value === "http" ? "http" : "stdio";
249
+ i++;
250
+ }
251
+ else if (args[i] === "--port" && args[i + 1]) {
252
+ port = parseInt(args[i + 1], 10);
253
+ i++;
254
+ }
255
+ }
256
+ return { transport, port };
257
+ }
230
258
  // ── Main ──
231
259
  async function main() {
232
- const transport = new StdioServerTransport();
233
- await server.connect(transport);
234
- console.error("devglow MCP server running on stdio");
260
+ const { transport: mode, port } = parseArgs();
261
+ if (mode === "http") {
262
+ const sseTransports = new Map();
263
+ const streamableTransports = new Map();
264
+ 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));
281
+ return;
282
+ }
283
+ if (!sessionId && req.method === "POST") {
284
+ const transport = new StreamableHTTPServerTransport({
285
+ sessionIdGenerator: () => randomUUID(),
286
+ onsessioninitialized: (sid) => {
287
+ streamableTransports.set(sid, transport);
288
+ },
289
+ });
290
+ transport.onclose = () => {
291
+ const sid = transport.sessionId;
292
+ if (sid)
293
+ streamableTransports.delete(sid);
294
+ };
295
+ const mcpServer = createMcpServer();
296
+ await mcpServer.connect(transport);
297
+ await transport.handleRequest(req, res, await parseBody(req));
298
+ return;
299
+ }
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" }));
323
+ return;
324
+ }
325
+ await sseTransport.handlePostMessage(req, res, await parseBody(req));
326
+ return;
327
+ }
328
+ res.writeHead(404);
329
+ res.end("Not found");
330
+ });
331
+ httpServer.listen(port, () => {
332
+ console.error(`devglow MCP server running on http://localhost:${port}\n` +
333
+ ` Streamable HTTP: http://localhost:${port}/mcp\n` +
334
+ ` SSE (legacy): http://localhost:${port}/sse`);
335
+ });
336
+ process.on("SIGINT", async () => {
337
+ for (const [, t] of sseTransports)
338
+ await t.close();
339
+ for (const [, t] of streamableTransports)
340
+ await t.close();
341
+ httpServer.close();
342
+ process.exit(0);
343
+ });
344
+ }
345
+ else {
346
+ // stdio: single connection, single server
347
+ const mcpServer = createMcpServer();
348
+ const transport = new StdioServerTransport();
349
+ await mcpServer.connect(transport);
350
+ console.error("devglow MCP server running on stdio");
351
+ }
352
+ }
353
+ function parseBody(req) {
354
+ return new Promise((resolve) => {
355
+ let data = "";
356
+ req.on("data", (chunk) => (data += chunk));
357
+ req.on("end", () => {
358
+ try {
359
+ resolve(JSON.parse(data));
360
+ }
361
+ catch {
362
+ resolve(undefined);
363
+ }
364
+ });
365
+ });
235
366
  }
236
367
  main().catch((error) => {
237
368
  console.error("Fatal error:", error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devglow-mcp",
3
- "version": "0.2.1",
3
+ "version": "1.0.0",
4
4
  "description": "MCP server for DevGlow — manage local dev processes through AI agents",
5
5
  "type": "module",
6
6
  "bin": {