@lyric_dev/data-loom 0.4.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 +11 -0
- package/dist/mcpServer.js +23 -10
- package/dist/server.js +118 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,6 +81,17 @@ The running daemon also **hosts an MCP server** over HTTP, so your own Claude se
|
|
|
81
81
|
|
|
82
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
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.
|
|
94
|
+
|
|
84
95
|
## Design principles
|
|
85
96
|
|
|
86
97
|
- **Derived, not stored** — phase/order is a pure function of the OpenSpec files, recomputed on every change. Nothing about ordering is persisted.
|
package/dist/mcpServer.js
CHANGED
|
@@ -15,6 +15,14 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
15
15
|
import { OpenSpecClient } from "./openspecClient.js";
|
|
16
16
|
import { deriveModel } from "./derive.js";
|
|
17
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
|
+
}
|
|
18
26
|
// Advertised to the client on connect. The "confirm before writing" gate lives
|
|
19
27
|
// here, in the agent's behavior — the server cannot verify a human approved.
|
|
20
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.
|
|
@@ -67,18 +75,18 @@ const PROJECT_ARG = {
|
|
|
67
75
|
* the dashboard selection is the single fallback source of project truth.
|
|
68
76
|
*/
|
|
69
77
|
export function createMcpServer(deps) {
|
|
70
|
-
const server = new Server({ name: "data-loom", version: "0.4.
|
|
78
|
+
const server = new Server({ name: "data-loom", version: "0.4.1" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
|
71
79
|
// Resolve the target project for a call: explicit arg, then dashboard
|
|
72
80
|
// selection, then an instructive error. Validated as a real workspace before
|
|
73
81
|
// any read or write.
|
|
74
82
|
const resolveProject = (explicit) => {
|
|
75
83
|
const raw = typeof explicit === "string" && explicit.trim() ? explicit : deps.getCurrentProject();
|
|
76
84
|
if (!raw) {
|
|
77
|
-
throw new
|
|
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.");
|
|
78
86
|
}
|
|
79
87
|
const abs = resolve(raw);
|
|
80
88
|
if (!isViewableProject(abs)) {
|
|
81
|
-
throw new
|
|
89
|
+
throw new ToolError(`${abs} is not an OpenSpec workspace (no openspec/ directory). Call list_projects to see the available workspaces.`);
|
|
82
90
|
}
|
|
83
91
|
return abs;
|
|
84
92
|
};
|
|
@@ -161,7 +169,12 @@ export function createMcpServer(deps) {
|
|
|
161
169
|
return err(`unknown tool: ${name}`);
|
|
162
170
|
}
|
|
163
171
|
catch (e) {
|
|
164
|
-
|
|
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");
|
|
165
178
|
}
|
|
166
179
|
});
|
|
167
180
|
return server;
|
|
@@ -202,14 +215,14 @@ async function listOpenProposals(client, changesDir, project) {
|
|
|
202
215
|
}
|
|
203
216
|
async function setDependency(client, changesDir, project, from, to) {
|
|
204
217
|
if (!from || !to)
|
|
205
|
-
throw new
|
|
218
|
+
throw new ToolError("both 'from' and 'to' are required");
|
|
206
219
|
if (from === to)
|
|
207
|
-
throw new
|
|
220
|
+
throw new ToolError("a change cannot depend on itself");
|
|
208
221
|
const names = new Set((await openChanges(client)).map((c) => c.name));
|
|
209
222
|
if (!names.has(from))
|
|
210
|
-
throw new
|
|
223
|
+
throw new ToolError(`unknown open change: ${from}`);
|
|
211
224
|
if (!names.has(to))
|
|
212
|
-
throw new
|
|
225
|
+
throw new ToolError(`unknown open change: ${to}`);
|
|
213
226
|
const path = join(changesDir, from, "proposal.md");
|
|
214
227
|
const text = await readFile(path, "utf8");
|
|
215
228
|
const updated = addDependsOn(text, to);
|
|
@@ -220,10 +233,10 @@ async function setDependency(client, changesDir, project, from, to) {
|
|
|
220
233
|
}
|
|
221
234
|
async function markIndependent(client, changesDir, project, change) {
|
|
222
235
|
if (!change)
|
|
223
|
-
throw new
|
|
236
|
+
throw new ToolError("'change' is required");
|
|
224
237
|
const names = new Set((await openChanges(client)).map((c) => c.name));
|
|
225
238
|
if (!names.has(change))
|
|
226
|
-
throw new
|
|
239
|
+
throw new ToolError(`unknown open change: ${change}`);
|
|
227
240
|
const path = join(changesDir, change, "proposal.md");
|
|
228
241
|
const text = await readFile(path, "utf8");
|
|
229
242
|
const updated = ensureDependsOnSection(text);
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Serve the SPA on loopback and push roadmap + MCP + project state over a websocket.
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { extname } from "node:path";
|
|
4
|
+
import { extname, resolve, sep } from "node:path";
|
|
5
5
|
import { WebSocketServer, WebSocket } from "ws";
|
|
6
6
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
7
7
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -24,53 +24,110 @@ export async function startServer(opts) {
|
|
|
24
24
|
ws.send(data);
|
|
25
25
|
}
|
|
26
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
|
+
};
|
|
27
56
|
// One MCP server + transport per client session (stateful Streamable-HTTP),
|
|
28
|
-
// keyed by
|
|
29
|
-
//
|
|
30
|
-
// every project.
|
|
31
|
-
const
|
|
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();
|
|
32
73
|
const handleMcp = async (req, res) => {
|
|
33
74
|
const sessionId = req.headers["mcp-session-id"];
|
|
34
75
|
try {
|
|
35
76
|
if (req.method === "POST") {
|
|
36
77
|
const body = await readJsonBody(req);
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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);
|
|
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");
|
|
53
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);
|
|
54
101
|
await transport.handleRequest(req, res, body);
|
|
55
102
|
return;
|
|
56
103
|
}
|
|
57
104
|
if (req.method === "GET" || req.method === "DELETE") {
|
|
58
|
-
const
|
|
59
|
-
if (!
|
|
105
|
+
const s = sessionId ? mcpSessions.get(sessionId) : undefined;
|
|
106
|
+
if (!s)
|
|
60
107
|
return sendError(res, 400, "missing or invalid mcp-session-id");
|
|
61
|
-
|
|
108
|
+
s.lastSeen = Date.now();
|
|
109
|
+
await s.transport.handleRequest(req, res);
|
|
62
110
|
return;
|
|
63
111
|
}
|
|
64
112
|
return sendError(res, 405, "method not allowed");
|
|
65
113
|
}
|
|
66
114
|
catch (e) {
|
|
67
|
-
if (
|
|
68
|
-
|
|
115
|
+
if (e instanceof PayloadTooLargeError) {
|
|
116
|
+
if (!res.headersSent)
|
|
117
|
+
sendError(res, 413, "request body too large");
|
|
118
|
+
return;
|
|
69
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");
|
|
70
125
|
}
|
|
71
126
|
};
|
|
72
127
|
const http = createServer(async (req, res) => {
|
|
73
128
|
try {
|
|
129
|
+
if (!isAllowed(req))
|
|
130
|
+
return sendError(res, 403, "forbidden");
|
|
74
131
|
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
75
132
|
if (url.pathname === "/mcp")
|
|
76
133
|
return handleMcp(req, res);
|
|
@@ -96,9 +153,12 @@ export async function startServer(opts) {
|
|
|
96
153
|
return sendError(res, 400, err instanceof Error ? err.message : "invalid project");
|
|
97
154
|
}
|
|
98
155
|
}
|
|
99
|
-
// 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.
|
|
100
158
|
const rel = url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, "");
|
|
101
|
-
|
|
159
|
+
const root = resolve(publicDir);
|
|
160
|
+
const full = resolve(root, rel);
|
|
161
|
+
if (full !== root && !full.startsWith(root + sep))
|
|
102
162
|
return sendError(res, 403, "forbidden");
|
|
103
163
|
const file = await loadAsset(rel, publicDir);
|
|
104
164
|
if (!file)
|
|
@@ -110,7 +170,10 @@ export async function startServer(opts) {
|
|
|
110
170
|
sendError(res, 500, "error");
|
|
111
171
|
}
|
|
112
172
|
});
|
|
113
|
-
wss = new WebSocketServer({
|
|
173
|
+
wss = new WebSocketServer({
|
|
174
|
+
server: http,
|
|
175
|
+
verifyClient: (info) => isAllowed(info.req),
|
|
176
|
+
});
|
|
114
177
|
wss.on("connection", async (ws) => {
|
|
115
178
|
const roadmap = getRoadmap();
|
|
116
179
|
if (roadmap)
|
|
@@ -123,11 +186,14 @@ export async function startServer(opts) {
|
|
|
123
186
|
/* project list optional */
|
|
124
187
|
}
|
|
125
188
|
});
|
|
126
|
-
await new Promise((
|
|
189
|
+
await new Promise((ready) => http.listen(port, host, ready));
|
|
190
|
+
const actualPort = http.address()?.port ?? port;
|
|
191
|
+
setAllowed(actualPort);
|
|
127
192
|
return {
|
|
128
|
-
port,
|
|
193
|
+
port: actualPort,
|
|
129
194
|
broadcast,
|
|
130
195
|
close: () => {
|
|
196
|
+
clearInterval(idleSweep);
|
|
131
197
|
wss.close();
|
|
132
198
|
http.close();
|
|
133
199
|
},
|
|
@@ -141,12 +207,32 @@ function sendError(res, code, message) {
|
|
|
141
207
|
res.writeHead(code, { "content-type": MIME[".json"] });
|
|
142
208
|
res.end(JSON.stringify({ error: message }));
|
|
143
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;
|
|
144
215
|
/** Read and JSON-parse a request body; returns undefined for an empty body. */
|
|
145
216
|
function readJsonBody(req) {
|
|
146
217
|
return new Promise((resolve, reject) => {
|
|
147
218
|
let data = "";
|
|
148
|
-
|
|
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
|
+
});
|
|
149
233
|
req.on("end", () => {
|
|
234
|
+
if (aborted)
|
|
235
|
+
return;
|
|
150
236
|
if (!data)
|
|
151
237
|
return resolve(undefined);
|
|
152
238
|
try {
|
package/package.json
CHANGED