@lyric_dev/data-loom 0.3.0 → 0.4.1

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,39 @@ 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.
83
+
84
+ ## Security
85
+
86
+ DataLoom runs entirely on `127.0.0.1` and is built for a single local user.
87
+
88
+ - **Loopback only** — the daemon (dashboard, WebSocket, and MCP endpoint) binds to the loopback interface and is never exposed to the network.
89
+ - **Host + Origin validated** — every HTTP request and WebSocket upgrade is checked: requests with a non-loopback `Host` (DNS-rebinding) or a non-loopback `Origin` (cross-site requests from a web page) are rejected. Native MCP clients (which send no `Origin`) are allowed. This is what keeps a random website you visit from driving the daemon.
90
+ - **No secrets** — the MCP tools carry only proposal text and change names; the topology view redacts server commands/args and shows scheme+host only. No API keys or config are read or returned, and the daemon holds no credential.
91
+ - **Bounded** — request bodies are capped (4 MB) and MCP sessions are capped and idle-evicted, so a local client can't exhaust memory.
92
+
93
+ The MCP tools can read and write any OpenSpec workspace path you point them at (this is intentional, so the tool can stay LLM-provider-independent). That means: treat the daemon as you would any local dev server — fine for your own machine, not something to run on a shared/multi-user host.
80
94
 
81
95
  ## Design principles
82
96
 
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,42 @@
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";
18
+ /**
19
+ * A validation error whose message is safe to return to the client because it
20
+ * only references caller-supplied input (a change name, a path the caller named).
21
+ * Anything that is NOT a ToolError is treated as an unexpected internal failure
22
+ * and reported generically, so host filesystem detail never leaks.
23
+ */
24
+ class ToolError extends Error {
25
+ }
13
26
  // Advertised to the client on connect. The "confirm before writing" gate lives
14
27
  // here, in the agent's behavior — the server cannot verify a human approved.
15
28
  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
29
 
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):
30
+ 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.
31
+
32
+ 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
33
  1. Read its proposal text alongside the others and reason about which changes it should be implemented after.
19
34
  2. PROPOSE the dependencies — or that it is independent — to the user in plain language, with your reasoning.
20
35
  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
36
 
22
37
  Never write a dependency the user has not confirmed. Proposals already marked "declared" need no action.
23
38
 
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.`;
39
+ 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
40
  // The `/loom:weave` slash command this server installs into the user's global
26
41
  // Claude commands dir. Static content that only orchestrates this server's tools.
27
42
  const WEAVE_COMMAND = `---
@@ -33,33 +48,64 @@ tags: [loom, dependencies, mcp, review]
33
48
 
34
49
  Weave the dependency order of this project's open OpenSpec proposals, using the **data-loom** MCP server.
35
50
 
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.
51
+ **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
52
 
38
53
  Steps:
