@geml/geml 1.4.3 → 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 +32 -1
- package/codemap/find.mjs +9 -23
- package/codemap/mcp-server.mjs +319 -60
- package/dist/geml.d.ts +1 -0
- package/dist/geml.js +248 -36
- package/dist/mcp.d.ts +11 -3
- package/dist/mcp.js +251 -70
- package/dist/render-html.js +35 -35
- package/package.json +2 -1
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
|
|
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
|
-
// 3. EVERY PATH IS CONFINED to a server-side `--
|
|
24
|
-
// cannot override or widen.
|
|
27
|
+
// 3. EVERY PATH IS CONFINED to a server-side `--root` directory the client
|
|
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
|
|
@@ -32,10 +41,14 @@ import { resolve, dirname, sep } from "node:path";
|
|
|
32
41
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
33
42
|
import { spawnSync } from "node:child_process";
|
|
34
43
|
import { createInterface } from "node:readline";
|
|
35
|
-
import { parse } from "./geml.js";
|
|
44
|
+
import { parse, PARSER_VERSION } from "./geml.js";
|
|
36
45
|
import { commit, listRevisions, isCurrent } from "./history.js";
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
// One version for the whole package: `geml --version` and the MCP handshake
|
|
47
|
+
// must not disagree. This used to be its own literal and had drifted to 0.1.0
|
|
48
|
+
// against a 1.4.x package — invisible to everyone except the user reading their
|
|
49
|
+
// client's server list.
|
|
50
|
+
const SERVER_VERSION = PARSER_VERSION;
|
|
51
|
+
let OPTS = { root: process.cwd(), history: true };
|
|
39
52
|
/** Configure the server. Exported so the suite can point it at a temp dir. */
|
|
40
53
|
export function configure(o) {
|
|
41
54
|
OPTS = { ...OPTS, ...o };
|
|
@@ -45,49 +58,65 @@ export function configure(o) {
|
|
|
45
58
|
// Workspace confinement
|
|
46
59
|
// ---------------------------------------------------------------------------
|
|
47
60
|
// `file` is client-supplied, so `../../../etc/passwd` — or a symlink planted
|
|
48
|
-
// inside the
|
|
61
|
+
// inside the root that points out of it — must not resolve. Canonicalize
|
|
49
62
|
// BOTH sides with realpathSync (which follows every link component) and require
|
|
50
63
|
// the real target to sit at or under the real root. Unlike the code-graph
|
|
51
64
|
// server, whose `graph_dir` is intentionally client-chosen, the root here is
|
|
52
65
|
// fixed by the operator at startup: this server WRITES, so a client that could
|
|
53
66
|
// name its own root could write anywhere.
|
|
54
|
-
export function
|
|
67
|
+
export function resolveInRoot(file) {
|
|
55
68
|
if (typeof file !== "string" || file === "")
|
|
56
69
|
throw new Error("`file` is required");
|
|
57
|
-
const root = realpathSync(OPTS.
|
|
70
|
+
const root = realpathSync(OPTS.root);
|
|
58
71
|
const target = resolve(root, file);
|
|
59
72
|
let real;
|
|
60
73
|
try {
|
|
61
74
|
real = realpathSync(target);
|
|
62
75
|
}
|
|
63
76
|
catch {
|
|
64
|
-
throw new Error(`no such file
|
|
77
|
+
throw new Error(`no such file under the server root: ${file}`);
|
|
65
78
|
}
|
|
66
79
|
if (real !== root && !real.startsWith(root + sep)) {
|
|
67
|
-
throw new Error(`path escapes the
|
|
80
|
+
throw new Error(`path escapes the server root: ${file}`);
|
|
68
81
|
}
|
|
69
82
|
if (!statSync(real).isFile())
|
|
70
83
|
throw new Error(`not a file: ${file}`);
|
|
71
84
|
return real;
|
|
72
85
|
}
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const target = resolve(ws, 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) {
|
|
90
|
+
const serverRoot = realpathSync(OPTS.root);
|
|
91
|
+
const target = resolve(serverRoot, dir);
|
|
80
92
|
let real;
|
|
81
93
|
try {
|
|
82
94
|
real = realpathSync(target);
|
|
83
95
|
}
|
|
84
96
|
catch {
|
|
85
|
-
throw new Error(`no such directory
|
|
97
|
+
throw new Error(`no such directory under the server root: ${dir}`);
|
|
86
98
|
}
|
|
87
|
-
if (real !==
|
|
88
|
-
throw new Error(
|
|
99
|
+
if (real !== serverRoot && !real.startsWith(serverRoot + sep))
|
|
100
|
+
throw new Error(`${label} escapes the server root: ${dir}`);
|
|
89
101
|
return real;
|
|
90
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
|
+
}
|
|
91
120
|
// ---------------------------------------------------------------------------
|
|
92
121
|
// Driving the CLI
|
|
93
122
|
// ---------------------------------------------------------------------------
|
|
@@ -130,9 +159,9 @@ function parseRefusal(stderr) {
|
|
|
130
159
|
}
|
|
131
160
|
const asText = (v) => (typeof v === "string" ? v : JSON.stringify(v, null, 1));
|
|
132
161
|
function applyWrite(spec) {
|
|
133
|
-
const real =
|
|
162
|
+
const real = resolveInRoot(spec.file);
|
|
134
163
|
const before = readFileSync(real, "utf8");
|
|
135
|
-
const root = realpathSync(OPTS.
|
|
164
|
+
const root = realpathSync(OPTS.root);
|
|
136
165
|
const errorKey = (d) => `${d.code}:${d.message}`;
|
|
137
166
|
const preexisting = new Set(parse(before, { resolveDoc: docResolver(root) }).diagnostics
|
|
138
167
|
.filter((d) => d.severity === "error")
|
|
@@ -206,15 +235,15 @@ function docResolver(root) {
|
|
|
206
235
|
};
|
|
207
236
|
}
|
|
208
237
|
const hashId = (id) => (id.startsWith("#") ? id : `#${id}`);
|
|
209
|
-
const FILE_ARG = { type: "string", description: "Document path relative to the server's --
|
|
238
|
+
const FILE_ARG = { type: "string", description: "Document path relative to the server's --root directory, e.g. notes/spec.geml" };
|
|
210
239
|
export const TOOLS = [
|
|
211
240
|
// ----- read -----
|
|
212
241
|
{
|
|
213
|
-
name: "
|
|
242
|
+
name: "geml_list",
|
|
214
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.",
|
|
215
244
|
inputSchema: { type: "object", properties: { file: FILE_ARG }, required: ["file"] },
|
|
216
245
|
run: (args) => {
|
|
217
|
-
const real =
|
|
246
|
+
const real = resolveInRoot(args.file);
|
|
218
247
|
const run = runCli(["get", real, "--json"]);
|
|
219
248
|
if (!run.ok)
|
|
220
249
|
throw new Error(run.stderr || "could not list ids");
|
|
@@ -222,8 +251,8 @@ export const TOOLS = [
|
|
|
222
251
|
},
|
|
223
252
|
},
|
|
224
253
|
{
|
|
225
|
-
name: "
|
|
226
|
-
description: "Read ONE block from a GEML document by its `#id`. Use this instead of reading the whole file: it returns only that block, typically a few percent of the document. Get available ids from `
|
|
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.",
|
|
227
256
|
inputSchema: {
|
|
228
257
|
type: "object",
|
|
229
258
|
properties: {
|
|
@@ -233,7 +262,7 @@ export const TOOLS = [
|
|
|
233
262
|
required: ["file", "id"],
|
|
234
263
|
},
|
|
235
264
|
run: (args) => {
|
|
236
|
-
const real =
|
|
265
|
+
const real = resolveInRoot(args.file);
|
|
237
266
|
const run = runCli(["get", real, hashId(args.id)]);
|
|
238
267
|
if (!run.ok)
|
|
239
268
|
throw new Error(run.stderr || `no block with id ${hashId(args.id)}`);
|
|
@@ -247,12 +276,12 @@ export const TOOLS = [
|
|
|
247
276
|
type: "object",
|
|
248
277
|
properties: {
|
|
249
278
|
file: FILE_ARG,
|
|
250
|
-
root: { type: "string", description: "Directory (inside the
|
|
279
|
+
root: { type: "string", description: "Directory (inside the server root) against which cross-document references resolve. Defaults to the server root itself. This is a REFERENCE root and is distinct from the server's own --root sandbox, which it can only narrow." },
|
|
251
280
|
},
|
|
252
281
|
required: ["file"],
|
|
253
282
|
},
|
|
254
283
|
run: (args) => {
|
|
255
|
-
const real =
|
|
284
|
+
const real = resolveInRoot(args.file);
|
|
256
285
|
const root = resolveRoot(args.root);
|
|
257
286
|
const doc = parse(readFileSync(real, "utf8"), { resolveDoc: docResolver(root) });
|
|
258
287
|
const errors = doc.diagnostics.filter((d) => d.severity === "error").length;
|
|
@@ -266,20 +295,65 @@ export const TOOLS = [
|
|
|
266
295
|
},
|
|
267
296
|
},
|
|
268
297
|
{
|
|
269
|
-
name: "
|
|
270
|
-
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.",
|
|
271
300
|
inputSchema: { type: "object", properties: { file: FILE_ARG }, required: ["file"] },
|
|
272
301
|
run: (args) => {
|
|
273
|
-
const real =
|
|
302
|
+
const real = resolveInRoot(args.file);
|
|
274
303
|
const historyPath = real.replace(/\.geml$/, "") + ".gemlhistory";
|
|
275
304
|
if (!existsSync(historyPath))
|
|
276
305
|
return { file: args.file, revisions: [], note: "no .gemlhistory sidecar yet — the first write through this server creates one" };
|
|
277
306
|
return { file: args.file, revisions: listRevisions(historyPath) };
|
|
278
307
|
},
|
|
279
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
|
+
},
|
|
280
354
|
// ----- write -----
|
|
281
355
|
{
|
|
282
|
-
name: "
|
|
356
|
+
name: "geml_set",
|
|
283
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.",
|
|
284
358
|
inputSchema: {
|
|
285
359
|
type: "object",
|
|
@@ -292,7 +366,7 @@ export const TOOLS = [
|
|
|
292
366
|
required: ["file", "id", "body"],
|
|
293
367
|
},
|
|
294
368
|
run: (args) => {
|
|
295
|
-
const real =
|
|
369
|
+
const real = resolveInRoot(args.file);
|
|
296
370
|
const part = args.part ?? "whole";
|
|
297
371
|
if (!["whole", "head", "body"].includes(part))
|
|
298
372
|
throw new Error(`part must be whole|head|body, got \`${part}\``);
|
|
@@ -306,7 +380,7 @@ export const TOOLS = [
|
|
|
306
380
|
},
|
|
307
381
|
},
|
|
308
382
|
{
|
|
309
|
-
name: "
|
|
383
|
+
name: "geml_add",
|
|
310
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.",
|
|
311
385
|
inputSchema: {
|
|
312
386
|
type: "object",
|
|
@@ -319,7 +393,7 @@ export const TOOLS = [
|
|
|
319
393
|
required: ["file", "content", "position"],
|
|
320
394
|
},
|
|
321
395
|
run: (args) => {
|
|
322
|
-
const real =
|
|
396
|
+
const real = resolveInRoot(args.file);
|
|
323
397
|
let where;
|
|
324
398
|
if (args.position === "append")
|
|
325
399
|
where = ["--append"];
|
|
@@ -339,7 +413,7 @@ export const TOOLS = [
|
|
|
339
413
|
},
|
|
340
414
|
},
|
|
341
415
|
{
|
|
342
|
-
name: "
|
|
416
|
+
name: "geml_delete",
|
|
343
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.",
|
|
344
418
|
inputSchema: {
|
|
345
419
|
type: "object",
|
|
@@ -350,7 +424,7 @@ export const TOOLS = [
|
|
|
350
424
|
required: ["file", "ids"],
|
|
351
425
|
},
|
|
352
426
|
run: (args) => {
|
|
353
|
-
const real =
|
|
427
|
+
const real = resolveInRoot(args.file);
|
|
354
428
|
const ids = Array.isArray(args.ids) ? args.ids : [args.ids];
|
|
355
429
|
if (!ids.length)
|
|
356
430
|
throw new Error("`ids` must name at least one block");
|
|
@@ -363,7 +437,7 @@ export const TOOLS = [
|
|
|
363
437
|
},
|
|
364
438
|
},
|
|
365
439
|
{
|
|
366
|
-
name: "
|
|
440
|
+
name: "geml_rename",
|
|
367
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.",
|
|
368
442
|
inputSchema: {
|
|
369
443
|
type: "object",
|
|
@@ -375,7 +449,7 @@ export const TOOLS = [
|
|
|
375
449
|
required: ["file", "old", "new"],
|
|
376
450
|
},
|
|
377
451
|
run: (args) => {
|
|
378
|
-
const real =
|
|
452
|
+
const real = resolveInRoot(args.file);
|
|
379
453
|
return applyWrite({
|
|
380
454
|
file: args.file,
|
|
381
455
|
cliArgs: ["rename", real, hashId(args.old), hashId(args.new), "-o", "-"],
|
|
@@ -384,8 +458,8 @@ export const TOOLS = [
|
|
|
384
458
|
},
|
|
385
459
|
},
|
|
386
460
|
{
|
|
387
|
-
name: "
|
|
388
|
-
description: "Undo ONE block, leaving every other block byte-for-byte unchanged — recover a single block after a bad edit without losing the good edits around it. `rev` defaults to undoing this block's LAST change (its previous distinct version), which holds even when other blocks were edited afterwards; or pass `0` for the tip, a `-N` offset, or a revision id from `
|
|
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.",
|
|
389
463
|
inputSchema: {
|
|
390
464
|
type: "object",
|
|
391
465
|
properties: {
|
|
@@ -396,7 +470,7 @@ export const TOOLS = [
|
|
|
396
470
|
required: ["file", "id"],
|
|
397
471
|
},
|
|
398
472
|
run: (args) => {
|
|
399
|
-
const real =
|
|
473
|
+
const real = resolveInRoot(args.file);
|
|
400
474
|
// Default to `--rev changed`, NOT the tip (`0`) or the CLI's own `-1`. Each
|
|
401
475
|
// write commits the PRE-write state, so the tip undoes the block only when
|
|
402
476
|
// it was the MOST RECENT write — a later write to ANOTHER block moves the
|
|
@@ -413,6 +487,68 @@ export const TOOLS = [
|
|
|
413
487
|
},
|
|
414
488
|
];
|
|
415
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
|
+
// ---------------------------------------------------------------------------
|
|
416
552
|
// newline-delimited JSON-RPC 2.0 over stdio
|
|
417
553
|
// ---------------------------------------------------------------------------
|
|
418
554
|
export function handleLine(line, write = (s) => process.stdout.write(s)) {
|
|
@@ -434,7 +570,7 @@ export function handleLine(line, write = (s) => process.stdout.write(s)) {
|
|
|
434
570
|
reply(id, {
|
|
435
571
|
protocolVersion: params?.protocolVersion ?? "2024-11-05",
|
|
436
572
|
capabilities: { tools: {} },
|
|
437
|
-
serverInfo: { name: "geml
|
|
573
|
+
serverInfo: { name: "geml", version: SERVER_VERSION },
|
|
438
574
|
});
|
|
439
575
|
}
|
|
440
576
|
else if (method?.startsWith("notifications/")) {
|
|
@@ -444,10 +580,10 @@ export function handleLine(line, write = (s) => process.stdout.write(s)) {
|
|
|
444
580
|
reply(id, {});
|
|
445
581
|
}
|
|
446
582
|
else if (method === "tools/list") {
|
|
447
|
-
reply(id, { tools:
|
|
583
|
+
reply(id, { tools: allTools().map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
|
|
448
584
|
}
|
|
449
585
|
else if (method === "tools/call") {
|
|
450
|
-
const tool =
|
|
586
|
+
const tool = allTools().find((t) => t.name === params?.name);
|
|
451
587
|
if (!tool) {
|
|
452
588
|
replyError(id, -32602, `unknown tool: ${params?.name}`);
|
|
453
589
|
return;
|
|
@@ -475,39 +611,79 @@ export function handleLine(line, write = (s) => process.stdout.write(s)) {
|
|
|
475
611
|
// ---------------------------------------------------------------------------
|
|
476
612
|
// Entry
|
|
477
613
|
// ---------------------------------------------------------------------------
|
|
478
|
-
export const MCP_USAGE = `usage: geml mcp --
|
|
614
|
+
export const MCP_USAGE = `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
|
|
479
615
|
|
|
480
|
-
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.
|
|
481
618
|
|
|
482
|
-
--
|
|
619
|
+
--root <dir> REQUIRED. Root directory holding the .geml documents.
|
|
620
|
+
Relative paths resolve against the server process's CWD,
|
|
621
|
+
which the CLIENT chooses — pass an absolute path.
|
|
483
622
|
Every path a client names is confined to this directory;
|
|
484
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).
|
|
485
628
|
--no-history Do not auto-commit a .gemlhistory revision before each
|
|
486
|
-
write. Default is to commit, so
|
|
629
|
+
write. Default is to commit, so geml_revert always
|
|
487
630
|
has a revision to undo to.
|
|
488
631
|
|
|
489
632
|
Register with a client:
|
|
490
|
-
claude mcp add geml
|
|
633
|
+
claude mcp add geml -- geml mcp --root /abs/path/to/repo`;
|
|
491
634
|
export function parseArgs(args) {
|
|
492
|
-
let
|
|
635
|
+
let root;
|
|
636
|
+
let graph;
|
|
493
637
|
let history = true;
|
|
494
638
|
for (let i = 0; i < args.length; i++) {
|
|
495
639
|
const a = args[i];
|
|
496
|
-
if (a === "--
|
|
497
|
-
|
|
498
|
-
else if (a.startsWith("--
|
|
499
|
-
|
|
640
|
+
if (a === "--root" || a === "-r")
|
|
641
|
+
root = args[++i];
|
|
642
|
+
else if (a.startsWith("--root="))
|
|
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);
|
|
500
648
|
else if (a === "--no-history")
|
|
501
649
|
history = false;
|
|
650
|
+
// The flag used to be --workspace/-w. Name the replacement instead of
|
|
651
|
+
// failing with a bare `unknown option`: this runs inside a client's server
|
|
652
|
+
// config, where the only thing the user sees is that the server did not
|
|
653
|
+
// start, and guessing from `unknown option '--workspace'` is a bad evening.
|
|
654
|
+
else if (a === "--workspace" || a === "-w" || a.startsWith("--workspace=")) {
|
|
655
|
+
throw new Error("--workspace is now --root (same meaning: the one directory the server may read and write)");
|
|
656
|
+
}
|
|
502
657
|
else
|
|
503
658
|
throw new Error(`unknown option '${a}'`);
|
|
504
659
|
}
|
|
505
|
-
if (!
|
|
506
|
-
throw new Error("--
|
|
507
|
-
|
|
660
|
+
if (!root)
|
|
661
|
+
throw new Error("--root <dir> is required (the one directory the server may read and write)");
|
|
662
|
+
// Relative paths resolve against THIS process's cwd, which an MCP client
|
|
663
|
+
// picks — so they work from a shell and are a coin flip from a client config.
|
|
664
|
+
const abs = resolve(root);
|
|
508
665
|
if (!existsSync(abs) || !statSync(abs).isDirectory())
|
|
509
|
-
throw new Error(`--
|
|
510
|
-
|
|
666
|
+
throw new Error(`--root is not a directory: ${root}`);
|
|
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;
|
|
511
687
|
}
|
|
512
688
|
// Auto-run only as a MAIN module: the CLI dispatcher spawns this file as a
|
|
513
689
|
// child's entry script, while an in-process `import` (the test suite) stays inert.
|
|
@@ -524,5 +700,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
524
700
|
console.error(`geml mcp: ${e.message}\n\n${MCP_USAGE}`);
|
|
525
701
|
process.exit(2);
|
|
526
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();
|
|
527
708
|
createInterface({ input: process.stdin }).on("line", (line) => handleLine(line));
|
|
528
709
|
}
|
package/dist/render-html.js
CHANGED
|
@@ -36,43 +36,43 @@ function page(title, body, ctx, source) {
|
|
|
36
36
|
? `<script type="importmap">{"imports":{"node:fs":"${lg}_node-stub.js","node:path":"${lg}_node-stub.js","node:crypto":"${lg}_node-stub.js","node:url":"${lg}_node-stub.js","node:child_process":"${lg}_node-stub.js"}}</script>\n`
|
|
37
37
|
: "";
|
|
38
38
|
const liveJs = wantLive
|
|
39
|
-
? `<script type="module">
|
|
40
|
-
globalThis.process ??= { argv: [], env: {} };
|
|
41
|
-
const { parse } = await import("${lg}geml.js");
|
|
42
|
-
const { codeGraphWaves } = await import("${lg}render.js");
|
|
43
|
-
const w = codeGraphWaves(async (rel) => {
|
|
44
|
-
try { const r = await fetch(rel, { cache: "no-cache" }); return r.ok ? await r.text() : null; } catch { return null; }
|
|
45
|
-
}, parse);
|
|
46
|
-
for (const m of document.querySelectorAll(".cg-mount[data-start]")) {
|
|
47
|
-
const start = m.getAttribute("data-start");
|
|
48
|
-
m._cgView = async (view) => {
|
|
49
|
-
// A directed view builds from the node's OWN document (its meta names the
|
|
50
|
-
// module and graph-depth); {doc} opens that document; else the mount's.
|
|
51
|
-
const src = view && view.doc ? view.doc
|
|
52
|
-
: view && view.node ? view.node.slice(0, view.node.lastIndexOf("#"))
|
|
53
|
-
: start;
|
|
54
|
-
const r = await w.build(src, view && view.doc ? undefined : view);
|
|
55
|
-
return r.error !== undefined ? null : r.data;
|
|
56
|
-
};
|
|
57
|
-
}
|
|
39
|
+
? `<script type="module">
|
|
40
|
+
globalThis.process ??= { argv: [], env: {} };
|
|
41
|
+
const { parse } = await import("${lg}geml.js");
|
|
42
|
+
const { codeGraphWaves } = await import("${lg}render.js");
|
|
43
|
+
const w = codeGraphWaves(async (rel) => {
|
|
44
|
+
try { const r = await fetch(rel, { cache: "no-cache" }); return r.ok ? await r.text() : null; } catch { return null; }
|
|
45
|
+
}, parse);
|
|
46
|
+
for (const m of document.querySelectorAll(".cg-mount[data-start]")) {
|
|
47
|
+
const start = m.getAttribute("data-start");
|
|
48
|
+
m._cgView = async (view) => {
|
|
49
|
+
// A directed view builds from the node's OWN document (its meta names the
|
|
50
|
+
// module and graph-depth); {doc} opens that document; else the mount's.
|
|
51
|
+
const src = view && view.doc ? view.doc
|
|
52
|
+
: view && view.node ? view.node.slice(0, view.node.lastIndexOf("#"))
|
|
53
|
+
: start;
|
|
54
|
+
const r = await w.build(src, view && view.doc ? undefined : view);
|
|
55
|
+
return r.error !== undefined ? null : r.data;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
58
|
</script>\n`
|
|
59
59
|
: "";
|
|
60
|
-
return `<!doctype html>
|
|
61
|
-
<html lang="en">
|
|
62
|
-
<head>
|
|
63
|
-
<meta charset="utf-8">
|
|
64
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
65
|
-
<title>${esc(title)}</title>
|
|
66
|
-
<style>${CSS}</style>
|
|
67
|
-
${importMap}${mathHead}${mermaidHead}</head>
|
|
68
|
-
<body>
|
|
69
|
-
<main>
|
|
70
|
-
${body}
|
|
71
|
-
</main>
|
|
72
|
-
${footer}
|
|
73
|
-
<script>${JS}</script>
|
|
74
|
-
${ctx.usedCodeGraph ? `<script>${CODE_GRAPH_JS}</script>\n` : ""}${liveJs}</body>
|
|
75
|
-
</html>
|
|
60
|
+
return `<!doctype html>
|
|
61
|
+
<html lang="en">
|
|
62
|
+
<head>
|
|
63
|
+
<meta charset="utf-8">
|
|
64
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
65
|
+
<title>${esc(title)}</title>
|
|
66
|
+
<style>${CSS}</style>
|
|
67
|
+
${importMap}${mathHead}${mermaidHead}</head>
|
|
68
|
+
<body>
|
|
69
|
+
<main>
|
|
70
|
+
${body}
|
|
71
|
+
</main>
|
|
72
|
+
${footer}
|
|
73
|
+
<script>${JS}</script>
|
|
74
|
+
${ctx.usedCodeGraph ? `<script>${CODE_GRAPH_JS}</script>\n` : ""}${liveJs}</body>
|
|
75
|
+
</html>
|
|
76
76
|
`;
|
|
77
77
|
}
|
|
78
78
|
export function renderHtml(doc, opts = {}) {
|