loop-task 2.1.12 → 2.1.14

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.
Files changed (58) hide show
  1. package/README.md +27 -9
  2. package/dist/app/App.js +3 -1
  3. package/dist/cli.js +89 -2
  4. package/dist/client/commands.js +4 -1
  5. package/dist/client/project-commands.js +7 -2
  6. package/dist/core/context/template.js +1 -1
  7. package/dist/core/context/validate-context.js +2 -2
  8. package/dist/core/loop/chain-executor.js +11 -4
  9. package/dist/core/loop/loop-controller.js +9 -0
  10. package/dist/daemon/http/openapi.js +30 -3
  11. package/dist/daemon/http/route-loops.js +85 -0
  12. package/dist/daemon/http/route-misc.js +16 -0
  13. package/dist/daemon/http/route-projects.js +2 -2
  14. package/dist/daemon/http/route-tasks.js +58 -0
  15. package/dist/daemon/http/routes.js +3 -2
  16. package/dist/daemon/http/server.js +20 -2
  17. package/dist/daemon/index.js +69 -7
  18. package/dist/daemon/managers/loop-entry.js +2 -0
  19. package/dist/daemon/managers/loop-manager.js +4 -4
  20. package/dist/daemon/managers/project-manager.js +16 -2
  21. package/dist/daemon/mcp/index.js +2 -0
  22. package/dist/daemon/mcp/openapi-sync.js +50 -0
  23. package/dist/daemon/mcp/server.js +167 -0
  24. package/dist/daemon/mcp/tools.js +368 -0
  25. package/dist/daemon/server/handlers/index.js +8 -1
  26. package/dist/daemon/server/handlers/project-handlers.js +10 -5
  27. package/dist/daemon/server/handlers/settings-handlers.js +8 -0
  28. package/dist/daemon/server/index.js +3 -1
  29. package/dist/daemon/settings-manager.js +54 -0
  30. package/dist/entities/tasks/filters.js +1 -1
  31. package/dist/features/commands/commands.js +4 -2
  32. package/dist/features/commands/useCommandHandlers.js +32 -1
  33. package/dist/features/commands/useGlobalShortcuts.js +0 -2
  34. package/dist/features/overlays/ExportModal.js +1 -1
  35. package/dist/loop-config.js +1 -1
  36. package/dist/shared/clipboard.js +2 -2
  37. package/dist/shared/config/paths.js +3 -0
  38. package/dist/shared/container/index.js +2 -0
  39. package/dist/shared/hooks/useDaemonSettings.js +39 -0
  40. package/dist/shared/hooks/useUndoRedo.js +1 -1
  41. package/dist/shared/i18n/en.json +25 -3
  42. package/dist/shared/services/project-service.js +4 -4
  43. package/dist/shared/services/settings-service.js +49 -0
  44. package/dist/shared/services/types.js +1 -0
  45. package/dist/shared/ui/FocusableInput.js +1 -1
  46. package/dist/shared/ui/SelectModal.js +1 -1
  47. package/dist/shared/utils/syntax.js +3 -3
  48. package/dist/widgets/header/Header.js +15 -2
  49. package/dist/widgets/left-panel/Navigator.js +3 -2
  50. package/dist/widgets/left-panel/TaskBrowser.js +3 -2
  51. package/dist/widgets/log-modal/LogModal.js +1 -1
  52. package/dist/widgets/loop-form/WizardForm.js +2 -2
  53. package/dist/widgets/loop-form/useHandleComplete.js +4 -1
  54. package/dist/widgets/project-form/ProjectForm.js +11 -2
  55. package/dist/widgets/right-panel/Inspector.js +1 -1
  56. package/dist/widgets/right-panel/RightPanel.js +1 -1
  57. package/dist/widgets/task-form/TaskForm.js +24 -4
  58. package/package.json +4 -2
@@ -67,4 +67,62 @@ export function registerTaskRoutes(taskManager, r) {
67
67
  }
68
68
  sendOk(res);
69
69
  });
