@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/dist/mcp.js CHANGED
@@ -1,13 +1,17 @@
1
1
  #!/usr/bin/env node
2
- // `geml mcp` — MCP server for GEML document CRUD.
2
+ // `geml mcp` — MCP server for GEML documents and the code graph.
3
3
  //
4
- // Nine tools over a confined workspace of `.geml` documents: four read-only,
5
- // five that write. It is the document-editing counterpart to the read-only
6
- // code-graph server in `codemap/mcp-server.mjs`, and deliberately mirrors its
7
- // shape (newline-delimited JSON-RPC 2.0 over stdio, zero dependencies, an
8
- // exported `handleLine` so the suite can drive it in-process).
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-docs -- geml mcp --workspace /abs/path/to/docs
14
+ // claude mcp add geml -- geml mcp --root /abs/path/to/repo
11
15
  //
12
16
  // Three invariants make this worth more than letting a model `str_replace` the
13
17
  // file itself:
@@ -17,11 +21,16 @@
17
21
  // is only overwritten when the result is clean. A bad generation is
18
22
  // refused with the diagnostics that refused it — it does not land and
19
23
  // then wait for a human to notice.
20
- // 2. EVERY WRITE IS PRECEDED BY A HISTORY COMMIT, so `geml_revert_block` can
24
+ // 2. EVERY WRITE IS PRECEDED BY A HISTORY COMMIT, so `geml_revert` can
21
25
  // always undo the block that was just touched. Without this the strongest
22
26
  // tool in the set would have nothing to revert to.
23
- // 3. EVERY PATH IS CONFINED to a server-side `--workspace` root the client
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
- const SERVER_VERSION = "0.1.0";
38
- let OPTS = { workspace: process.cwd(), history: true };
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 workspace that points out of it — must not resolve. Canonicalize
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 resolveInWorkspace(file) {
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.workspace);
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 in the workspace: ${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 workspace: ${file}`);
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
- // Cross-document references resolve against the workspace root, never against
74
- // a client-named directory: `root` may only NARROW to a directory inside it.
75
- function resolveRoot(root) {
76
- const ws = realpathSync(OPTS.workspace);
77
- if (root === undefined || root === "")
78
- return ws;
79
- const target = resolve(ws, root);
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 in the workspace: ${root}`);
97
+ throw new Error(`no such directory under the server root: ${dir}`);
86
98
  }
87
- if (real !== ws && !real.startsWith(ws + sep))
88
- throw new Error(`root escapes the workspace: ${root}`);
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 = resolveInWorkspace(spec.file);
162
+ const real = resolveInRoot(spec.file);
134
163
  const before = readFileSync(real, "utf8");
135
- const root = realpathSync(OPTS.workspace);
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 --workspace root, e.g. notes/spec.geml" };
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: "geml_list_ids",
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 = resolveInWorkspace(args.file);
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: "geml_read_block",
226
- description: "Read ONE block from a GEML document by its `#id`. Use this instead of reading the whole file: it returns only that block, typically a few percent of the document. Get available ids from `geml_list_ids` first. Reading the whole file to change one block wastes context and risks modifying unrelated content.",
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 = resolveInWorkspace(args.file);
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 workspace) against which cross-document references resolve. Defaults to the workspace root." },
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 = resolveInWorkspace(args.file);
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: "geml_history_log",
270
- description: "List the recorded revisions of a document, newest first. Each entry's `offset` is the selector `geml_revert_block` takes as `rev` (-1 is the revision before the current one). Use this to find WHICH revision to revert a block to; an empty list means the document has no sidecar yet and nothing can be reverted.",
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 = resolveInWorkspace(args.file);
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: "geml_write_block",
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 = resolveInWorkspace(args.file);
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: "geml_add_block",
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 = resolveInWorkspace(args.file);
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: "geml_delete_block",
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 = resolveInWorkspace(args.file);
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: "geml_rename_id",
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 = resolveInWorkspace(args.file);
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: "geml_revert_block",
388
- description: "Undo ONE block, leaving every other block byte-for-byte unchanged — recover a single block after a bad edit without losing the good edits around it. `rev` defaults to undoing this block's LAST change (its previous distinct version), which holds even when other blocks were edited afterwards; or pass `0` for the tip, a `-N` offset, or a revision id from `geml_history_log`. Reverting across a revision where the block was deleted restores it; across one where it did not exist removes it.",
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 = resolveInWorkspace(args.file);
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-docs", version: SERVER_VERSION },
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: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
583
+ reply(id, { tools: allTools().map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
448
584
  }
449
585
  else if (method === "tools/call") {
450
- const tool = TOOLS.find((t) => t.name === params?.name);
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 --workspace <dir> [--no-history]
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
- --workspace <dir> REQUIRED. Root directory holding the .geml documents.
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 geml_revert_block always
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-docs -- geml mcp --workspace /abs/path/to/docs`;
633
+ claude mcp add geml -- geml mcp --root /abs/path/to/repo`;
491
634
  export function parseArgs(args) {
492
- let workspace;
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 === "--workspace" || a === "-w")
497
- workspace = args[++i];
498
- else if (a.startsWith("--workspace="))
499
- workspace = a.slice("--workspace=".length);
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 (!workspace)
506
- throw new Error("--workspace <dir> is required (the root the server may read and write)");
507
- const abs = resolve(workspace);
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(`--workspace is not a directory: ${workspace}`);
510
- return { workspace: realpathSync(abs), history };
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
  }
@@ -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 = {}) {
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@geml/geml",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
+ "mcpName": "io.github.geml-spec/geml",
4
5
  "publishConfig": {
5
6
  "access": "public"
6
7
  },