@geml/geml 1.4.2 → 1.4.3

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/dist/history.js CHANGED
@@ -591,12 +591,7 @@ export function verify(historyPath, gemlPath) {
591
591
  }
592
592
  export function restore(o) {
593
593
  const h = parseHistory(o.historyPath);
594
- // accept an unambiguous id prefix
595
- const ids = [...h.revisions.keys()];
596
- const matches = ids.filter((x) => x === o.revision || x.startsWith(o.revision) || x.endsWith(o.revision));
597
- if (matches.length !== 1)
598
- throw new Error(`history: revision selector "${o.revision}" matched ${matches.length} revisions`);
599
- const target = matches[0];
594
+ const target = resolveRevision(h, o.revision); // `0` | `-N` | id — see resolveRevision
600
595
  const content = reconstruct(h, target);
601
596
  if (o.write) {
602
597
  if (existsSync(o.gemlPath)) {
@@ -648,27 +643,33 @@ export function listRevisions(historyPath) {
648
643
  /** Resolve a revision selector to its id + reconstructed full text. Selectors:
649
644
  * `-N` (N revisions back from current; `-0` is the tip), `latest`/`current`, or
650
645
  * an unambiguous id prefix/suffix (the same forms `restore` accepts). */
651
- export function resolveContent(historyPath, selector) {
652
- const h = parseHistory(historyPath);
653
- const chain = chainFrom(h); // chain[0] = current tip
654
- let id;
655
- const off = /^-(\d+)$/.exec(selector);
646
+ /** Resolve a revision selector to its id, for EVERY command that takes one.
647
+ *
648
+ * There is exactly one selector grammar — `0` (the tip), `-N` (N revisions
649
+ * back), or an unambiguous revision id (prefix, suffix, or exact) — and it is
650
+ * the grammar `history log` prints in its first column, so its output is
651
+ * copy-pasteable into `revert --rev`, `history show`, and `history restore`
652
+ * alike. Keeping this in one function is what makes that true: it used to be
653
+ * written twice, and the copy in `restore` never grew the `0`/`-N` arm, so the
654
+ * selectors `history log` advertised were rejected by `history show`. */
655
+ export function resolveRevision(h, selector) {
656
+ const off = /^(0|-\d+)$/.exec(selector);
656
657
  if (off) {
657
- const n = Number(off[1]);
658
+ const chain = chainFrom(h); // chain[0] = current tip
659
+ const n = Math.abs(Number(selector)); // "0" -> 0 (tip), "-1" -> 1 back, …
658
660
  if (n >= chain.length)
659
- throw new Error(`history: offset -${n} is out of range (only ${chain.length} revision(s))`);
660
- id = chain[n].id;
661
- }
662
- else if (selector === "latest" || selector === "current") {
663
- id = chain[0].id;
664
- }
665
- else {
666
- const ids = [...h.revisions.keys()];
667
- const matches = ids.filter((x) => x === selector || x.startsWith(selector) || x.endsWith(selector));
668
- if (matches.length !== 1)
669
- throw new Error(`history: revision selector "${selector}" matched ${matches.length} revisions`);
670
- id = matches[0];
661
+ throw new Error(`history: offset ${selector} is out of range (only ${chain.length} revision(s))`);
662
+ return chain[n].id;
671
663
  }
664
+ const ids = [...h.revisions.keys()];
665
+ const matches = ids.filter((x) => x === selector || x.startsWith(selector) || x.endsWith(selector));
666
+ if (matches.length !== 1)
667
+ throw new Error(`history: revision selector "${selector}" matched ${matches.length} revisions`);
668
+ return matches[0];
669
+ }
670
+ export function resolveContent(historyPath, selector) {
671
+ const h = parseHistory(historyPath);
672
+ const id = resolveRevision(h, selector);
672
673
  return { id, text: reconstruct(h, id) };
673
674
  }
674
675
  /** Walk the chain newest→oldest; return the first revision whose block (as
package/dist/inline.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // GEML reference parser — Milestone 2: inline content (§5).
2
2
  //
3
- // Parses the inline grammar of flow blocks (paragraphs, headings, list items):
4
- // escapes, code spans, inline math, images, links, auto-references, footnote
5
- // references, then emphasis/strong/strike — in the §5.3 priority order. Every
6
- // internal/cross-document reference is reported to a `RefSink` so the document
7
- // layer can resolve and validate it at build time (§8).
3
+ // Parses the inline grammar of unfenced blocks (paragraphs, headings, list
4
+ // items): escapes, code spans, inline math, images, links, auto-references,
5
+ // footnote references, then emphasis/strong/strike — in the §5.3 priority order.
6
+ // Every internal/cross-document reference is reported to a `RefSink` so the
7
+ // document layer can resolve and validate it at build time (§8).
8
8
  import { parseAttrs } from "./attrs.js";
9
9
  const MAX_INLINE_NESTING = 100; // cap parseInline<->scanAtoms recursion (R2-7 DoS)
10
10
  // §4: the source pattern of a `{{key}}` metadata reference. Owned here as the
@@ -455,8 +455,8 @@ export function parseInline(s, line, sink, depth = 0) {
455
455
  // call stack (R2-7). Degrade the over-deep content to text — emphasis only,
456
456
  // no further link recursion — and flag it; never throw RangeError.
457
457
  const diags = sink.diags;
458
- if (Array.isArray(diags) && !diags.some((d) => d.message.startsWith("inline nesting too deep")))
459
- diags.push({ severity: "error", message: `inline nesting too deep (max ${MAX_INLINE_NESTING})`, line });
458
+ if (Array.isArray(diags) && !diags.some((d) => d.code === "inline-nesting-too-deep"))
459
+ diags.push({ severity: "error", code: "inline-nesting-too-deep", message: `inline nesting too deep (max ${MAX_INLINE_NESTING})`, line });
460
460
  return mergeText(emphasize(s));
461
461
  }
462
462
  const atoms = scanAtoms(s, line, sink, depth);
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ export interface McpOptions {
3
+ workspace: string;
4
+ history: boolean;
5
+ }
6
+ /** Configure the server. Exported so the suite can point it at a temp dir. */
7
+ export declare function configure(o: Partial<McpOptions>): McpOptions;
8
+ export declare function resolveInWorkspace(file: string): string;
9
+ export interface Tool {
10
+ name: string;
11
+ description: string;
12
+ inputSchema: unknown;
13
+ run: (args: Record<string, any>) => unknown;
14
+ }
15
+ export declare const TOOLS: Tool[];
16
+ export declare function handleLine(line: string, write?: (s: string) => void): void;
17
+ export declare const MCP_USAGE = "usage: geml mcp --workspace <dir> [--no-history]\n\n Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).\n\n --workspace <dir> REQUIRED. Root directory holding the .geml documents.\n Every path a client names is confined to this directory;\n a client cannot widen or override it.\n --no-history Do not auto-commit a .gemlhistory revision before each\n write. Default is to commit, so geml_revert_block always\n has a revision to undo to.\n\n Register with a client:\n claude mcp add geml-docs -- geml mcp --workspace /abs/path/to/docs";
18
+ export declare function parseArgs(args: string[]): McpOptions;
package/dist/mcp.js ADDED
@@ -0,0 +1,528 @@
1
+ #!/usr/bin/env node
2
+ // `geml mcp` — MCP server for GEML document CRUD.
3
+ //
4
+ // Nine tools over a confined workspace 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-docs -- geml mcp --workspace /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 `--workspace` root 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 } from "./geml.js";
36
+ import { commit, listRevisions, isCurrent } from "./history.js";
37
+ const SERVER_VERSION = "0.1.0";
38
+ let OPTS = { workspace: process.cwd(), history: true };
39
+ /** Configure the server. Exported so the suite can point it at a temp dir. */
40
+ export function configure(o) {
41
+ OPTS = { ...OPTS, ...o };
42
+ return OPTS;
43
+ }
44
+ // ---------------------------------------------------------------------------
45
+ // Workspace confinement
46
+ // ---------------------------------------------------------------------------
47
+ // `file` is client-supplied, so `../../../etc/passwd` — or a symlink planted
48
+ // inside the workspace that points out of it — must not resolve. Canonicalize
49
+ // BOTH sides with realpathSync (which follows every link component) and require
50
+ // the real target to sit at or under the real root. Unlike the code-graph
51
+ // server, whose `graph_dir` is intentionally client-chosen, the root here is
52
+ // fixed by the operator at startup: this server WRITES, so a client that could
53
+ // name its own root could write anywhere.
54
+ export function resolveInWorkspace(file) {
55
+ if (typeof file !== "string" || file === "")
56
+ throw new Error("`file` is required");
57
+ const root = realpathSync(OPTS.workspace);
58
+ const target = resolve(root, file);
59
+ let real;
60
+ try {
61
+ real = realpathSync(target);
62
+ }
63
+ catch {
64
+ throw new Error(`no such file in the workspace: ${file}`);
65
+ }
66
+ if (real !== root && !real.startsWith(root + sep)) {
67
+ throw new Error(`path escapes the workspace: ${file}`);
68
+ }
69
+ if (!statSync(real).isFile())
70
+ throw new Error(`not a file: ${file}`);
71
+ return real;
72
+ }
73
+ // Cross-document references resolve against the workspace root, never against
74
+ // a client-named directory: `root` may only NARROW to a directory inside it.
75
+ function resolveRoot(root) {
76
+ const ws = realpathSync(OPTS.workspace);
77
+ if (root === undefined || root === "")
78
+ return ws;
79
+ const target = resolve(ws, root);
80
+ let real;
81
+ try {
82
+ real = realpathSync(target);
83
+ }
84
+ catch {
85
+ throw new Error(`no such directory in the workspace: ${root}`);
86
+ }
87
+ if (real !== ws && !real.startsWith(ws + sep))
88
+ throw new Error(`root escapes the workspace: ${root}`);
89
+ return real;
90
+ }
91
+ // ---------------------------------------------------------------------------
92
+ // Driving the CLI
93
+ // ---------------------------------------------------------------------------
94
+ const CLI = resolve(dirname(fileURLToPath(import.meta.url)), "geml.js");
95
+ function runCli(args, input) {
96
+ const r = spawnSync(process.execPath, [CLI, ...args], {
97
+ input: input ?? "",
98
+ encoding: "utf8",
99
+ maxBuffer: 64 * 1024 * 1024,
100
+ });
101
+ if (r.error)
102
+ throw new Error(`cannot run the geml CLI: ${r.error.message}`);
103
+ return { ok: r.status === 0, stdout: r.stdout ?? "", stderr: (r.stderr ?? "").trim() };
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // Result shapes
107
+ // ---------------------------------------------------------------------------
108
+ // A refusal tells the model, in so many words, that the file did not change.
109
+ // Without that sentence a model reads "error" and still assumes its edit landed.
110
+ const UNCHANGED = "The write was refused; the file on disk is unchanged.";
111
+ function refuse(file, diagnostics, hint = UNCHANGED) {
112
+ return { ok: false, file, diagnostics, hint };
113
+ }
114
+ // The CLI's `--json` refusal: {error, code, diagnostics?}. A usage error (bad
115
+ // id, prose where a block was wanted) carries no diagnostics — it never got as
116
+ // far as parsing a candidate — so the message alone is the whole answer.
117
+ function parseRefusal(stderr) {
118
+ for (const line of stderr.split("\n").reverse()) {
119
+ if (!line.trim().startsWith("{"))
120
+ continue;
121
+ try {
122
+ const j = JSON.parse(line);
123
+ if (typeof j?.error === "string") {
124
+ return { message: j.error, diagnostics: Array.isArray(j.diagnostics) ? j.diagnostics : [] };
125
+ }
126
+ }
127
+ catch { /* not the JSON frame; keep looking */ }
128
+ }
129
+ return { message: stderr || "the operation was refused", diagnostics: [] };
130
+ }
131
+ const asText = (v) => (typeof v === "string" ? v : JSON.stringify(v, null, 1));
132
+ function applyWrite(spec) {
133
+ const real = resolveInWorkspace(spec.file);
134
+ const before = readFileSync(real, "utf8");
135
+ const root = realpathSync(OPTS.workspace);
136
+ const errorKey = (d) => `${d.code}:${d.message}`;
137
+ const preexisting = new Set(parse(before, { resolveDoc: docResolver(root) }).diagnostics
138
+ .filter((d) => d.severity === "error")
139
+ .map(errorKey));
140
+ // 1. Produce the mutated document WITHOUT touching the file. `--json` makes
141
+ // a refusal machine-readable: the CLI runs its pre-write check and reports
142
+ // every diagnostic with its Appendix A code.
143
+ const run = runCli([...spec.cliArgs, "--json"], spec.input);
144
+ if (!run.ok) {
145
+ const { message, diagnostics } = parseRefusal(run.stderr);
146
+ // A refusal caused ENTIRELY by errors the document already had is worth
147
+ // saying out loud: the model did not break anything, and retrying this
148
+ // edit will keep failing until the pre-existing errors are repaired.
149
+ const stale = diagnostics.length > 0 && diagnostics.every((d) => preexisting.has(errorKey(d)));
150
+ const why = stale
151
+ ? "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."
152
+ : UNCHANGED;
153
+ return refuse(spec.file, diagnostics, `${message}. ${why}`);
154
+ }
155
+ const after = run.stdout;
156
+ // A CLI that exits 0 having written nothing must never be read as "the new
157
+ // document is empty" — an empty document parses clean, so validation below
158
+ // would wave it through and the write would destroy the file.
159
+ if (before.trim() !== "" && after.trim() === "") {
160
+ return refuse(spec.file, [], `the command produced no output, so nothing was written. ${UNCHANGED}`);
161
+ }
162
+ // 2. Validate the RESULT independently of the CLI. This is what catches the
163
+ // tools the CLI lets through — deleting a referenced block, above all.
164
+ const diags = parse(after, { resolveDoc: docResolver(root) }).diagnostics;
165
+ let blocking = diags.filter((d) => d.severity === "error" && !preexisting.has(errorKey(d)));
166
+ if (spec.danglingIsWarning) {
167
+ blocking = blocking.filter((d) => d.code !== "unresolved-reference" && d.code !== "unresolved-footnote");
168
+ }
169
+ if (blocking.length)
170
+ return refuse(spec.file, blocking);
171
+ if (after === before) {
172
+ return { ok: true, file: spec.file, diagnostics: diags, hint: "No change: the document already had this content." };
173
+ }
174
+ // 3. Commit the PRE-write state so this edit is revertible, then write.
175
+ const revision = spec.summary && OPTS.history ? snapshot(real, spec.summary) : undefined;
176
+ writeFileSync(real, after, "utf8");
177
+ return { ok: true, file: spec.file, diagnostics: diags, revision };
178
+ }
179
+ // Commit the file's CURRENT bytes as a revision, so the about-to-happen write
180
+ // has something to revert to. A file already identical to its tip needs no
181
+ // second revision.
182
+ function snapshot(realPath, summary) {
183
+ const historyPath = realPath.replace(/\.geml$/, "") + ".gemlhistory";
184
+ try {
185
+ if (existsSync(historyPath) && isCurrent(historyPath, realPath))
186
+ return undefined;
187
+ return commit({ gemlPath: realPath, historyPath, summary }).id;
188
+ }
189
+ catch {
190
+ // A sidecar that cannot be written must not cost the caller their edit;
191
+ // the write still proceeds, just without a revert point.
192
+ return undefined;
193
+ }
194
+ }
195
+ function docResolver(root) {
196
+ return (doc) => {
197
+ try {
198
+ const target = realpathSync(resolve(root, doc));
199
+ if (target !== root && !target.startsWith(root + sep))
200
+ return null;
201
+ return readFileSync(target, "utf8");
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ };
207
+ }
208
+ const hashId = (id) => (id.startsWith("#") ? id : `#${id}`);
209
+ const FILE_ARG = { type: "string", description: "Document path relative to the server's --workspace root, e.g. notes/spec.geml" };
210
+ export const TOOLS = [
211
+ // ----- read -----
212
+ {
213
+ name: "geml_list_ids",
214
+ 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.",
215
+ inputSchema: { type: "object", properties: { file: FILE_ARG }, required: ["file"] },
216
+ run: (args) => {
217
+ const real = resolveInWorkspace(args.file);
218
+ const run = runCli(["get", real, "--json"]);
219
+ if (!run.ok)
220
+ throw new Error(run.stderr || "could not list ids");
221
+ return run.stdout.trim();
222
+ },
223
+ },
224
+ {
225
+ name: "geml_read_block",
226
+ 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.",
227
+ inputSchema: {
228
+ type: "object",
229
+ properties: {
230
+ file: FILE_ARG,
231
+ id: { type: "string", description: "Block id, with or without the leading `#`" },
232
+ },
233
+ required: ["file", "id"],
234
+ },
235
+ run: (args) => {
236
+ const real = resolveInWorkspace(args.file);
237
+ const run = runCli(["get", real, hashId(args.id)]);
238
+ if (!run.ok)
239
+ throw new Error(run.stderr || `no block with id ${hashId(args.id)}`);
240
+ return run.stdout;
241
+ },
242
+ },
243
+ {
244
+ name: "geml_check",
245
+ 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.",
246
+ inputSchema: {
247
+ type: "object",
248
+ properties: {
249
+ file: FILE_ARG,
250
+ root: { type: "string", description: "Directory (inside the workspace) against which cross-document references resolve. Defaults to the workspace root." },
251
+ },
252
+ required: ["file"],
253
+ },
254
+ run: (args) => {
255
+ const real = resolveInWorkspace(args.file);
256
+ const root = resolveRoot(args.root);
257
+ const doc = parse(readFileSync(real, "utf8"), { resolveDoc: docResolver(root) });
258
+ const errors = doc.diagnostics.filter((d) => d.severity === "error").length;
259
+ return {
260
+ ok: errors === 0,
261
+ file: args.file,
262
+ errors,
263
+ warnings: doc.diagnostics.length - errors,
264
+ diagnostics: doc.diagnostics,
265
+ };
266
+ },
267
+ },
268
+ {
269
+ name: "geml_history_log",
270
+ 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.",
271
+ inputSchema: { type: "object", properties: { file: FILE_ARG }, required: ["file"] },
272
+ run: (args) => {
273
+ const real = resolveInWorkspace(args.file);
274
+ const historyPath = real.replace(/\.geml$/, "") + ".gemlhistory";
275
+ if (!existsSync(historyPath))
276
+ return { file: args.file, revisions: [], note: "no .gemlhistory sidecar yet — the first write through this server creates one" };
277
+ return { file: args.file, revisions: listRevisions(historyPath) };
278
+ },
279
+ },
280
+ // ----- write -----
281
+ {
282
+ name: "geml_write_block",
283
+ 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.",
284
+ inputSchema: {
285
+ type: "object",
286
+ properties: {
287
+ file: FILE_ARG,
288
+ id: { type: "string", description: "Block id to replace, with or without `#`" },
289
+ body: { type: "string", description: "The replacement text" },
290
+ part: { type: "string", enum: ["whole", "head", "body"], description: "What to replace (default: whole)" },
291
+ },
292
+ required: ["file", "id", "body"],
293
+ },
294
+ run: (args) => {
295
+ const real = resolveInWorkspace(args.file);
296
+ const part = args.part ?? "whole";
297
+ if (!["whole", "head", "body"].includes(part))
298
+ throw new Error(`part must be whole|head|body, got \`${part}\``);
299
+ const flag = part === "head" ? ["--head"] : part === "body" ? ["--body"] : [];
300
+ return applyWrite({
301
+ file: args.file,
302
+ cliArgs: ["set", real, hashId(args.id), ...flag, "--in", "-", "-o", "-"],
303
+ input: args.body,
304
+ summary: `mcp: before write to ${hashId(args.id)}`,
305
+ });
306
+ },
307
+ },
308
+ {
309
+ name: "geml_add_block",
310
+ 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.",
311
+ inputSchema: {
312
+ type: "object",
313
+ properties: {
314
+ file: FILE_ARG,
315
+ content: { type: "string", description: "The GEML fragment to insert" },
316
+ position: { type: "string", enum: ["append", "before", "after"], description: "Where to insert" },
317
+ anchor: { type: "string", description: "Block id the insertion is relative to; required for before/after" },
318
+ },
319
+ required: ["file", "content", "position"],
320
+ },
321
+ run: (args) => {
322
+ const real = resolveInWorkspace(args.file);
323
+ let where;
324
+ if (args.position === "append")
325
+ where = ["--append"];
326
+ else if (args.position === "before" || args.position === "after") {
327
+ if (!args.anchor)
328
+ throw new Error(`position \`${args.position}\` needs an \`anchor\` block id`);
329
+ where = [`--${args.position}`, hashId(args.anchor)];
330
+ }
331
+ else
332
+ throw new Error(`position must be append|before|after, got \`${args.position}\``);
333
+ return applyWrite({
334
+ file: args.file,
335
+ cliArgs: ["add", real, ...where, "--in", "-", "-o", "-"],
336
+ input: args.content,
337
+ summary: `mcp: before insert (${args.position}${args.anchor ? " " + hashId(args.anchor) : ""})`,
338
+ });
339
+ },
340
+ },
341
+ {
342
+ name: "geml_delete_block",
343
+ 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.",
344
+ inputSchema: {
345
+ type: "object",
346
+ properties: {
347
+ file: FILE_ARG,
348
+ ids: { type: "array", items: { type: "string" }, description: "Block ids to remove" },
349
+ },
350
+ required: ["file", "ids"],
351
+ },
352
+ run: (args) => {
353
+ const real = resolveInWorkspace(args.file);
354
+ const ids = Array.isArray(args.ids) ? args.ids : [args.ids];
355
+ if (!ids.length)
356
+ throw new Error("`ids` must name at least one block");
357
+ return applyWrite({
358
+ file: args.file,
359
+ cliArgs: ["delete", real, ...ids.map((i) => hashId(i)), "-o", "-"],
360
+ summary: `mcp: before delete ${ids.map((i) => hashId(i)).join(" ")}`,
361
+ danglingIsWarning: true,
362
+ });
363
+ },
364
+ },
365
+ {
366
+ name: "geml_rename_id",
367
+ 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.",
368
+ inputSchema: {
369
+ type: "object",
370
+ properties: {
371
+ file: FILE_ARG,
372
+ old: { type: "string", description: "Current id" },
373
+ new: { type: "string", description: "New id" },
374
+ },
375
+ required: ["file", "old", "new"],
376
+ },
377
+ run: (args) => {
378
+ const real = resolveInWorkspace(args.file);
379
+ return applyWrite({
380
+ file: args.file,
381
+ cliArgs: ["rename", real, hashId(args.old), hashId(args.new), "-o", "-"],
382
+ summary: `mcp: before rename ${hashId(args.old)} -> ${hashId(args.new)}`,
383
+ });
384
+ },
385
+ },
386
+ {
387
+ name: "geml_revert_block",
388
+ 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.",
389
+ inputSchema: {
390
+ type: "object",
391
+ properties: {
392
+ file: FILE_ARG,
393
+ id: { type: "string", description: "Block id to revert" },
394
+ 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)." },
395
+ },
396
+ required: ["file", "id"],
397
+ },
398
+ run: (args) => {
399
+ const real = resolveInWorkspace(args.file);
400
+ // Default to `--rev changed`, NOT the tip (`0`) or the CLI's own `-1`. Each
401
+ // write commits the PRE-write state, so the tip undoes the block only when
402
+ // it was the MOST RECENT write — a later write to ANOTHER block moves the
403
+ // tip, and the revert then silently degrades to a no-op (ok:true, nothing
404
+ // undone). `changed` walks back to THIS block's previous distinct version,
405
+ // so it undoes the block's last edit regardless of intervening writes.
406
+ const sel = ["--rev", args.rev ? String(args.rev) : "changed"];
407
+ return applyWrite({
408
+ file: args.file,
409
+ cliArgs: ["revert", real, hashId(args.id), ...sel, "-o", "-"],
410
+ summary: `mcp: before revert ${hashId(args.id)}`,
411
+ });
412
+ },
413
+ },
414
+ ];
415
+ // ---------------------------------------------------------------------------
416
+ // newline-delimited JSON-RPC 2.0 over stdio
417
+ // ---------------------------------------------------------------------------
418
+ export function handleLine(line, write = (s) => process.stdout.write(s)) {
419
+ const reply = (id, result) => write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
420
+ const replyError = (id, code, message) => write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
421
+ line = line.trim();
422
+ if (!line)
423
+ return;
424
+ let msg;
425
+ try {
426
+ msg = JSON.parse(line);
427
+ }
428
+ catch {
429
+ return;
430
+ }
431
+ const { id, method, params } = msg;
432
+ try {
433
+ if (method === "initialize") {
434
+ reply(id, {
435
+ protocolVersion: params?.protocolVersion ?? "2024-11-05",
436
+ capabilities: { tools: {} },
437
+ serverInfo: { name: "geml-docs", version: SERVER_VERSION },
438
+ });
439
+ }
440
+ else if (method?.startsWith("notifications/")) {
441
+ // notifications get no response
442
+ }
443
+ else if (method === "ping") {
444
+ reply(id, {});
445
+ }
446
+ else if (method === "tools/list") {
447
+ reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
448
+ }
449
+ else if (method === "tools/call") {
450
+ const tool = TOOLS.find((t) => t.name === params?.name);
451
+ if (!tool) {
452
+ replyError(id, -32602, `unknown tool: ${params?.name}`);
453
+ return;
454
+ }
455
+ try {
456
+ const out = tool.run(params?.arguments ?? {});
457
+ // A refused write is a RESULT, not a protocol error: the model must be
458
+ // able to read the diagnostics that refused it.
459
+ const isError = typeof out === "object" && out !== null && out.ok === false;
460
+ reply(id, { content: [{ type: "text", text: asText(out) }], ...(isError ? { isError: true } : {}) });
461
+ }
462
+ catch (e) {
463
+ reply(id, { content: [{ type: "text", text: `error: ${e.message}` }], isError: true });
464
+ }
465
+ }
466
+ else if (id !== undefined) {
467
+ replyError(id, -32601, `method not found: ${method}`);
468
+ }
469
+ }
470
+ catch (e) {
471
+ if (id !== undefined)
472
+ replyError(id, -32603, String(e?.message ?? e));
473
+ }
474
+ }
475
+ // ---------------------------------------------------------------------------
476
+ // Entry
477
+ // ---------------------------------------------------------------------------
478
+ export const MCP_USAGE = `usage: geml mcp --workspace <dir> [--no-history]
479
+
480
+ Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
481
+
482
+ --workspace <dir> REQUIRED. Root directory holding the .geml documents.
483
+ Every path a client names is confined to this directory;
484
+ a client cannot widen or override it.
485
+ --no-history Do not auto-commit a .gemlhistory revision before each
486
+ write. Default is to commit, so geml_revert_block always
487
+ has a revision to undo to.
488
+
489
+ Register with a client:
490
+ claude mcp add geml-docs -- geml mcp --workspace /abs/path/to/docs`;
491
+ export function parseArgs(args) {
492
+ let workspace;
493
+ let history = true;
494
+ for (let i = 0; i < args.length; i++) {
495
+ const a = args[i];
496
+ if (a === "--workspace" || a === "-w")
497
+ workspace = args[++i];
498
+ else if (a.startsWith("--workspace="))
499
+ workspace = a.slice("--workspace=".length);
500
+ else if (a === "--no-history")
501
+ history = false;
502
+ else
503
+ throw new Error(`unknown option '${a}'`);
504
+ }
505
+ if (!workspace)
506
+ throw new Error("--workspace <dir> is required (the root the server may read and write)");
507
+ const abs = resolve(workspace);
508
+ if (!existsSync(abs) || !statSync(abs).isDirectory())
509
+ throw new Error(`--workspace is not a directory: ${workspace}`);
510
+ return { workspace: realpathSync(abs), history };
511
+ }
512
+ // Auto-run only as a MAIN module: the CLI dispatcher spawns this file as a
513
+ // child's entry script, while an in-process `import` (the test suite) stays inert.
514
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
515
+ const args = process.argv.slice(2);
516
+ if (args.includes("--help") || args.includes("-h")) {
517
+ console.log(MCP_USAGE);
518
+ process.exit(0);
519
+ }
520
+ try {
521
+ configure(parseArgs(args));
522
+ }
523
+ catch (e) {
524
+ console.error(`geml mcp: ${e.message}\n\n${MCP_USAGE}`);
525
+ process.exit(2);
526
+ }
527
+ createInterface({ input: process.stdin }).on("line", (line) => handleLine(line));
528
+ }