70
+ r("POST", "/api/task-chains", async (req, res) => {
71
+ try {
72
+ const body = await readBody(req);
73
+ if (!body.tasks || !Array.isArray(body.tasks) || body.tasks.length === 0) {
74
+ sendError(res, 400, "tasks array must not be empty");
75
+ return;
76
+ }
77
+ if (body.chain && body.chain !== "sequential-success" && body.chain !== "sequential-failure" && body.chain !== "none") {
78
+ sendError(res, 400, `Invalid chain mode: "${body.chain}". Must be "sequential-success", "sequential-failure", or "none"`);
79
+ return;
80
+ }
81
+ for (const task of body.tasks) {
82
+ if (!task.name?.trim()) {
83
+ sendError(res, 400, "Each task must have a name");
84
+ return;
85
+ }
86
+ if (!task.command?.trim()) {
87
+ sendError(res, 400, "Each task must have a command");
88
+ return;
89
+ }
90
+ }
91
+ const created = [];
92
+ try {
93
+ for (const taskInput of body.tasks) {
94
+ const task = taskManager.create(taskInput);
95
+ created.push(task);
96
+ }
97
+ const chainMode = body.chain ?? "none";
98
+ if (chainMode === "sequential-success") {
99
+ for (let i = 0; i < created.length - 1; i++) {
100
+ taskManager.update(created[i].id, {
101
+ ...created[i],
102
+ onSuccessTaskId: created[i + 1].id,
103
+ });
104
+ }
105
+ }
106
+ else if (chainMode === "sequential-failure") {
107
+ for (let i = 0; i < created.length - 1; i++) {
108
+ taskManager.update(created[i].id, {
109
+ ...created[i],
110
+ onFailureTaskId: created[i + 1].id,
111
+ });
112
+ }
113
+ }
114
+ }
115
+ catch (err) {
116
+ for (const task of created) {
117
+ taskManager.delete(task.id);
118
+ }
119
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
120
+ return;
121
+ }
122
+ sendOk(res, { taskIds: created.map((t) => t.id) }, 201);
123
+ }
124
+ catch (err) {
125
+ sendError(res, 400, err instanceof Error ? err.message : String(err));
126
+ }
127
+ });
70
128
  }
@@ -1,9 +1,9 @@
1
1
  import { registerLoopRoutes } from "./route-loops.js";
2
2
  import { registerTaskRoutes } from "./route-tasks.js";
3
3
  import { registerProjectRoutes } from "./route-projects.js";
4
- import { registerMiscRoutes } from "./route-misc.js";
4
+ import { registerMiscRoutes, registerSettingsRoutes } from "./route-misc.js";
5
5
  export function registerRoutes(deps) {
6
- const { manager, taskManager, projectManager, sseClients } = deps;
6
+ const { manager, taskManager, projectManager, sseClients, settingsManager } = deps;
7
7
  const routes = [];
8
8
  const r = (method, path, handler) => {
9
9
  const segments = path.split("/").filter((s) => s.length > 0);
@@ -13,5 +13,6 @@ export function registerRoutes(deps) {
13
13
  registerTaskRoutes(taskManager, r);
14
14
  registerProjectRoutes(projectManager, r);
15
15
  registerMiscRoutes(sseClients, r);
16
+ registerSettingsRoutes(settingsManager, r);
16
17
  return routes;
17
18
  }
@@ -5,18 +5,21 @@ import { sendError, matchRoute, parsePath } from "./helpers.js";
5
5
  import { SseClientSet } from "./sse.js";
6
6
  import { registerRoutes } from "./routes.js";
7
7
  export class HttpApiServer {
8
- constructor(manager, taskManager, projectManager) {
8
+ constructor(manager, taskManager, projectManager, settingsManager) {
9
9
  this.manager = manager;
10
10
  this.taskManager = taskManager;
11
11
  this.projectManager = projectManager;
12
+ this.settingsManager = settingsManager;
12
13
  this.routes = [];
13
14
  this.sseClients = new SseClientSet();
15
+ this.isListening = false;
14
16
  this.server = http.createServer((req, res) => this.handleRequest(req, res));
15
17
  this.routes = registerRoutes({
16
18
  manager: this.manager,
17
19
  taskManager: this.taskManager,
18
20
  projectManager: this.projectManager,
19
21
  sseClients: this.sseClients,
22
+ settingsManager: this.settingsManager,
20
23
  });
21
24
  }
22
25
  async listen(port = HTTP_API_PORT, host = HTTP_API_HOST) {
@@ -31,17 +34,32 @@ export class HttpApiServer {
31
34
  }
32
35
  });
33
36
  this.server.listen(port, host, () => {
37
+ this.isListening = true;
34
38
  daemonLog(`HTTP API server listening on ${host}:${port}`);
35
39
  resolve();
36
40
  });
37
41
  });
38
42
  }
