🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@geml/geml

Package Overview
Dependencies
Maintainers
1
Versions
23
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@geml/geml - npm Package Compare versions

Comparing version
1.4.4
to
1.4.5
+9
-23
codemap/find.mjs

@@ -9,7 +9,12 @@ #!/usr/bin/env node

//
// Same index the MCP `resolve_name` tool and the viewer search box use
// Same index the MCP `geml_codemap_search` tool and the viewer search box use
// (_index/name-lookup.json); a name with several rows is real ambiguity
// (overloads / same short name across classes) — every candidate is printed.
import { readFileSync, existsSync } from "node:fs";
//
// The matching rule and the src lookup are IMPORTED, not repeated here: this
// command and `geml_codemap_search` answer the same question, and two copies of
// "what counts as a match" would drift the moment one of them is tuned.
import { existsSync } from "node:fs";
import { join } from "node:path";
import { searchNames, srcOf } from "./mcp-server.mjs";

@@ -34,28 +39,9 @@ // `find x | head` closes stdout after a few lines — that is normal pipe

}
const lookup = JSON.parse(readFileSync(lookupPath, "utf8"));
const q = query.toLowerCase();
const names = Object.keys(lookup).filter((n) => n.toLowerCase().includes(q)).sort();
const { names, lookup } = searchNames(dir, query);
if (!names.length) { console.error(`no symbol matching "${query}"`); process.exit(1); }
// src= lives on the block header line in the doc; read each doc once, index by id.
const docCache = new Map(); // doc -> Map(id -> src)
const srcOf = (doc, id) => {
if (!docCache.has(doc)) {
const map = new Map();
try {
const text = readFileSync(join(dir, doc), "utf8");
// src= may be quoted or a bare token (path#Lx-y, no spaces).
const re = /\{#([A-Za-z0-9._-]+)\b[^}]*?\bsrc=(?:"([^"]+)"|([^\s}]+))/g;
let m;
while ((m = re.exec(text))) map.set(m[1], m[2] || m[3]);
} catch { /* doc unreadable — skip src */ }
docCache.set(doc, map);
}
return docCache.get(doc).get(id) || "";
};
let n = 0;
for (const name of names) {
for (const c of lookup[name]) {
const src = srcOf(c.doc, c.id);
const src = srcOf(dir, c.doc, c.id);
process.stdout.write(`${name}\t${c.doc}#${c.id}${src ? `\t${src}` : ""}\n`);

@@ -62,0 +48,0 @@ n++;

#!/usr/bin/env node
// geml-code-graph MCP server — the thin consumption wrapper of DESIGN §8 (P2).
// Three navigation tools over a built graph/ directory, each "give an
// identifier, get readable text back" (the original proposal's 2.6):
// resolve_name name -> candidate anchors (doc + block id)
// open_symbol doc + id -> that symbol's block, verbatim
// get_backlinks doc + id -> the symbol's backlink block (who calls it)
// geml-code-graph MCP tools — the thin consumption wrapper of DESIGN §8 (P2).
// Navigation over a built graph/ directory, each "give an identifier, get
// readable text back" (the original proposal's 2.6). Every name mirrors its CLI
// path — `geml codemap <sub>` -> `geml_codemap_<sub>` — so one vocabulary covers
// both surfaces:
// geml_codemap_search name or substring -> candidates (start here)
// geml_codemap_list no arg -> modules; a module -> its symbols
// geml_codemap_node doc + id -> that symbol's block, verbatim
// geml_codemap_callchain doc + id -> several hops, either direction
//
// Zero dependencies: newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio
// transport). Register e.g.:
// claude mcp add geml-code-graph -e GEML_GRAPH_DIR=/abs/path/to/graph \
// -- geml codemap mcp
// The graph dir comes from GEML_GRAPH_DIR or a per-call `graph_dir` argument.
// The four cover reading the graph, not producing it: building and refreshing
// stay CLI-only on purpose (both run indexers or recorded shell steps, which is
// not something a model should trigger), and `codemap serve` renders HTML for a
// human, which a model cannot consume. See DESIGN §8.
//
// The dispatch is exported (and the stdio wiring below is main-module guarded)
// so the test suite can drive it in-process; the CLI dispatcher always runs
// this file as a child's MAIN module, where nothing changes.
// This file is a LIBRARY, not a server entry point. `geml codemap mcp` was
// removed: `geml mcp --root <dir>` serves these three tools next to the
// document tools, importing the TOOLS table below rather than duplicating it,
// so a client registers one server instead of two.
//
// claude mcp add geml -- geml mcp --root /abs/path/to/repo
//
// `graphDirOf` still honours GEML_GRAPH_DIR and a per-call `graph_dir`. Nothing
// reaches those defaults through `geml mcp`, which resolves the directory
// against its own --root before calling a tool — a client-chosen directory is
// safe only on a process that cannot write, and that one can.
//
// Zero dependencies; the handlers speak newline-delimited JSON-RPC 2.0 (the MCP
// stdio transport) and `handleLine` is exported so both `geml mcp` and the test
// suite drive it in-process.
import { readFileSync, existsSync, realpathSync } from "node:fs";
import { join, resolve, dirname, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
// Where the sources live is serve.mjs's rule (the recipe's `root`, else the
// graph dir's parent). Imported, not restated: two copies would drift and the
// source panel and this tool would disagree about which file a symbol is in.
import { resolveSrcRoot } from "./serve.mjs";

@@ -57,31 +74,199 @@ // blockSpans from the reference parser (its CLI entry is guarded, so importing

// ---- CSV-table reading (profile §4) -----------------------------------------
// Serves the edge tables — `#calls` is `from, to, kind, confidence`, `#called-by`
// is `from, to, kind, site`, cells being `#id` (this document) or `doc.geml#id`
// (a sibling) — and the index's `#modules`, which has the same shape: a fence
// line, a header row, then data.
const tableRows = (graphDir, doc, tableId) => {
let raw;
try { raw = readBlock(graphDir, doc, tableId); } catch { return []; }
// readBlock returns the whole block: fence line, header row, data, close.
return raw.split("\n").slice(2)
.filter((l) => l.trim() && !l.trimStart().startsWith("==="))
.map((l) => l.split(",").map((c) => c.trim()));
};
// A from/to cell as a target, or null when it is plain text (an `#unresolved`
// target or a `file:line` site — profile §4 says those are unchecked).
const refTarget = (cell, fromDoc) => {
const m = /^([^#]*)#(.+)$/.exec(cell ?? "");
return m ? { doc: m[1] || fromDoc, id: m[2] } : null;
};
// One hop. `callees` reads this document's out-edges; `callers` reads the
// in-edge table, which the generator aggregates per document, so both
// directions are a single read of the symbol's OWN document.
const neighbours = (graphDir, doc, id, direction) => {
const table = direction === "callers" ? "called-by" : "calls";
const [self, other] = direction === "callers" ? [1, 0] : [0, 1];
const want = `#${id.replace(/^#/, "")}`;
const out = [];
const seen = new Set();
for (const row of tableRows(graphDir, doc, table)) {
if (row[self] !== want) continue;
const t = refTarget(row[other], doc);
// A symbol called from three sites yields three identical rows; the caller
// wants the shape of the graph, not the call count.
if (!t) continue;
const key = `${t.doc}#${t.id}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ ...t, kind: row[2] || "call" });
}
return out;
};
// The symbol index: name -> [{ anchor, doc, id }].
const loadLookup = (graphDir) => {
const lookupPath = join(graphDir, "_index/name-lookup.json");
if (!existsSync(lookupPath)) throw new Error(`no name-lookup at ${lookupPath} — build the graph first`);
return JSON.parse(readFileSync(lookupPath, "utf8"));
};
// ---- name search (shared with `geml codemap find`) --------------------------
// One definition of "matches", so the CLI and the tool cannot answer the same
// query differently: case-insensitive substring over the name index, sorted.
// `exact` narrows to the whole name — the former `resolve_name`, now a flag,
// because two tools differing only in strictness is two chances to pick wrong.
export const searchNames = (graphDir, query, exact = false) => {
const lookup = loadLookup(graphDir);
if (exact) return { names: lookup[query] ? [query] : [], lookup };
const q = query.toLowerCase();
return { names: Object.keys(lookup).filter((n) => n.toLowerCase().includes(q)).sort(), lookup };
};
// The index's `#modules` table: module, doc, methods, entries, tests.
const moduleRows = (graphDir) =>
tableRows(graphDir, "index.geml", "modules").filter((r) => r[0] && r[1]);
// `src=` lives on the block header line; read each doc once and index by id.
// The id charset excludes `.`, and src may be quoted or a bare token.
const srcCache = new Map(); // `${graphDir}\0${doc}` -> Map(id -> src)
export const srcOf = (graphDir, doc, id) => {
const key = `${graphDir}\0${doc}`;
if (!srcCache.has(key)) {
const map = new Map();
try {
const text = readFileSync(join(graphDir, doc), "utf8");
const re = /\{#([A-Za-z0-9._-]+)\b[^}]*?\bsrc=(?:"([^"]+)"|([^\s}]+))/g;
let m;
while ((m = re.exec(text))) map.set(m[1], m[2] || m[3]);
} catch { /* doc unreadable — a src is a nicety, not the answer */ }
srcCache.set(key, map);
}
return srcCache.get(key).get(id) || "";
};
// ---- reading the real source a `src=` pointer names -------------------------
// Same two rules `codemap serve` uses for its source panel, imported rather
// than restated: WHERE the sources are (the recipe's `root`, else the graph
// dir's parent) and that a file is only served when it really sits under that
// root, symlinks resolved. What differs is the slice — serve hands the browser
// the whole file and lets the viewer highlight the range; a model wants the
// symbol's own lines and nothing else.
const MAX_SOURCE_LINES = 400;
// `geml mcp` also confines every path to its own --root. The source root is
// derived from `_index/refresh.json`, a file inside the graph — data, not
// configuration this process chose — so a hand-edited `root: "../../.."` must
// not reach outside the server's root. Unset (a bare library use) = no extra
// bound beyond the source root itself.
let SOURCE_BOUND = null;
export const confineSourceTo = (dir) => { SOURCE_BOUND = dir ? realpathSync(dir) : null; };
const underRoot = (real, root) => real === root || real.startsWith(root + sep);
/** The `src=` attribute of a block header: `path` or `path#Lstart-end`. */
const srcPointer = (block) => {
const m = /\bsrc=(?:"([^"]+)"|([^\s}]+))/.exec(block.split("\n", 1)[0] ?? "");
if (!m) return null;
const raw = m[1] || m[2];
const range = /^(.*?)#L(\d+)(?:-(\d+))?$/.exec(raw);
return range
? { path: range[1], start: Number(range[2]), end: Number(range[3] ?? range[2]) }
: { path: raw, start: null, end: null };
};
export const readSource = (graphDir, block) => {
const ptr = srcPointer(block);
if (!ptr) return "(no `src=` on this block — nothing to read; edge tables and index blocks have no source)";
const srcRoot = resolveSrcRoot(graphDir);
let realRoot;
try { realRoot = realpathSync(srcRoot); } catch { return `(source root ${srcRoot} does not exist — the sources are not next to the graph on this machine)`; }
if (SOURCE_BOUND && !underRoot(realRoot, SOURCE_BOUND)) {
return `(refused: the graph's recorded source root ${srcRoot} is outside this server's --root)`;
}
let real;
try { real = realpathSync(resolve(realRoot, ptr.path)); } catch { return `(no such source file: ${ptr.path})`; }
if (!underRoot(real, realRoot) || (SOURCE_BOUND && !underRoot(real, SOURCE_BOUND))) {
return `(refused: ${ptr.path} resolves outside the source root)`;
}
let text;
try { text = readFileSync(real, "utf8"); } catch (e) { return `(cannot read ${ptr.path}: ${e.message})`; }
const all = text.split("\n");
const start = ptr.start ?? 1;
const end = Math.min(ptr.end ?? all.length, start + MAX_SOURCE_LINES - 1);
const slice = all.slice(start - 1, end);
if (!slice.length) return `(${ptr.path} has no lines ${start}-${end} — the graph is stale; rebuild with \`geml codemap build\`)`;
const cut = (ptr.end ?? all.length) > end ? `\n… truncated at ${MAX_SOURCE_LINES} lines` : "";
// Line numbers so a model can cite `file:line` without recounting.
const body = slice.map((l, i) => `${String(start + i).padStart(5)} ${l}`).join("\n");
return `--- ${ptr.path}:${start}-${end} ---\n${body}${cut}`;
};
export const TOOLS = [
{
name: "resolve_name",
description: "Find a function/class by name in the code graph. Returns candidate anchors with the document and block id to open. Multiple candidates = real ambiguity (overloads/same name) — inspect each, never assume.",
name: "geml_codemap_search",
description:
"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.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Exact symbol name (function/class short name)" },
query: { type: "string", description: "The symbol name, or a substring of it (e.g. `token` matches issueToken and TokenStore)" },
exact: { type: "boolean", description: "Match the WHOLE name instead of a substring (default false)" },
limit: { type: "number", description: "Maximum candidates to return (default 50). Narrow the query rather than raising this." },
graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
},
required: ["name"],
required: ["query"],
},
run: (args) => {
const lookupPath = join(graphDirOf(args), "_index/name-lookup.json");
if (!existsSync(lookupPath)) throw new Error(`no name-lookup at ${lookupPath} — build the graph first`);
const lookup = JSON.parse(readFileSync(lookupPath, "utf8"));
const hits = lookup[args.name];
if (!hits?.length) return `no symbol named \`${args.name}\` in the graph`;
return JSON.stringify(hits, null, 1);
const graphDir = graphDirOf(args);
const query = String(args.query ?? "");
if (!query) throw new Error("`query` is required");
const exact = args.exact === true;
const { names, lookup } = searchNames(graphDir, query, exact);
if (!names.length) {
return exact
? `no symbol named \`${query}\` in the graph — drop \`exact\` to match substrings`
: `no symbol matching "${query}" in the graph`;
}
const limit = Math.max(1, Math.min(Number(args.limit) || 50, 500));
const lines = [];
let total = 0;
for (const name of names) {
for (const c of lookup[name]) {
total++;
if (lines.length < limit) {
const src = srcOf(graphDir, c.doc, c.id);
lines.push(`${name}\t${c.doc}#${c.id}${src ? `\t${src}` : ""}`);
}
}
}
const tail = total > lines.length
? `\n\n${lines.length} of ${total} match(es) shown — narrow the query.`
: `\n\n${total} match(es) across ${names.length} name(s).`;
return lines.join("\n") + tail;
},
},
{
name: "open_symbol",
description: "Open ONE symbol's block from the code graph (its callees as checked references, confidence annotations, called-by pointer). Equivalent to following a link. Get doc+id from resolve_name.",
name: "geml_codemap_callchain",
description:
"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.",
inputSchema: {
type: "object",
properties: {
doc: { type: "string", description: "Document path relative to the codemap dir, e.g. hashtable.c.geml" },
id: { type: "string", description: "Block id, e.g. hashtableFind (or #calls / #called-by for the edge tables)" },
doc: { type: "string", description: "The symbol's document path, e.g. hashtable.c.geml" },
id: { type: "string", description: "The symbol's block id, e.g. hashtableFind" },
direction: { type: "string", enum: ["callees", "callers"], description: "`callees` = what this calls (default); `callers` = what calls this" },
depth: { type: "number", description: "How many hops to follow (default 3, max 6)" },
graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },

@@ -91,38 +276,112 @@ },

},
run: (args) => readBlock(graphDirOf(args), args.doc, args.id),
run: (args) => {
const graphDir = graphDirOf(args);
const direction = args.direction === "callers" ? "callers" : "callees";
const depth = Math.max(1, Math.min(Number(args.depth) || 3, 6));
const rootId = String(args.id ?? "").replace(/^#/, "");
if (!args.doc || !rootId) throw new Error("`doc` and `id` are required");
// Prove the symbol exists before reporting an empty chain: "no edges" and
// "no such symbol" are different answers and a model must not conflate them.
readBlock(graphDir, args.doc, rootId);
const MAX_NODES = 200;
const lines = [];
const expanded = new Set();
let truncated = false;
// EVERY line carries the full `doc.geml#id`, including same-document
// targets that profile §4 would abbreviate to `#id`. The reader here is
// an agent, and each line has to be usable as-is for the next
// `open_symbol`/`trace_calls` call. A bare id would make it infer the
// document from the line's ancestors — cheaper output, one more thing to
// get wrong.
const walk = (doc, id, level, prefix, last) => {
const key = `${doc}#${id}`;
const label = level === 0 ? `${key}` : `${prefix}${last ? "└─ " : "├─ "}${key}`;
if (lines.length >= MAX_NODES) { truncated = true; return; }
if (expanded.has(key)) { lines.push(`${label} (already shown)`); return; }
lines.push(label);
expanded.add(key);
if (level >= depth) {
// Say whether the cut hides anything, so a model knows to go deeper.
if (neighbours(graphDir, doc, id, direction).length) lines.push(`${prefix}${last ? " " : "│ "} … (depth limit)`);
return;
}
const next = neighbours(graphDir, doc, id, direction);
const childPrefix = level === 0 ? "" : prefix + (last ? " " : "│ ");
next.forEach((n, i) => walk(n.doc, n.id, level + 1, childPrefix, i === next.length - 1));
};
walk(args.doc, rootId, 0, "", true);
const noun = direction === "callers" ? "callers" : "callees";
if (lines.length === 1) {
return `${lines[0]}\n\nno resolved ${noun} — under heuristic extraction that is a blind spot, not proof of none (see the #unresolved table).`;
}
return lines.join("\n") +
`\n\n${direction}, depth ${depth}${truncated ? `, truncated at ${MAX_NODES} nodes` : ""}. ` +
"Resolved edges only: `#unresolved` holds the blind spots.";
},
},
{
name: "get_backlinks",
description: "Who calls this symbol: opens its backlink block (callers with file:line sites, each a followable reference). Absence means no RESOLVED callers — never proof of none.",
name: "geml_codemap_list",
description:
"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.",
inputSchema: {
type: "object",
properties: {
doc: { type: "string", description: "The symbol's document path, e.g. hashtable.c.geml" },
id: { type: "string", description: "The symbol's block id (e.g. hashtableFind); omit to get the whole #called-by table" },
graph_dir: { type: "string", description: "Codemap directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
module: { type: "string", description: "Module name (e.g. geml-parser) or its document (geml-parser.geml). Omit to list every module." },
graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
},
required: ["doc"],
},
run: (args) => {
// codemap profile: in-edges live in the SAME document's #called-by table.
let table;
try {
table = readBlock(graphDirOf(args), args.doc, "called-by");
} catch {
return `no #called-by table in ${args.doc} — no resolved callers recorded (under heuristic extraction this is a blind spot, not proof of none)`;
const graphDir = graphDirOf(args);
const rows = moduleRows(graphDir);
if (!rows.length) throw new Error(`no #modules table in index.geml (graph dir: ${graphDir}) — build the graph first`);
const want = String(args.module ?? "").trim();
if (!want) {
return rows.map((r) => `${r[0]}\t${r[1]}\t${r[2] || 0} symbol(s)`).join("\n") +
`\n\n${rows.length} module(s). Pass one as \`module\` to list its symbols.`;
}
if (!args.id) return table;
const id = args.id.replace(/^#/, "");
// `id` is client-supplied and goes straight into a RegExp: escape every
// regex metacharacter so it matches LITERALLY (an id like `.*` or a
// catastrophic-backtracking pattern can neither widen the match nor cause
// ReDoS — the pattern is a fixed string wrapped in `,\s*#…\s*,`).
const escId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`,\\s*#${escId}\\s*,`);
const lines = table.split("\n");
const hits = lines.filter((l, i) => i < 2 || re.test(l));
return hits.length > 2 ? hits.join("\n")
: `no resolved callers of #${id} in ${args.doc} (blind spots live in the #unresolved table)`;
const row = rows.find((r) => r[0] === want || r[1] === want);
if (!row) return `no module \`${want}\` in the graph — call this tool with no argument to list them`;
const doc = row[1];
// The name index is the symbol list: filtering it by document skips the
// per-document edge tables (#calls / #called-by) a raw id listing returns.
const lookup = loadLookup(graphDir);
const lines = [];
for (const [name, cands] of Object.entries(lookup)) {
for (const c of cands) {
if (c.doc !== doc) continue;
const src = srcOf(graphDir, doc, c.id);
lines.push(`${name}\t${doc}#${c.id}${src ? `\t${src}` : ""}`);
}
}
// Name the module CANONICALLY (its #modules name), not however the caller
// addressed it, so `auth` and `auth.geml` return byte-identical answers.
if (!lines.length) return `module \`${row[0]}\` (${doc}) has no symbols in the name index`;
lines.sort();
return lines.join("\n") + `\n\n${lines.length} symbol(s) in ${row[0]}.`;
},
},
{
name: "geml_codemap_node",
description:
"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.",
inputSchema: {
type: "object",
properties: {
doc: { type: "string", description: "Document path relative to the graph dir, e.g. hashtable.c.geml" },
id: { type: "string", description: "Block id, e.g. hashtableFind (or #calls / #called-by / #unresolved for the edge tables)" },
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." },
graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
},
required: ["doc", "id"],
},
run: (args) => {
const graphDir = graphDirOf(args);
const block = readBlock(graphDir, args.doc, args.id);
if (args.source !== true) return block;
return `${block}\n${readSource(graphDir, block)}`;
},
},
];

@@ -170,6 +429,6 @@

// Auto-run only as a MAIN module (the CLI dispatcher spawns this file as a
// child's entry script) — an in-process `import` stays inert.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
createInterface({ input: process.stdin }).on("line", (line) => handleLine(line));
}
// No main-module block: this file no longer starts a server. Running it
// directly used to serve the three tools on stdio, and leaving that in would
// keep the removed entry point alive as a back door — `node codemap/
// mcp-server.mjs` reachable from any client config. `geml mcp` owns the
// transport now.

@@ -5,2 +5,3 @@ #!/usr/bin/env node

history: boolean;
graph?: string;
}

@@ -17,4 +18,11 @@ /** Configure the server. Exported so the suite can point it at a temp dir. */

export declare const TOOLS: Tool[];
/** Tools served right now: the ten document tools, plus the graph tools when a graph is configured. */
export declare function allTools(): Tool[];
/**
* Load and confine the code-graph tools. Idempotent; awaited at startup and by
* the suite, which drives `handleLine` in-process.
*/
export declare function loadGraphTools(): Promise<Tool[]>;
export declare function handleLine(line: string, write?: (s: string) => void): void;
export declare const MCP_USAGE = "usage: geml mcp --root <dir> [--no-history]\n\n Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).\n\n --root <dir> REQUIRED. Root directory holding the .geml documents.\n Relative paths resolve against the server process's CWD,\n which the CLIENT chooses \u2014 pass an absolute path.\n Every path a client names is confined to this directory;\n a client cannot widen or override it.\n --no-history Do not auto-commit a .gemlhistory revision before each\n write. Default is to commit, so geml_revert_block always\n has a revision to undo to.\n\n Register with a client:\n claude mcp add geml -- geml mcp --root /abs/path/to/docs";
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";
export declare function parseArgs(args: string[]): McpOptions;
#!/usr/bin/env node
// `geml mcp` — MCP server for GEML document CRUD.
// `geml mcp` — MCP server for GEML documents and the code graph.
//
// Nine tools over a confined root directory of `.geml` documents: four read-only,
// five that write. It is the document-editing counterpart to the read-only
// code-graph server in `codemap/mcp-server.mjs`, and deliberately mirrors its
// shape (newline-delimited JSON-RPC 2.0 over stdio, zero dependencies, an
// exported `handleLine` so the suite can drive it in-process).
// Ten tools over a confined root directory of `.geml` documents: five read-only,
// five that write, each named after the CLI verb it wraps (`geml set` ->
// `geml_set`, the bare transform entry -> `geml_to`). When that root holds a code graph, the four read-only
// code-graph tools from `codemap/mcp-server.mjs` are served from this SAME
// process, so a client registers one server instead of two. That file stays a
// standalone `geml codemap mcp` entry point; this one imports its tool table
// rather than copying it, which is cheap because the two were deliberately
// built to the same shape (newline-delimited JSON-RPC 2.0 over stdio, zero
// dependencies, an exported `handleLine` so the suite can drive it in-process).
//
// claude mcp add geml -- geml mcp --root /abs/path/to/docs
// claude mcp add geml -- geml mcp --root /abs/path/to/repo
//

@@ -20,7 +24,12 @@ // Three invariants make this worth more than letting a model `str_replace` the

// then wait for a human to notice.
// 2. EVERY WRITE IS PRECEDED BY A HISTORY COMMIT, so `geml_revert_block` can
// 2. EVERY WRITE IS PRECEDED BY A HISTORY COMMIT, so `geml_revert` can
// always undo the block that was just touched. Without this the strongest
// tool in the set would have nothing to revert to.
// 3. EVERY PATH IS CONFINED to a server-side `--root` directory the client
// cannot override or widen.
// cannot override or widen. This is where the two servers disagreed, and
// merging had to pick one: standalone `codemap mcp` lets the client name
// `graph_dir` per call (it is pointed AT a graph and only reads). Here the
// same process can write, so a client-named directory is narrowed to the
// server root like every other path — a read-anywhere argument does not
// belong on a server that also writes.
//

@@ -78,9 +87,8 @@ // The mutations run through the CLI rather than re-implementing block editing:

}
// Cross-document references resolve against the SERVER root, never against
// a client-named directory: `root` may only NARROW to a directory inside it.
function resolveRoot(root) {
// A client-named directory may only NARROW to one inside the server root — it
// can never widen or escape it. `label` names the argument in the error so the
// model can tell which of its arguments was refused.
function narrowToRoot(dir, label) {
const serverRoot = realpathSync(OPTS.root);
if (root === undefined || root === "")
return serverRoot;
const target = resolve(serverRoot, root);
const target = resolve(serverRoot, dir);
let real;

@@ -91,8 +99,25 @@ try {

catch {
throw new Error(`no such directory under the server root: ${root}`);
throw new Error(`no such directory under the server root: ${dir}`);
}
if (real !== serverRoot && !real.startsWith(serverRoot + sep))
throw new Error(`root escapes the server root: ${root}`);
throw new Error(`${label} escapes the server root: ${dir}`);
return real;
}
// Cross-document references resolve against the SERVER root, never against
// a client-named directory.
function resolveRoot(root) {
if (root === undefined || root === "")
return realpathSync(OPTS.root);
return narrowToRoot(root, "root");
}
// The code-graph directory for one call: the server's `--graph` unless the
// client named one, and a client-named one is narrowed like any other path.
function resolveGraphDir(graphDir) {
if (graphDir === undefined || graphDir === "") {
if (!OPTS.graph)
throw new Error("this server has no code graph; start it with --graph <dir> under --root");
return OPTS.graph;
}
return narrowToRoot(String(graphDir), "graph_dir");
}
// ---------------------------------------------------------------------------

@@ -220,3 +245,3 @@ // Driving the CLI

{
name: "geml_list_ids",
name: "geml_list",
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.",

@@ -233,4 +258,4 @@ inputSchema: { type: "object", properties: { file: FILE_ARG }, required: ["file"] },

{
name: "geml_read_block",
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.",
name: "geml_get",
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.",
inputSchema: {

@@ -278,4 +303,4 @@ type: "object",

{
name: "geml_history_log",
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.",
name: "geml_history",
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.",
inputSchema: { type: "object", properties: { file: FILE_ARG }, required: ["file"] },

@@ -290,5 +315,50 @@ run: (args) => {

},
{
name: "geml_to",
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.",
inputSchema: {
type: "object",
properties: {
file: FILE_ARG,
to: {
type: "string",
enum: ["json", "md", "geml", "html"],
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.",
},
from: {
type: "string",
enum: ["geml", "md", "json"],
description: "Override the input format, which is otherwise inferred from the extension (.md -> md, .json -> json, else geml).",
},
},
required: ["file"],
},
run: (args) => {
const real = resolveInRoot(args.file);
// Enforce the enums here too: a client is free to ignore the schema, and a
// typo'd format should come back as this server's clear error rather than
// whatever the CLI makes of it.
const to = args.to === undefined ? undefined : String(args.to);
const from = args.from === undefined ? undefined : String(args.from);
if (to !== undefined && !["json", "md", "geml", "html"].includes(to))
throw new Error(`unknown \`to\` format: ${to} (want json | md | geml | html)`);
if (from !== undefined && !["geml", "md", "json"].includes(from))
throw new Error(`unknown \`from\` format: ${from} (want geml | md | json)`);
const argv = [real];
if (to !== undefined)
argv.push("--to", to);
if (from !== undefined)
argv.push("--from", from);
const run = runCli(argv);
// The transform exits 1 on a document with errors but still prints the
// result; surface the diagnostics rather than the text in that case, so a
// model is never handed the output of a document it was told nothing about.
if (!run.ok)
throw new Error(run.stderr || `could not convert ${args.file}`);
return run.stdout;
},
},
// ----- write -----
{
name: "geml_write_block",
name: "geml_set",
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.",

@@ -320,3 +390,3 @@ inputSchema: {

{
name: "geml_add_block",
name: "geml_add",
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.",

@@ -354,3 +424,3 @@ inputSchema: {

{
name: "geml_delete_block",
name: "geml_delete",
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.",

@@ -379,3 +449,3 @@ inputSchema: {

{
name: "geml_rename_id",
name: "geml_rename",
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.",

@@ -401,4 +471,4 @@ inputSchema: {

{
name: "geml_revert_block",
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.",
name: "geml_revert",
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.",
inputSchema: {

@@ -431,2 +501,64 @@ type: "object",

// ---------------------------------------------------------------------------
// Code-graph tools, imported from the standalone server
// ---------------------------------------------------------------------------
// The four read-only code-graph tools, re-served here with this
// server's confinement. Empty until `loadGraphTools()` runs — the import is
// dynamic because `codemap/mcp-server.mjs` is a plain .mjs script that itself
// top-level-awaits the parser, and because a server started without a graph
// should not pay for loading it at all.
let GRAPH_TOOLS = [];
/** Tools served right now: the ten document tools, plus the graph tools when a graph is configured. */
export function allTools() {
return OPTS.graph ? [...TOOLS, ...GRAPH_TOOLS] : TOOLS;
}
// The upstream `graph_dir` description advertises `$GEML_GRAPH_DIR or
// ./.geml-code-graph`, neither of which applies here — the env var is bypassed
// (we always pass a resolved directory) and the default is this server's
// --graph. A tool description that names something the server will refuse is
// the exact failure `eb7390a` fixed for `latest`, so rewrite it rather than
// re-serve it.
function confineSchema(schema) {
const props = schema?.properties;
if (!props?.graph_dir)
return schema;
return {
...schema,
properties: {
...props,
graph_dir: {
type: "string",
description: "Code-graph directory, relative to the server's --root (defaults to the server's --graph). Paths outside --root are refused.",
},
},
};
}
/**
* Load and confine the code-graph tools. Idempotent; awaited at startup and by
* the suite, which drives `handleLine` in-process.
*/
export async function loadGraphTools() {
if (GRAPH_TOOLS.length)
return GRAPH_TOOLS;
// Non-literal specifier on purpose: this resolves at RUNTIME from dist/ to
// the sibling codemap/ directory (both are shipped), and it keeps tsc from
// demanding types for an untyped .mjs script.
const spec = new URL("../codemap/mcp-server.mjs", import.meta.url).href;
const mod = await import(spec);
// `geml_codemap_node(source: true)` reads the real sources, and WHERE those
// are comes from `_index/refresh.json` inside the graph — data this server
// did not choose. Bound it to --root like every other path, so a hand-edited
// recipe cannot point the reader out of the tree the operator opened.
mod.confineSourceTo(OPTS.root);
GRAPH_TOOLS = mod.TOOLS.map((t) => ({
name: t.name,
description: t.description,
inputSchema: confineSchema(t.inputSchema),
// Resolve the directory HERE, then hand the tool an absolute path: its own
// `graphDirOf` prefers an explicit `graph_dir`, so this shuts out both the
// env var and the relative default without touching that file.
run: (args) => t.run({ ...args, graph_dir: resolveGraphDir(args.graph_dir) }),
}));
return GRAPH_TOOLS;
}
// ---------------------------------------------------------------------------
// newline-delimited JSON-RPC 2.0 over stdio

@@ -463,6 +595,6 @@ // ---------------------------------------------------------------------------

else if (method === "tools/list") {
reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
reply(id, { tools: allTools().map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
}
else if (method === "tools/call") {
const tool = TOOLS.find((t) => t.name === params?.name);
const tool = allTools().find((t) => t.name === params?.name);
if (!tool) {

@@ -495,5 +627,6 @@ replyError(id, -32602, `unknown tool: ${params?.name}`);

// ---------------------------------------------------------------------------
export const MCP_USAGE = `usage: geml mcp --root <dir> [--no-history]
export const MCP_USAGE = `usage: geml mcp --root <dir> [--graph <dir>] [--no-history]
Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0).
Serve GEML document CRUD over the MCP stdio transport (JSON-RPC 2.0), plus the
read-only code-graph tools when the root holds a code graph.

@@ -505,10 +638,15 @@ --root <dir> REQUIRED. Root directory holding the .geml documents.

a client cannot widen or override it.
--graph <dir> Code-graph directory, inside --root. Defaults to
<root>/.geml-code-graph when that holds an index.geml.
With no graph, the code-graph tools are not served
at all (a client sees only the document tools).
--no-history Do not auto-commit a .gemlhistory revision before each
write. Default is to commit, so geml_revert_block always
write. Default is to commit, so geml_revert always
has a revision to undo to.
Register with a client:
claude mcp add geml -- geml mcp --root /abs/path/to/docs`;
claude mcp add geml -- geml mcp --root /abs/path/to/repo`;
export function parseArgs(args) {
let root;
let graph;
let history = true;

@@ -521,2 +659,6 @@ for (let i = 0; i < args.length; i++) {

root = a.slice("--root=".length);
else if (a === "--graph")
graph = args[++i];
else if (a.startsWith("--graph="))
graph = a.slice("--graph=".length);
else if (a === "--no-history")

@@ -541,4 +683,23 @@ history = false;

throw new Error(`--root is not a directory: ${root}`);
return { root: realpathSync(abs), history };
const realRoot = realpathSync(abs);
return { root: realRoot, history, graph: resolveGraphOpt(realRoot, graph) };
}
// An EXPLICIT --graph is trusted to be a graph (the operator said so) and only
// has to exist inside the root — failing fast beats starting a server whose
// graph tools all error. The IMPLICIT default has to be sure it found one, so
// it requires an index.geml: an unrelated `.geml-code-graph` directory must not
// make three broken tools appear.
function resolveGraphOpt(realRoot, graph) {
if (graph === undefined || graph === "") {
const guess = resolve(realRoot, ".geml-code-graph");
return existsSync(resolve(guess, "index.geml")) ? realpathSync(guess) : undefined;
}
const abs = resolve(realRoot, graph);
if (!existsSync(abs) || !statSync(abs).isDirectory())
throw new Error(`--graph is not a directory: ${graph}`);
const real = realpathSync(abs);
if (real !== realRoot && !real.startsWith(realRoot + sep))
throw new Error(`--graph must live inside --root: ${graph}`);
return real;
}
// Auto-run only as a MAIN module: the CLI dispatcher spawns this file as a

@@ -559,3 +720,8 @@ // child's entry script, while an in-process `import` (the test suite) stays inert.

}
// Load the graph tools BEFORE the first frame can arrive: `tools/list` is
// synchronous, so a client that lists during the load would be told the
// server has no code graph and would never ask again.
if (OPTS.graph)
await loadGraphTools();
createInterface({ input: process.stdin }).on("line", (line) => handleLine(line));
}
{
"name": "@geml/geml",
"version": "1.4.4",
"version": "1.4.5",
"mcpName": "io.github.geml-spec/geml",

@@ -5,0 +5,0 @@ "publishConfig": {

@@ -26,3 +26,3 @@ <p align="center">

read or patch one section without re-emitting the whole file (on this repo's
own spec, ~**31× less context** than shipping the whole document).
own spec, ~**66× less context** than shipping the whole document).
- **Verifiable** — references are checked at build time (a dangling `#id` is an

@@ -163,2 +163,33 @@ error, not a silent dead link), and the parser emits a document-model JSON

## MCP Server
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.
To connect it to an MCP-compatible client, provide the `npx` execution command and specify the `--root` argument (the directory containing your `.geml` files).
### Claude Desktop
Add to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"geml": {
"command": "npx",
"args": [
"-y",
"@geml/geml@latest",
"mcp",
"--root",
"/absolute/path/to/your/docs"
]
}
}
}
```
### Claude Code / CLI Clients
Run the following command to add the server:
```sh
/mcp add npx -y @geml/geml@latest mcp --root /absolute/path/to/your/docs
```
## Library

@@ -165,0 +196,0 @@

Sorry, the diff of this file is too big to display