@lyric_dev/data-loom 0.2.4 → 0.4.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/README.md +12 -9
- package/dist/index.js +5 -15
- package/dist/mcpServer.js +88 -42
- package/dist/server.js +70 -1
- package/package.json +1 -1
- package/public/app.js +459 -244
- package/public/index.html +51 -18
- package/public/style.css +829 -189
package/README.md
CHANGED
|
@@ -58,25 +58,28 @@ npm start # serves the current directory's project
|
|
|
58
58
|
|
|
59
59
|
## Plan dependencies with your Claude (MCP server)
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
The running daemon also **hosts an MCP server** over HTTP, so your own Claude session determines and applies the order of interdependent proposals — DataLoom holds no API key and the reasoning runs under your authenticated Claude. One registration serves **every** project; the target project is resolved per call (an explicit `project` argument, falling back to whatever the dashboard has selected).
|
|
62
62
|
|
|
63
|
-
1. Register it
|
|
63
|
+
1. Register it once, globally — no per-project setup:
|
|
64
64
|
|
|
65
65
|
```
|
|
66
|
-
claude mcp add
|
|
66
|
+
claude mcp add --transport http --scope user data-loom http://127.0.0.1:4317/mcp
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
(
|
|
69
|
+
The MCP server lives in the daemon, so **DataLoom must be running** for the tools to be reachable (start it with `npx @lyric_dev/data-loom "C:\path\to\your\project"`). It binds to loopback only.
|
|
70
70
|
|
|
71
|
-
2. In
|
|
72
|
-
- `
|
|
73
|
-
- `
|
|
74
|
-
- `
|
|
71
|
+
2. In any project, ask Claude something like *"review DataLoom's open proposals and set the dependencies."* On connect, the server asks Claude to surface any proposal that hasn't been reviewed for dependencies yet, propose the edges, and **confirm with you before writing**. It exposes these tools:
|
|
72
|
+
- `list_projects` — the selectable OpenSpec workspaces plus the current selection (read-only), to discover/confirm a `project` path.
|
|
73
|
+
- `list_open_proposals(project?)` — the open changes with their proposal text, current phase/readiness, and dependency-review state (read-only; proposal text only, no secrets).
|
|
74
|
+
- `set_dependency(from, to, project?)` — writes a `## Depends On` entry into a proposal.
|
|
75
|
+
- `mark_independent(change, project?)` — records that a proposal genuinely depends on nothing, by writing an empty `## Depends On` block.
|
|
75
76
|
- `install_weave_skill` — installs the `/loom:weave` shortcut command (one-time setup, see below).
|
|
76
77
|
|
|
77
78
|
Each write is an explicit, reviewable `## Depends On` edit; the roadmap then recomputes deterministically. Proposals that still need a dependency decision are flagged in the roadmap with a **"needs review"** badge, and DataLoom appears as a server in its own MCP Topology tab once registered.
|
|
78
79
|
|
|
79
|
-
3. **One command for it all: `/loom:weave`.** Ask Claude to *"install the weave skill"* (it calls `install_weave_skill`). That writes a `/loom:weave` slash command into your global Claude config (`~/.claude/commands/loom/weave.md`); reload Claude Code, and from then on `/loom:weave` runs the whole review — list, propose, confirm, apply — in any project
|
|
80
|
+
3. **One command for it all: `/loom:weave`.** Ask Claude to *"install the weave skill"* (it calls `install_weave_skill`). That writes a `/loom:weave` slash command into your global Claude config (`~/.claude/commands/loom/weave.md`); reload Claude Code, and from then on `/loom:weave` runs the whole review — list, propose, confirm, apply — in any project, passing that project explicitly. (It needs the daemon running; if the tools aren't reachable it tells you to start DataLoom.)
|
|
81
|
+
|
|
82
|
+
> **Upgrading from an earlier version?** The MCP server used to be a per-project stdio registration (`claude mcp add data-loom -- npx … mcp "<path>"`). That mode is gone. Remove any old per-project `data-loom` registrations and add the single user-scope HTTP one above.
|
|
80
83
|
|
|
81
84
|
## Design principles
|
|
82
85
|
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,6 @@ import { discover } from "./mcp/discovery.js";
|
|
|
12
12
|
import { checkServer } from "./mcp/availability.js";
|
|
13
13
|
import { discoverProjects, isViewableProject } from "./projects.js";
|
|
14
14
|
import { resolvePublicDir } from "./assets.js";
|
|
15
|
-
import { runMcpServer } from "./mcpServer.js";
|
|
16
15
|
const host = "127.0.0.1";
|
|
17
16
|
const port = Number(process.env.PORT ?? 4317);
|
|
18
17
|
// Initial project: CLI argument, then DATA_LOOM_ROOT, then cwd.
|
|
@@ -52,6 +51,7 @@ async function main() {
|
|
|
52
51
|
checkMcp,
|
|
53
52
|
getProjects,
|
|
54
53
|
selectProject,
|
|
54
|
+
getCurrentProject: () => session?.project ?? null,
|
|
55
55
|
});
|
|
56
56
|
console.log(`[data-loom] dashboard ready at http://${host}:${server.port}`);
|
|
57
57
|
if (session)
|
|
@@ -141,17 +141,7 @@ function openBrowser(url) {
|
|
|
141
141
|
/* non-fatal — the URL is already logged */
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
console.error(err);
|
|
149
|
-
process.exit(1);
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
else {
|
|
153
|
-
main().catch((err) => {
|
|
154
|
-
console.error(err);
|
|
155
|
-
process.exit(1);
|
|
156
|
-
});
|
|
157
|
-
}
|
|
144
|
+
main().catch((err) => {
|
|
145
|
+
console.error(err);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
});
|
package/dist/mcpServer.js
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
1
|
-
// data_loom as an MCP server: exposes
|
|
1
|
+
// data_loom as an MCP server: exposes a project's open proposals (read) and a
|
|
2
2
|
// dependency-writing action (write `## Depends On`) so an MCP client — the
|
|
3
3
|
// user's own authenticated Claude — can determine and apply the order.
|
|
4
4
|
// Holds no credentials; tools carry only proposal text and change names.
|
|
5
|
+
//
|
|
6
|
+
// Hosted by the dashboard daemon over Streamable-HTTP (see server.ts). A single
|
|
7
|
+
// server serves every project: the target project is resolved per call from an
|
|
8
|
+
// explicit `project` argument, falling back to the daemon's current dashboard
|
|
9
|
+
// selection. The server holds no single project frozen for its lifetime.
|
|
5
10
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
11
|
import { homedir } from "node:os";
|
|
7
|
-
import { join } from "node:path";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
8
13
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
14
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
11
15
|
import { OpenSpecClient } from "./openspecClient.js";
|
|
12
16
|
import { deriveModel } from "./derive.js";
|
|
17
|
+
import { discoverProjects, isViewableProject } from "./projects.js";
|
|
13
18
|
// Advertised to the client on connect. The "confirm before writing" gate lives
|
|
14
19
|
// here, in the agent's behavior — the server cannot verify a human approved.
|
|
15
20
|
const INSTRUCTIONS = `This server exposes a project's open OpenSpec proposals and lets you record the dependency order between them. It holds no model and cannot infer anything on its own — the judgment is yours and the decision is the user's.
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
It is hosted by the running DataLoom dashboard daemon and serves every project from one registration. Each tool takes an optional \`project\` (an absolute path); when omitted it acts on the project currently selected in the dashboard. Call list_projects to see the available OpenSpec workspaces. For automated/repeatable use, always pass \`project\` explicitly.
|
|
23
|
+
|
|
24
|
+
When you connect, call list_open_proposals (passing the project you are working in) and look at each proposal's "dependencyReview" field. For every proposal whose state is "pending" (it has no \`## Depends On\` declaration yet):
|
|
18
25
|
1. Read its proposal text alongside the others and reason about which changes it should be implemented after.
|
|
19
26
|
2. PROPOSE the dependencies — or that it is independent — to the user in plain language, with your reasoning.
|
|
20
27
|
3. Only AFTER the user confirms, record the result: call set_dependency(from, to) for each confirmed edge, or mark_independent(change) for a change that genuinely depends on nothing.
|
|
21
28
|
|
|
22
29
|
Never write a dependency the user has not confirmed. Proposals already marked "declared" need no action.
|
|
23
30
|
|
|
24
|
-
Tip: call install_weave_skill once to add a \`/loom:weave\` command (written to the user's global Claude config) that runs this whole review in one step; the user reloads Claude Code to pick it up
|
|
31
|
+
Tip: call install_weave_skill once to add a \`/loom:weave\` command (written to the user's global Claude config) that runs this whole review in one step; the user reloads Claude Code to pick it up. Register this server once, globally, with: claude mcp add --transport http --scope user data-loom http://127.0.0.1:4317/mcp`;
|
|
25
32
|
// The `/loom:weave` slash command this server installs into the user's global
|
|
26
33
|
// Claude commands dir. Static content that only orchestrates this server's tools.
|
|
27
34
|
const WEAVE_COMMAND = `---
|
|
@@ -33,33 +40,64 @@ tags: [loom, dependencies, mcp, review]
|
|
|
33
40
|
|
|
34
41
|
Weave the dependency order of this project's open OpenSpec proposals, using the **data-loom** MCP server.
|
|
35
42
|
|
|
36
|
-
**Prerequisite:** the data-loom MCP server
|
|
43
|
+
**Prerequisite:** the DataLoom dashboard daemon must be running and the data-loom MCP server registered in this session — its tools are \`list_open_proposals\`, \`set_dependency\`, \`mark_independent\`, and \`list_projects\`. If those tools are not available, the daemon is almost certainly not running: tell the user to **start DataLoom** (\`npx @lyric_dev/data-loom "<project path>"\`) and, if they have not already, register it once with \`claude mcp add --transport http --scope user data-loom http://127.0.0.1:4317/mcp\`, then stop.
|
|
37
44
|
|
|
38
45
|
Steps:
|
|
39
46
|
|
|
40
|
-
1.
|
|
41
|
-
2.
|
|
42
|
-
3.
|
|
43
|
-
4.
|
|
44
|
-
5. **
|
|
45
|
-
6.
|
|
46
|
-
|
|
47
|
-
- \`
|
|
48
|
-
|
|
47
|
+
1. Determine this session's project — the absolute path of the current working directory — and pass it as the \`project\` argument to **every** tool call below, so the review is deterministic and acts on the project you are in (never on whatever the dashboard happens to be showing).
|
|
48
|
+
2. Call \`list_open_proposals\` with that \`project\` and note each proposal's \`dependencyReview\` state.
|
|
49
|
+
3. Focus on the proposals whose state is \`pending\` (no \`## Depends On\` declaration yet). Proposals already \`declared\` need no action — leave them alone.
|
|
50
|
+
4. Read the pending proposals alongside the others. Reason about which change should be implemented **after** which, from their capabilities and content: a change that extends or modifies what another change introduces depends on it; changes that touch disjoint areas are independent.
|
|
51
|
+
5. **Propose** your conclusion to the user in plain language — for each pending proposal, the dependencies you would record (or that it is independent), each with a one-line reason. Do not write anything yet.
|
|
52
|
+
6. **Wait for the user to confirm.** Never write a dependency the user has not approved.
|
|
53
|
+
7. After the user confirms, record it (passing the same \`project\` to each):
|
|
54
|
+
- \`set_dependency(from, to, project)\` for each confirmed edge (the dependent is \`from\`).
|
|
55
|
+
- \`mark_independent(change, project)\` for a proposal the user confirms depends on nothing.
|
|
56
|
+
8. Call \`list_open_proposals\` again and report the resulting phases — what moved, and what is now ready versus blocked.
|
|
49
57
|
|
|
50
58
|
The reasoning stays the user's to approve: you surface and apply, the user decides.
|
|
51
59
|
`;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
const PROJECT_ARG = {
|
|
61
|
+
type: "string",
|
|
62
|
+
description: "Absolute path to the target OpenSpec project. Optional — defaults to the project currently selected in the DataLoom dashboard. Call list_projects to discover valid workspaces.",
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Build a project-agnostic MCP server. A fresh instance is created per client
|
|
66
|
+
* session (see server.ts); all instances share the injected daemon state, so
|
|
67
|
+
* the dashboard selection is the single fallback source of project truth.
|
|
68
|
+
*/
|
|
69
|
+
export function createMcpServer(deps) {
|
|
70
|
+
const server = new Server({ name: "data-loom", version: "0.4.0" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
|
71
|
+
// Resolve the target project for a call: explicit arg, then dashboard
|
|
72
|
+
// selection, then an instructive error. Validated as a real workspace before
|
|
73
|
+
// any read or write.
|
|
74
|
+
const resolveProject = (explicit) => {
|
|
75
|
+
const raw = typeof explicit === "string" && explicit.trim() ? explicit : deps.getCurrentProject();
|
|
76
|
+
if (!raw) {
|
|
77
|
+
throw new Error("No `project` given and no project is selected in the DataLoom dashboard. Call list_projects to see the available OpenSpec workspaces, then pass one as the `project` argument.");
|
|
78
|
+
}
|
|
79
|
+
const abs = resolve(raw);
|
|
80
|
+
if (!isViewableProject(abs)) {
|
|
81
|
+
throw new Error(`${abs} is not an OpenSpec workspace (no openspec/ directory). Call list_projects to see the available workspaces.`);
|
|
82
|
+
}
|
|
83
|
+
return abs;
|
|
84
|
+
};
|
|
56
85
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
57
86
|
tools: [
|
|
58
87
|
{
|
|
59
|
-
name: "
|
|
60
|
-
description: "List the
|
|
88
|
+
name: "list_projects",
|
|
89
|
+
description: "List the selectable projects — the OpenSpec workspaces discoverable from Claude Code's known projects, plus the dashboard's current selection. Read-only. Use it to discover and confirm a valid `project` path before calling a project-scoped tool.",
|
|
61
90
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
62
91
|
},
|
|
92
|
+
{
|
|
93
|
+
name: "list_open_proposals",
|
|
94
|
+
description: "List the open (non-archived) OpenSpec proposals for the target project, each with its proposal text (Why / What Changes / Capabilities) and currently derived dependencies, phase, and readiness. Use this to reason about which proposals should be implemented after which.",
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: "object",
|
|
97
|
+
properties: { project: PROJECT_ARG },
|
|
98
|
+
additionalProperties: false,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
63
101
|
{
|
|
64
102
|
name: "set_dependency",
|
|
65
103
|
description: "Declare that one open proposal depends on (should be implemented after) another by writing a `## Depends On` entry into the dependent's proposal. The roadmap then recomputes deterministically from that file.",
|
|
@@ -68,6 +106,7 @@ export async function runMcpServer(project) {
|
|
|
68
106
|
properties: {
|
|
69
107
|
from: { type: "string", description: "The dependent change (gets the `## Depends On` entry)" },
|
|
70
108
|
to: { type: "string", description: "The change it depends on" },
|
|
109
|
+
project: PROJECT_ARG,
|
|
71
110
|
},
|
|
72
111
|
required: ["from", "to"],
|
|
73
112
|
additionalProperties: false,
|
|
@@ -80,6 +119,7 @@ export async function runMcpServer(project) {
|
|
|
80
119
|
type: "object",
|
|
81
120
|
properties: {
|
|
82
121
|
change: { type: "string", description: "The open change to mark as having no dependencies" },
|
|
122
|
+
project: PROJECT_ARG,
|
|
83
123
|
},
|
|
84
124
|
required: ["change"],
|
|
85
125
|
additionalProperties: false,
|
|
@@ -87,28 +127,36 @@ export async function runMcpServer(project) {
|
|
|
87
127
|
},
|
|
88
128
|
{
|
|
89
129
|
name: "install_weave_skill",
|
|
90
|
-
description: "Install the `/loom:weave` slash command into the user's global Claude commands directory (~/.claude/commands/loom/weave.md), so this dependency review can be run as a single command from any project
|
|
130
|
+
description: "Install the `/loom:weave` slash command into the user's global Claude commands directory (~/.claude/commands/loom/weave.md), so this dependency review can be run as a single command from any project. Writes a static command file only (no project data or secrets) and overwrites any existing copy. Run once, then the user reloads Claude Code.",
|
|
91
131
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
92
132
|
},
|
|
93
133
|
],
|
|
94
134
|
}));
|
|
95
135
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
96
136
|
const { name, arguments: args } = req.params;
|
|
137
|
+
const a = (args ?? {});
|
|
97
138
|
try {
|
|
139
|
+
if (name === "list_projects") {
|
|
140
|
+
return ok(await discoverProjects(deps.getCurrentProject() ?? ""));
|
|
141
|
+
}
|
|
142
|
+
if (name === "install_weave_skill") {
|
|
143
|
+
return ok(await installWeaveSkill());
|
|
144
|
+
}
|
|
145
|
+
// Project-scoped tools: resolve + validate the target, then act on it.
|
|
146
|
+
const project = resolveProject(a.project);
|
|
147
|
+
const client = new OpenSpecClient(project);
|
|
148
|
+
const changesDir = join(project, "openspec", "changes");
|
|
98
149
|
if (name === "list_open_proposals") {
|
|
99
|
-
return ok(await listOpenProposals(client, changesDir));
|
|
150
|
+
return ok(await listOpenProposals(client, changesDir, project));
|
|
100
151
|
}
|
|
101
152
|
if (name === "set_dependency") {
|
|
102
|
-
const from = String(
|
|
103
|
-
const to = String(
|
|
104
|
-
return ok(await setDependency(client, changesDir, from, to));
|
|
153
|
+
const from = String(a.from ?? "");
|
|
154
|
+
const to = String(a.to ?? "");
|
|
155
|
+
return ok(await setDependency(client, changesDir, project, from, to));
|
|
105
156
|
}
|
|
106
157
|
if (name === "mark_independent") {
|
|
107
|
-
const change = String(
|
|
108
|
-
return ok(await markIndependent(client, changesDir, change));
|
|
109
|
-
}
|
|
110
|
-
if (name === "install_weave_skill") {
|
|
111
|
-
return ok(await installWeaveSkill());
|
|
158
|
+
const change = String(a.change ?? "");
|
|
159
|
+
return ok(await markIndependent(client, changesDir, project, change));
|
|
112
160
|
}
|
|
113
161
|
return err(`unknown tool: ${name}`);
|
|
114
162
|
}
|
|
@@ -116,9 +164,7 @@ export async function runMcpServer(project) {
|
|
|
116
164
|
return err(e instanceof Error ? e.message : String(e));
|
|
117
165
|
}
|
|
118
166
|
});
|
|
119
|
-
|
|
120
|
-
// Logs go to stderr so they don't corrupt the stdio JSON-RPC channel.
|
|
121
|
-
console.error(`[data-loom mcp] serving project ${project}`);
|
|
167
|
+
return server;
|
|
122
168
|
}
|
|
123
169
|
function ok(data) {
|
|
124
170
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
@@ -130,7 +176,7 @@ async function openChanges(client) {
|
|
|
130
176
|
const model = await deriveModel(client);
|
|
131
177
|
return model.changes.filter((c) => !c.archived);
|
|
132
178
|
}
|
|
133
|
-
async function listOpenProposals(client, changesDir) {
|
|
179
|
+
async function listOpenProposals(client, changesDir, project) {
|
|
134
180
|
const changes = await openChanges(client);
|
|
135
181
|
const proposals = [];
|
|
136
182
|
for (const c of changes) {
|
|
@@ -152,9 +198,9 @@ async function listOpenProposals(client, changesDir) {
|
|
|
152
198
|
proposal, // proposal text only — never config, env, or secrets
|
|
153
199
|
});
|
|
154
200
|
}
|
|
155
|
-
return { proposals };
|
|
201
|
+
return { project, proposals };
|
|
156
202
|
}
|
|
157
|
-
async function setDependency(client, changesDir, from, to) {
|
|
203
|
+
async function setDependency(client, changesDir, project, from, to) {
|
|
158
204
|
if (!from || !to)
|
|
159
205
|
throw new Error("both 'from' and 'to' are required");
|
|
160
206
|
if (from === to)
|
|
@@ -170,9 +216,9 @@ async function setDependency(client, changesDir, from, to) {
|
|
|
170
216
|
const written = updated !== text;
|
|
171
217
|
if (written)
|
|
172
218
|
await writeFile(path, updated);
|
|
173
|
-
return { from, to, written, dependsOn: await client.readProposalDependsOn(from) };
|
|
219
|
+
return { project, from, to, written, dependsOn: await client.readProposalDependsOn(from) };
|
|
174
220
|
}
|
|
175
|
-
async function markIndependent(client, changesDir, change) {
|
|
221
|
+
async function markIndependent(client, changesDir, project, change) {
|
|
176
222
|
if (!change)
|
|
177
223
|
throw new Error("'change' is required");
|
|
178
224
|
const names = new Set((await openChanges(client)).map((c) => c.name));
|
|
@@ -184,12 +230,12 @@ async function markIndependent(client, changesDir, change) {
|
|
|
184
230
|
const written = updated !== text; // already declared -> idempotent no-op
|
|
185
231
|
if (written)
|
|
186
232
|
await writeFile(path, updated);
|
|
187
|
-
return { change, written, dependencyReview: "declared" };
|
|
233
|
+
return { project, change, written, dependencyReview: "declared" };
|
|
188
234
|
}
|
|
189
235
|
/**
|
|
190
236
|
* Provision the `/loom:weave` command into the user's GLOBAL Claude commands
|
|
191
|
-
* dir, so the review runs as one command from any project
|
|
192
|
-
*
|
|
237
|
+
* dir, so the review runs as one command from any project. Writes only the
|
|
238
|
+
* static command file; overwrites in place.
|
|
193
239
|
*/
|
|
194
240
|
async function installWeaveSkill() {
|
|
195
241
|
const dir = join(homedir(), ".claude", "commands", "loom");
|
package/dist/server.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
// Serve the SPA on loopback and push roadmap + MCP + project state over a websocket.
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { extname } from "node:path";
|
|
4
5
|
import { WebSocketServer, WebSocket } from "ws";
|
|
6
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
7
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
5
8
|
import { loadAsset } from "./assets.js";
|
|
9
|
+
import { createMcpServer } from "./mcpServer.js";
|
|
6
10
|
const MIME = {
|
|
7
11
|
".html": "text/html; charset=utf-8",
|
|
8
12
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -11,7 +15,7 @@ const MIME = {
|
|
|
11
15
|
".svg": "image/svg+xml",
|
|
12
16
|
};
|
|
13
17
|
export async function startServer(opts) {
|
|
14
|
-
const { publicDir, host, port, getRoadmap, getMcp, checkMcp, getProjects, selectProject } = opts;
|
|
18
|
+
const { publicDir, host, port, getRoadmap, getMcp, checkMcp, getProjects, selectProject, getCurrentProject } = opts;
|
|
15
19
|
let wss;
|
|
16
20
|
const broadcast = (msg) => {
|
|
17
21
|
const data = JSON.stringify(msg);
|
|
@@ -20,9 +24,56 @@ export async function startServer(opts) {
|
|
|
20
24
|
ws.send(data);
|
|
21
25
|
}
|
|
22
26
|
};
|
|
27
|
+
// One MCP server + transport per client session (stateful Streamable-HTTP),
|
|
28
|
+
// keyed by the session id. Each session's tools resolve their target project
|
|
29
|
+
// per call from getCurrentProject (see mcpServer.ts), so one daemon serves
|
|
30
|
+
// every project.
|
|
31
|
+
const mcpTransports = new Map();
|
|
32
|
+
const handleMcp = async (req, res) => {
|
|
33
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
34
|
+
try {
|
|
35
|
+
if (req.method === "POST") {
|
|
36
|
+
const body = await readJsonBody(req);
|
|
37
|
+
let transport = sessionId ? mcpTransports.get(sessionId) : undefined;
|
|
38
|
+
if (!transport) {
|
|
39
|
+
if (!isInitializeRequest(body)) {
|
|
40
|
+
return sendError(res, 400, "missing or invalid mcp-session-id");
|
|
41
|
+
}
|
|
42
|
+
transport = new StreamableHTTPServerTransport({
|
|
43
|
+
sessionIdGenerator: () => randomUUID(),
|
|
44
|
+
onsessioninitialized: (sid) => {
|
|
45
|
+
mcpTransports.set(sid, transport);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
transport.onclose = () => {
|
|
49
|
+
if (transport.sessionId)
|
|
50
|
+
mcpTransports.delete(transport.sessionId);
|
|
51
|
+
};
|
|
52
|
+
await createMcpServer({ getCurrentProject }).connect(transport);
|
|
53
|
+
}
|
|
54
|
+
await transport.handleRequest(req, res, body);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (req.method === "GET" || req.method === "DELETE") {
|
|
58
|
+
const transport = sessionId ? mcpTransports.get(sessionId) : undefined;
|
|
59
|
+
if (!transport)
|
|
60
|
+
return sendError(res, 400, "missing or invalid mcp-session-id");
|
|
61
|
+
await transport.handleRequest(req, res);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
return sendError(res, 405, "method not allowed");
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
if (!res.headersSent) {
|
|
68
|
+
sendError(res, 500, e instanceof Error ? e.message : "mcp error");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
23
72
|
const http = createServer(async (req, res) => {
|
|
24
73
|
try {
|
|
25
74
|
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
75
|
+
if (url.pathname === "/mcp")
|
|
76
|
+
return handleMcp(req, res);
|
|
26
77
|
if (url.pathname === "/api/model")
|
|
27
78
|
return sendJson(res, getRoadmap());
|
|
28
79
|
if (url.pathname === "/api/mcp")
|
|
@@ -90,3 +141,21 @@ function sendError(res, code, message) {
|
|
|
90
141
|
res.writeHead(code, { "content-type": MIME[".json"] });
|
|
91
142
|
res.end(JSON.stringify({ error: message }));
|
|
92
143
|
}
|
|
144
|
+
/** Read and JSON-parse a request body; returns undefined for an empty body. */
|
|
145
|
+
function readJsonBody(req) {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
let data = "";
|
|
148
|
+
req.on("data", (chunk) => (data += chunk));
|
|
149
|
+
req.on("end", () => {
|
|
150
|
+
if (!data)
|
|
151
|
+
return resolve(undefined);
|
|
152
|
+
try {
|
|
153
|
+
resolve(JSON.parse(data));
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
reject(e instanceof Error ? e : new Error("invalid JSON body"));
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
req.on("error", reject);
|
|
160
|
+
});
|
|
161
|
+
}
|
package/package.json
CHANGED