39
43
  async close() {
44
+ if (!this.isListening)
45
+ return;
46
+ this.isListening = false;
40
47
  this.sseClients.destroyAll();
41
48
  return new Promise((resolve) => {
42
- this.server.close(() => resolve());
49
+ this.server.close(() => {
50
+ resolve();
51
+ });
52
+ // Node's server.close() stops accepting new connections but leaves
53
+ // existing keep-alive sockets open, so a browser tab (e.g. Swagger UI)
54
+ // keeps being served on its persistent connection. Force them closed so
55
+ // the port is actually released when the API is toggled off.
56
+ this.server.closeAllConnections?.();
43
57
  });
44
58
  }
59
+ async restart(port = HTTP_API_PORT, host = HTTP_API_HOST) {
60
+ await this.close();
61
+ await this.listen(port, host);
62
+ }
45
63
  broadcastEvent(event, data) {
46
64
  this.sseClients.broadcast(event, data);
47
65
  }
@@ -2,20 +2,27 @@ import { LoopManager } from "./managers/loop-manager.js";
2
2
  import { TaskManager } from "./managers/task-manager.js";
3
3
  import { IpcServer } from "./server/index.js";
4
4
  import { HttpApiServer } from "./http/server.js";
5
+ import { McpApiServer } from "./mcp/index.js";
5
6
  import { FileWatcher } from "./watcher/index.js";
7
+ import { SettingsManager } from "./settings-manager.js";
6
8
  import { writeDaemonPid, removeDaemonPid, writeDaemonSignature, removeDaemonSignature, computeCodeSignature, migrateTasksToJson, migrateLoopsToJson, setSelfWriteNotifier, } from "./state/index.js";
7
9
  import { setProjectSelfWriteNotifier } from "./managers/project-manager.js";
8
10
  import { t } from "../shared/i18n/index.js";
9
11
  import { daemonLog } from "./daemon-log.js";
