@geml/geml 1.4.4 → 1.4.6
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 +32 -1
- package/codemap/find.mjs +9 -23
- package/codemap/mcp-server.mjs +319 -60
- package/dist/geml.js +185 -23
- package/dist/mcp.d.ts +9 -1
- package/dist/mcp.js +201 -35
- package/package.json +5 -2
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, ~**
|
|
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 `
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|
package/codemap/mcp-server.mjs
CHANGED
|
@@ -1,24 +1,41 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// geml-code-graph MCP
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
21
|
-
|
|
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: "
|
|
60
|
-
description:
|
|
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
|
-
|
|
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: ["
|
|
229
|
+
required: ["query"],
|
|
68
230
|
},
|
|
69
231
|
run: (args) => {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
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: "
|
|
80
|
-
description:
|
|
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: "
|
|
85
|
-
id: { type: "string", description: "
|
|
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) =>
|
|
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: "
|
|
94
|
-
description:
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
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,12 @@ 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
|
|
813
|
-
geml mcp --root <dir> [--no-history]
|
|
814
|
-
(
|
|
815
|
-
every write is validated before it
|
|
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)
|
|
829
|
+
(10 tools, each geml_ + its CLI verb: list/get/check/history/to +
|
|
830
|
+
set/add/delete/rename/revert; every write is validated before it
|
|
831
|
+
reaches disk. A code graph under --root adds four read-only
|
|
832
|
+
geml_codemap_* tools to the same server)
|
|
816
833
|
geml --help | --version [--json]
|
|
817
834
|
|
|
818
835
|
Use '-' as the file to read from stdin.
|
|
@@ -826,7 +843,7 @@ Exit codes:
|
|
|
826
843
|
// One-line usage for each subcommand — the single source for both the error
|
|
827
844
|
// shown on misuse and the `<cmd> --help` text.
|
|
828
845
|
const SUBHELP = {
|
|
829
|
-
get: "usage: geml get <file.geml|-> [#id] [--json] [--head] (
|
|
846
|
+
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
847
|
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
848
|
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
849
|
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 +858,29 @@ const SUBHELP = {
|
|
|
841
858
|
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
859
|
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
860
|
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
861
|
(<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]
|
|
862
|
+
mcp: `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
|
|
847
863
|
|
|
848
864
|
Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
865
|
+
Every tool is geml_ + its CLI verb, so the terminal and the assistant share
|
|
866
|
+
one vocabulary.
|
|
867
|
+
Ten tools: geml_list · geml_get · geml_check · geml_history · geml_to
|
|
868
|
+
geml_set · geml_add · geml_delete · geml_rename · geml_revert
|
|
869
|
+
With a code graph under --root, four more (read-only), so one client entry
|
|
870
|
+
covers both: geml_codemap_search · geml_codemap_callchain
|
|
871
|
+
geml_codemap_list · geml_codemap_node
|
|
852
872
|
|
|
853
873
|
--root <dir> REQUIRED. Root holding the .geml documents. Every path a
|
|
854
874
|
client names is confined here; a client cannot widen it.
|
|
875
|
+
--graph <dir> Code-graph directory, inside --root. Defaults to
|
|
876
|
+
<root>/.geml-code-graph when it holds an index.geml; with
|
|
877
|
+
no graph the four graph tools are not served at all.
|
|
855
878
|
--no-history Skip the .gemlhistory commit taken before each write
|
|
856
|
-
(default: commit, so
|
|
857
|
-
|
|
879
|
+
(default: commit, so geml_revert always has a revision to
|
|
880
|
+
undo to).
|
|
858
881
|
|
|
859
882
|
Register with a client:
|
|
860
|
-
claude mcp add geml -- geml mcp --root /abs/path/to/
|
|
883
|
+
claude mcp add geml -- geml mcp --root /abs/path/to/repo`,
|
|
861
884
|
};
|
|
862
885
|
// Set from argv at dispatch time; when true, errors are emitted as a JSON
|
|
863
886
|
// envelope so an agent that standardizes on --json never has to parse text.
|
|
@@ -1236,6 +1259,72 @@ function positionals(args, valued) {
|
|
|
1236
1259
|
}
|
|
1237
1260
|
return out;
|
|
1238
1261
|
}
|
|
1262
|
+
// Resolve a block SELECTOR to an id. Three spellings address the same block:
|
|
1263
|
+
//
|
|
1264
|
+
// `#intro` / `intro` the id — the CANONICAL address
|
|
1265
|
+
// `## Getting Started` the heading LINE, copied out of the document
|
|
1266
|
+
// `##Getting Started` …the space after the `#` run is optional
|
|
1267
|
+
//
|
|
1268
|
+
// Why more than one form: the id is what `[[#id]]` references, codemap tables
|
|
1269
|
+
// and URL fragments (§0.6) all carry, so it must stay accepted verbatim — an id
|
|
1270
|
+
// copied out of a reference or out of `geml get <file>` has to work. But a
|
|
1271
|
+
// heading's id is AUTO-DERIVED from its text (`## API 设计 (v1)` → `#api-设计-v1`),
|
|
1272
|
+
// and nobody can be expected to hand-derive that slug for a heading they can
|
|
1273
|
+
// read on screen. So the heading line itself is accepted too.
|
|
1274
|
+
//
|
|
1275
|
+
// Resolution order, first match wins:
|
|
1276
|
+
// 1. the id, exactly — a pasted id is NEVER reinterpreted as prose. (When a
|
|
1277
|
+
// heading's TEXT happens to equal another block's ID, the id wins.)
|
|
1278
|
+
// 2. the exact heading LINE: `#` count AND text both match.
|
|
1279
|
+
// 3. the text alone, at any level — a heading remembered at the wrong depth
|
|
1280
|
+
// still resolves while its text is unique.
|
|
1281
|
+
// 4. text shared by several headings: the `#` count picks one, or the
|
|
1282
|
+
// candidates are listed. Never guessed at.
|
|
1283
|
+
function resolveSelector(source, file, raw) {
|
|
1284
|
+
const bare = raw.replace(/^#/, "");
|
|
1285
|
+
const m = /^(#{1,6})[ \t]*(.+?)[ \t]*$/.exec(raw);
|
|
1286
|
+
if (!m)
|
|
1287
|
+
return bare; // not a `#`-run form: an id, verbatim
|
|
1288
|
+
// 1. The id is canonical and always wins. Checked without a parse, so the
|
|
1289
|
+
// common `get #id` stays a byte-slice on a document with diagnostics.
|
|
1290
|
+
if (blockSpans(source).has(bare))
|
|
1291
|
+
return bare;
|
|
1292
|
+
const level = m[1].length;
|
|
1293
|
+
const want = m[2];
|
|
1294
|
+
const doc = parse(source, { resolveDoc: resolverFor(file) });
|
|
1295
|
+
const heads = doc.ids.flatMap((id) => {
|
|
1296
|
+
const site = findBlockSite(doc.children, id);
|
|
1297
|
+
const b = site?.siblings[site.index];
|
|
1298
|
+
return b?.kind === "heading" ? [{ id, level: b.level, text: b.text.trim() }] : [];
|
|
1299
|
+
});
|
|
1300
|
+
// 2. exact line — what the caller actually typed.
|
|
1301
|
+
const line = heads.find((h) => h.level === level && h.text === want);
|
|
1302
|
+
if (line)
|
|
1303
|
+
return line.id;
|
|
1304
|
+
// 3. the text alone (exact, then case-insensitive).
|
|
1305
|
+
let byText = heads.filter((h) => h.text === want);
|
|
1306
|
+
if (!byText.length) {
|
|
1307
|
+
const lc = want.toLocaleLowerCase();
|
|
1308
|
+
byText = heads.filter((h) => h.text.toLocaleLowerCase() === lc);
|
|
1309
|
+
}
|
|
1310
|
+
if (byText.length === 1)
|
|
1311
|
+
return byText[0].id;
|
|
1312
|
+
// 4. shared text: the level disambiguates, else show the candidates.
|
|
1313
|
+
if (byText.length > 1) {
|
|
1314
|
+
const atLevel = byText.filter((h) => h.level === level);
|
|
1315
|
+
if (atLevel.length === 1)
|
|
1316
|
+
return atLevel[0].id;
|
|
1317
|
+
const list = byText.map((h) => ` #${h.id} (h${h.level})`).join("\n");
|
|
1318
|
+
fail(`\`${want}\` matches ${byText.length} headings — address one by its id:\n${list}`, 1);
|
|
1319
|
+
}
|
|
1320
|
+
// Nothing matched. A lone `#` with no whitespace was almost certainly meant as
|
|
1321
|
+
// an id, so hand it back and let the caller's own `no block with id` error
|
|
1322
|
+
// stand — the precise diagnosis for a typo'd id. Only a heading-SHAPED
|
|
1323
|
+
// selector gets the heading-flavoured message.
|
|
1324
|
+
if (level === 1 && !/\s/.test(bare))
|
|
1325
|
+
return bare;
|
|
1326
|
+
fail(`no id or heading matches \`${raw}\` — run \`geml get ${file === "-" ? "-" : file}\` to list every addressable id`, 1);
|
|
1327
|
+
}
|
|
1239
1328
|
// `geml get <file>` with no id: list every addressable id — the document's
|
|
1240
1329
|
// table of contents. Default output is one id per line with its kind (and, for
|
|
1241
1330
|
// a heading, its level and text); `--json` is a machine-readable array so an
|
|
@@ -1285,6 +1374,61 @@ function listIds(source, file, json) {
|
|
|
1285
1374
|
// content: a block/footnote id prints its document-model node; a heading id
|
|
1286
1375
|
// prints a section envelope `{kind:"section", id, level, blocks:[heading,
|
|
1287
1376
|
// …siblings up to the boundary]}`.
|
|
1377
|
+
// `geml get <file> '=== <type>'` — address a block by its TYPE. One match is
|
|
1378
|
+
// the block itself; several are LISTED with their line ranges rather than
|
|
1379
|
+
// guessed between, so a document with three notes answers "which one" instead
|
|
1380
|
+
// of failing. The uniqueness that makes `=== meta` work is checked here, at
|
|
1381
|
+
// resolve time — nothing in the format has to promise a document holds only one.
|
|
1382
|
+
function getByType(source, file, type, json, headOnly) {
|
|
1383
|
+
const where = file === "-" ? "stdin" : file;
|
|
1384
|
+
const matches = blockTypeSpans(source).get(type) ?? [];
|
|
1385
|
+
if (!matches.length) {
|
|
1386
|
+
fail(`no \`${type}\` block in ${where} — run \`geml get ${where}\` to list every addressable id`, 1);
|
|
1387
|
+
}
|
|
1388
|
+
if (matches.length === 1) {
|
|
1389
|
+
const m = matches[0];
|
|
1390
|
+
if (json) {
|
|
1391
|
+
// The ONLY block of its type: locating it in the model needs no index, so
|
|
1392
|
+
// --json can still answer with the parsed node (meta's key/values, a
|
|
1393
|
+
// table's model) rather than a mere location.
|
|
1394
|
+
const node = onlyBlockOfType(parse(source, { resolveDoc: resolverFor(file) }).children, type);
|
|
1395
|
+
if (node) {
|
|
1396
|
+
console.log(JSON.stringify(node, null, 2));
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
const span = headOnly ? narrowToHead(m.span) : m.span;
|
|
1401
|
+
process.stdout.write(splitLines(source).slice(span.start, span.end).join(""));
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
// Several: report WHERE they are (data on stdout, the explanation on stderr),
|
|
1405
|
+
// so the caller can name one — by adding an #id, or via its section.
|
|
1406
|
+
if (json) {
|
|
1407
|
+
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));
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
console.error(`${matches.length} \`${type}\` blocks in ${where} — give one an #id, or address its section:`);
|
|
1411
|
+
for (const m of matches) {
|
|
1412
|
+
console.log(`=== ${type}${m.id ? ` {#${m.id}}` : ""} L${m.span.start + 1}-${m.span.end}`);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
// The single block of `type` in a document, or undefined when there is not
|
|
1416
|
+
// exactly one (nested flow children included, matching the span scan's reach).
|
|
1417
|
+
function onlyBlockOfType(blocks, type) {
|
|
1418
|
+
const hits = [];
|
|
1419
|
+
const walk = (list) => {
|
|
1420
|
+
for (const b of list) {
|
|
1421
|
+
if (b.kind === "block") {
|
|
1422
|
+
if (b.type === type)
|
|
1423
|
+
hits.push(b);
|
|
1424
|
+
if (b.children)
|
|
1425
|
+
walk(b.children);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
walk(blocks);
|
|
1430
|
+
return hits.length === 1 ? hits[0] : undefined;
|
|
1431
|
+
}
|
|
1288
1432
|
function runGet(args) {
|
|
1289
1433
|
const json = args.includes("--json");
|
|
1290
1434
|
const headOnly = args.includes("--head");
|
|
@@ -1293,12 +1437,23 @@ function runGet(args) {
|
|
|
1293
1437
|
fail(SUBHELP.get);
|
|
1294
1438
|
// No id: list every addressable id — the document's "table of contents", so
|
|
1295
1439
|
// an agent can discover what `get #id` can target without pulling the model.
|
|
1440
|
+
// One read: stdin can only be consumed once, and the selector resolver needs
|
|
1441
|
+
// the same bytes the slice below works on.
|
|
1442
|
+
const source = readInput(file);
|
|
1296
1443
|
if (!rawId) {
|
|
1297
|
-
listIds(
|
|
1444
|
+
listIds(source, file, json);
|
|
1298
1445
|
return;
|
|
1299
1446
|
}
|
|
1300
|
-
|
|
1301
|
-
|
|
1447
|
+
// A FENCE line as the selector (`=== meta`): the same "copy the line out of
|
|
1448
|
+
// the document" move as a heading line, for the blocks that carry no id.
|
|
1449
|
+
// A pasted fence that DOES declare an id defers to the id path below.
|
|
1450
|
+
const fence = /^={3,}[ \t]*([A-Za-z][A-Za-z0-9_-]*)[ \t]*(\{.*\})?[ \t]*$/.exec(rawId.trim());
|
|
1451
|
+
const fenceId = fence?.[2] ? parseAttrs(fence[2]).id : undefined;
|
|
1452
|
+
if (fence && fenceId === undefined) {
|
|
1453
|
+
getByType(source, file, fence[1], json, headOnly);
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
const id = fenceId ?? resolveSelector(source, file, rawId);
|
|
1302
1457
|
if (json) {
|
|
1303
1458
|
// The model node(s) — same shapes `geml <file>` emits. Parsing is needed
|
|
1304
1459
|
// to resolve the tree (and nested-block ids), but only the target prints.
|
|
@@ -2017,9 +2172,15 @@ function runCodemap(args) {
|
|
|
2017
2172
|
serve: "serve.mjs",
|
|
2018
2173
|
refresh: "refresh.mjs",
|
|
2019
2174
|
find: "find.mjs",
|
|
2020
|
-
mcp: "mcp-server.mjs",
|
|
2021
2175
|
};
|
|
2022
2176
|
const sub = args[0] ?? "";
|
|
2177
|
+
// `codemap mcp` was a second stdio server over the same repository. It is
|
|
2178
|
+
// gone, not renamed, so name the replacement instead of letting it fall into
|
|
2179
|
+
// `unknown codemap subcommand`: this string is what an operator sees in a
|
|
2180
|
+
// client's server log when the entry they registered stops starting.
|
|
2181
|
+
if (sub === "mcp") {
|
|
2182
|
+
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>).");
|
|
2183
|
+
}
|
|
2023
2184
|
const script = scripts[sub];
|
|
2024
2185
|
if (!script)
|
|
2025
2186
|
fail(`unknown codemap subcommand '${sub}'.\n${SUBHELP.codemap}`);
|
|
@@ -2027,10 +2188,11 @@ function runCodemap(args) {
|
|
|
2027
2188
|
const r = spawnSync(process.execPath, [mod, ...args.slice(1)], { stdio: "inherit" });
|
|
2028
2189
|
process.exit(r.status ?? 1);
|
|
2029
2190
|
}
|
|
2030
|
-
// geml mcp: the
|
|
2031
|
-
//
|
|
2032
|
-
// (the stdio transport), and dispatching by
|
|
2033
|
-
// runtime import cycle (mcp.js imports the
|
|
2191
|
+
// geml mcp: the MCP server — document CRUD, plus the code-graph tools when the
|
|
2192
|
+
// root holds a graph. It runs as a child's MAIN module because it owns
|
|
2193
|
+
// stdin/stdout for the whole session (the stdio transport), and dispatching by
|
|
2194
|
+
// spawn keeps this module free of a runtime import cycle (mcp.js imports the
|
|
2195
|
+
// parser from here).
|
|
2034
2196
|
function runMcp(args) {
|
|
2035
2197
|
const mod = join(dirname(fileURLToPath(import.meta.url)), "mcp.js");
|
|
2036
2198
|
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
|
|
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
|
|
2
|
+
// `geml mcp` — MCP server for GEML documents and the code graph.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
// five that write
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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/
|
|
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 `
|
|
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
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
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
|
-
|
|
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: ${
|
|
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(
|
|
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: "
|
|
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: "
|
|
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 `
|
|
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: "
|
|
274
|
-
description: "List the recorded revisions of a document, newest first. Each entry's `offset` is the selector `
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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 `
|
|
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:
|
|
583
|
+
reply(id, { tools: allTools().map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
|
|
452
584
|
}
|
|
453
585
|
else if (method === "tools/call") {
|
|
454
|
-
const tool =
|
|
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
|
|
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/
|
|
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
|
-
|
|
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,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geml/geml",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.6",
|
|
4
4
|
"mcpName": "io.github.geml-spec/geml",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
8
|
-
"description": "
|
|
8
|
+
"description": "CLI and parser for GEML, a plain-text document format where every block has an id — so an AI patches one block instead of rewriting the file; edits, reference checks and rollbacks are all per block. Ships an MCP server.",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"bin": {
|
|
11
11
|
"geml": "dist/geml.js"
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|
|
25
25
|
"geml",
|
|
26
|
+
"general expressive markup language",
|
|
27
|
+
"mcp",
|
|
28
|
+
"mcp-server",
|
|
26
29
|
"markup",
|
|
27
30
|
"markdown",
|
|
28
31
|
"parser",
|