@geml/geml 1.4.2 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,172 +1,172 @@
1
- #!/usr/bin/env node
2
- // geml-code-graph MCP server — the thin consumption wrapper of DESIGN §8 (P2).
3
- // Three navigation tools over a built graph/ directory, each "give an
4
- // identifier, get readable text back" (the original proposal's 2.6):
5
- // resolve_name name -> candidate anchors (doc + block id)
6
- // open_symbol doc + id -> that symbol's block, verbatim
7
- // get_backlinks doc + id -> the symbol's backlink block (who calls it)
8
- //
9
- // Zero dependencies: newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio
10
- // transport). Register e.g.:
11
- // claude mcp add geml-code-graph -e GEML_GRAPH_DIR=/abs/path/to/graph \
12
- // -- geml codemap mcp
13
- // The graph dir comes from GEML_GRAPH_DIR or a per-call `graph_dir` argument.
14
- //
15
- // The dispatch is exported (and the stdio wiring below is main-module guarded)
16
- // so the test suite can drive it in-process; the CLI dispatcher always runs
17
- // this file as a child's MAIN module, where nothing changes.
18
- import { readFileSync, existsSync, realpathSync } from "node:fs";
19
- import { join, resolve, dirname, sep } from "node:path";
20
- import { fileURLToPath, pathToFileURL } from "node:url";
21
- import { createInterface } from "node:readline";
22
-
23
- // blockSpans from the reference parser (its CLI entry is guarded, so importing
24
- // is side-effect free). Falls back with a clear error if the parser isn't built.
25
- const parserPath = resolve(dirname(fileURLToPath(import.meta.url)), "../dist/geml.js");
26
- if (!existsSync(parserPath)) {
27
- console.error("geml-code-graph mcp: build the parser first (cd geml-parser && npm install && npm run build)");
28
- process.exit(1);
29
- }
30
- const { blockSpans } = await import(`file://${parserPath.replace(/\\/g, "/")}`);
31
- const splitLines = (s) => s.split(/(?<=\n)/);
32
-
33
- export const graphDirOf = (args) => resolve(args?.graph_dir ?? process.env.GEML_GRAPH_DIR ?? ".geml-code-graph");
34
-
35
- export const readBlock = (graphDir, doc, id) => {
36
- const p = join(graphDir, doc);
37
- // Confine `doc` to the graph dir. `doc` is client-supplied, so a value like
38
- // ../../../etc/hosts joins OUT of the dir; realpathSync canonicalizes both
39
- // sides (also defeating symlink escapes and normalizing Windows casing) and
40
- // we verify the real doc path stays within the real graph dir. A missing
41
- // file makes realpathSync throw — that is the normal "no such document"
42
- // miss. (graph_dir itself is intentionally client-chosen — the server is
43
- // pointed at a graph — so only the doc path is confined, to that dir.)
44
- let realDir;
45
- try { realDir = realpathSync(graphDir); } catch { realDir = resolve(graphDir); }
46
- let realP;
47
- try { realP = realpathSync(p); } catch { throw new Error(`no such document: ${doc} (graph dir: ${graphDir})`); }
48
- if (realP !== realDir && !realP.startsWith(realDir + sep)) {
49
- throw new Error(`document escapes the graph dir: ${doc} (graph dir: ${graphDir})`);
50
- }
51
- const source = readFileSync(realP, "utf8");
52
- const span = blockSpans(source).get(id.replace(/^#/, ""));
53
- if (!span) throw new Error(`no block with id \`${id}\` in ${doc}`);
54
- return splitLines(source).slice(span.start, span.end).join("");
55
- };
56
-
57
- export const TOOLS = [
58
- {
59
- name: "resolve_name",
60
- 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.",
61
- inputSchema: {
62
- type: "object",
63
- properties: {
64
- name: { type: "string", description: "Exact symbol name (function/class short name)" },
65
- graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
66
- },
67
- required: ["name"],
68
- },
69
- run: (args) => {
70
- const lookupPath = join(graphDirOf(args), "_index/name-lookup.json");
71
- if (!existsSync(lookupPath)) throw new Error(`no name-lookup at ${lookupPath} — build the graph first`);
72
- const lookup = JSON.parse(readFileSync(lookupPath, "utf8"));
73
- const hits = lookup[args.name];
74
- if (!hits?.length) return `no symbol named \`${args.name}\` in the graph`;
75
- return JSON.stringify(hits, null, 1);
76
- },
77
- },
78
- {
79
- name: "open_symbol",
80
- 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.",
81
- inputSchema: {
82
- type: "object",
83
- properties: {
84
- doc: { type: "string", description: "Document path relative to the codemap dir, e.g. hashtable.c.geml" },
85
- id: { type: "string", description: "Block id, e.g. hashtableFind (or #calls / #called-by for the edge tables)" },
86
- graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
87
- },
88
- required: ["doc", "id"],
89
- },
90
- run: (args) => readBlock(graphDirOf(args), args.doc, args.id),
91
- },
92
- {
93
- name: "get_backlinks",
94
- 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.",
95
- inputSchema: {
96
- type: "object",
97
- properties: {
98
- doc: { type: "string", description: "The symbol's document path, e.g. hashtable.c.geml" },
99
- id: { type: "string", description: "The symbol's block id (e.g. hashtableFind); omit to get the whole #called-by table" },
100
- graph_dir: { type: "string", description: "Codemap directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
101
- },
102
- required: ["doc"],
103
- },
104
- run: (args) => {
105
- // codemap profile: in-edges live in the SAME document's #called-by table.
106
- let table;
107
- try {
108
- table = readBlock(graphDirOf(args), args.doc, "called-by");
109
- } catch {
110
- return `no #called-by table in ${args.doc} — no resolved callers recorded (under heuristic extraction this is a blind spot, not proof of none)`;
111
- }
112
- if (!args.id) return table;
113
- const id = args.id.replace(/^#/, "");
114
- // `id` is client-supplied and goes straight into a RegExp: escape every
115
- // regex metacharacter so it matches LITERALLY (an id like `.*` or a
116
- // catastrophic-backtracking pattern can neither widen the match nor cause
117
- // ReDoS — the pattern is a fixed string wrapped in `,\s*#…\s*,`).
118
- const escId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
119
- const re = new RegExp(`,\\s*#${escId}\\s*,`);
120
- const lines = table.split("\n");
121
- const hits = lines.filter((l, i) => i < 2 || re.test(l));
122
- return hits.length > 2 ? hits.join("\n")
123
- : `no resolved callers of #${id} in ${args.doc} (blind spots live in the #unresolved table)`;
124
- },
125
- },
126
- ];
127
-
128
- // ---- newline-delimited JSON-RPC 2.0 over stdio ----
129
- // One frame in, zero or one frame out via `write` (stdout in production).
130
- export function handleLine(line, write = (s) => process.stdout.write(s)) {
131
- const reply = (id, result) => write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
132
- const replyError = (id, code, message) =>
133
- write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
134
- line = line.trim();
135
- if (!line) return;
136
- let msg;
137
- try { msg = JSON.parse(line); } catch { return; }
138
- const { id, method, params } = msg;
139
- try {
140
- if (method === "initialize") {
141
- reply(id, {
142
- protocolVersion: params?.protocolVersion ?? "2024-11-05",
143
- capabilities: { tools: {} },
144
- serverInfo: { name: "geml-code-graph", version: "0.2.0" },
145
- });
146
- } else if (method === "notifications/initialized" || method?.startsWith("notifications/")) {
147
- // notifications get no response
148
- } else if (method === "ping") {
149
- reply(id, {});
150
- } else if (method === "tools/list") {
151
- reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
152
- } else if (method === "tools/call") {
153
- const tool = TOOLS.find((t) => t.name === params?.name);
154
- if (!tool) { replyError(id, -32602, `unknown tool: ${params?.name}`); return; }
155
- try {
156
- reply(id, { content: [{ type: "text", text: tool.run(params?.arguments ?? {}) }] });
157
- } catch (e) {
158
- reply(id, { content: [{ type: "text", text: `error: ${e.message}` }], isError: true });
159
- }
160
- } else if (id !== undefined) {
161
- replyError(id, -32601, `method not found: ${method}`);
162
- }
163
- } catch (e) {
164
- if (id !== undefined) replyError(id, -32603, String(e?.message ?? e));
165
- }
166
- }
167
-
168
- // Auto-run only as a MAIN module (the CLI dispatcher spawns this file as a
169
- // child's entry script) — an in-process `import` stays inert.
170
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
171
- createInterface({ input: process.stdin }).on("line", (line) => handleLine(line));
172
- }
1
+ #!/usr/bin/env node
2
+ // geml-code-graph MCP server — the thin consumption wrapper of DESIGN §8 (P2).
3
+ // Three navigation tools over a built graph/ directory, each "give an
4
+ // identifier, get readable text back" (the original proposal's 2.6):
5
+ // resolve_name name -> candidate anchors (doc + block id)
6
+ // open_symbol doc + id -> that symbol's block, verbatim
7
+ // get_backlinks doc + id -> the symbol's backlink block (who calls it)
8
+ //
9
+ // Zero dependencies: newline-delimited JSON-RPC 2.0 over stdio (the MCP stdio
10
+ // transport). Register e.g.:
11
+ // claude mcp add geml-code-graph -e GEML_GRAPH_DIR=/abs/path/to/graph \
12
+ // -- geml codemap mcp
13
+ // The graph dir comes from GEML_GRAPH_DIR or a per-call `graph_dir` argument.
14
+ //
15
+ // The dispatch is exported (and the stdio wiring below is main-module guarded)
16
+ // so the test suite can drive it in-process; the CLI dispatcher always runs
17
+ // this file as a child's MAIN module, where nothing changes.
18
+ import { readFileSync, existsSync, realpathSync } from "node:fs";
19
+ import { join, resolve, dirname, sep } from "node:path";
20
+ import { fileURLToPath, pathToFileURL } from "node:url";
21
+ import { createInterface } from "node:readline";
22
+
23
+ // blockSpans from the reference parser (its CLI entry is guarded, so importing
24
+ // is side-effect free). Falls back with a clear error if the parser isn't built.
25
+ const parserPath = resolve(dirname(fileURLToPath(import.meta.url)), "../dist/geml.js");
26
+ if (!existsSync(parserPath)) {
27
+ console.error("geml-code-graph mcp: build the parser first (cd geml-parser && npm install && npm run build)");
28
+ process.exit(1);
29
+ }
30
+ const { blockSpans } = await import(`file://${parserPath.replace(/\\/g, "/")}`);
31
+ const splitLines = (s) => s.split(/(?<=\n)/);
32
+
33
+ export const graphDirOf = (args) => resolve(args?.graph_dir ?? process.env.GEML_GRAPH_DIR ?? ".geml-code-graph");
34
+
35
+ export const readBlock = (graphDir, doc, id) => {
36
+ const p = join(graphDir, doc);
37
+ // Confine `doc` to the graph dir. `doc` is client-supplied, so a value like
38
+ // ../../../etc/hosts joins OUT of the dir; realpathSync canonicalizes both
39
+ // sides (also defeating symlink escapes and normalizing Windows casing) and
40
+ // we verify the real doc path stays within the real graph dir. A missing
41
+ // file makes realpathSync throw — that is the normal "no such document"
42
+ // miss. (graph_dir itself is intentionally client-chosen — the server is
43
+ // pointed at a graph — so only the doc path is confined, to that dir.)
44
+ let realDir;
45
+ try { realDir = realpathSync(graphDir); } catch { realDir = resolve(graphDir); }
46
+ let realP;
47
+ try { realP = realpathSync(p); } catch { throw new Error(`no such document: ${doc} (graph dir: ${graphDir})`); }
48
+ if (realP !== realDir && !realP.startsWith(realDir + sep)) {
49
+ throw new Error(`document escapes the graph dir: ${doc} (graph dir: ${graphDir})`);
50
+ }
51
+ const source = readFileSync(realP, "utf8");
52
+ const span = blockSpans(source).get(id.replace(/^#/, ""));
53
+ if (!span) throw new Error(`no block with id \`${id}\` in ${doc}`);
54
+ return splitLines(source).slice(span.start, span.end).join("");
55
+ };
56
+
57
+ export const TOOLS = [
58
+ {
59
+ name: "resolve_name",
60
+ 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.",
61
+ inputSchema: {
62
+ type: "object",
63
+ properties: {
64
+ name: { type: "string", description: "Exact symbol name (function/class short name)" },
65
+ graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
66
+ },
67
+ required: ["name"],
68
+ },
69
+ run: (args) => {
70
+ const lookupPath = join(graphDirOf(args), "_index/name-lookup.json");
71
+ if (!existsSync(lookupPath)) throw new Error(`no name-lookup at ${lookupPath} — build the graph first`);
72
+ const lookup = JSON.parse(readFileSync(lookupPath, "utf8"));
73
+ const hits = lookup[args.name];
74
+ if (!hits?.length) return `no symbol named \`${args.name}\` in the graph`;
75
+ return JSON.stringify(hits, null, 1);
76
+ },
77
+ },
78
+ {
79
+ name: "open_symbol",
80
+ 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.",
81
+ inputSchema: {
82
+ type: "object",
83
+ properties: {
84
+ doc: { type: "string", description: "Document path relative to the codemap dir, e.g. hashtable.c.geml" },
85
+ id: { type: "string", description: "Block id, e.g. hashtableFind (or #calls / #called-by for the edge tables)" },
86
+ graph_dir: { type: "string", description: "Graph directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
87
+ },
88
+ required: ["doc", "id"],
89
+ },
90
+ run: (args) => readBlock(graphDirOf(args), args.doc, args.id),
91
+ },
92
+ {
93
+ name: "get_backlinks",
94
+ 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.",
95
+ inputSchema: {
96
+ type: "object",
97
+ properties: {
98
+ doc: { type: "string", description: "The symbol's document path, e.g. hashtable.c.geml" },
99
+ id: { type: "string", description: "The symbol's block id (e.g. hashtableFind); omit to get the whole #called-by table" },
100
+ graph_dir: { type: "string", description: "Codemap directory (default: $GEML_GRAPH_DIR or ./.geml-code-graph)" },
101
+ },
102
+ required: ["doc"],
103
+ },
104
+ run: (args) => {
105
+ // codemap profile: in-edges live in the SAME document's #called-by table.
106
+ let table;
107
+ try {
108
+ table = readBlock(graphDirOf(args), args.doc, "called-by");
109
+ } catch {
110
+ return `no #called-by table in ${args.doc} — no resolved callers recorded (under heuristic extraction this is a blind spot, not proof of none)`;
111
+ }
112
+ if (!args.id) return table;
113
+ const id = args.id.replace(/^#/, "");
114
+ // `id` is client-supplied and goes straight into a RegExp: escape every
115
+ // regex metacharacter so it matches LITERALLY (an id like `.*` or a
116
+ // catastrophic-backtracking pattern can neither widen the match nor cause
117
+ // ReDoS — the pattern is a fixed string wrapped in `,\s*#…\s*,`).
118
+ const escId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
119
+ const re = new RegExp(`,\\s*#${escId}\\s*,`);
120
+ const lines = table.split("\n");
121
+ const hits = lines.filter((l, i) => i < 2 || re.test(l));
122
+ return hits.length > 2 ? hits.join("\n")
123
+ : `no resolved callers of #${id} in ${args.doc} (blind spots live in the #unresolved table)`;
124
+ },
125
+ },
126
+ ];
127
+
128
+ // ---- newline-delimited JSON-RPC 2.0 over stdio ----
129
+ // One frame in, zero or one frame out via `write` (stdout in production).
130
+ export function handleLine(line, write = (s) => process.stdout.write(s)) {
131
+ const reply = (id, result) => write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
132
+ const replyError = (id, code, message) =>
133
+ write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
134
+ line = line.trim();
135
+ if (!line) return;
136
+ let msg;
137
+ try { msg = JSON.parse(line); } catch { return; }
138
+ const { id, method, params } = msg;
139
+ try {
140
+ if (method === "initialize") {
141
+ reply(id, {
142
+ protocolVersion: params?.protocolVersion ?? "2024-11-05",
143
+ capabilities: { tools: {} },
144
+ serverInfo: { name: "geml-code-graph", version: "0.2.0" },
145
+ });
146
+ } else if (method === "notifications/initialized" || method?.startsWith("notifications/")) {
147
+ // notifications get no response
148
+ } else if (method === "ping") {
149
+ reply(id, {});
150
+ } else if (method === "tools/list") {
151
+ reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
152
+ } else if (method === "tools/call") {
153
+ const tool = TOOLS.find((t) => t.name === params?.name);
154
+ if (!tool) { replyError(id, -32602, `unknown tool: ${params?.name}`); return; }
155
+ try {
156
+ reply(id, { content: [{ type: "text", text: tool.run(params?.arguments ?? {}) }] });
157
+ } catch (e) {
158
+ reply(id, { content: [{ type: "text", text: `error: ${e.message}` }], isError: true });
159
+ }
160
+ } else if (id !== undefined) {
161
+ replyError(id, -32601, `method not found: ${method}`);
162
+ }
163
+ } catch (e) {
164
+ if (id !== undefined) replyError(id, -32603, String(e?.message ?? e));
165
+ }
166
+ }
167
+
168
+ // Auto-run only as a MAIN module (the CLI dispatcher spawns this file as a
169
+ // child's entry script) — an in-process `import` stays inert.
170
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
171
+ createInterface({ input: process.stdin }).on("line", (line) => handleLine(line));
172
+ }