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.
- package/README.md +27 -9
- package/dist/app/App.js +3 -1
- package/dist/cli.js +89 -2
- package/dist/client/commands.js +4 -1
- package/dist/client/project-commands.js +7 -2
- package/dist/core/context/template.js +1 -1
- package/dist/core/context/validate-context.js +2 -2
- package/dist/core/loop/chain-executor.js +11 -4
- package/dist/core/loop/loop-controller.js +9 -0
- package/dist/daemon/http/openapi.js +30 -3
- package/dist/daemon/http/route-loops.js +85 -0
- package/dist/daemon/http/route-misc.js +16 -0
- package/dist/daemon/http/route-projects.js +2 -2
- package/dist/daemon/http/route-tasks.js +58 -0
- package/dist/daemon/http/routes.js +3 -2
- package/dist/daemon/http/server.js +20 -2
- package/dist/daemon/index.js +69 -7
- package/dist/daemon/managers/loop-entry.js +2 -0
- package/dist/daemon/managers/loop-manager.js +4 -4
- package/dist/daemon/managers/project-manager.js +16 -2
- package/dist/daemon/mcp/index.js +2 -0
- package/dist/daemon/mcp/openapi-sync.js +50 -0
- package/dist/daemon/mcp/server.js +167 -0
- package/dist/daemon/mcp/tools.js +368 -0
- package/dist/daemon/server/handlers/index.js +8 -1
- package/dist/daemon/server/handlers/project-handlers.js +10 -5
- package/dist/daemon/server/handlers/settings-handlers.js +8 -0
- package/dist/daemon/server/index.js +3 -1
- package/dist/daemon/settings-manager.js +54 -0
- package/dist/entities/tasks/filters.js +1 -1
- package/dist/features/commands/commands.js +4 -2
- package/dist/features/commands/useCommandHandlers.js +32 -1
- package/dist/features/commands/useGlobalShortcuts.js +0 -2
- package/dist/features/overlays/ExportModal.js +1 -1
- package/dist/loop-config.js +1 -1
- package/dist/shared/clipboard.js +2 -2
- package/dist/shared/config/paths.js +3 -0
- package/dist/shared/container/index.js +2 -0
- package/dist/shared/hooks/useDaemonSettings.js +39 -0
- package/dist/shared/hooks/useUndoRedo.js +1 -1
- package/dist/shared/i18n/en.json +25 -3
- package/dist/shared/services/project-service.js +4 -4
- package/dist/shared/services/settings-service.js +49 -0
- package/dist/shared/services/types.js +1 -0
- package/dist/shared/ui/FocusableInput.js +1 -1
- package/dist/shared/ui/SelectModal.js +1 -1
- package/dist/shared/utils/syntax.js +3 -3
- package/dist/widgets/header/Header.js +15 -2
- package/dist/widgets/left-panel/Navigator.js +3 -2
- package/dist/widgets/left-panel/TaskBrowser.js +3 -2
- package/dist/widgets/log-modal/LogModal.js +1 -1
- package/dist/widgets/loop-form/WizardForm.js +2 -2
- package/dist/widgets/loop-form/useHandleComplete.js +4 -1
- package/dist/widgets/project-form/ProjectForm.js +11 -2
- package/dist/widgets/right-panel/Inspector.js +1 -1
- package/dist/widgets/right-panel/RightPanel.js +1 -1
- package/dist/widgets/task-form/TaskForm.js +24 -4
- 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(() =>
|
|
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
|
}
|
package/dist/daemon/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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)}`);
|
|
@@ -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,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
|
+
}
|