@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/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 +2 -5
- package/dist/geml.js +191 -98
- 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 +528 -0
- package/dist/render.js +136 -136
- package/dist/table.d.ts +2 -0
- package/dist/table.js +11 -11
- package/package.json +62 -62
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
|
-
//
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
|
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
|
|
660
|
-
|
|
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
|
|
4
|
-
// escapes, code spans, inline math, images, links, auto-references,
|
|
5
|
-
// references, then emphasis/strong/strike — in the §5.3 priority order.
|
|
6
|
-
// internal/cross-document reference is reported to a `RefSink` so the
|
|
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.
|
|
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
|
+
}
|