39
54
 
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.
55
+ 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).
56
+ 2. Call \`list_open_proposals\` with that \`project\` and note each proposal's \`dependencyReview\` state.
57
+ 3. Focus on the proposals whose state is \`pending\` (no \`## Depends On\` declaration yet). Proposals already \`declared\` need no action leave them alone.
58
+ 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.
59
+ 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.
60
+ 6. **Wait for the user to confirm.** Never write a dependency the user has not approved.
61
+ 7. After the user confirms, record it (passing the same \`project\` to each):
62
+ - \`set_dependency(from, to, project)\` for each confirmed edge (the dependent is \`from\`).
63
+ - \`mark_independent(change, project)\` for a proposal the user confirms depends on nothing.
64
+ 8. Call \`list_open_proposals\` again and report the resulting phases — what moved, and what is now ready versus blocked.
49
65
 
50
66
  The reasoning stays the user's to approve: you surface and apply, the user decides.
51
67
  `;
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 });
68
+ const PROJECT_ARG = {
69
+ type: "string",
70
+ 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.",
71
+ };
72
+ /**
73
+ * Build a project-agnostic MCP server. A fresh instance is created per client
74
+ * session (see server.ts); all instances share the injected daemon state, so
75
+ * the dashboard selection is the single fallback source of project truth.
76
+ */
77
+ export function createMcpServer(deps) {
78
+ const server = new Server({ name: "data-loom", version: "0.4.1" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
79
+ // Resolve the target project for a call: explicit arg, then dashboard
80
+ // selection, then an instructive error. Validated as a real workspace before
81
+ // any read or write.
82
+ const resolveProject = (explicit) => {
83
+ const raw = typeof explicit === "string" && explicit.trim() ? explicit : deps.getCurrentProject();
84
+ if (!raw) {
85
+ throw new ToolError("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.");
86
+ }
87
+ const abs = resolve(raw);
88
+ if (!isViewableProject(abs)) {
89
+ throw new ToolError(`${abs} is not an OpenSpec workspace (no openspec/ directory). Call list_projects to see the available workspaces.`);
90
+ }
91
+ return abs;
92
+ };
56
93
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
57
94
  tools: [
58
95
  {
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.",
96
+ name: "list_projects",
97
+ 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
98
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
62
99
  },
100
+ {
101
+ name: "list_open_proposals",
102
+ 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.",
103
+ inputSchema: {
104
+ type: "object",
105
+ properties: { project: PROJECT_ARG },
106
+ additionalProperties: false,
107
+ },
108
+ },
63
109
  {
64
110
  name: "set_dependency",
65
111
  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 +114,7 @@ export async function runMcpServer(project) {
68
114
  properties: {
69
115
  from: { type: "string", description: "The dependent change (gets the `## Depends On` entry)" },
70
116
  to: { type: "string", description: "The change it depends on" },
117
+ project: PROJECT_ARG,
71
118
  },
72
119
  required: ["from", "to"],
73
120
  additionalProperties: false,
@@ -80,6 +127,7 @@ export async function runMcpServer(project) {
80
127
  type: "object",
81
128
  properties: {
82
129
  change: { type: "string", description: "The open change to mark as having no dependencies" },
130
+ project: PROJECT_ARG,
83
131
  },
84
132
  required: ["change"],
85
133
  additionalProperties: false,
@@ -87,38 +135,49 @@ export async function runMcpServer(project) {
87
135
  },
88
136
  {
89
137
  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.",
138
+ 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
139
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
92
140
  },
93
141
  ],
94
142
  }));
95
143
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
96
144
  const { name, arguments: args } = req.params;
145
+ const a = (args ?? {});
97
146
  try {
147
+ if (name === "list_projects") {
148
+ return ok(await discoverProjects(deps.getCurrentProject() ?? ""));
149
+ }
150
+ if (name === "install_weave_skill") {
151
+ return ok(await installWeaveSkill());
152
+ }
153
+ // Project-scoped tools: resolve + validate the target, then act on it.
154
+ const project = resolveProject(a.project);
155
+ const client = new OpenSpecClient(project);
156
+ const changesDir = join(project, "openspec", "changes");
98
157
  if (name === "list_open_proposals") {
99
- return ok(await listOpenProposals(client, changesDir));
158
+ return ok(await listOpenProposals(client, changesDir, project));
100
159
  }
101
160
  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));
161
+ const from = String(a.from ?? "");
162
+ const to = String(a.to ?? "");
163
+ return ok(await setDependency(client, changesDir, project, from, to));
105
164
  }
106
165
  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());
166
+ const change = String(a.change ?? "");
167
+ return ok(await markIndependent(client, changesDir, project, change));
112
168
  }
113
169
  return err(`unknown tool: ${name}`);
114
170
  }
115
171
  catch (e) {
116
- return err(e instanceof Error ? e.message : String(e));
172
+ // Validation errors (caller-supplied input) are safe to surface; anything
173
+ // else is an unexpected internal failure → generic message, detail to log.
174
+ if (e instanceof ToolError)
175
+ return err(e.message);
176
+ console.error("[data-loom mcp] tool error:", e);
177
+ return err("internal error");
117
178
  }