10
12
  async function main() {
13
+ const mcpEnabled = (process.env.LOOP_CLI_MCP_ENABLED ?? "true") !== "false";
14
+ const mcpTransport = process.env.LOOP_CLI_MCP_TRANSPORT === "stdio" ? "stdio" : "sse";
11
15
  const taskManager = new TaskManager();
12
16
  migrateLoopsToJson();
13
17
  migrateTasksToJson();
14
18
  taskManager.init();
15
19
  const manager = new LoopManager(taskManager);
16
- const server = new IpcServer(manager, taskManager);
20
+ const settingsManager = new SettingsManager();
21
+ settingsManager.load();
22
+ const server = new IpcServer(manager, taskManager, settingsManager);
17
23
  const projectManager = manager.projectManager;
18
- const httpServer = new HttpApiServer(manager, taskManager, projectManager);
24
+ const httpServer = new HttpApiServer(manager, taskManager, projectManager, settingsManager);
25
+ const mcpServer = new McpApiServer(manager, taskManager, projectManager, mcpTransport);
19
26
  try {
20
27
  await server.listen();
21
28
  }
@@ -23,13 +30,67 @@ async function main() {
23
30
  daemonLog(`listen failed (another daemon already holds the socket): ${String(err)}`);
24
31
  process.exit(0);
25
32
  }
26
- try {
27
- const httpPort = parseInt(process.env.LOOP_CLI_HTTP_PORT ?? "", 10);
28
- await httpServer.listen(Number.isNaN(httpPort) ? undefined : httpPort);
33
+ const httpPort = parseInt(process.env.LOOP_CLI_HTTP_PORT ?? "", 10);
34
+ const resolvedHttpPort = Number.isNaN(httpPort) ? undefined : httpPort;
35
+ let currentHttpHost = settingsManager.get().httpApiHost;
36
+ if (settingsManager.get().httpApiEnabled) {
37
+ try {
38
+ await httpServer.listen(resolvedHttpPort, currentHttpHost);
39
+ }
40
+ catch (err) {
41
+ daemonLog(`HTTP API server failed to start: ${String(err)}`);
42
+ }
29
43
  }
30
- catch (err) {
31
- daemonLog(`HTTP API server failed to start: ${String(err)}`);
44
+ mcpServer.setHost(currentHttpHost);
45
+ if (mcpEnabled) {
46
+ try {
47
+ await mcpServer.start();
48
+ }
49
+ catch (err) {
50
+ daemonLog(`MCP server failed to start: ${String(err)}`);
51
+ }
32
52
  }
53
+ settingsManager.onChange((settings) => {
54
+ const newHost = settings.httpApiHost;
55
+ const hostChanged = newHost !== currentHttpHost;
56
+ // HTTP API (8845)
57
+ if (settings.httpApiEnabled && !httpServer["isListening"]) {
58
+ httpServer.listen(resolvedHttpPort, newHost).catch((err) => {
59
+ daemonLog(`HTTP API server failed to restart: ${String(err)}`);
60
+ });
61
+ }
62
+ else if (settings.httpApiEnabled && httpServer["isListening"] && hostChanged) {
63
+ httpServer.restart(resolvedHttpPort, newHost).catch((err) => {
64
+ daemonLog(`HTTP API server failed to rebind to ${newHost}: ${String(err)}`);
65
+ });
66
+ }
67
+ else if (!settings.httpApiEnabled && httpServer["isListening"]) {
68
+ httpServer.close().catch((err) => {
69
+ daemonLog(`HTTP API server failed to stop: ${String(err)}`);
70
+ });
71
+ }
72
+ // MCP SSE (8846) — shares the same bind host as the HTTP API.
73
+ mcpServer.setHost(newHost);
74
+ if (settings.mcpApiEnabled && !mcpServer.isListening) {
75
+ mcpServer.start().catch((err) => {
76
+ daemonLog(`MCP server failed to restart: ${String(err)}`);
77
+ });
78
+ }
79
+ else if (settings.mcpApiEnabled && mcpServer.isListening && hostChanged) {
80
+ mcpServer
81
+ .close()
82
+ .then(() => mcpServer.start())
83
+ .catch((err) => {
84
+ daemonLog(`MCP server failed to rebind to ${newHost}: ${String(err)}`);
85
+ });
86
+ }
87
+ else if (!settings.mcpApiEnabled && mcpServer.isListening) {
88
+ mcpServer.close().catch((err) => {
89
+ daemonLog(`MCP server failed to stop: ${String(err)}`);
90
+ });
91
+ }
92
+ currentHttpHost = newHost;
93
+ });
33
94
  manager.init();
34
95
  writeDaemonPid(process.pid);
35
96
  writeDaemonSignature(computeCodeSignature());
@@ -53,6 +114,7 @@ async function main() {
53
114
  await manager.shutdown();
54
115
  await server.close();
55
116
  await httpServer.close();
117
+ await mcpServer.close();
56
118
  }
57
119
  catch (err) {
58
120
  daemonLog(`error during shutdown: ${String(err)}`);
@@ -20,5 +20,7 @@ export function metaToState(meta) {
20
20
  nextRunAt: meta.nextRunAt,
21
21
  remainingDelayMs: meta.remainingDelayMs,
22
22
  runHistory: meta.runHistory,
23
+ skippedCount: meta.skippedCount,
24
+ silentChainCount: meta.silentChainCount,
23
25
  };
24
26
  }
@@ -92,11 +92,11 @@ export class LoopManager {
92
92
  listProjects() {
93
93
  return this.projectManager.getAll();
94
94
  }
95
- createProject(name, color, directory) {
96
- return this.projectManager.create(name, color, directory);
95
+ createProject(name, color, directory, githubSource) {
96
+ return this.projectManager.create(name, color, directory, githubSource);
97
97
  }
98
- updateProject(id, name, color, directory) {
99
- this.projectManager.update(id, name, color, directory);
98
+ updateProject(id, name, color, directory, githubSource) {
99
+ this.projectManager.update(id, name, color, directory, githubSource);
100
100
  }
101
101
  deleteProject(id) {
102
102
  for (const [loopId, entry] of this.loops) {
@@ -8,6 +8,12 @@ let selfWriteNotifier = null;
8
8
  export function setProjectSelfWriteNotifier(notifier) {
9
9
  selfWriteNotifier = notifier;
10
10
  }
11
+ const GITHUB_SOURCE_REGEX = /^[a-zA-Z0-9_.\-]+\/[a-zA-Z0-9_.\-]+$/;
12
+ function validateGithubSource(githubSource) {
13
+ if (!GITHUB_SOURCE_REGEX.test(githubSource)) {
14
+ throw new Error(`Invalid githubSource format: "${githubSource}". Expected owner/repo (e.g. CKGrafico/loop-task)`);
15
+ }
16
+ }
11
17
  export class ProjectManager {
12
18
  constructor() {
13
19
  this.projects = new Map();
@@ -99,13 +105,16 @@ export class ProjectManager {
99
105
  get(id) {
100
106
  return this.projects.get(id);
101
107
  }
102
- create(name, color, directory) {
108
+ create(name, color, directory, githubSource) {
109
+ if (githubSource !== undefined && githubSource !== "")
110
+ validateGithubSource(githubSource);
103
111
  const id = crypto.randomBytes(4).toString("hex");
104
112
  const project = {
105
113
  id,
106
114
  name,
107
115
  color,
108
116
  directory: directory ?? "",
117
+ githubSource: githubSource || undefined,
109
118
  createdAt: new Date().toISOString(),
110
119
  isSystem: false,
111
120
  isDefault: false,
@@ -113,7 +122,7 @@ export class ProjectManager {
113
122
  this.saveProject(project);
114
123
  return project;
115
124
  }
116
- update(id, name, color, directory) {
125
+ update(id, name, color, directory, githubSource) {
117
126
  const project = this.projects.get(id);
118
127
  if (!project)
119
128
  throw new Error(`Project ${id} not found`);
@@ -124,6 +133,11 @@ export class ProjectManager {
124
133
  project.color = color;
125
134
  if (directory !== undefined)
126
135
  project.directory = directory;
136
+ if (githubSource !== undefined) {
137
+ if (githubSource !== "")
138
+ validateGithubSource(githubSource);
139
+ project.githubSource = githubSource || undefined;
140
+ }
127
141
  this.saveProject(project);
128
142
  }
129
143
  delete(id) {
@@ -0,0 +1,2 @@
1
+ export { McpApiServer } from "./server.js";
2
+ export { registerMcpTools } from "./tools.js";
@@ -0,0 +1,50 @@
1
+ import { buildOpenApiSpec } from "../http/openapi.js";
2
+ export function buildMcpToolDefsFromOpenApi() {
3
+ const spec = buildOpenApiSpec();
4
+ const defs = [];
5
+ if (!spec.paths)
6
+ return defs;
7
+ for (const [path, methods] of Object.entries(spec.paths)) {
8
+ for (const [method, operation] of Object.entries(methods)) {
9
+ if (!operation.summary)
10
+ continue;
11
+ const toolName = httpToToolName(method, path);
12
+ if (!toolName)
13
+ continue;
14
+ defs.push({
15
+ name: toolName,
16
+ description: operation.summary,
17
+ });
18
+ }
19
+ }
20
+ return defs;
21
+ }
22
+ function httpToToolName(method, path) {
23
+ const mappings = {
24
+ "GET /api/loops": "list_loops",
25
+ "POST /api/loops": "create_loop",
26
+ "GET /api/loops/:id": "get_loop",
27
+ "PATCH /api/loops/:id": "update_loop",
28
+ "DELETE /api/loops/:id": "delete_loop",
29
+ "POST /api/loops/:id/pause": "pause_loop",
30
+ "POST /api/loops/:id/resume": "resume_loop",
31
+ "POST /api/loops/:id/play": "resume_loop",
32
+ "POST /api/loops/:id/trigger": "trigger_loop",
33
+ "POST /api/loops/:id/stop": "stop_loop",
34
+ "POST /api/loops/stop-all": "stop_all_loops",
35
+ "GET /api/loops/:id/logs": "get_loop_logs",
36
+ "GET /api/loops/:id/runs/:num": "get_run_log",
37
+ "GET /api/tasks": "list_tasks",
38
+ "POST /api/tasks": "create_task",
39
+ "GET /api/tasks/:id": "get_task",
40
+ "PATCH /api/tasks/:id": "update_task",
41
+ "DELETE /api/tasks/:id": "delete_task",
42
+ "GET /api/projects": "list_projects",
43
+ "POST /api/projects": "create_project",
44
+ "PATCH /api/projects/:id": "update_project",
45
+ "DELETE /api/projects/:id": "delete_project",
46
+ };
47
+ const normalizedPath = path.replace(/\{[^}]+\}/g, (m) => ":" + m.slice(1, -1));
48
+ const key = `${method.toUpperCase()} ${normalizedPath}`;
49
+ return mappings[key] ?? null;
50
+ }
@@ -0,0 +1,167 @@
1
+ import http from "node:http";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
5
+ import { daemonLog } from "../daemon-log.js";
6
+ import { registerMcpTools } from "./tools.js";
7
+ const MCP_DEFAULT_PORT = 8846;
8
+ export class McpApiServer {
9
+ constructor(manager, taskManager, projectManager, transport) {
10
+ this.manager = manager;
11
+ this.taskManager = taskManager;
12
+ this.projectManager = projectManager;
13
+ this.mcpServer = null;
14
+ this.activeTransport = null;
15
+ this.httpServer = null;
16
+ this._isListening = false;
17
+ this.host = "127.0.0.1";
18
+ this.transportType = transport ?? "sse";
19
+ }
20
+ /** Set the bind host for the SSE transport (takes effect on next start). */
21
+ setHost(host) {
22
+ this.host = host;
23
+ }
24
+ createServer() {
25
+ const server = new McpServer({ name: "loop-task", version: "1.0.0" }, { capabilities: { tools: {} } });
26
+ registerMcpTools(server, { manager: this.manager, taskManager: this.taskManager, projectManager: this.projectManager });
27
+ return server;
28
+ }
29
+ get isListening() {
30
+ return this._isListening;
31
+ }
32
+ async start() {
33
+ if (this._isListening)
34
+ return;
35
+ try {
36
+ if (this.transportType === "stdio") {
37
+ await this.startStdio();
38
+ }
39
+ else {
40
+ await this.startSse();
41
+ }
42
+ }
43
+ catch (err) {
44
+ daemonLog(`MCP server failed to start: ${String(err)}`);
45
+ }
46
+ }
47
+ async close() {
48
+ if (!this._isListening)
49
+ return;
50
+ this._isListening = false;
51
+ if (this.mcpServer) {
52
+ try {
53
+ await this.mcpServer.close();
54
+ }
55
+ catch {
56
+ // ignore close errors
57
+ }
58
+ this.mcpServer = null;
59
+ }
60
+ if (this.httpServer) {
61
+ await new Promise((resolve) => {
62
+ this.httpServer.close(() => resolve());
63
+ this.httpServer.closeAllConnections?.();
64
+ });
65
+ this.httpServer = null;
66
+ }
67
+ this.activeTransport = null;
68
+ daemonLog("MCP server stopped");
69
+ }
70
+ async startStdio() {
71
+ if (!process.stdin || !process.stdout) {
72
+ daemonLog("MCP stdio transport: stdin/stdout not available, skipping");
73
+ return;
74
+ }
75
+ const server = this.createServer();
76
+ const transport = new StdioServerTransport();
77
+ this.mcpServer = server;
78
+ this.activeTransport = transport;
79
+ await server.connect(transport);
80
+ this._isListening = true;
81
+ daemonLog("MCP server listening on stdio");
82
+ transport.onclose = () => {
83
+ this._isListening = false;
84
+ daemonLog("MCP stdio transport closed");
85
+ };
86
+ }
87
+ async startSse() {
88
+ const port = parseInt(process.env.LOOP_CLI_MCP_PORT ?? "", 10) || MCP_DEFAULT_PORT;
89
+ // Map session ID → transport for POST routing
90
+ const sessions = new Map();
91
+ this.httpServer = http.createServer(async (req, res) => {
92
+ try {
93
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
94
+ if (req.method === "GET" && url.pathname === "/sse") {
95
+ // Create a fresh McpServer per SSE connection — the SDK only allows
96
+ // one transport per McpServer instance, so reusing one would cause
97
+ // "Already connected to a transport" errors on reconnect.
98
+ const server = this.createServer();
99
+ const transport = new SSEServerTransport("/message", res);
100
+ sessions.set(transport.sessionId, transport);
101
+ await server.connect(transport);
102
+ if (!this.mcpServer)
103
+ this.mcpServer = server;
104
+ this._isListening = true;
105
+ daemonLog(`MCP SSE client connected (session: ${transport.sessionId})`);
106
+ transport.onclose = () => {
107
+ sessions.delete(transport.sessionId);
108
+ daemonLog(`MCP SSE client disconnected (session: ${transport.sessionId})`);
109
+ };
110
+ }
111
+ else if (req.method === "POST" && url.pathname === "/message") {
112
+ const body = await this.readBody(req);
113
+ // Route the POST to the correct transport by session ID
114
+ const sessionId = url.searchParams.get("sessionId");
115
+ const transport = sessionId ? sessions.get(sessionId) : sessions.values().next().value;
116
+ if (transport) {
117
+ await transport.handlePostMessage(req, res, body);
118
+ }
119
+ else {
120
+ res.writeHead(400).end("No active SSE connection");
121
+ }
122
+ }
123
+ else {
124
+ res.writeHead(404).end("Not found");
125
+ }
126
+ }
127
+ catch (err) {
128
+ daemonLog(`MCP SSE request error: ${String(err)}`);
129
+ if (!res.headersSent) {
130
+ res.writeHead(500).end("Internal error");
131
+ }
132
+ }
133
+ });
134
+ return new Promise((resolve) => {
135
+ this.httpServer.on("error", (err) => {
136
+ if (err.code === "EADDRINUSE") {
137
+ daemonLog(`MCP SSE server: port ${port} already in use, skipping SSE transport`);
138
+ }
139
+ else {
140
+ daemonLog(`MCP SSE server error: ${String(err)}`);
141
+ }
142
+ resolve();
143
+ });
144
+ this.httpServer.listen(port, this.host, () => {
145
+ daemonLog(`MCP SSE server listening on ${this.host}:${port}`);
146
+ this._isListening = true;
147
+ resolve();
148
+ });
149
+ });
150
+ }
151
+ readBody(req) {
152
+ return new Promise((resolve, reject) => {
153
+ const chunks = [];
154
+ req.on("data", (chunk) => chunks.push(chunk));
155
+ req.on("end", () => {
156
+ try {
157
+ const raw = Buffer.concat(chunks).toString("utf-8");
158
+ resolve(raw ? JSON.parse(raw) : {});
159
+ }
160
+ catch {
161
+ resolve({});
162
+ }
163
+ });
164
+ req.on("error", reject);
165
+ });
166
+ }
167
+ }