@lyric_dev/data-loom 0.3.0 → 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 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
- DataLoom can also run as an **MCP server**, 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.
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 in Claude Code (stdio server):
63
+ 1. Register it once, globally no per-project setup:
64
64
 
65
65
  ```
66
- claude mcp add data-loom -- npx @lyric_dev/data-loom mcp "C:\path\to\your\project"
66
+ claude mcp add --transport http --scope user data-loom http://127.0.0.1:4317/mcp
67
67
  ```
68
68
 
69
- (From source: `node dist/index.js mcp "C:\path\to\project"`.)
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 that 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 three tools:
72
- - `list_open_proposals` — the open changes with their proposal text, current phase/readiness, and dependency-review state (read-only; proposal text only, no secrets).
73
- - `set_dependency(from, to)` — writes a `## Depends On` entry into a proposal.
74
- - `mark_independent(change)` — records that a proposal genuinely depends on nothing, by writing an empty `## Depends On` block.
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 where DataLoom is registered.
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
- if (process.argv[2] === "mcp") {
145
- // MCP stdio server mode: `data-loom mcp [project]`
146
- const project = resolve(process.argv[3] ?? process.env.DATA_LOOM_ROOT ?? process.cwd());
147
- runMcpServer(project).catch((err) => {
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 the open proposals (read) and a
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
- When you connect, call list_open_proposals and look at each proposal's "dependencyReview" field. For every proposal whose state is "pending" (it has no \`## Depends On\` declaration yet):
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 must be registered in this session — its tools are \`list_open_proposals\`, \`set_dependency\`, and \`mark_independent\`. If those tools are not available, tell the user to register it (\`claude mcp add data-loom -- npx @lyric_dev/data-loom mcp "<project path>"\`) and stop.
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. Call \`list_open_proposals\` and note each proposal's \`dependencyReview\` state.
41
- 2. Focus on the proposals whose state is \`pending\` (no \`## Depends On\` declaration yet). Proposals already \`declared\` need no action — leave them alone.
42
- 3. 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.
43
- 4. **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.
44
- 5. **Wait for the user to confirm.** Never write a dependency the user has not approved.
45
- 6. After the user confirms, record it:
46
- - \`set_dependency(from, to)\` for each confirmed edge (the dependent is \`from\`).
47
- - \`mark_independent(change)\` for a proposal the user confirms depends on nothing.
48
- 7. Call \`list_open_proposals\` again and report the resulting phases what moved, and what is now ready versus blocked.
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
- export async function runMcpServer(project) {
53
- const client = new OpenSpecClient(project);
54
- const changesDir = join(project, "openspec", "changes");
55
- const server = new Server({ name: "data-loom", version: "0.2.4" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
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: "list_open_proposals",
60
- description: "List the open (non-archived) OpenSpec proposals for this 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.",
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 where this server is registered. Writes a static command file only (no project data or secrets) and overwrites any existing copy. Run once, then the user reloads Claude Code.",
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(args?.from ?? "");
103
- const to = String(args?.to ?? "");
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(args?.change ?? "");
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
- await server.connect(new StdioServerTransport());
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 where this server is
192
- * registered. Writes only the static command file; overwrites in place.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyric_dev/data-loom",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Local dashboard for spec-driven development: a phased OpenSpec roadmap (WHAT) and an MCP topology (HOW).",
5
5
  "type": "module",
6
6
  "license": "MIT",