118
179
  });
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}`);
180
+ return server;
122
181
  }
123
182
  function ok(data) {
124
183
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
@@ -130,7 +189,7 @@ async function openChanges(client) {
130
189
  const model = await deriveModel(client);
131
190
  return model.changes.filter((c) => !c.archived);
132
191
  }
133
- async function listOpenProposals(client, changesDir) {
192
+ async function listOpenProposals(client, changesDir, project) {
134
193
  const changes = await openChanges(client);
135
194
  const proposals = [];
136
195
  for (const c of changes) {
@@ -152,44 +211,44 @@ async function listOpenProposals(client, changesDir) {
152
211
  proposal, // proposal text only — never config, env, or secrets
153
212
  });
154
213
  }
155
- return { proposals };
214
+ return { project, proposals };
156
215
  }
157
- async function setDependency(client, changesDir, from, to) {
216
+ async function setDependency(client, changesDir, project, from, to) {
158
217
  if (!from || !to)
159
- throw new Error("both 'from' and 'to' are required");
218
+ throw new ToolError("both 'from' and 'to' are required");
160
219
  if (from === to)
161
- throw new Error("a change cannot depend on itself");
220
+ throw new ToolError("a change cannot depend on itself");
162
221
  const names = new Set((await openChanges(client)).map((c) => c.name));
163
222
  if (!names.has(from))
164
- throw new Error(`unknown open change: ${from}`);
223
+ throw new ToolError(`unknown open change: ${from}`);
165
224
  if (!names.has(to))
166
- throw new Error(`unknown open change: ${to}`);
225
+ throw new ToolError(`unknown open change: ${to}`);
167
226
  const path = join(changesDir, from, "proposal.md");
168
227
  const text = await readFile(path, "utf8");
169
228
  const updated = addDependsOn(text, to);
170
229
  const written = updated !== text;
171
230
  if (written)
172
231
  await writeFile(path, updated);
173
- return { from, to, written, dependsOn: await client.readProposalDependsOn(from) };
232
+ return { project, from, to, written, dependsOn: await client.readProposalDependsOn(from) };
174
233
  }
175
- async function markIndependent(client, changesDir, change) {
234
+ async function markIndependent(client, changesDir, project, change) {
176
235
  if (!change)
177
- throw new Error("'change' is required");
236
+ throw new ToolError("'change' is required");
178
237
  const names = new Set((await openChanges(client)).map((c) => c.name));
179
238
  if (!names.has(change))
180
- throw new Error(`unknown open change: ${change}`);
239
+ throw new ToolError(`unknown open change: ${change}`);
181
240
  const path = join(changesDir, change, "proposal.md");
182
241
  const text = await readFile(path, "utf8");
183
242
  const updated = ensureDependsOnSection(text);
184
243
  const written = updated !== text; // already declared -> idempotent no-op
185
244
  if (written)
186
245
  await writeFile(path, updated);
187
- return { change, written, dependencyReview: "declared" };
246
+ return { project, change, written, dependencyReview: "declared" };
188
247
  }
189
248
  /**
190
249
  * 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.
250
+ * dir, so the review runs as one command from any project. Writes only the
251
+ * static command file; overwrites in place.
193
252
  */
194
253
  async function installWeaveSkill() {
195
254
  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 { extname } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { extname, resolve, sep } 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,113 @@ export async function startServer(opts) {
20
24
  ws.send(data);
21
25
  }
22
26
  };
27
+ // Loopback origin/host allowlist — the DNS-rebinding + CSRF guard. Built from
28
+ // the actually-bound port after listen; seeded from the requested port so the
29
+ // guard is active from the first request.
30
+ const loopbackHosts = ["127.0.0.1", "localhost", "[::1]"];
31
+ let allowedAuthorities = new Set();
32
+ const setAllowed = (p) => {
33
+ allowedAuthorities = new Set(loopbackHosts.map((h) => `${h}:${p}`));
34
+ };
35
+ setAllowed(port);
36
+ // Reject what a hostile web page or a rebound DNS name could mount: a Host
37
+ // that is not our loopback authority (DNS rebinding), or any *present* Origin
38
+ // that is not a loopback origin (CSRF). An absent Origin = a native, non-
39
+ // browser client (e.g. Claude Code), which is allowed.
40
+ const isAllowed = (req) => {
41
+ const host = req.headers.host;
42
+ if (!host || !allowedAuthorities.has(host))
43
+ return false;
44
+ const origin = req.headers.origin;
45
+ if (origin) {
46
+ try {
47
+ if (!allowedAuthorities.has(new URL(origin).host))
48
+ return false;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ return true;
55
+ };
56
+ // One MCP server + transport per client session (stateful Streamable-HTTP),
57
+ // keyed by session id. Sessions are capped and idle-evicted so churn cannot
58
+ // grow memory without bound. Tools resolve their target project per call from
59
+ // getCurrentProject (see mcpServer.ts), so one daemon serves every project.
60
+ const MAX_SESSIONS = 32;
61
+ const SESSION_IDLE_TTL_MS = 30 * 60 * 1000;
62
+ const mcpSessions = new Map();
63
+ const idleSweep = setInterval(() => {
64
+ const now = Date.now();
65
+ for (const [sid, s] of mcpSessions) {
66
+ if (now - s.lastSeen > SESSION_IDLE_TTL_MS) {
67
+ mcpSessions.delete(sid);
68
+ void s.transport.close();
69
+ }
70
+ }
71
+ }, 60 * 1000);
72
+ idleSweep.unref();
73
+ const handleMcp = async (req, res) => {
74
+ const sessionId = req.headers["mcp-session-id"];
75
+ try {
76
+ if (req.method === "POST") {
77
+ const body = await readJsonBody(req);
78
+ const existing = sessionId ? mcpSessions.get(sessionId) : undefined;
79
+ if (existing) {
80
+ existing.lastSeen = Date.now();
81
+ await existing.transport.handleRequest(req, res, body);
82
+ return;
83
+ }
84
+ if (!isInitializeRequest(body)) {
85
+ return sendError(res, 400, "missing or invalid mcp-session-id");
86
+ }
87
+ if (mcpSessions.size >= MAX_SESSIONS) {
88
+ return sendError(res, 503, "too many sessions");
89
+ }
90
+ const transport = new StreamableHTTPServerTransport({
91
+ sessionIdGenerator: () => randomUUID(),
92
+ onsessioninitialized: (sid) => {
93
+ mcpSessions.set(sid, { transport, lastSeen: Date.now() });
94
+ },
95
+ });
96
+ transport.onclose = () => {
97
+ if (transport.sessionId)
98
+ mcpSessions.delete(transport.sessionId);
99
+ };
100
+ await createMcpServer({ getCurrentProject }).connect(transport);
101
+ await transport.handleRequest(req, res, body);
102
+ return;
103
+ }
104
+ if (req.method === "GET" || req.method === "DELETE") {
105
+ const s = sessionId ? mcpSessions.get(sessionId) : undefined;
106
+ if (!s)
107
+ return sendError(res, 400, "missing or invalid mcp-session-id");
108
+ s.lastSeen = Date.now();
109
+ await s.transport.handleRequest(req, res);
110
+ return;
111
+ }
112
+ return sendError(res, 405, "method not allowed");
113
+ }
114
+ catch (e) {
115
+ if (e instanceof PayloadTooLargeError) {
116
+ if (!res.headersSent)
117
+ sendError(res, 413, "request body too large");
118
+ return;
119
+ }
120
+ // Unexpected failures stay in the server log; the client gets nothing
121
+ // about the host (no paths, no stack).
122
+ console.error("[data-loom] mcp request error:", e);
123
+ if (!res.headersSent)
124
+ sendError(res, 500, "internal error");
125
+ }
126
+ };
23
127
  const http = createServer(async (req, res) => {
24
128
  try {
129
+ if (!isAllowed(req))
130
+ return sendError(res, 403, "forbidden");
25
131
  const url = new URL(req.url ?? "/", `http://${host}`);
