@geml/geml 1.4.4 → 1.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,7 +23,7 @@ print("hi")
23
23
 
24
24
  - **Addressable** — every block has an `#id`; `geml get` / `geml set '#id'`
25
25
  read or patch one section without re-emitting the whole file (on this repo's
26
- own spec, ~**31× less context** than shipping the whole document).
26
+ own spec, ~**66× less context** than shipping the whole document).
27
27
  - **Verifiable** — references are checked at build time (a dangling `#id` is an
28
28
  error, not a silent dead link), and the parser emits a document-model JSON
29
29
  with a `diagnostics` array, so agents and CI get a structured pass/fail signal.
@@ -159,6 +159,37 @@ attributes (caption, compute, …) without touching the body. Convention: keep
159
159
  the document title in `=== meta` (`title = "…"`), not an H1 — a lone top-level
160
160
  `#` section is the whole document, the telltale that it is really a title.
161
161
 
162
+ ## MCP Server
163
+
164
+ This package includes a standard Model Context Protocol (MCP) server that exposes GEML document CRUD operations. It runs locally and supports Windows, macOS, and Linux.
165
+
166
+ To connect it to an MCP-compatible client, provide the `npx` execution command and specify the `--root` argument (the directory containing your `.geml` files).
167
+
168
+ ### Claude Desktop
169
+ Add to your `claude_desktop_config.json`:
170
+ ```json
171
+ {
172
+ "mcpServers": {
173
+ "geml": {
174
+ "command": "npx",
175
+ "args": [
176
+ "-y",
177
+ "@geml/geml@latest",
178
+ "mcp",
179
+ "--root",
180
+ "/absolute/path/to/your/docs"
181
+ ]
182
+ }
183
+ }
184
+ }
185
+ ```
186
+
187
+ ### Claude Code / CLI Clients
188
+ Run the following command to add the server:
189
+ ```sh
190
+ /mcp add npx -y @geml/geml@latest mcp --root /absolute/path/to/your/docs
191
+ ```
192
+
162
193
  ## Library
163
194
 
164
195
  ```js
package/codemap/find.mjs CHANGED
@@ -6,11 +6,16 @@
6
6
  // document + block id to open, and the true source location. NO browser: pure
7
7
  // stdout, so it pipes/greps. `dir` defaults to ./.geml-code-graph.
8
8
  //
9
- // Same index the MCP `resolve_name` tool and the viewer search box use
9
+ // Same index the MCP `geml_codemap_search` tool and the viewer search box use
10
10
  // (_index/name-lookup.json); a name with several rows is real ambiguity
11
11
  // (overloads / same short name across classes) — every candidate is printed.
12
- import { readFileSync, existsSync } from "node:fs";
12
+ //
13
+ // The matching rule and the src lookup are IMPORTED, not repeated here: this
14
+ // command and `geml_codemap_search` answer the same question, and two copies of
15
+ // "what counts as a match" would drift the moment one of them is tuned.
16
+ import { existsSync } from "node:fs";
13
17
  import { join } from "node:path";
18
+ import { searchNames, srcOf } from "./mcp-server.mjs";
14
19
 
15
20
  // `find x | head` closes stdout after a few lines — that is normal pipe
16
21
  // usage, not an error (POSIX would kill us silently with SIGPIPE; Windows
@@ -30,32 +35,13 @@ if (!existsSync(lookupPath)) {
30
35
  console.error(`no name-lookup at ${lookupPath} — build the codemap first (geml codemap build)`);
31
36
  process.exit(1);
32
37
  }
33
- const lookup = JSON.parse(readFileSync(lookupPath, "utf8"));
34
- const q = query.toLowerCase();
35
- const names = Object.keys(lookup).filter((n) => n.toLowerCase().includes(q)).sort();
38
+ const { names, lookup } = searchNames(dir, query);
36
39
  if (!names.length) { console.error(`no symbol matching "${query}"`); process.exit(1); }
37
40
 
38
- // src= lives on the block header line in the doc; read each doc once, index by id.
39
- const docCache = new Map(); // doc -> Map(id -> src)
40
- const srcOf = (doc, id) => {
41
- if (!docCache.has(doc)) {
42
- const map = new Map();
43
- try {
44
- const text = readFileSync(join(dir, doc), "utf8");
45
- // src= may be quoted or a bare token (path#Lx-y, no spaces).
46
- const re = /\{#([A-Za-z0-9._-]+)\b[^}]*?\bsrc=(?:"([^"]+)"|([^\s}]+))/g;
47
- let m;
48
- while ((m = re.exec(text))) map.set(m[1], m[2] || m[3]);
49
- } catch { /* doc unreadable — skip src */ }
50
- docCache.set(doc, map);
51
- }
52
- return docCache.get(doc).get(id) || "";
53
- };
54
-
55
41
  let n = 0;
56
42
  for (const name of names) {
57
43
  for (const c of lookup[name]) {
58
- const src = srcOf(c.doc, c.id);
44
+ const src = srcOf(dir, c.doc, c.id);
59
45
  process.stdout.write(`${name}\t${c.doc}#${c.id}${src ? `\t${src}` : ""}\n`);
60
46
  n++;
61
47
  }
@@ -1,24 +1,41 @@
1
1
  #!/usr/bin/env node
2
- // geml-code-graph MCP server — the thin consumption wrapper of DESIGN §8 (P2).
3
- // Three navigation tools over a built graph/ directory, each "give an
4
- // identifier, get readable text back" (the original proposal's 2.6):
5
- // resolve_name name -> candidate anchors (doc + block id)
6
- // open_symbol doc + id -> that symbol's block, verbatim
7
- // get_backlinks doc + id -> the symbol's backlink block (who calls it)
2
+ // geml-code-graph MCP tools — the thin consumption wrapper of DESIGN §8 (P2).
3
+ // Navigation over a built graph/ directory, each "give an identifier, get
4
+ // readable text back" (the original proposal's 2.6). Every name mirrors its CLI
5
+ // path — `geml codemap <sub>` -> `geml_codemap_<sub>` so one vocabulary covers
6
+ // both surfaces:
7
+ // geml_codemap_search name or substring -> candidates (start here)
8
+ // geml_codemap_list no arg -> modules; a module -> its symbols
9
+ // geml_codemap_node doc + id -> that symbol's block, verbatim
10
+ // geml_codemap_callchain doc + id -> several hops, either direction
8
11
  //
9
- // Zero dependencies: newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio
10
- // transport). Register e.g.:
11
- // claude mcp add geml-code-graph -e GEML_GRAPH_DIR=/abs/path/to/graph \
12
- // -- geml codemap mcp
13
- // The graph dir comes from GEML_GRAPH_DIR or a per-call `graph_dir` argument.
12
+ // The four cover reading the graph, not producing it: building and refreshing
13
+ // stay CLI-only on purpose (both run indexers or recorded shell steps, which is
14
+ // not something a model should trigger), and `codemap serve` renders HTML for a
15
+ // human, which a model cannot consume. See DESIGN §8.
14
16
  //
15
- // The dispatch is exported (and the stdio wiring below is main-module guarded)
16
- // so the test suite can drive it in-process; the CLI dispatcher always runs
17
- // this file as a child's MAIN module, where nothing changes.
17
+ // This file is a LIBRARY, not a server entry point. `geml codemap mcp` was
18
+ // removed: `geml mcp --root <dir>` serves these three tools next to the
19
+ // document tools, importing the TOOLS table below rather than duplicating it,
20
+ // so a client registers one server instead of two.
21
+ //
22
+ // claude mcp add geml -- geml mcp --root /abs/path/to/repo
23
+ //
24
+ // `graphDirOf` still honours GEML_GRAPH_DIR and a per-call `graph_dir`. Nothing
25
+ // reaches those defaults through `geml mcp`, which resolves the directory
26
+ // against its own --root before calling a tool — a client-chosen directory is
27
+ // safe only on a process that cannot write, and that one can.
28
+ //
29
+ // Zero dependencies; the handlers speak newline-delimited JSON-RPC 2.0 (the MCP
30
+ // stdio transport) and `handleLine` is exported so both `geml mcp` and the test
31
+ // suite drive it in-process.
18
32
  import { readFileSync, existsSync, realpathSync } from "node:fs";
19
33
  import { join, resolve, dirname, sep } from "node:path";
20
- import { fileURLToPath, pathToFileURL } from "node:url";
21
- import { createInterface } from "node:readline";
34
+ import { fileURLToPath } from "node:url";
35
+ // Where the sources live is serve.mjs's rule (the recipe's `root`, else the
36
+ // graph dir's parent). Imported, not restated: two copies would drift and the
37
+ // source panel and this tool would disagree about which file a symbol is in.
38
+ import { resolveSrcRoot } from "./serve.mjs";
22
39
 
23
40
  // blockSpans from the reference parser (its CLI entry is guarded, so importing
24
41
  // is side-effect free). Falls back with a clear error if the parser isn't built.
