@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 +23 -9
- package/dist/index.js +5 -15
- package/dist/mcpServer.js +108 -49
- package/dist/server.js +162 -7
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,42 @@
|
|
|
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";
|
|
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
|
-
|
|
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
|
|
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.
|
|
41
|
-
2.
|
|
42
|
-
3.
|
|
43
|
-
4.
|
|
44
|
-
5. **
|
|
45
|
-
6.
|
|
46
|
-
|
|
47
|
-
- \`
|
|
48
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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: "
|
|
60
|
-
description: "List the
|
|
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
|
|
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(
|
|
103
|
-
const to = String(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
218
|
+
throw new ToolError("both 'from' and 'to' are required");
|
|
160
219
|
if (from === to)
|
|
161
|
-
throw new
|
|
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
|
|
223
|
+
throw new ToolError(`unknown open change: ${from}`);
|
|
165
224
|
if (!names.has(to))
|
|
166
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
192
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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({
|
|
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((
|
|
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