132
+ if (url.pathname === "/mcp")
133
+ return handleMcp(req, res);
26
134
  if (url.pathname === "/api/model")
27
135
  return sendJson(res, getRoadmap());
28
136
  if (url.pathname === "/api/mcp")
@@ -45,9 +153,12 @@ export async function startServer(opts) {
45
153
  return sendError(res, 400, err instanceof Error ? err.message : "invalid project");
46
154
  }
47
155
  }
48
- // static assets (served from public/ on the filesystem)
156
+ // static assets (served from public/ on the filesystem) — resolve and
157
+ // assert the path stays within publicDir before reading.
49
158
  const rel = url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, "");
50
- if (rel.includes(".."))
159
+ const root = resolve(publicDir);
160
+ const full = resolve(root, rel);
161
+ if (full !== root && !full.startsWith(root + sep))
51
162
  return sendError(res, 403, "forbidden");
52
163
  const file = await loadAsset(rel, publicDir);
53
164
  if (!file)
@@ -59,7 +170,10 @@ export async function startServer(opts) {
59
170
  sendError(res, 500, "error");
60
171
  }
61
172
  });
62
- wss = new WebSocketServer({ server: http });
173
+ wss = new WebSocketServer({
174
+ server: http,
175
+ verifyClient: (info) => isAllowed(info.req),
176
+ });
63
177
  wss.on("connection", async (ws) => {
64
178
  const roadmap = getRoadmap();
65
179
  if (roadmap)
@@ -72,11 +186,14 @@ export async function startServer(opts) {
72
186
  /* project list optional */
73
187
  }
74
188
  });
