@geml/geml 1.4.2 → 1.4.4
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/LICENSE +21 -21
- package/README.md +186 -155
- package/codemap/adapters/crg.mjs +120 -120
- package/codemap/adapters/joern.mjs +131 -131
- package/codemap/adapters/scip.mjs +658 -658
- package/codemap/browser-stub.mjs +29 -29
- package/codemap/build.mjs +609 -609
- package/codemap/cross-stack.mjs +303 -303
- package/codemap/detect.mjs +399 -399
- package/codemap/emit.mjs +480 -480
- package/codemap/entries.mjs +129 -129
- package/codemap/exclude.mjs +52 -52
- package/codemap/find.mjs +63 -63
- package/codemap/foldings.mjs +110 -110
- package/codemap/joern-export.sc +83 -83
- package/codemap/mcp-server.mjs +172 -172
- package/codemap/normalize.mjs +275 -275
- package/codemap/recipe-trust.mjs +103 -103
- package/codemap/refresh.mjs +310 -310
- package/codemap/render-all.mjs +64 -64
- package/codemap/serve.mjs +578 -578
- package/codemap/sfc-virtualize.mjs +367 -367
- package/codemap/verify.mjs +148 -148
- package/dist/chart.d.ts +2 -0
- package/dist/chart.js +15 -15
- package/dist/diagnostics.d.ts +9 -0
- package/dist/diagnostics.js +73 -0
- package/dist/geml.d.ts +3 -5
- package/dist/geml.js +261 -115
- package/dist/history.d.ts +10 -0
- package/dist/history.js +25 -24
- package/dist/inline.js +7 -7
- package/dist/mcp.d.ts +18 -0
- package/dist/mcp.js +543 -0
- package/dist/render-html.js +35 -35
- package/dist/render.js +136 -136
- package/dist/table.d.ts +2 -0
- package/dist/table.js +11 -11
- package/package.json +63 -62
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `geml mcp` — MCP server for GEML document CRUD.
|
|
3
|
+
//
|
|
4
|
+
// Nine tools over a confined root directory of `.geml` documents: four read-only,
|
|
5
|
+
// five that write. It is the document-editing counterpart to the read-only
|
|
6
|
+
// code-graph server in `codemap/mcp-server.mjs`, and deliberately mirrors its
|
|
7
|
+
// shape (newline-delimited JSON-RPC 2.0 over stdio, zero dependencies, an
|
|
8
|
+
// exported `handleLine` so the suite can drive it in-process).
|
|
9
|
+
//
|
|
10
|
+
// claude mcp add geml -- geml mcp --root /abs/path/to/docs
|
|
11
|
+
//
|
|
12
|
+
// Three invariants make this worth more than letting a model `str_replace` the
|
|
13
|
+
// file itself:
|
|
14
|
+
//
|
|
15
|
+
// 1. A WRITE IS VALIDATED BEFORE IT REACHES DISK. Every mutation is first
|
|
16
|
+
// run to stdout (`geml <op> … -o -`), the RESULT is parsed, and the file
|
|
17
|
+
// is only overwritten when the result is clean. A bad generation is
|
|
18
|
+
// refused with the diagnostics that refused it — it does not land and
|
|
19
|
+
// then wait for a human to notice.
|
|
20
|
+
// 2. EVERY WRITE IS PRECEDED BY A HISTORY COMMIT, so `geml_revert_block` can
|
|
21
|
+
// always undo the block that was just touched. Without this the strongest
|
|
22
|
+
// tool in the set would have nothing to revert to.
|
|
23
|
+
// 3. EVERY PATH IS CONFINED to a server-side `--root` directory the client
|
|
24
|
+
// cannot override or widen.
|
|
25
|
+
//
|
|
26
|
+
// The mutations run through the CLI rather than re-implementing block editing:
|
|
27
|
+
// the tool table is *defined* as CLI equivalences, and `-o -` already yields
|
|
28
|
+
// the mutated document without touching the file — exactly the "produce, then
|
|
29
|
+
// validate, then commit" order invariant 1 needs.
|
|
30
|
+
import { readFileSync, writeFileSync, existsSync, realpathSync, statSync } from "node:fs";
|
|
31
|
+
import { resolve, dirname, sep } from "node:path";
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
33
|
+
import { spawnSync } from "node:child_process";
|
|
34
|
+
import { createInterface } from "node:readline";
|
|
35
|
+
import { parse, PARSER_VERSION } from "./geml.js";
|
|
36
|
+
import { commit, listRevisions, isCurrent } from "./history.js";
|
|
37
|
+
// One version for the whole package: `geml --version` and the MCP handshake
|
|
38
|
+
// must not disagree. This used to be its own literal and had drifted to 0.1.0
|
|
39
|
+
// against a 1.4.x package — invisible to everyone except the user reading their
|
|
40
|
+
// client's server list.
|
|
41
|
+
const SERVER_VERSION = PARSER_VERSION;
|
|
42
|
+
let OPTS = { root: process.cwd(), history: true };
|
|
43
|
+
/** Configure the server. Exported so the suite can point it at a temp dir. */
|
|
44
|
+
export function configure(o) {
|
|
45
|
+
OPTS = { ...OPTS, ...o };
|
|
46
|
+
return OPTS;
|
|
47
|
+
}
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Workspace confinement
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// `file` is client-supplied, so `../../../etc/passwd` — or a symlink planted
|
|
52
|
+
// inside the root that points out of it — must not resolve. Canonicalize
|
|
53
|
+
// BOTH sides with realpathSync (which follows every link component) and require
|
|
54
|
+
// the real target to sit at or under the real root. Unlike the code-graph
|
|
55
|
+
// server, whose `graph_dir` is intentionally client-chosen, the root here is
|
|
56
|
+
// fixed by the operator at startup: this server WRITES, so a client that could
|
|
57
|
+
// name its own root could write anywhere.
|
|
58
|
+
export function resolveInRoot(file) {
|
|
59
|
+
if (typeof file !== "string" || file === "")
|
|
60
|
+
throw new Error("`file` is required");
|
|
61
|
+
const root = realpathSync(OPTS.root);
|
|
62
|
+
const target = resolve(root, file);
|
|
63
|
+
let real;
|
|
64
|
+
try {
|
|
65
|
+
real = realpathSync(target);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
throw new Error(`no such file under the server root: ${file}`);
|
|
69
|
+
}
|
|
70
|
+
if (real !== root && !real.startsWith(root + sep)) {
|
|
71
|
+
throw new Error(`path escapes the server root: ${file}`);
|
|
72
|
+
}
|
|
73
|
+
if (!statSync(real).isFile())
|
|
74
|
+
throw new Error(`not a file: ${file}`);
|
|
75
|
+
return real;
|
|
76
|
+
}
|
|
77
|
+
// Cross-document references resolve against the SERVER root, never against
|
|
78
|
+
// a client-named directory: `root` may only NARROW to a directory inside it.
|
|
79
|
+
function resolveRoot(root) {
|
|
80
|
+
const serverRoot = realpathSync(OPTS.root);
|
|
81
|
+
if (root === undefined || root === "")
|
|
82
|
+
return serverRoot;
|
|
83
|
+
const target = resolve(serverRoot, root);
|
|
84
|
+
let real;
|
|
85
|
+
try {
|
|
86
|
+
real = realpathSync(target);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
throw new Error(`no such directory under the server root: ${root}`);
|
|
90
|
+
}
|
|
91
|
+
if (real !== serverRoot && !real.startsWith(serverRoot + sep))
|
|
92
|
+
throw new Error(`root escapes the server root: ${root}`);
|
|
93
|
+
return real;
|
|
94
|
+
}
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Driving the CLI
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
const CLI = resolve(dirname(fileURLToPath(import.meta.url)), "geml.js");
|
|
99
|
+
function runCli(args, input) {
|
|
100
|
+
const r = spawnSync(process.execPath, [CLI, ...args], {
|
|
101
|
+
input: input ?? "",
|
|
102
|
+
encoding: "utf8",
|
|
103
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
104
|
+
});
|
|
105
|
+
if (r.error)
|
|
106
|
+
throw new Error(`cannot run the geml CLI: ${r.error.message}`);
|
|
107
|
+
return { ok: r.status === 0, stdout: r.stdout ?? "", stderr: (r.stderr ?? "").trim() };
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Result shapes
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// A refusal tells the model, in so many words, that the file did not change.
|
|
113
|
+
// Without that sentence a model reads "error" and still assumes its edit landed.
|
|
114
|
+
const UNCHANGED = "The write was refused; the file on disk is unchanged.";
|
|
115
|
+
function refuse(file, diagnostics, hint = UNCHANGED) {
|
|
116
|
+
return { ok: false, file, diagnostics, hint };
|
|
117
|
+
}
|
|
118
|
+
// The CLI's `--json` refusal: {error, code, diagnostics?}. A usage error (bad
|
|
119
|
+
// id, prose where a block was wanted) carries no diagnostics — it never got as
|
|
120
|
+
// far as parsing a candidate — so the message alone is the whole answer.
|
|
121
|
+
function parseRefusal(stderr) {
|
|
122
|
+
for (const line of stderr.split("\n").reverse()) {
|
|
123
|
+
if (!line.trim().startsWith("{"))
|
|
124
|
+
continue;
|
|
125
|
+
try {
|
|
126
|
+
const j = JSON.parse(line);
|
|
127
|
+
if (typeof j?.error === "string") {
|
|
128
|
+
return { message: j.error, diagnostics: Array.isArray(j.diagnostics) ? j.diagnostics : [] };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch { /* not the JSON frame; keep looking */ }
|
|
132
|
+
}
|
|
133
|
+
return { message: stderr || "the operation was refused", diagnostics: [] };
|
|
134
|
+
}
|
|
135
|
+
const asText = (v) => (typeof v === "string" ? v : JSON.stringify(v, null, 1));
|
|
136
|
+
function applyWrite(spec) {
|
|
137
|
+
const real = resolveInRoot(spec.file);
|
|
138
|
+
const before = readFileSync(real, "utf8");
|
|
139
|
+
const root = realpathSync(OPTS.root);
|
|
140
|
+
const errorKey = (d) => `${d.code}:${d.message}`;
|
|
141
|
+
const preexisting = new Set(parse(before, { resolveDoc: docResolver(root) }).diagnostics
|
|
142
|
+
.filter((d) => d.severity === "error")
|
|
143
|
+
.map(errorKey));
|
|
144
|
+
// 1. Produce the mutated document WITHOUT touching the file. `--json` makes
|
|
145
|
+
// a refusal machine-readable: the CLI runs its pre-write check and reports
|
|
146
|
+
// every diagnostic with its Appendix A code.
|
|
147
|
+
const run = runCli([...spec.cliArgs, "--json"], spec.input);
|
|
148
|
+
if (!run.ok) {
|
|
149
|
+
const { message, diagnostics } = parseRefusal(run.stderr);
|
|
150
|
+
// A refusal caused ENTIRELY by errors the document already had is worth
|
|
151
|
+
// saying out loud: the model did not break anything, and retrying this
|
|
152
|
+
// edit will keep failing until the pre-existing errors are repaired.
|
|
153
|
+
const stale = diagnostics.length > 0 && diagnostics.every((d) => preexisting.has(errorKey(d)));
|
|
154
|
+
const why = stale
|
|
155
|
+
? "These errors were ALREADY in the document before this edit — your content did not cause them. Repair them first (geml_check lists them); until then no write to this document can be validated."
|
|
156
|
+
: UNCHANGED;
|
|
157
|
+
return refuse(spec.file, diagnostics, `${message}. ${why}`);
|
|
158
|
+
}
|
|
159
|
+
const after = run.stdout;
|
|
160
|
+
// A CLI that exits 0 having written nothing must never be read as "the new
|
|
161
|
+
// document is empty" — an empty document parses clean, so validation below
|
|
162
|
+
// would wave it through and the write would destroy the file.
|
|
163
|
+
if (before.trim() !== "" && after.trim() === "") {
|
|
164
|
+
return refuse(spec.file, [], `the command produced no output, so nothing was written. ${UNCHANGED}`);
|
|
165
|
+
}
|
|
166
|
+
// 2. Validate the RESULT independently of the CLI. This is what catches the
|
|
167
|
+
// tools the CLI lets through — deleting a referenced block, above all.
|
|
168
|
+
const diags = parse(after, { resolveDoc: docResolver(root) }).diagnostics;
|
|
169
|
+
let blocking = diags.filter((d) => d.severity === "error" && !preexisting.has(errorKey(d)));
|
|
170
|
+
if (spec.danglingIsWarning) {
|
|
171
|
+
blocking = blocking.filter((d) => d.code !== "unresolved-reference" && d.code !== "unresolved-footnote");
|
|
172
|
+
}
|
|
173
|
+
if (blocking.length)
|
|
174
|
+
return refuse(spec.file, blocking);
|
|
175
|
+
if (after === before) {
|
|
176
|
+
return { ok: true, file: spec.file, diagnostics: diags, hint: "No change: the document already had this content." };
|
|
177
|
+
}
|
|
178
|
+
// 3. Commit the PRE-write state so this edit is revertible, then write.
|
|
179
|
+
const revision = spec.summary && OPTS.history ? snapshot(real, spec.summary) : undefined;
|
|
180
|
+
writeFileSync(real, after, "utf8");
|
|
181
|
+
return { ok: true, file: spec.file, diagnostics: diags, revision };
|
|
182
|
+
}
|
|
183
|
+
// Commit the file's CURRENT bytes as a revision, so the about-to-happen write
|
|
184
|
+
// has something to revert to. A file already identical to its tip needs no
|
|
185
|
+
// second revision.
|
|
186
|
+
function snapshot(realPath, summary) {
|
|
187
|
+
const historyPath = realPath.replace(/\.geml$/, "") + ".gemlhistory";
|
|
188
|
+
try {
|
|
189
|
+
if (existsSync(historyPath) && isCurrent(historyPath, realPath))
|
|
190
|
+
return undefined;
|
|
191
|
+
return commit({ gemlPath: realPath, historyPath, summary }).id;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// A sidecar that cannot be written must not cost the caller their edit;
|
|
195
|
+
// the write still proceeds, just without a revert point.
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function docResolver(root) {
|
|
200
|
+
return (doc) => {
|
|
201
|
+
try {
|
|
202
|
+
const target = realpathSync(resolve(root, doc));
|
|
203
|
+
if (target !== root && !target.startsWith(root + sep))
|
|
204
|
+
return null;
|
|
205
|
+
return readFileSync(target, "utf8");
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const hashId = (id) => (id.startsWith("#") ? id : `#${id}`);
|
|
213
|
+
const FILE_ARG = { type: "string", description: "Document path relative to the server's --root directory, e.g. notes/spec.geml" };
|
|
214
|
+
export const TOOLS = [
|
|
215
|
+
// ----- read -----
|
|
216
|
+
{
|
|
217
|
+
name: "geml_list_ids",
|
|
218
|
+
description: "List every addressable block in a GEML document: its `#id`, kind, and heading text. Call this FIRST — the ids it returns are what every other tool in this server addresses. Cheaper and more reliable than reading the file to find out what is in it.",
|
|
219
|
+
inputSchema: { type: "object", properties: { file: FILE_ARG }, required: ["file"] },
|
|
220
|
+
run: (args) => {
|
|
221
|
+
const real = resolveInRoot(args.file);
|
|
222
|
+
const run = runCli(["get", real, "--json"]);
|
|
223
|
+
if (!run.ok)
|
|
224
|
+
throw new Error(run.stderr || "could not list ids");
|
|
225
|
+
return run.stdout.trim();
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: "geml_read_block",
|
|
230
|
+
description: "Read ONE block from a GEML document by its `#id`. Use this instead of reading the whole file: it returns only that block, typically a few percent of the document. Get available ids from `geml_list_ids` first. Reading the whole file to change one block wastes context and risks modifying unrelated content.",
|
|
231
|
+
inputSchema: {
|
|
232
|
+
type: "object",
|
|
233
|
+
properties: {
|
|
234
|
+
file: FILE_ARG,
|
|
235
|
+
id: { type: "string", description: "Block id, with or without the leading `#`" },
|
|
236
|
+
},
|
|
237
|
+
required: ["file", "id"],
|
|
238
|
+
},
|
|
239
|
+
run: (args) => {
|
|
240
|
+
const real = resolveInRoot(args.file);
|
|
241
|
+
const run = runCli(["get", real, hashId(args.id)]);
|
|
242
|
+
if (!run.ok)
|
|
243
|
+
throw new Error(run.stderr || `no block with id ${hashId(args.id)}`);
|
|
244
|
+
return run.stdout;
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: "geml_check",
|
|
249
|
+
description: "Validate a GEML document: returns every diagnostic with a stable `code`, a severity, and a line. An empty list means the document is valid. Use this to confirm a document is sound before reporting work as finished — and note that writes through this server are already checked, so a refusal from a write tool is the same information delivered earlier.",
|
|
250
|
+
inputSchema: {
|
|
251
|
+
type: "object",
|
|
252
|
+
properties: {
|
|
253
|
+
file: FILE_ARG,
|
|
254
|
+
root: { type: "string", description: "Directory (inside the server root) against which cross-document references resolve. Defaults to the server root itself. This is a REFERENCE root and is distinct from the server's own --root sandbox, which it can only narrow." },
|
|
255
|
+
},
|
|
256
|
+
required: ["file"],
|
|
257
|
+
},
|
|
258
|
+
run: (args) => {
|
|
259
|
+
const real = resolveInRoot(args.file);
|
|
260
|
+
const root = resolveRoot(args.root);
|
|
261
|
+
const doc = parse(readFileSync(real, "utf8"), { resolveDoc: docResolver(root) });
|
|
262
|
+
const errors = doc.diagnostics.filter((d) => d.severity === "error").length;
|
|
263
|
+
return {
|
|
264
|
+
ok: errors === 0,
|
|
265
|
+
file: args.file,
|
|
266
|
+
errors,
|
|
267
|
+
warnings: doc.diagnostics.length - errors,
|
|
268
|
+
diagnostics: doc.diagnostics,
|
|
269
|
+
};
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: "geml_history_log",
|
|
274
|
+
description: "List the recorded revisions of a document, newest first. Each entry's `offset` is the selector `geml_revert_block` takes as `rev` (-1 is the revision before the current one). Use this to find WHICH revision to revert a block to; an empty list means the document has no sidecar yet and nothing can be reverted.",
|
|
275
|
+
inputSchema: { type: "object", properties: { file: FILE_ARG }, required: ["file"] },
|
|
276
|
+
run: (args) => {
|
|
277
|
+
const real = resolveInRoot(args.file);
|
|
278
|
+
const historyPath = real.replace(/\.geml$/, "") + ".gemlhistory";
|
|
279
|
+
if (!existsSync(historyPath))
|
|
280
|
+
return { file: args.file, revisions: [], note: "no .gemlhistory sidecar yet — the first write through this server creates one" };
|
|
281
|
+
return { file: args.file, revisions: listRevisions(historyPath) };
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
// ----- write -----
|
|
285
|
+
{
|
|
286
|
+
name: "geml_write_block",
|
|
287
|
+
description: "Replace ONE block, addressed by `#id`, leaving every other byte of the document untouched. Prefer this over rewriting a file. The replacement is VALIDATED BEFORE it is written: if it would break the document, nothing is written and you get the diagnostics back — re-read them and fix the body rather than retrying the same content. `part` selects whole block (default), just the head/fence line, or just the body.",
|
|
288
|
+
inputSchema: {
|
|
289
|
+
type: "object",
|
|
290
|
+
properties: {
|
|
291
|
+
file: FILE_ARG,
|
|
292
|
+
id: { type: "string", description: "Block id to replace, with or without `#`" },
|
|
293
|
+
body: { type: "string", description: "The replacement text" },
|
|
294
|
+
part: { type: "string", enum: ["whole", "head", "body"], description: "What to replace (default: whole)" },
|
|
295
|
+
},
|
|
296
|
+
required: ["file", "id", "body"],
|
|
297
|
+
},
|
|
298
|
+
run: (args) => {
|
|
299
|
+
const real = resolveInRoot(args.file);
|
|
300
|
+
const part = args.part ?? "whole";
|
|
301
|
+
if (!["whole", "head", "body"].includes(part))
|
|
302
|
+
throw new Error(`part must be whole|head|body, got \`${part}\``);
|
|
303
|
+
const flag = part === "head" ? ["--head"] : part === "body" ? ["--body"] : [];
|
|
304
|
+
return applyWrite({
|
|
305
|
+
file: args.file,
|
|
306
|
+
cliArgs: ["set", real, hashId(args.id), ...flag, "--in", "-", "-o", "-"],
|
|
307
|
+
input: args.body,
|
|
308
|
+
summary: `mcp: before write to ${hashId(args.id)}`,
|
|
309
|
+
});
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: "geml_add_block",
|
|
314
|
+
description: "Insert new content — one or more blocks, or prose — at a chosen point. `position` is append (end of document), or before/after a block named by `anchor`. Ids inside the content are kept, and a clash with an existing id is refused. Validated before writing, like every write here.",
|
|
315
|
+
inputSchema: {
|
|
316
|
+
type: "object",
|
|
317
|
+
properties: {
|
|
318
|
+
file: FILE_ARG,
|
|
319
|
+
content: { type: "string", description: "The GEML fragment to insert" },
|
|
320
|
+
position: { type: "string", enum: ["append", "before", "after"], description: "Where to insert" },
|
|
321
|
+
anchor: { type: "string", description: "Block id the insertion is relative to; required for before/after" },
|
|
322
|
+
},
|
|
323
|
+
required: ["file", "content", "position"],
|
|
324
|
+
},
|
|
325
|
+
run: (args) => {
|
|
326
|
+
const real = resolveInRoot(args.file);
|
|
327
|
+
let where;
|
|
328
|
+
if (args.position === "append")
|
|
329
|
+
where = ["--append"];
|
|
330
|
+
else if (args.position === "before" || args.position === "after") {
|
|
331
|
+
if (!args.anchor)
|
|
332
|
+
throw new Error(`position \`${args.position}\` needs an \`anchor\` block id`);
|
|
333
|
+
where = [`--${args.position}`, hashId(args.anchor)];
|
|
334
|
+
}
|
|
335
|
+
else
|
|
336
|
+
throw new Error(`position must be append|before|after, got \`${args.position}\``);
|
|
337
|
+
return applyWrite({
|
|
338
|
+
file: args.file,
|
|
339
|
+
cliArgs: ["add", real, ...where, "--in", "-", "-o", "-"],
|
|
340
|
+
input: args.content,
|
|
341
|
+
summary: `mcp: before insert (${args.position}${args.anchor ? " " + hashId(args.anchor) : ""})`,
|
|
342
|
+
});
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
name: "geml_delete_block",
|
|
347
|
+
description: "Remove one or more blocks by id. References left pointing at a removed block are reported as diagnostics but do NOT block the deletion — read them and decide whether to repair or restore. A missing id is skipped, not an error.",
|
|
348
|
+
inputSchema: {
|
|
349
|
+
type: "object",
|
|
350
|
+
properties: {
|
|
351
|
+
file: FILE_ARG,
|
|
352
|
+
ids: { type: "array", items: { type: "string" }, description: "Block ids to remove" },
|
|
353
|
+
},
|
|
354
|
+
required: ["file", "ids"],
|
|
355
|
+
},
|
|
356
|
+
run: (args) => {
|
|
357
|
+
const real = resolveInRoot(args.file);
|
|
358
|
+
const ids = Array.isArray(args.ids) ? args.ids : [args.ids];
|
|
359
|
+
if (!ids.length)
|
|
360
|
+
throw new Error("`ids` must name at least one block");
|
|
361
|
+
return applyWrite({
|
|
362
|
+
file: args.file,
|
|
363
|
+
cliArgs: ["delete", real, ...ids.map((i) => hashId(i)), "-o", "-"],
|
|
364
|
+
summary: `mcp: before delete ${ids.map((i) => hashId(i)).join(" ")}`,
|
|
365
|
+
danglingIsWarning: true,
|
|
366
|
+
});
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
name: "geml_rename_id",
|
|
371
|
+
description: "Rename a block id AND every reference to it in the same document, in one id-boundary-safe operation. Use this instead of a text search-and-replace, which would also hit ids that merely share a prefix.",
|
|
372
|
+
inputSchema: {
|
|
373
|
+
type: "object",
|
|
374
|
+
properties: {
|
|
375
|
+
file: FILE_ARG,
|
|
376
|
+
old: { type: "string", description: "Current id" },
|
|
377
|
+
new: { type: "string", description: "New id" },
|
|
378
|
+
},
|
|
379
|
+
required: ["file", "old", "new"],
|
|
380
|
+
},
|
|
381
|
+
run: (args) => {
|
|
382
|
+
const real = resolveInRoot(args.file);
|
|
383
|
+
return applyWrite({
|
|
384
|
+
file: args.file,
|
|
385
|
+
cliArgs: ["rename", real, hashId(args.old), hashId(args.new), "-o", "-"],
|
|
386
|
+
summary: `mcp: before rename ${hashId(args.old)} -> ${hashId(args.new)}`,
|
|
387
|
+
});
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
name: "geml_revert_block",
|
|
392
|
+
description: "Undo ONE block, leaving every other block byte-for-byte unchanged — recover a single block after a bad edit without losing the good edits around it. `rev` defaults to undoing this block's LAST change (its previous distinct version), which holds even when other blocks were edited afterwards; or pass `0` for the tip, a `-N` offset, or a revision id from `geml_history_log`. Reverting across a revision where the block was deleted restores it; across one where it did not exist removes it.",
|
|
393
|
+
inputSchema: {
|
|
394
|
+
type: "object",
|
|
395
|
+
properties: {
|
|
396
|
+
file: FILE_ARG,
|
|
397
|
+
id: { type: "string", description: "Block id to revert" },
|
|
398
|
+
rev: { type: "string", description: "Revision selector: 0 (the tip) | -N (N revisions back) | id prefix. Omit to undo this block's last change (robust to edits of other blocks since)." },
|
|
399
|
+
},
|
|
400
|
+
required: ["file", "id"],
|
|
401
|
+
},
|
|
402
|
+
run: (args) => {
|
|
403
|
+
const real = resolveInRoot(args.file);
|
|
404
|
+
// Default to `--rev changed`, NOT the tip (`0`) or the CLI's own `-1`. Each
|
|
405
|
+
// write commits the PRE-write state, so the tip undoes the block only when
|
|
406
|
+
// it was the MOST RECENT write — a later write to ANOTHER block moves the
|
|
407
|
+
// tip, and the revert then silently degrades to a no-op (ok:true, nothing
|
|
408
|
+
// undone). `changed` walks back to THIS block's previous distinct version,
|
|
409
|
+
// so it undoes the block's last edit regardless of intervening writes.
|
|
410
|
+
const sel = ["--rev", args.rev ? String(args.rev) : "changed"];
|
|
411
|
+
return applyWrite({
|
|
412
|
+
file: args.file,
|
|
413
|
+
cliArgs: ["revert", real, hashId(args.id), ...sel, "-o", "-"],
|
|
414
|
+
summary: `mcp: before revert ${hashId(args.id)}`,
|
|
415
|
+
});
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
];
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// newline-delimited JSON-RPC 2.0 over stdio
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
export function handleLine(line, write = (s) => process.stdout.write(s)) {
|
|
423
|
+
const reply = (id, result) => write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
|
|
424
|
+
const replyError = (id, code, message) => write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
|
|
425
|
+
line = line.trim();
|
|
426
|
+
if (!line)
|
|
427
|
+
return;
|
|
428
|
+
let msg;
|
|
429
|
+
try {
|
|
430
|
+
msg = JSON.parse(line);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const { id, method, params } = msg;
|
|
436
|
+
try {
|
|
437
|
+
if (method === "initialize") {
|
|
438
|
+
reply(id, {
|
|
439
|
+
protocolVersion: params?.protocolVersion ?? "2024-11-05",
|
|
440
|
+
capabilities: { tools: {} },
|
|
441
|
+
serverInfo: { name: "geml", version: SERVER_VERSION },
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
else if (method?.startsWith("notifications/")) {
|
|
445
|
+
// notifications get no response
|
|
446
|
+
}
|
|
447
|
+
else if (method === "ping") {
|
|
448
|
+
reply(id, {});
|
|
449
|
+
}
|
|
450
|
+
else if (method === "tools/list") {
|
|
451
|
+
reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
|
|
452
|
+
}
|
|
453
|
+
else if (method === "tools/call") {
|
|
454
|
+
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
455
|
+
if (!tool) {
|
|
456
|
+
replyError(id, -32602, `unknown tool: ${params?.name}`);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
const out = tool.run(params?.arguments ?? {});
|
|
461
|
+
// A refused write is a RESULT, not a protocol error: the model must be
|
|
462
|
+
// able to read the diagnostics that refused it.
|
|
463
|
+
const isError = typeof out === "object" && out !== null && out.ok === false;
|
|
464
|
+
reply(id, { content: [{ type: "text", text: asText(out) }], ...(isError ? { isError: true } : {}) });
|
|
465
|
+
}
|
|
466
|
+
catch (e) {
|
|
467
|
+
reply(id, { content: [{ type: "text", text: `error: ${e.message}` }], isError: true });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
else if (id !== undefined) {
|
|
471
|
+
replyError(id, -32601, `method not found: ${method}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
if (id !== undefined)
|
|
476
|
+
replyError(id, -32603, String(e?.message ?? e));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
// Entry
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
export const MCP_USAGE = `usage: geml mcp --root <dir> [--no-history]
|
|
483
|
+
|
|
484
|
+
Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
|
|
485
|
+
|
|
486
|
+
--root <dir> REQUIRED. Root directory holding the .geml documents.
|
|
487
|
+
Relative paths resolve against the server process's CWD,
|
|
488
|
+
which the CLIENT chooses — pass an absolute path.
|
|
489
|
+
Every path a client names is confined to this directory;
|
|
490
|
+
a client cannot widen or override it.
|
|
491
|
+
--no-history Do not auto-commit a .gemlhistory revision before each
|
|
492
|
+
write. Default is to commit, so geml_revert_block always
|
|
493
|
+
has a revision to undo to.
|
|
494
|
+
|
|
495
|
+
Register with a client:
|
|
496
|
+
claude mcp add geml -- geml mcp --root /abs/path/to/docs`;
|
|
497
|
+
export function parseArgs(args) {
|
|
498
|
+
let root;
|
|
499
|
+
let history = true;
|
|
500
|
+
for (let i = 0; i < args.length; i++) {
|
|
501
|
+
const a = args[i];
|
|
502
|
+
if (a === "--root" || a === "-r")
|
|
503
|
+
root = args[++i];
|
|
504
|
+
else if (a.startsWith("--root="))
|
|
505
|
+
root = a.slice("--root=".length);
|
|
506
|
+
else if (a === "--no-history")
|
|
507
|
+
history = false;
|
|
508
|
+
// The flag used to be --workspace/-w. Name the replacement instead of
|
|
509
|
+
// failing with a bare `unknown option`: this runs inside a client's server
|
|
510
|
+
// config, where the only thing the user sees is that the server did not
|
|
511
|
+
// start, and guessing from `unknown option '--workspace'` is a bad evening.
|
|
512
|
+
else if (a === "--workspace" || a === "-w" || a.startsWith("--workspace=")) {
|
|
513
|
+
throw new Error("--workspace is now --root (same meaning: the one directory the server may read and write)");
|
|
514
|
+
}
|
|
515
|
+
else
|
|
516
|
+
throw new Error(`unknown option '${a}'`);
|
|
517
|
+
}
|
|
518
|
+
if (!root)
|
|
519
|
+
throw new Error("--root <dir> is required (the one directory the server may read and write)");
|
|
520
|
+
// Relative paths resolve against THIS process's cwd, which an MCP client
|
|
521
|
+
// picks — so they work from a shell and are a coin flip from a client config.
|
|
522
|
+
const abs = resolve(root);
|
|
523
|
+
if (!existsSync(abs) || !statSync(abs).isDirectory())
|
|
524
|
+
throw new Error(`--root is not a directory: ${root}`);
|
|
525
|
+
return { root: realpathSync(abs), history };
|
|
526
|
+
}
|
|
527
|
+
// Auto-run only as a MAIN module: the CLI dispatcher spawns this file as a
|
|
528
|
+
// child's entry script, while an in-process `import` (the test suite) stays inert.
|
|
529
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
530
|
+
const args = process.argv.slice(2);
|
|
531
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
532
|
+
console.log(MCP_USAGE);
|
|
533
|
+
process.exit(0);
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
configure(parseArgs(args));
|
|
537
|
+
}
|
|
538
|
+
catch (e) {
|
|
539
|
+
console.error(`geml mcp: ${e.message}\n\n${MCP_USAGE}`);
|
|
540
|
+
process.exit(2);
|
|
541
|
+
}
|
|
542
|
+
createInterface({ input: process.stdin }).on("line", (line) => handleLine(line));
|
|
543
|
+
}
|
package/dist/render-html.js
CHANGED
|
@@ -36,43 +36,43 @@ function page(title, body, ctx, source) {
|
|
|
36
36
|
? `<script type="importmap">{"imports":{"node:fs":"${lg}_node-stub.js","node:path":"${lg}_node-stub.js","node:crypto":"${lg}_node-stub.js","node:url":"${lg}_node-stub.js","node:child_process":"${lg}_node-stub.js"}}</script>\n`
|
|
37
37
|
: "";
|
|
38
38
|
const liveJs = wantLive
|
|
39
|
-
? `<script type="module">
|
|
40
|
-
globalThis.process ??= { argv: [], env: {} };
|
|
41
|
-
const { parse } = await import("${lg}geml.js");
|
|
42
|
-
const { codeGraphWaves } = await import("${lg}render.js");
|
|
43
|
-
const w = codeGraphWaves(async (rel) => {
|
|
44
|
-
try { const r = await fetch(rel, { cache: "no-cache" }); return r.ok ? await r.text() : null; } catch { return null; }
|
|
45
|
-
}, parse);
|
|
46
|
-
for (const m of document.querySelectorAll(".cg-mount[data-start]")) {
|
|
47
|
-
const start = m.getAttribute("data-start");
|
|
48
|
-
m._cgView = async (view) => {
|
|
49
|
-
// A directed view builds from the node's OWN document (its meta names the
|
|
50
|
-
// module and graph-depth); {doc} opens that document; else the mount's.
|
|
51
|
-
const src = view && view.doc ? view.doc
|
|
52
|
-
: view && view.node ? view.node.slice(0, view.node.lastIndexOf("#"))
|
|
53
|
-
: start;
|
|
54
|
-
const r = await w.build(src, view && view.doc ? undefined : view);
|
|
55
|
-
return r.error !== undefined ? null : r.data;
|
|
56
|
-
};
|
|
57
|
-
}
|
|
39
|
+
? `<script type="module">
|
|
40
|
+
globalThis.process ??= { argv: [], env: {} };
|
|
41
|
+
const { parse } = await import("${lg}geml.js");
|
|
42
|
+
const { codeGraphWaves } = await import("${lg}render.js");
|
|
43
|
+
const w = codeGraphWaves(async (rel) => {
|
|
44
|
+
try { const r = await fetch(rel, { cache: "no-cache" }); return r.ok ? await r.text() : null; } catch { return null; }
|
|
45
|
+
}, parse);
|
|
46
|
+
for (const m of document.querySelectorAll(".cg-mount[data-start]")) {
|
|
47
|
+
const start = m.getAttribute("data-start");
|
|
48
|
+
m._cgView = async (view) => {
|
|
49
|
+
// A directed view builds from the node's OWN document (its meta names the
|
|
50
|
+
// module and graph-depth); {doc} opens that document; else the mount's.
|
|
51
|
+
const src = view && view.doc ? view.doc
|
|
52
|
+
: view && view.node ? view.node.slice(0, view.node.lastIndexOf("#"))
|
|
53
|
+
: start;
|
|
54
|
+
const r = await w.build(src, view && view.doc ? undefined : view);
|
|
55
|
+
return r.error !== undefined ? null : r.data;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
58
|
</script>\n`
|
|
59
59
|
: "";
|
|
60
|
-
return `<!doctype html>
|
|
61
|
-
<html lang="en">
|
|
62
|
-
<head>
|
|
63
|
-
<meta charset="utf-8">
|
|
64
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
65
|
-
<title>${esc(title)}</title>
|
|
66
|
-
<style>${CSS}</style>
|
|
67
|
-
${importMap}${mathHead}${mermaidHead}</head>
|
|
68
|
-
<body>
|
|
69
|
-
<main>
|
|
70
|
-
${body}
|
|
71
|
-
</main>
|
|
72
|
-
${footer}
|
|
73
|
-
<script>${JS}</script>
|
|
74
|
-
${ctx.usedCodeGraph ? `<script>${CODE_GRAPH_JS}</script>\n` : ""}${liveJs}</body>
|
|
75
|
-
</html>
|
|
60
|
+
return `<!doctype html>
|
|
61
|
+
<html lang="en">
|
|
62
|
+
<head>
|
|
63
|
+
<meta charset="utf-8">
|
|
64
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
65
|
+
<title>${esc(title)}</title>
|
|
66
|
+
<style>${CSS}</style>
|
|
67
|
+
${importMap}${mathHead}${mermaidHead}</head>
|
|
68
|
+
<body>
|
|
69
|
+
<main>
|
|
70
|
+
${body}
|
|
71
|
+
</main>
|
|
72
|
+
${footer}
|
|
73
|
+
<script>${JS}</script>
|
|
74
|
+
${ctx.usedCodeGraph ? `<script>${CODE_GRAPH_JS}</script>\n` : ""}${liveJs}</body>
|
|
75
|
+
</html>
|
|
76
76
|
`;
|
|
77
77
|
}
|
|
78
78
|
export function renderHtml(doc, opts = {}) {
|