@geml/geml
Advanced tools
+9
-23
@@ -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++; |
+319
-60
| #!/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. |
+9
-1
@@ -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; |
+201
-35
| #!/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)); | ||
| } |
+1
-1
| { | ||
| "name": "@geml/geml", | ||
| "version": "1.4.4", | ||
| "version": "1.4.5", | ||
| "mcpName": "io.github.geml-spec/geml", | ||
@@ -5,0 +5,0 @@ "publishConfig": { |
+32
-1
@@ -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
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
713495
4.98%13877
4.31%218
16.58%46
6.98%