75
- await new Promise((resolve) => http.listen(port, host, resolve));
189
+ await new Promise((ready) => http.listen(port, host, ready));
190
+ const actualPort = http.address()?.port ?? port;
191
+ setAllowed(actualPort);
76
192
  return {
77
- port,
193
+ port: actualPort,
78
194
  broadcast,
79
195
  close: () => {
196
+ clearInterval(idleSweep);
80
197
  wss.close();
81
198
  http.close();
82
199
  },
@@ -90,3 +207,41 @@ function sendError(res, code, message) {
90
207
  res.writeHead(code, { "content-type": MIME[".json"] });
91
208
  res.end(JSON.stringify({ error: message }));
92
209
  }
210
+ /** Raised when a request body exceeds the cap; mapped to HTTP 413. */
211
+ class PayloadTooLargeError extends Error {
212
+ }
213
+ /** Largest request body we will buffer (JSON-RPC tool calls are tiny). */
214
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
215
+ /** Read and JSON-parse a request body; returns undefined for an empty body. */
216
+ function readJsonBody(req) {
217
+ return new Promise((resolve, reject) => {
218
+ let data = "";
219
+ let size = 0;
220
+ let aborted = false;
221
+ req.on("data", (chunk) => {
222
+ if (aborted)
223
+ return;
224
+ size += chunk.length;
225
+ if (size > MAX_BODY_BYTES) {
226
+ aborted = true;
227
+ reject(new PayloadTooLargeError("request body too large"));
228
+ req.destroy();
229
+ return;
230
+ }
231
+ data += chunk;
232
+ });
233
+ req.on("end", () => {
234
+ if (aborted)
235
+ return;
236
+ if (!data)
237
+ return resolve(undefined);
238
+ try {
239
+ resolve(JSON.parse(data));
240
+ }
241
+ catch (e) {
242
+ reject(e instanceof Error ? e : new Error("invalid JSON body"));
243
+ }
244
+ });
245
+ req.on("error", reject);
246
+ });
247
+ }
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.1",
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",