@@ -54,73 +71,315 @@ export const readBlock = (graphDir, doc, id) => {
54
71
  return splitLines(source).slice(span.start, span.end).join("");
55
72
  };
56
73
 
74
+ // ---- CSV-table reading (profile §4) -----------------------------------------
75
+ // Serves the edge tables — `#calls` is `from, to, kind, confidence`, `#called-by`
76
+ // is `from, to, kind, site`, cells being `#id` (this document) or `doc.geml#id`
77
+ // (a sibling) — and the index's `#modules`, which has the same shape: a fence
78
+ // line, a header row, then data.
79
+ const tableRows = (graphDir, doc, tableId) => {
80
+ let raw;
81
+ try { raw = readBlock(graphDir, doc, tableId); } catch { return []; }
82
+ // readBlock returns the whole block: fence line, header row, data, close.
83
+ return raw.split("\n").slice(2)
84
+ .filter((l) => l.trim() && !l.trimStart().startsWith("==="))
85
+ .map((l) => l.split(",").map((c) => c.trim()));
86
+ };
87
+
88
+ // A from/to cell as a target, or null when it is plain text (an `#unresolved`
89
+ // target or a `file:line` site — profile §4 says those are unchecked).
90
+ const refTarget = (cell, fromDoc) => {
91
+ const m = /^([^#]*)#(.+)$/.exec(cell ?? "");
92
+ return m ? { doc: m[1] || fromDoc, id: m[2] } : null;
93
+ };
94
+
95
+ // One hop. `callees` reads this document's out-edges; `callers` reads the
96
+ // in-edge table, which the generator aggregates per document, so both
97
+ // directions are a single read of the symbol's OWN document.
98
+ const neighbours = (graphDir, doc, id, direction) => {
99
+ const table = direction === "callers" ? "called-by" : "calls";
100
+ const [self, other] = direction === "callers" ? [1, 0] : [0, 1];
101
+ const want = `#${id.replace(/^#/, "")}`;
102
+ const out = [];
103
+ const seen = new Set();
104
+ for (const row of tableRows(graphDir, doc, table)) {
105
+ if (row[self] !== want) continue;
106
+ const t = refTarget(row[other], doc);
107
+ // A symbol called from three sites yields three identical rows; the caller
108
+ // wants the shape of the graph, not the call count.
109
+ if (!t) continue;
110
+ const key = `${t.doc}#${t.id}`;
111
+ if (seen.has(key)) continue;
112
+ seen.add(key);
113
+ out.push({ ...t, kind: row[2] || "call" });
114
+ }
115
+ return out;
116
+ };
117
+
118
+ // The symbol index: name -> [{ anchor, doc, id }].
119
+ const loadLookup = (graphDir) => {
120
+ const lookupPath = join(graphDir, "_index/name-lookup.json");
121
+ if (!existsSync(lookupPath)) throw new Error(`no name-lookup at ${lookupPath} — build the graph first`);
122
+ return JSON.parse(readFileSync(lookupPath, "utf8"));
123
+ };
124
+
125
+ // ---- name search (shared with `geml codemap find`) --------------------------
126
+ // One definition of "matches", so the CLI and the tool cannot answer the same
127
+ // query differently: case-insensitive substring over the name index, sorted.
128
+ // `exact` narrows to the whole name — the former `resolve_name`, now a flag,
129
+ // because two tools differing only in strictness is two chances to pick wrong.
130
+ export const searchNames = (graphDir, query, exact = false) => {
131
+ const lookup = loadLookup(graphDir);
132
+ if (exact) return { names: lookup[query] ? [query] : [], lookup };
133
+ const q = query.toLowerCase();
134
+ return { names: Object.keys(lookup).filter((n) => n.toLowerCase().includes(q)).sort(), lookup };
135
+ };
136
+
137
+ // The index's `#modules` table: module, doc, methods, entries, tests.
138
+ const moduleRows = (graphDir) =>
139
+ tableRows(graphDir, "index.geml", "modules").filter((r) => r[0] && r[1]);
140
+
141
+ // `src=` lives on the block header line; read each doc once and index by id.
142
+ // The id charset excludes `.`, and src may be quoted or a bare token.
143
+ const srcCache = new Map(); // `${graphDir}\0${doc}` -> Map(id -> src)
144
+ export const srcOf = (graphDir, doc, id) => {
145
+ const key = `${graphDir}\0${doc}`;
146
+ if (!srcCache.has(key)) {
147
+ const map = new Map();
148
+ try {
149
+ const text = readFileSync(join(graphDir, doc), "utf8");
150
+ const re = /\{#([A-Za-z0-9._-]+)\b[^}]*?\bsrc=(?:"([^"]+)"|([^\s}]+))/g;
151
+ let m;
152
+ while ((m = re.exec(text))) map.set(m[1], m[2] || m[3]);
153
+ } catch { /* doc unreadable — a src is a nicety, not the answer */ }
154
+ srcCache.set(key, map);
155
+ }
156
+ return srcCache.get(key).get(id) || "";
157
+ };
158
+
159
+ // ---- reading the real source a `src=` pointer names -------------------------
160
+ // Same two rules `codemap serve` uses for its source panel, imported rather
161
+ // than restated: WHERE the sources are (the recipe's `root`, else the graph
162
+ // dir's parent) and that a file is only served when it really sits under that
163
+ // root, symlinks resolved. What differs is the slice — serve hands the browser
164
+ // the whole file and lets the viewer highlight the range; a model wants the
165
+ // symbol's own lines and nothing else.
166
+ const MAX_SOURCE_LINES = 400;
167
+
168
+ // `geml mcp` also confines every path to its own --root. The source root is
169
+ // derived from `_index/refresh.json`, a file inside the graph — data, not
170
+ // configuration this process chose — so a hand-edited `root: "../../.."` must
171
+ // not reach outside the server's root. Unset (a bare library use) = no extra
172
+ // bound beyond the source root itself.
173
+ let SOURCE_BOUND = null;
174
+ export const confineSourceTo = (dir) => { SOURCE_BOUND = dir ? realpathSync(dir) : null; };
175
+
176
+ const underRoot = (real, root) => real === root || real.startsWith(root + sep);
177
+
178
+ /** The `src=` attribute of a block header: `path` or `path#Lstart-end`. */
179
+ const srcPointer = (block) => {
180
+ const m = /\bsrc=(?:"([^"]+)"|([^\s}]+))/.exec(block.split("\n", 1)[0] ?? "");
181
+ if (!m) return null;
182
+ const raw = m[1] || m[2];
183
+ const range = /^(.*?)#L(\d+)(?:-(\d+))?$/.exec(raw);
184
+ return range
185
+ ? { path: range[1], start: Number(range[2]), end: Number(range[3] ?? range[2]) }
186
+ : { path: raw, start: null, end: null };
187
+ };
188
+
189
+ export const readSource = (graphDir, block) => {
190
+ const ptr = srcPointer(block);
191
+ if (!ptr) return "(no `src=` on this block — nothing to read; edge tables and index blocks have no source)";
192
+ const srcRoot = resolveSrcRoot(graphDir);
193
+ let realRoot;
194
+ try { realRoot = realpathSync(srcRoot); } catch { return `(source root ${srcRoot} does not exist — the sources are not next to the graph on this machine)`; }
195
+ if (SOURCE_BOUND && !underRoot(realRoot, SOURCE_BOUND)) {
196
+ return `(refused: the graph's recorded source root ${srcRoot} is outside this server's --root)`;
197
+ }
198
+ let real;
199
+ try { real = realpathSync(resolve(realRoot, ptr.path)); } catch { return `(no such source file: ${ptr.path})`; }
200
+ if (!underRoot(real, realRoot) || (SOURCE_BOUND && !underRoot(real, SOURCE_BOUND))) {
201
+ return `(refused: ${ptr.path} resolves outside the source root)`;
202
+ }
203
+ let text;
204
+ try { text = readFileSync(real, "utf8"); } catch (e) { return `(cannot read ${ptr.path}: ${e.message})`; }
205
+ const all = text.split("\n");
206
+ const start = ptr.start ?? 1;
207
+ const end = Math.min(ptr.end ?? all.length, start + MAX_SOURCE_LINES - 1);
208
+ const slice = all.slice(start - 1, end);
209
+ if (!slice.length) return `(${ptr.path} has no lines ${start}-${end} — the graph is stale; rebuild with \`geml codemap build\`)`;
210
+ const cut = (ptr.end ?? all.length) > end ? `\n… truncated at ${MAX_SOURCE_LINES} lines` : "";
211
+ // Line numbers so a model can cite `file:line` without recounting.
212
+ const body = slice.map((l, i) => `${String(start + i).padStart(5)} ${l}`).join("\n");
213
+ return `--- ${ptr.path}:${start}-${end} ---\n${body}${cut}`;
214
+ };
215
+
57
216
  export const TOOLS = [
58
217
  {
59
- name: "resolve_name",
60
- description: "Find a function/class by name in the code graph. Returns candidate anchors with the document and block id to open. Multiple candidates = real ambiguity (overloads/same name) — inspect each, never assume.",
218
+ name: "geml_codemap_search",
219
+ description:
220
+ "Find symbols in the code graph BY NAME — case-insensitive substring by default, or the whole name with `exact: true` when you already know it. Returns `name doc#id src` per candidate, the same index the CLI's `geml codemap find` and the viewer's search box use, and `doc`+`id` are what geml_codemap_node and geml_codemap_callchain take. Start here on an unfamiliar codebase (or geml_codemap_list to browse by module). Several candidates for one name is real ambiguity — overloads, or the same name in two modules — so inspect each rather than assuming the first.",
61
221
  inputSchema: {
62
222
  type: "object",
63
223
  properties: {
64
- name: { type: "string", description: "Exact symbol name (function/class short name)" },
224
+ query: { type: "string", description: "The symbol name, or a substring of it (e.g. `token` matches issueToken and TokenStore)" },
225
+ exact: { type: "boolean", description: "Match the WHOLE name instead of a substring (default false)" },
226
+ limit: { type: "number", description: "Maximum candidates to return (default 50). Narrow the query rather than raising this." },
65
227
  graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
66
228
  },
67
- required: ["name"],
229
+ required: ["query"],
68
230
  },
69
231
  run: (args) => {
70
- const lookupPath = join(graphDirOf(args), "_index/name-lookup.json");
71
- if (!existsSync(lookupPath)) throw new Error(`no name-lookup at ${lookupPath} — build the graph first`);
72
- const lookup = JSON.parse(readFileSync(lookupPath, "utf8"));
73
- const hits = lookup[args.name];
74
- if (!hits?.length) return `no symbol named \`${args.name}\` in the graph`;
75
- return JSON.stringify(hits, null, 1);
232
+ const graphDir = graphDirOf(args);
233
+ const query = String(args.query ?? "");
234
+ if (!query) throw new Error("`query` is required");
235
+ const exact = args.exact === true;
236
+ const { names, lookup } = searchNames(graphDir, query, exact);
237
+ if (!names.length) {
238
+ return exact
239
+ ? `no symbol named \`${query}\` in the graph — drop \`exact\` to match substrings`
240
+ : `no symbol matching "${query}" in the graph`;
241
+ }
242
+ const limit = Math.max(1, Math.min(Number(args.limit) || 50, 500));
243
+ const lines = [];
244
+ let total = 0;
245
+ for (const name of names) {
246
+ for (const c of lookup[name]) {
247
+ total++;
248
+ if (lines.length < limit) {
249
+ const src = srcOf(graphDir, c.doc, c.id);
250
+ lines.push(`${name}\t${c.doc}#${c.id}${src ? `\t${src}` : ""}`);
251
+ }
252
+ }
253
+ }
254
+ const tail = total > lines.length
255
+ ? `\n\n${lines.length} of ${total} match(es) shown — narrow the query.`
256
+ : `\n\n${total} match(es) across ${names.length} name(s).`;
257
+ return lines.join("\n") + tail;
76
258
  },
77
259
  },
78
260
  {
79
- name: "open_symbol",
80
- description: "Open ONE symbol's block from the code graph (its callees as checked references, confidence annotations, called-by pointer). Equivalent to following a link. Get doc+id from resolve_name.",
261
+ name: "geml_codemap_callchain",
262
+ description:
263
+ "Walk the call graph SEVERAL hops from one symbol and get the whole chain back as an indented tree — `direction: callees` for what it calls (downstream, for tracing a behaviour), `callers` for what reaches it (upstream, the impact path). Use this instead of opening one symbol per level: one call replaces N round trips and returns only the edges, not each symbol's full block. `depth: 1` with `callers` answers \"who calls this\" alone. A repeated symbol is marked and not expanded twice, so recursion terminates. Call SITES (file:line) are not in the tree — read the `#called-by` table with geml_codemap_node(doc, \"#called-by\") for those.",
81
264
  inputSchema: {
82
265
  type: "object",
83
266
  properties: {
84
- doc: { type: "string", description: "Document path relative to the codemap dir, e.g. hashtable.c.geml" },
85
- id: { type: "string", description: "Block id, e.g. hashtableFind (or #calls / #called-by for the edge tables)" },
267
+ doc: { type: "string", description: "The symbol's document path, e.g. hashtable.c.geml" },
268
+ id: { type: "string", description: "The symbol's block id, e.g. hashtableFind" },
269
+ direction: { type: "string", enum: ["callees", "callers"], description: "`callees` = what this calls (default); `callers` = what calls this" },
270
+ depth: { type: "number", description: "How many hops to follow (default 3, max 6)" },
86
271
  graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
87
272
  },
88
273
  required: ["doc", "id"],
89
274
  },
90
- run: (args) => readBlock(graphDirOf(args), args.doc, args.id),
275
+ run: (args) => {
276
+ const graphDir = graphDirOf(args);
277
+ const direction = args.direction === "callers" ? "callers" : "callees";
278
+ const depth = Math.max(1, Math.min(Number(args.depth) || 3, 6));
279
+ const rootId = String(args.id ?? "").replace(/^#/, "");
280
+ if (!args.doc || !rootId) throw new Error("`doc` and `id` are required");
281
+ // Prove the symbol exists before reporting an empty chain: "no edges" and
282
+ // "no such symbol" are different answers and a model must not conflate them.
283
+ readBlock(graphDir, args.doc, rootId);
284
+
285
+ const MAX_NODES = 200;
286
+ const lines = [];
287
+ const expanded = new Set();
288
+ let truncated = false;
289
+
290
+ // EVERY line carries the full `doc.geml#id`, including same-document
291
+ // targets that profile §4 would abbreviate to `#id`. The reader here is
292
+ // an agent, and each line has to be usable as-is for the next
293
+ // `open_symbol`/`trace_calls` call. A bare id would make it infer the
294
+ // document from the line's ancestors — cheaper output, one more thing to
295
+ // get wrong.
296
+ const walk = (doc, id, level, prefix, last) => {
297
+ const key = `${doc}#${id}`;
298
+ const label = level === 0 ? `${key}` : `${prefix}${last ? "└─ " : "├─ "}${key}`;
299
+ if (lines.length >= MAX_NODES) { truncated = true; return; }
300
+ if (expanded.has(key)) { lines.push(`${label} (already shown)`); return; }
301
+ lines.push(label);
302
+ expanded.add(key);
303
+ if (level >= depth) {
304
+ // Say whether the cut hides anything, so a model knows to go deeper.
305
+ if (neighbours(graphDir, doc, id, direction).length) lines.push(`${prefix}${last ? " " : "│ "} … (depth limit)`);
306
+ return;
307
+ }
308
+ const next = neighbours(graphDir, doc, id, direction);
309
+ const childPrefix = level === 0 ? "" : prefix + (last ? " " : "│ ");
310
+ next.forEach((n, i) => walk(n.doc, n.id, level + 1, childPrefix, i === next.length - 1));
311
+ };
312
+ walk(args.doc, rootId, 0, "", true);
313
+
314
+ const noun = direction === "callers" ? "callers" : "callees";
315
+ if (lines.length === 1) {
316
+ return `${lines[0]}\n\nno resolved ${noun} — under heuristic extraction that is a blind spot, not proof of none (see the #unresolved table).`;
317
+ }
318
+ return lines.join("\n") +
319
+ `\n\n${direction}, depth ${depth}${truncated ? `, truncated at ${MAX_NODES} nodes` : ""}. ` +
320
+ "Resolved edges only: `#unresolved` holds the blind spots.";
321
+ },
91
322
  },
92
323
  {
93
- name: "get_backlinks",
94
- description: "Who calls this symbol: opens its backlink block (callers with file:line sites, each a followable reference). Absence means no RESOLVED callers — never proof of none.",
324
+ name: "geml_codemap_list",
325
+ description:
326
+ "Browse the graph by MODULE. Called with no argument it lists every module with its document and symbol count — the map to open first on an unfamiliar repo, before you know any name to search for. Called with a `module` it lists that module's symbols as `name doc#id src`, ready to hand to geml_codemap_node or geml_codemap_callchain. Accepts a module name or its document path.",
95
327
  inputSchema: {
96
328
  type: "object",
97
329
  properties: {
98
- doc: { type: "string", description: "The symbol's document path, e.g. hashtable.c.geml" },
99
- id: { type: "string", description: "The symbol's block id (e.g. hashtableFind); omit to get the whole #called-by table" },
100
- graph_dir: { type: "string", description: "Codemap directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
330
+ module: { type: "string", description: "Module name (e.g. geml-parser) or its document (geml-parser.geml). Omit to list every module." },
331
+ graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
101
332
  },
102
- required: ["doc"],
103
333
  },
104
334
  run: (args) => {
105
- // codemap profile: in-edges live in the SAME document's #called-by table.
106
- let table;
107
- try {
108
- table = readBlock(graphDirOf(args), args.doc, "called-by");
109
- } catch {
110
- return `no #called-by table in ${args.doc} no resolved callers recorded (under heuristic extraction this is a blind spot, not proof of none)`;
335
+ const graphDir = graphDirOf(args);
336
+ const rows = moduleRows(graphDir);
337
+ if (!rows.length) throw new Error(`no #modules table in index.geml (graph dir: ${graphDir}) — build the graph first`);
338
+ const want = String(args.module ?? "").trim();
339
+ if (!want) {
340
+ return rows.map((r) => `${r[0]}\t${r[1]}\t${r[2] || 0} symbol(s)`).join("\n") +
341
+ `\n\n${rows.length} module(s). Pass one as \`module\` to list its symbols.`;
111
342
  }
112
- if (!args.id) return table;
113
- const id = args.id.replace(/^#/, "");
114
- // `id` is client-supplied and goes straight into a RegExp: escape every
115
- // regex metacharacter so it matches LITERALLY (an id like `.*` or a
116
- // catastrophic-backtracking pattern can neither widen the match nor cause
117
- // ReDoS the pattern is a fixed string wrapped in `,\s*#…\s*,`).
118
- const escId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
119
- const re = new RegExp(`,\\s*#${escId}\\s*,`);
120
- const lines = table.split("\n");
121
- const hits = lines.filter((l, i) => i < 2 || re.test(l));
122
- return hits.length > 2 ? hits.join("\n")
123
- : `no resolved callers of #${id} in ${args.doc} (blind spots live in the #unresolved table)`;
343
+ const row = rows.find((r) => r[0] === want || r[1] === want);
344
+ if (!row) return `no module \`${want}\` in the graph — call this tool with no argument to list them`;
345
+ const doc = row[1];
346
+ // The name index is the symbol list: filtering it by document skips the
347
+ // per-document edge tables (#calls / #called-by) a raw id listing returns.
348
+ const lookup = loadLookup(graphDir);
349
+ const lines = [];
350
+ for (const [name, cands] of Object.entries(lookup)) {
351
+ for (const c of cands) {
352
+ if (c.doc !== doc) continue;
353
+ const src = srcOf(graphDir, doc, c.id);
354
+ lines.push(`${name}\t${doc}#${c.id}${src ? `\t${src}` : ""}`);
355
+ }
356
+ }
357
+ // Name the module CANONICALLY (its #modules name), not however the caller
358
+ // addressed it, so `auth` and `auth.geml` return byte-identical answers.
359
+ if (!lines.length) return `module \`${row[0]}\` (${doc}) has no symbols in the name index`;
360
+ lines.sort();
361
+ return lines.join("\n") + `\n\n${lines.length} symbol(s) in ${row[0]}.`;
362
+ },
363
+ },
364
+ {
365
+ name: "geml_codemap_node",
366
+ description:
367
+ "Open ONE node of the graph verbatim: a symbol's block (its `src=` pointer into the real file, confidence annotations), or a document's edge table — pass `#calls` / `#called-by` / `#unresolved` as the id for those. `#called-by` is where call SITES (file:line) live. Pass `source: true` to also read the REAL SOURCE the `src=` pointer names — the symbol's own lines, the same text the local viewer shows in its source panel — so you do not have to open the file yourself. Get `doc` and `id` from geml_codemap_search or geml_codemap_list.",
368
+ inputSchema: {
369
+ type: "object",
370
+ properties: {
371
+ doc: { type: "string", description: "Document path relative to the graph dir, e.g. hashtable.c.geml" },
372
+ id: { type: "string", description: "Block id, e.g. hashtableFind (or #calls / #called-by / #unresolved for the edge tables)" },
373
+ source: { type: "boolean", description: "Also return the real source lines that `src=` points at (default false). Off by default because a node is often opened in a loop, where the pointer is enough." },
374
+ graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
375
+ },
376
+ required: ["doc", "id"],
377
+ },
378
+ run: (args) => {
379
+ const graphDir = graphDirOf(args);
380
+ const block = readBlock(graphDir, args.doc, args.id);
381
+ if (args.source !== true) return block;
382
+ return `${block}\n${readSource(graphDir, block)}`;
124
383
  },
125
384
  },
126
385
  ];
@@ -165,8 +424,8 @@ export function handleLine(line, write = (s) => process.stdout.write(s)) {
165
424
  }
166
425
  }
167
426
 
168
- // Auto-run only as a MAIN module (the CLI dispatcher spawns this file as a
169
- // child's entry script) an in-process `import` stays inert.
170
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
171
- createInterface({ input: process.stdin }).on("line", (line) => handleLine(line));
172
- }
427
+ // No main-module block: this file no longer starts a server. Running it
428
+ // directly used to serve the three tools on stdio, and leaving that in would
429
+ // keep the removed entry point alive as a back door — `node codemap/
430
+ // mcp-server.mjs` reachable from any client config. `geml mcp` owns the
431
+ // transport now.
package/dist/geml.js CHANGED
@@ -598,7 +598,10 @@ function sectionEnd(lines, i, level) {
598
598
  // First definition wins, mirroring ctx.ids (a duplicate id is a build error, so
599
599
  // `get`/`set` operate on the one the parser actually registered). `base` is the
600
600
  // absolute line offset of this slice within the whole document.
601
- function collectSpans(lines, base, out, ctx, depth = 0) {
601
+ function collectSpans(lines, base, out, ctx, depth = 0,
602
+ // Optional second index: every typed block by TYPE, id-bearing or not, so a
603
+ // block the author never named is still addressable (`=== meta`).
604
+ types) {
602
605
  const add = (id, start, end) => {
603
606
  if (!out.has(id))
604
607
  out.set(id, { start, end });
@@ -627,11 +630,16 @@ function collectSpans(lines, base, out, ctx, depth = 0) {
627
630
  const { end, closed } = fenceClose(lines, i, open);
628
631
  if (id !== undefined)
629
632
  add(id, base + i, base + end);
633
+ if (types) {
634
+ const list = types.get(type) ?? [];
635
+ list.push({ span: { start: base + i, end: base + end }, id });
636
+ types.set(type, list);
637
+ }
630
638
  // Only a flow body is scanned for nested blocks (raw/data bodies are
631
639
  // opaque), so an id inside a `code` body is *not* addressable — exactly
632
640
  // the parser's contract.
633
641
  if ((REGISTRY[type] ?? "raw") === "flow" && depth < MAX_NESTING) {
634
- collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1);
642
+ collectSpans(lines.slice(i + 1, closed ? end - 1 : end), base + i + 1, out, ctx, depth + 1, types);
635
643
  }
636
644
  i = end;
637
645
  continue;
@@ -660,6 +668,13 @@ export function blockSpans(source) {
660
668
  collectSpans(lines, 0, out, ctx);
661
669
  return out;
662
670
  }
671
+ function blockTypeSpans(source) {
672
+ const lines = normalizeSource(source).split("\n");
673
+ const ctx = { diags: [], ids: new Map(), refs: [], meta: collectMeta(lines) };
674
+ const types = new Map();
675
+ collectSpans(lines, 0, new Map(), ctx, 0, types);
676
+ return types;
677
+ }
663
678
  // Split into physical lines while *keeping* each line's terminator, so
664
679
  // join("") is byte-exact and slicing by span never rewrites line endings.
665
680
  // A line ends at `\n` or at a LONE `\r` (old-Mac style) — the same boundaries
@@ -809,10 +824,11 @@ Usage:
809
824
  geml check <file.geml|-> [--root d] [--json] validate only: diagnostics + exit code
810
825
  (--root widens cross-doc refs to dir d, e.g. the repo root)
811
826
  geml history <commit|verify|show|restore|log> <file.geml> [...] .gemlhistory version sidecar
812
- geml codemap <build|verify|render|serve|refresh|find|mcp> [...] code-graph toolkit (alias: codegraph)
813
- geml mcp --root <dir> [--no-history] serve document CRUD over MCP (stdio)
827
+ geml codemap <build|verify|render|serve|refresh|find> [...] code-graph toolkit (alias: codegraph)
828
+ geml mcp --root <dir> [--graph <dir>] [--no-history] serve documents (and the code graph) over MCP (stdio)
814
829
  (9 tools: list/read/check/history + write/add/delete/rename/revert;
815
- every write is validated before it reaches disk)
830
+ every write is validated before it reaches disk. A code graph under
831
+ --root adds resolve_name/open_symbol/get_backlinks to the same server)
816
832
  geml --help | --version [--json]
817
833
 
818
834
  Use '-' as the file to read from stdin.
@@ -826,7 +842,7 @@ Exit codes:
826
842
  // One-line usage for each subcommand — the single source for both the error
827
843
  // shown on misuse and the `<cmd> --help` text.
828
844
  const SUBHELP = {
829
- get: "usage: geml get <file.geml|-> [#id] [--json] [--head] (with #id: that block, a heading id = its whole section, --head = its head line; without #id: list every addressable id, --json = array)",
845
+ get: "usage: geml get <file.geml|-> [#id | '## Heading' | '=== type'] [--json] [--head] (selector: an #id, or a LINE copied from the document — a heading `## Title` addresses its whole section, a fence `=== meta` addresses that block by type and lists the candidates when several match; --head = the head line; without a selector: list every addressable id, --json = array)",
830
846
  set: "usage: geml set <file.geml|-> #id [--head|--body] [--in F | --in F#src | --in -] [-o out.geml] (content: --in F takes F's block #id, --in F#src takes #src, else stdin raw; default = whole block, --head = head line — both normalize the id to #id — --body = body; guarded splice, refused if it breaks the doc)",
831
847
  add: "usage: geml add <file.geml|-> (--append | --before #id | --after #id) [--in F | --in F#src | --in -] [-o out.geml] (insert a GEML fragment — 1+ blocks and/or prose — at a position; --in F takes all of F, --in F#src takes #src, else stdin raw; content keeps its own ids, a collision is refused)",
832
848
  delete: "usage: geml delete <file.geml|-> #id [#id2 …] [-o out.geml] (remove one or more blocks; a missing id is skipped with a note, not an error; a reference left dangling is a warning, not a refusal — delete never fails on a live reference)",
@@ -841,23 +857,27 @@ const SUBHELP = {
841
857
  geml codemap serve [dir] [--port 8140] [--watch] [--background|--stop] live viewer: pages render from .geml on request; --watch re-runs the recipe when sources change
842
858
  geml codemap refresh [dir] [--force] [--commit] [--background|--hook] re-run the recorded build recipe (_index/refresh.json); --commit lands it as its own commit
843
859
  geml codemap find <name> [dir] locate a symbol by substring name -> doc#id + src (stdout, no browser)
844
- geml codemap mcp stdio MCP server (GEML_GRAPH_DIR or graph_dir arg)
845
860
  (<dir> for verify/render/serve/refresh/find defaults to ./.geml-code-graph; codegraph and code-graph are accepted as aliases of codemap)`,
846
- mcp: `usage: geml mcp --root <dir> [--no-history]
861
+ mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
847
862
 
848
863
  Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
849
864
  Nine tools: geml_list_ids · geml_read_block · geml_check · geml_history_log
850
865
  geml_write_block · geml_add_block · geml_delete_block
851
866
  geml_rename_id · geml_revert_block
867
+ With a code graph under --root, three more (read-only), so one client entry
868
+ covers both: resolve_name · open_symbol · get_backlinks
852
869
 
853
870
  --root <dir> REQUIRED. Root holding the .geml documents. Every path a
854
871
  client names is confined here; a client cannot widen it.
872
+ --graph <dir> Code-graph directory, inside --root. Defaults to
873
+ <root>/.geml-code-graph when it holds an index.geml; with
874
+ no graph the three graph tools are not served at all.
855
875
  --no-history Skip the .gemlhistory commit taken before each write
856
876
  (default: commit, so geml_revert_block always has a
857
877
  revision to undo to).
858
878
 
859
879
  Register with a client:
860
- claude mcp add geml -- geml mcp --root /abs/path/to/docs`,
880
+ claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
861
881
  };
862
882
  // Set from argv at dispatch time; when true, errors are emitted as a JSON
863
883
  // envelope so an agent that standardizes on --json never has to parse text.
@@ -1236,6 +1256,72 @@ function positionals(args, valued) {
1236
1256
  }
1237
1257
  return out;
1238
1258
  }
1259
+ // Resolve a block SELECTOR to an id. Three spellings address the same block:
1260
+ //
1261
+ // `#intro` / `intro` the id — the CANONICAL address
1262
+ // `## Getting Started` the heading LINE, copied out of the document
1263
+ // `##Getting Started` …the space after the `#` run is optional
1264
+ //
1265
+ // Why more than one form: the id is what `[[#id]]` references, codemap tables
1266
+ // and URL fragments (§0.6) all carry, so it must stay accepted verbatim — an id
1267
+ // copied out of a reference or out of `geml get <file>` has to work. But a
1268
+ // heading's id is AUTO-DERIVED from its text (`## API 设计 (v1)` → `#api-设计-v1`),
1269
+ // and nobody can be expected to hand-derive that slug for a heading they can
1270
+ // read on screen. So the heading line itself is accepted too.
1271
+ //
1272
+ // Resolution order, first match wins:
1273
+ // 1. the id, exactly — a pasted id is NEVER reinterpreted as prose. (When a
1274
+ // heading's TEXT happens to equal another block's ID, the id wins.)
1275
+ // 2. the exact heading LINE: `#` count AND text both match.
1276
+ // 3. the text alone, at any level — a heading remembered at the wrong depth
1277
+ // still resolves while its text is unique.
1278
+ // 4. text shared by several headings: the `#` count picks one, or the
1279
+ // candidates are listed. Never guessed at.
1280
+ function resolveSelector(source, file, raw) {
1281
+ const bare = raw.replace(/^#/, "");
1282
+ const m = /^(#{1,6})[ \t]*(.+?)[ \t]*$/.exec(raw);
1283
+ if (!m)
1284
+ return bare; // not a `#`-run form: an id, verbatim
1285
+ // 1. The id is canonical and always wins. Checked without a parse, so the
1286
+ // common `get #id` stays a byte-slice on a document with diagnostics.
1287
+ if (blockSpans(source).has(bare))
1288
+ return bare;
1289
+ const level = m[1].length;
1290
+ const want = m[2];
1291
+ const doc = parse(source, { resolveDoc: resolverFor(file) });
1292
+ const heads = doc.ids.flatMap((id) => {
1293
+ const site = findBlockSite(doc.children, id);
1294
+ const b = site?.siblings[site.index];
1295
+ return b?.kind === "heading" ? [{ id, level: b.level, text: b.text.trim() }] : [];
1296
+ });
1297
+ // 2. exact line — what the caller actually typed.
1298
+ const line = heads.find((h) => h.level === level && h.text === want);
1299
+ if (line)
1300
+ return line.id;
1301
+ // 3. the text alone (exact, then case-insensitive).
1302
+ let byText = heads.filter((h) => h.text === want);
1303
+ if (!byText.length) {
1304
+ const lc = want.toLocaleLowerCase();
1305
+ byText = heads.filter((h) => h.text.toLocaleLowerCase() === lc);
1306
+ }
1307
+ if (byText.length === 1)
1308
+ return byText[0].id;
1309
+ // 4. shared text: the level disambiguates, else show the candidates.
1310
+ if (byText.length > 1) {
1311
+ const atLevel = byText.filter((h) => h.level === level);
1312
+ if (atLevel.length === 1)
1313
+ return atLevel[0].id;
1314
+ const list = byText.map((h) => ` #${h.id} (h${h.level})`).join("\n");
1315
+ fail(`\`${want}\` matches ${byText.length} headings — address one by its id:\n${list}`, 1);
1316
+ }
1317
+ // Nothing matched. A lone `#` with no whitespace was almost certainly meant as
1318
+ // an id, so hand it back and let the caller's own `no block with id` error
1319
+ // stand — the precise diagnosis for a typo'd id. Only a heading-SHAPED
1320
+ // selector gets the heading-flavoured message.
1321
+ if (level === 1 && !/\s/.test(bare))
1322
+ return bare;
1323
+ fail(`no id or heading matches \`${raw}\` — run \`geml get ${file === "-" ? "-" : file}\` to list every addressable id`, 1);
1324
+ }
1239
1325
  // `geml get <file>` with no id: list every addressable id — the document's
1240
1326
  // table of contents. Default output is one id per line with its kind (and, for
1241
1327
  // a heading, its level and text); `--json` is a machine-readable array so an
@@ -1285,6 +1371,61 @@ function listIds(source, file, json) {
1285
1371
  // content: a block/footnote id prints its document-model node; a heading id
1286
1372
  // prints a section envelope `{kind:"section", id, level, blocks:[heading,
1287
1373
  // …siblings up to the boundary]}`.
1374
+ // `geml get <file> '=== <type>'` — address a block by its TYPE. One match is
1375
+ // the block itself; several are LISTED with their line ranges rather than
1376
+ // guessed between, so a document with three notes answers "which one" instead
1377
+ // of failing. The uniqueness that makes `=== meta` work is checked here, at
1378
+ // resolve time — nothing in the format has to promise a document holds only one.
1379
+ function getByType(source, file, type, json, headOnly) {
1380
+ const where = file === "-" ? "stdin" : file;
1381
+ const matches = blockTypeSpans(source).get(type) ?? [];
1382
+ if (!matches.length) {
1383
+ fail(`no \`${type}\` block in ${where} — run \`geml get ${where}\` to list every addressable id`, 1);
1384
+ }
1385
+ if (matches.length === 1) {
1386
+ const m = matches[0];
1387
+ if (json) {
1388
+ // The ONLY block of its type: locating it in the model needs no index, so
1389
+ // --json can still answer with the parsed node (meta's key/values, a
1390
+ // table's model) rather than a mere location.
1391
+ const node = onlyBlockOfType(parse(source, { resolveDoc: resolverFor(file) }).children, type);
1392
+ if (node) {
1393
+ console.log(JSON.stringify(node, null, 2));
1394
+ return;
1395
+ }
1396
+ }
1397
+ const span = headOnly ? narrowToHead(m.span) : m.span;
1398
+ process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
1399
+ return;
1400
+ }
1401
+ // Several: report WHERE they are (data on stdout, the explanation on stderr),
1402
+ // so the caller can name one — by adding an #id, or via its section.
1403
+ if (json) {
1404
+ console.log(JSON.stringify({ kind: "blocks", type, matches: matches.map((m) => ({ ...(m.id ? { id: m.id } : {}), lines: [m.span.start + 1, m.span.end] })) }, null, 2));
1405
+ return;
1406
+ }
1407
+ console.error(`${matches.length} \`${type}\` blocks in ${where} — give one an #id, or address its section:`);
1408
+ for (const m of matches) {
1409
+ console.log(`=== ${type}${m.id ? ` {#${m.id}}` : ""} L${m.span.start + 1}-${m.span.end}`);
1410
+ }
1411
+ }
1412
+ // The single block of `type` in a document, or undefined when there is not
1413
+ // exactly one (nested flow children included, matching the span scan's reach).
1414
+ function onlyBlockOfType(blocks, type) {
1415
+ const hits = [];
1416
+ const walk = (list) => {
1417
+ for (const b of list) {
1418
+ if (b.kind === "block") {
1419
+ if (b.type === type)
1420
+ hits.push(b);
1421
+ if (b.children)
1422
+ walk(b.children);
1423
+ }
1424
+ }
1425
+ };
1426
+ walk(blocks);
1427
+ return hits.length === 1 ? hits[0] : undefined;
1428
+ }
1288
1429
  function runGet(args) {
1289
1430
  const json = args.includes("--json");
1290
1431
  const headOnly = args.includes("--head");
@@ -1293,12 +1434,23 @@ function runGet(args) {
1293
1434
  fail(SUBHELP.get);
1294
1435
  // No id: list every addressable id — the document's "table of contents", so
1295
1436
  // an agent can discover what `get #id` can target without pulling the model.
1437
+ // One read: stdin can only be consumed once, and the selector resolver needs
1438
+ // the same bytes the slice below works on.
1439
+ const source = readInput(file);
1296
1440
  if (!rawId) {
1297
- listIds(readInput(file), file, json);
1441
+ listIds(source, file, json);
1298
1442
  return;
1299
1443
  }
1300
- const id = rawId.replace(/^#/, "");
1301
- const source = readInput(file);
1444
+ // A FENCE line as the selector (`=== meta`): the same "copy the line out of
1445
+ // the document" move as a heading line, for the blocks that carry no id.
1446
+ // A pasted fence that DOES declare an id defers to the id path below.
1447
+ const fence = /^={3,}[ \t]*([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/.exec(rawId.trim());
1448
+ const fenceId = fence?.[2] ? parseAttrs(fence[2]).id : undefined;
1449
+ if (fence && fenceId === undefined) {
1450
+ getByType(source, file, fence[1], json, headOnly);
1451
+ return;
1452
+ }
1453
+ const id = fenceId ?? resolveSelector(source, file, rawId);
1302
1454
  if (json) {
1303
1455
  // The model node(s) — same shapes `geml <file>` emits. Parsing is needed
1304
1456
  // to resolve the tree (and nested-block ids), but only the target prints.
@@ -2017,9 +2169,15 @@ function runCodemap(args) {
2017
2169
  serve: "serve.mjs",
2018
2170
  refresh: "refresh.mjs",
2019
2171
  find: "find.mjs",
2020
- mcp: "mcp-server.mjs",
2021
2172
  };
2022
2173
  const sub = args[0] ?? "";
2174
+ // `codemap mcp` was a second stdio server over the same repository. It is
2175
+ // gone, not renamed, so name the replacement instead of letting it fall into
2176
+ // `unknown codemap subcommand`: this string is what an operator sees in a
2177
+ // client's server log when the entry they registered stops starting.
2178
+ if (sub === "mcp") {
2179
+ fail("geml codemap mcp was removed: use `geml mcp --root <dir>`, which serves the three code-graph tools alongside the document tools (graph: <root>/.geml-code-graph, or --graph <dir>).");
2180
+ }
2023
2181
  const script = scripts[sub];
2024
2182
  if (!script)
2025
2183
  fail(`unknown codemap subcommand '${sub}'.\n${SUBHELP.codemap}`);
@@ -2027,10 +2185,11 @@ function runCodemap(args) {
2027
2185
  const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
2028
2186
  process.exit(r.status ?? 1);
2029
2187
  }
2030
- // geml mcp: the document-CRUD MCP server. Runs as a child's MAIN module for the
2031
- // same reason `codemap mcp` does it owns stdin/stdout for the whole session
2032
- // (the stdio transport), and dispatching by spawn keeps this module free of a
2033
- // runtime import cycle (mcp.js imports the parser from here).
2188
+ // geml mcp: the MCP server document CRUD, plus the code-graph tools when the
2189
+ // root holds a graph. It runs as a child's MAIN module because it owns
2190
+ // stdin/stdout for the whole session (the stdio transport), and dispatching by
2191
+ // spawn keeps this module free of a runtime import cycle (mcp.js imports the
2192
+ // parser from here).
2034
2193
  function runMcp(args) {
2035
2194
  const mod = join(dirname(fileURLToPath(import.meta.url)), "mcp.js");
2036
2195
  const r = spawnSync(process.execPath, [mod, ...args], { stdio: "inherit" });
package/dist/mcp.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  export interface McpOptions {
3
3
  root: string;
4
4
  history: boolean;
5
+ graph?: string;
5
6
  }
6
7
  /** Configure the server. Exported so the suite can point it at a temp dir. */
7
8
  export declare function configure(o: Partial<McpOptions>): McpOptions;
@@ -13,6 +14,13 @@ export interface Tool {
13
14
  run: (args: Record<string, any>) => unknown;
14
15
  }
15
16
  export declare const TOOLS: Tool[];
17
+ /** Tools served right now: the ten document tools, plus the graph tools when a graph is configured. */
18
+ export declare function allTools(): Tool[];
19
+ /**
20
+ * Load and confine the code-graph tools. Idempotent; awaited at startup and by
21
+ * the suite, which drives `handleLine` in-process.
22
+ */
23
+ export declare function loadGraphTools(): Promise<Tool[]>;
16
24
  export declare function handleLine(line: string, write?: (s: string) => void): void;
17
- export declare const MCP_USAGE = "usage: geml mcp --root <dir> [--no-history]\n\n Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).\n\n --root <dir> REQUIRED. Root directory holding the .geml documents.\n Relative paths resolve against the server process's CWD,\n which the CLIENT chooses \u2014 pass an absolute path.\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 -- geml mcp --root /abs/path/to/docs";
25
+ export declare const MCP_USAGE = "usage: geml mcp --root <dir> [--graph <dir>] [--no-history]\n\n Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0), plus the\n read-only code-graph tools when the root holds a code graph.\n\n --root <dir> REQUIRED. Root directory holding the .geml documents.\n Relative paths resolve against the server process's CWD,\n which the CLIENT chooses \u2014 pass an absolute path.\n Every path a client names is confined to this directory;\n a client cannot widen or override it.\n --graph <dir> Code-graph directory, inside --root. Defaults to\n <root>/.geml-code-graph when that holds an index.geml.\n With no graph, the code-graph tools are not served\n at all (a client sees only the document tools).\n --no-history Do not auto-commit a .gemlhistory revision before each\n write. Default is to commit, so geml_revert always\n has a revision to undo to.\n\n Register with a client:\n claude mcp add geml -- geml mcp --root /abs/path/to/repo";
18
26
  export declare function parseArgs(args: string[]): McpOptions;
package/dist/mcp.js CHANGED
@@ -1,13 +1,17 @@
1
1
  #!/usr/bin/env node
2
- // `geml mcp` — MCP server for GEML document CRUD.
2
+ // `geml mcp` — MCP server for GEML documents and the code graph.
3
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).
4
+ // Ten tools over a confined root directory of `.geml` documents: five read-only,
5
+ // five that write, each named after the CLI verb it wraps (`geml set` ->
6
+ // `geml_set`, the bare transform entry -> `geml_to`). When that root holds a code graph, the four read-only
7
+ // code-graph tools from `codemap/mcp-server.mjs` are served from this SAME
8
+ // process, so a client registers one server instead of two. That file stays a
9
+ // standalone `geml codemap mcp` entry point; this one imports its tool table
10
+ // rather than copying it, which is cheap because the two were deliberately
11
+ // built to the same shape (newline-delimited JSON-RPC 2.0 over stdio, zero
12
+ // dependencies, an exported `handleLine` so the suite can drive it in-process).
9
13
  //
10
- // claude mcp add geml -- geml mcp --root /abs/path/to/docs
14
+ // claude mcp add geml -- geml mcp --root /abs/path/to/repo
11
15
  //
12
16
  // Three invariants make this worth more than letting a model `str_replace` the
13
17
  // file itself:
@@ -17,11 +21,16 @@
17
21
  // is only overwritten when the result is clean. A bad generation is
18
22
  // refused with the diagnostics that refused it — it does not land and
19
23
  // then wait for a human to notice.
20
- // 2. EVERY WRITE IS PRECEDED BY A HISTORY COMMIT, so `geml_revert_block` can
24
+ // 2. EVERY WRITE IS PRECEDED BY A HISTORY COMMIT, so `geml_revert` can
21
25
  // always undo the block that was just touched. Without this the strongest
22
26
  // tool in the set would have nothing to revert to.
23
27
  // 3. EVERY PATH IS CONFINED to a server-side `--root` directory the client
24
- // cannot override or widen.
28
+ // cannot override or widen. This is where the two servers disagreed, and
29
+ // merging had to pick one: standalone `codemap mcp` lets the client name
30
+ // `graph_dir` per call (it is pointed AT a graph and only reads). Here the
31
+ // same process can write, so a client-named directory is narrowed to the
32
+ // server root like every other path — a read-anywhere argument does not
33
+ // belong on a server that also writes.
25
34
  //
26
35
  // The mutations run through the CLI rather than re-implementing block editing:
27
36
  // the tool table is *defined* as CLI equivalences, and `-o -` already yields
@@ -74,24 +83,40 @@ export function resolveInRoot(file) {
74
83
  throw new Error(`not a file: ${file}`);
75
84
  return real;
76
85
  }
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) {
86
+ // A client-named directory may only NARROW to one inside the server root it
87
+ // can never widen or escape it. `label` names the argument in the error so the
88
+ // model can tell which of its arguments was refused.
89
+ function narrowToRoot(dir, label) {
80
90
  const serverRoot = realpathSync(OPTS.root);
81
- if (root === undefined || root === "")
82
- return serverRoot;
83
- const target = resolve(serverRoot, root);
91
+ const target = resolve(serverRoot, dir);
84
92
  let real;
85
93
  try {
86
94
  real = realpathSync(target);
87
95
  }
88
96
  catch {
89
- throw new Error(`no such directory under the server root: ${root}`);
97
+ throw new Error(`no such directory under the server root: ${dir}`);
90
98
  }
91
99
  if (real !== serverRoot && !real.startsWith(serverRoot + sep))
92
- throw new Error(`root escapes the server root: ${root}`);
100
+ throw new Error(`${label} escapes the server root: ${dir}`);
93
101
  return real;
94
102
  }
103
+ // Cross-document references resolve against the SERVER root, never against
104
+ // a client-named directory.
105
+ function resolveRoot(root) {
106
+ if (root === undefined || root === "")
107
+ return realpathSync(OPTS.root);
108
+ return narrowToRoot(root, "root");
109
+ }
110
+ // The code-graph directory for one call: the server's `--graph` unless the
111
+ // client named one, and a client-named one is narrowed like any other path.
112
+ function resolveGraphDir(graphDir) {
113
+ if (graphDir === undefined || graphDir === "") {
114
+ if (!OPTS.graph)
115
+ throw new Error("this server has no code graph; start it with --graph <dir> under --root");
116
+ return OPTS.graph;
117
+ }
118
+ return narrowToRoot(String(graphDir), "graph_dir");
119
+ }
95
120
  // ---------------------------------------------------------------------------
96
121
  // Driving the CLI
97
122
  // ---------------------------------------------------------------------------
@@ -214,7 +239,7 @@ const FILE_ARG = { type: "string", description: "Document path relative to the s
214
239
  export const TOOLS = [
215
240
  // ----- read -----
216
241
  {
217
- name: "geml_list_ids",
242
+ name: "geml_list",
218
243
  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
244
  inputSchema: { type: "object", properties: { file: FILE_ARG }, required: ["file"] },
220
245
  run: (args) => {
@@ -226,8 +251,8 @@ export const TOOLS = [
226
251
  },
227
252
  },
228
253
  {
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.",
254
+ name: "geml_get",
255
+ 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` first. Reading the whole file to change one block wastes context and risks modifying unrelated content.",
231
256
  inputSchema: {
232
257
  type: "object",
233
258
  properties: {
@@ -270,8 +295,8 @@ export const TOOLS = [
270
295
  },
271
296
  },
272
297
  {
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.",
298
+ name: "geml_history",
299
+ description: "List the recorded revisions of a document, newest first. Each entry's `offset` is the selector `geml_revert` 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
300
  inputSchema: { type: "object", properties: { file: FILE_ARG }, required: ["file"] },
276
301
  run: (args) => {
277
302
  const real = resolveInRoot(args.file);
@@ -281,9 +306,54 @@ export const TOOLS = [
281
306
  return { file: args.file, revisions: listRevisions(historyPath) };
282
307
  },
283
308
  },
309
+ {
310
+ name: "geml_to",
311
+ description: "Convert a WHOLE document and get the result back as text — the read half of the CLI's `geml <file> --to <fmt>`. `to: \"geml\"` on a Markdown file is the importer, the one thing the block tools cannot do; `to: \"md\"` projects a GEML document out (lossy); `to: \"json\"` returns the full document model, for when geml_list plus geml_get is not enough. Nothing is written — pass the result to geml_add or geml_set to land it. `to: \"html\"` also works but returns a whole self-contained page, usually tens of kilobytes this server cannot save for you: prefer the CLI (`geml <file> --to html -o out.html`) unless you really want the markup in the conversation.",
312
+ inputSchema: {
313
+ type: "object",
314
+ properties: {
315
+ file: FILE_ARG,
316
+ to: {
317
+ type: "string",
318
+ enum: ["json", "md", "geml", "html"],
319
+ description: "Target format. Default is the CLI's: a GEML input becomes json, a Markdown input becomes geml. `html` is a whole page — large, and not writable from here.",
320
+ },
321
+ from: {
322
+ type: "string",
323
+ enum: ["geml", "md", "json"],
324
+ description: "Override the input format, which is otherwise inferred from the extension (.md -> md, .json -> json, else geml).",
325
+ },
326
+ },
327
+ required: ["file"],
328
+ },
329
+ run: (args) => {
330
+ const real = resolveInRoot(args.file);
331
+ // Enforce the enums here too: a client is free to ignore the schema, and a
332
+ // typo'd format should come back as this server's clear error rather than
333
+ // whatever the CLI makes of it.
334
+ const to = args.to === undefined ? undefined : String(args.to);
335
+ const from = args.from === undefined ? undefined : String(args.from);
336
+ if (to !== undefined && !["json", "md", "geml", "html"].includes(to))
337
+ throw new Error(`unknown \`to\` format: ${to} (want json | md | geml | html)`);
338
+ if (from !== undefined && !["geml", "md", "json"].includes(from))
339
+ throw new Error(`unknown \`from\` format: ${from} (want geml | md | json)`);
340
+ const argv = [real];
341
+ if (to !== undefined)
342
+ argv.push("--to", to);
343
+ if (from !== undefined)
344
+ argv.push("--from", from);
345
+ const run = runCli(argv);
346
+ // The transform exits 1 on a document with errors but still prints the
347
+ // result; surface the diagnostics rather than the text in that case, so a
348
+ // model is never handed the output of a document it was told nothing about.
349
+ if (!run.ok)
350
+ throw new Error(run.stderr || `could not convert ${args.file}`);
351
+ return run.stdout;
352
+ },
353
+ },
284
354
  // ----- write -----
285
355
  {
286
- name: "geml_write_block",
356
+ name: "geml_set",
287
357
  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
358
  inputSchema: {
289
359
  type: "object",
@@ -310,7 +380,7 @@ export const TOOLS = [
310
380
  },
311
381
  },
312
382
  {
313
- name: "geml_add_block",
383
+ name: "geml_add",
314
384
  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
385
  inputSchema: {
316
386
  type: "object",
@@ -343,7 +413,7 @@ export const TOOLS = [
343
413
  },
344
414
  },
345
415
  {
346
- name: "geml_delete_block",
416
+ name: "geml_delete",
347
417
  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
418
  inputSchema: {
349
419
  type: "object",
@@ -367,7 +437,7 @@ export const TOOLS = [
367
437
  },
368
438
  },
369
439
  {
370
- name: "geml_rename_id",
440
+ name: "geml_rename",
371
441
  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
442
  inputSchema: {
373
443
  type: "object",
@@ -388,8 +458,8 @@ export const TOOLS = [
388
458
  },
389
459
  },
390
460
  {
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.",
461
+ name: "geml_revert",
462
+ 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`. Reverting across a revision where the block was deleted restores it; across one where it did not exist removes it.",
393
463
  inputSchema: {
394
464
  type: "object",
395
465
  properties: {
@@ -417,6 +487,68 @@ export const TOOLS = [
417
487
  },
418
488
  ];
419
489
  // ---------------------------------------------------------------------------
490
+ // Code-graph tools, imported from the standalone server
491
+ // ---------------------------------------------------------------------------
492
+ // The four read-only code-graph tools, re-served here with this
493
+ // server's confinement. Empty until `loadGraphTools()` runs — the import is
494
+ // dynamic because `codemap/mcp-server.mjs` is a plain .mjs script that itself
495
+ // top-level-awaits the parser, and because a server started without a graph
496
+ // should not pay for loading it at all.
497
+ let GRAPH_TOOLS = [];
498
+ /** Tools served right now: the ten document tools, plus the graph tools when a graph is configured. */
499
+ export function allTools() {
500
+ return OPTS.graph ? [...TOOLS, ...GRAPH_TOOLS] : TOOLS;
501
+ }
502
+ // The upstream `graph_dir` description advertises `$GEML_GRAPH_DIR or
503
+ // ./.geml-code-graph`, neither of which applies here — the env var is bypassed
504
+ // (we always pass a resolved directory) and the default is this server's
505
+ // --graph. A tool description that names something the server will refuse is
506
+ // the exact failure `eb7390a` fixed for `latest`, so rewrite it rather than
507
+ // re-serve it.
508
+ function confineSchema(schema) {
509
+ const props = schema?.properties;
510
+ if (!props?.graph_dir)
511
+ return schema;
512
+ return {
513
+ ...schema,
514
+ properties: {
515
+ ...props,
516
+ graph_dir: {
517
+ type: "string",
518
+ description: "Code-graph directory, relative to the server's --root (defaults to the server's --graph). Paths outside --root are refused.",
519
+ },
520
+ },
521
+ };
522
+ }
523
+ /**
524
+ * Load and confine the code-graph tools. Idempotent; awaited at startup and by
525
+ * the suite, which drives `handleLine` in-process.
526
+ */
527
+ export async function loadGraphTools() {
528
+ if (GRAPH_TOOLS.length)
529
+ return GRAPH_TOOLS;
530
+ // Non-literal specifier on purpose: this resolves at RUNTIME from dist/ to
531
+ // the sibling codemap/ directory (both are shipped), and it keeps tsc from
532
+ // demanding types for an untyped .mjs script.
533
+ const spec = new URL("../codemap/mcp-server.mjs", import.meta.url).href;
534
+ const mod = await import(spec);
535
+ // `geml_codemap_node(source: true)` reads the real sources, and WHERE those
536
+ // are comes from `_index/refresh.json` inside the graph — data this server
537
+ // did not choose. Bound it to --root like every other path, so a hand-edited
538
+ // recipe cannot point the reader out of the tree the operator opened.
539
+ mod.confineSourceTo(OPTS.root);
540
+ GRAPH_TOOLS = mod.TOOLS.map((t) => ({
541
+ name: t.name,
542
+ description: t.description,
543
+ inputSchema: confineSchema(t.inputSchema),
544
+ // Resolve the directory HERE, then hand the tool an absolute path: its own
545
+ // `graphDirOf` prefers an explicit `graph_dir`, so this shuts out both the
546
+ // env var and the relative default without touching that file.
547
+ run: (args) => t.run({ ...args, graph_dir: resolveGraphDir(args.graph_dir) }),
548
+ }));
549
+ return GRAPH_TOOLS;
550
+ }
551
+ // ---------------------------------------------------------------------------
420
552
  // newline-delimited JSON-RPC 2.0 over stdio
421
553
  // ---------------------------------------------------------------------------
422
554
  export function handleLine(line, write = (s) => process.stdout.write(s)) {
@@ -448,10 +580,10 @@ export function handleLine(line, write = (s) => process.stdout.write(s)) {
448
580
  reply(id, {});
449
581
  }
450
582
  else if (method === "tools/list") {
451
- reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
583
+ reply(id, { tools: allTools().map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
452
584
  }
453
585
  else if (method === "tools/call") {
454
- const tool = TOOLS.find((t) => t.name === params?.name);
586
+ const tool = allTools().find((t) => t.name === params?.name);
455
587
  if (!tool) {
456
588
  replyError(id, -32602, `unknown tool: ${params?.name}`);
457
589
  return;
@@ -479,23 +611,29 @@ export function handleLine(line, write = (s) => process.stdout.write(s)) {
479
611
  // ---------------------------------------------------------------------------
480
612
  // Entry
481
613
  // ---------------------------------------------------------------------------
482
- export const MCP_USAGE = `usage: geml mcp --root <dir> [--no-history]
614
+ export const MCP_USAGE = `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
483
615
 
484
- Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
616
+ Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0), plus the
617
+ read-only code-graph tools when the root holds a code graph.
485
618
 
486
619
  --root <dir> REQUIRED. Root directory holding the .geml documents.
487
620
  Relative paths resolve against the server process's CWD,
488
621
  which the CLIENT chooses — pass an absolute path.
489
622
  Every path a client names is confined to this directory;
490
623
  a client cannot widen or override it.
624
+ --graph <dir> Code-graph directory, inside --root. Defaults to
625
+ <root>/.geml-code-graph when that holds an index.geml.
626
+ With no graph, the code-graph tools are not served
627
+ at all (a client sees only the document tools).
491
628
  --no-history Do not auto-commit a .gemlhistory revision before each
492
- write. Default is to commit, so geml_revert_block always
629
+ write. Default is to commit, so geml_revert always
493
630
  has a revision to undo to.
494
631
 
495
632
  Register with a client:
496
- claude mcp add geml -- geml mcp --root /abs/path/to/docs`;
633
+ claude mcp add geml -- geml mcp --root /abs/path/to/repo`;
497
634
  export function parseArgs(args) {
498
635
  let root;
636
+ let graph;
499
637
  let history = true;
500
638
  for (let i = 0; i < args.length; i++) {
501
639
  const a = args[i];
@@ -503,6 +641,10 @@ export function parseArgs(args) {
503
641
  root = args[++i];
504
642
  else if (a.startsWith("--root="))
505
643
  root = a.slice("--root=".length);
644
+ else if (a === "--graph")
645
+ graph = args[++i];
646
+ else if (a.startsWith("--graph="))
647
+ graph = a.slice("--graph=".length);
506
648
  else if (a === "--no-history")
507
649
  history = false;
508
650
  // The flag used to be --workspace/-w. Name the replacement instead of
@@ -522,7 +664,26 @@ export function parseArgs(args) {
522
664
  const abs = resolve(root);
523
665
  if (!existsSync(abs) || !statSync(abs).isDirectory())
524
666
  throw new Error(`--root is not a directory: ${root}`);
525
- return { root: realpathSync(abs), history };
667
+ const realRoot = realpathSync(abs);
668
+ return { root: realRoot, history, graph: resolveGraphOpt(realRoot, graph) };
669
+ }
670
+ // An EXPLICIT --graph is trusted to be a graph (the operator said so) and only
671
+ // has to exist inside the root — failing fast beats starting a server whose
672
+ // graph tools all error. The IMPLICIT default has to be sure it found one, so
673
+ // it requires an index.geml: an unrelated `.geml-code-graph` directory must not
674
+ // make three broken tools appear.
675
+ function resolveGraphOpt(realRoot, graph) {
676
+ if (graph === undefined || graph === "") {
677
+ const guess = resolve(realRoot, ".geml-code-graph");
678
+ return existsSync(resolve(guess, "index.geml")) ? realpathSync(guess) : undefined;
679
+ }
680
+ const abs = resolve(realRoot, graph);
681
+ if (!existsSync(abs) || !statSync(abs).isDirectory())
682
+ throw new Error(`--graph is not a directory: ${graph}`);
683
+ const real = realpathSync(abs);
684
+ if (real !== realRoot && !real.startsWith(realRoot + sep))
685
+ throw new Error(`--graph must live inside --root: ${graph}`);
686
+ return real;
526
687
  }
527
688
  // Auto-run only as a MAIN module: the CLI dispatcher spawns this file as a
528
689
  // child's entry script, while an in-process `import` (the test suite) stays inert.
@@ -539,5 +700,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
539
700
  console.error(`geml mcp: ${e.message}\n\n${MCP_USAGE}`);
540
701
  process.exit(2);
541
702
  }
703
+ // Load the graph tools BEFORE the first frame can arrive: `tools/list` is
704
+ // synchronous, so a client that lists during the load would be told the
705
+ // server has no code graph and would never ask again.
706
+ if (OPTS.graph)
707
+ await loadGraphTools();
542
708
  createInterface({ input: process.stdin }).on("line", (line) => handleLine(line));
543
709
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geml/geml",
3
- "version": "1.4.4",
3
+ "version": "1.4.5",
4
4
  "mcpName": "io.github.geml-spec/geml",
5
5
  "publishConfig": {
6
6
  "access": "public"