graph-indexer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp-server.mjs ADDED
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file mcp-server.mjs
4
+ * @description MCP Server to expose in-memory graph index with hybrid search capabilities. Connects to local Ollama instance for embedding generation. Zero external dependencies.
5
+ * @author MaquinaTech <https://github.com/MaquinaTech>
6
+ * @copyright (c) 2026 MaquinaTech. All rights reserved.
7
+ * @license GPL-3.0-only
8
+ * * This program is free software: you can redistribute it and/or modify
9
+ * it under the terms of the GNU General Public License as published by
10
+ * the Free Software Foundation, either version 3 of the License, or
11
+ * (at your option) any later version.
12
+ *
13
+ * This program is distributed in the hope that it will be useful,
14
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
15
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16
+ * GNU General Public License for more details.
17
+ */
18
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
+ import { z } from "zod";
21
+ import fs from "fs";
22
+ import path, { resolve } from "path";
23
+ import { fileURLToPath } from "url";
24
+ import { spawn } from "child_process";
25
+ import { MemoryGraphIndex } from "./core-engine.mjs";
26
+ import { getLocalEmbedding, getParserForFile, extractFileSkeleton } from './parser-utils.mjs';
27
+
28
+ const PROJECT_ROOT = process.env.MCP_PROJECT_ROOT || process.cwd();
29
+ const INDEX_PATH = resolve(PROJECT_ROOT, "code-index.json");
30
+ const PID_FILE = resolve(PROJECT_ROOT, ".idx-daemon.pid");
31
+
32
+ // ─── Daemon Orchestration ─────────────────────────────────────────────────────
33
+
34
+ function ensureDaemonRunning() {
35
+ // 1. Check if a daemon is already running to avoid duplicate processes
36
+ if (fs.existsSync(PID_FILE)) {
37
+ const pid = parseInt(fs.readFileSync(PID_FILE, 'utf-8'), 10);
38
+ try {
39
+ process.kill(pid, 0); // Esto no mata el proceso, solo comprueba si existe
40
+ process.stderr.write(`✅ Daemon is already active in background (PID: ${pid}).\n`);
41
+ return;
42
+ } catch (e) {
43
+ // The process does not exist (crashed or exited improperly), clean up the stale pid file
44
+ fs.unlinkSync(PID_FILE);
45
+ }
46
+ }
47
+
48
+ // 2. Spawn the Daemon in detached mode
49
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
50
+ const daemonPath = path.join(__dirname, "watch-daemon.mjs");
51
+
52
+ const logPath = path.join(PROJECT_ROOT, ".idx-daemon.log");
53
+ process.stderr.write(`🚀 Starting the Watcher Daemon in background...\n`);
54
+ process.stderr.write(` Daemon log: ${logPath}\n`);
55
+
56
+ // Open a log file in append mode so daemon crashes are always visible
57
+ let logFd;
58
+ try { logFd = fs.openSync(logPath, 'a'); } catch { logFd = null; }
59
+ const stdioOpt = logFd !== null ? ['ignore', logFd, logFd] : 'ignore';
60
+
61
+ const child = spawn(process.execPath, [daemonPath], {
62
+ detached: true,
63
+ stdio: stdioOpt,
64
+ env: { ...process.env, MCP_PROJECT_ROOT: PROJECT_ROOT }
65
+ });
66
+
67
+ // 3. Unlink the child process so parent doesn't wait on its IO
68
+ child.unref();
69
+ if (logFd !== null) fs.closeSync(logFd);
70
+
71
+ // Save the PID for future checks
72
+ fs.writeFileSync(PID_FILE, child.pid.toString());
73
+ }
74
+
75
+ // ─── Inicialización del Servidor ──────────────────────────────────────────────
76
+
77
+ // Wake the watcher before loading the index
78
+ ensureDaemonRunning();
79
+
80
+ const server = new McpServer({ name: "graph-indexer", version: "1.0.0" });
81
+ const db = new MemoryGraphIndex(INDEX_PATH);
82
+
83
+ // If the index doesn't exist (first run), the newly started daemon will create it
84
+ // Perform a safe load
85
+ try { db.load(); } catch (e) { process.stderr.write("⏳ Waiting for initial indexing...\n"); }
86
+
87
+ // ─── Graph Topology Resource ──────────────────────────────────────────────────
88
+ // Exposes the bidirectional dependency graph via the graph:// URI scheme.
89
+ // Agents can inspect the topology of any indexed file without running a search.
90
+
91
+ server.resource(
92
+ "graph-dependencies",
93
+ new ResourceTemplate("graph://dependencies/{file_path}", {
94
+ list: async () => ({
95
+ resources: Object.keys(db.graph.dependencies).map(fp => ({
96
+ uri: `graph://dependencies/${encodeURIComponent(fp)}`,
97
+ name: fp,
98
+ mimeType: "text/markdown",
99
+ description: `Dependency topology for ${fp}`,
100
+ }))
101
+ })
102
+ }),
103
+ async (uri, { file_path }) => {
104
+ const decodedPath = decodeURIComponent(String(file_path));
105
+ const dependencies = db.graph.dependencies[decodedPath] || [];
106
+ const importedBy = db.graph.importedBy[decodedPath] || [];
107
+
108
+ const md = [
109
+ `# Dependency Topology: \`${decodedPath}\``,
110
+ ``,
111
+ `## Imports (${dependencies.length})`,
112
+ dependencies.length
113
+ ? dependencies.map(d => `- \`${d}\``).join('\n')
114
+ : '_No local imports_',
115
+ ``,
116
+ `## Imported By (${importedBy.length})`,
117
+ importedBy.length
118
+ ? importedBy.map(d => `- \`${d}\``).join('\n')
119
+ : '_No files import this_',
120
+ ].join('\n');
121
+
122
+ return {
123
+ contents: [{ uri: uri.href, mimeType: "text/markdown", text: md }]
124
+ };
125
+ }
126
+ );
127
+
128
+ server.tool(
129
+ "search_code",
130
+ "Hybrid semantic + lexical search (RRF). Returns compact signature cards for all results and fills remaining token budget with code bodies. Use get_chunk(id) to expand any result.",
131
+ {
132
+ query: z.string().describe("What logic to search for. Example: 'JWT authentication in middleware'"),
133
+ exact_tokens: z.string().optional().describe("Exact variable or function name to boost in results"),
134
+ include_topology: z.boolean().default(true),
135
+ min_score: z.number().min(0).max(1).default(0.3).describe("Minimum cosine similarity threshold (0–1)."),
136
+ top_k: z.number().int().min(1).max(20).default(5).describe("Number of results to return (1–20)."),
137
+ token_budget: z.number().int().min(100).optional().describe(
138
+ "Optional estimated token budget for code snippets (1 token ≈ 4 chars). All signature cards are always shown; snippets fill the remaining budget in rank order."
139
+ ),
140
+ },
141
+ async ({ query, exact_tokens, include_topology, min_score, top_k, token_budget }) => {
142
+ try {
143
+ const fullQueryText = exact_tokens ? `${query} ${exact_tokens}` : query;
144
+ let queryVector = null;
145
+ try { queryVector = await getLocalEmbedding(fullQueryText); } catch { /* degrade to lexical */ }
146
+
147
+ const matches = db.searchHybrid(fullQueryText, queryVector, top_k, min_score, exact_tokens || null);
148
+ if (matches.length === 0) return { content: [{ type: "text", text: "No results found." }] };
149
+
150
+ // Helper: build a compact dep signature string for a file path
151
+ const depSignature = (depPath) => {
152
+ const syms = [];
153
+ for (const c of db.chunks.values()) {
154
+ if (c.file_path === depPath && c.name && c.node_type !== 'expression_statement') {
155
+ syms.push(c.name);
156
+ if (syms.length >= 4) break;
157
+ }
158
+ }
159
+ return syms.length ? `${depPath} [${syms.join(', ')}]` : depPath;
160
+ };
161
+
162
+ const lines = [`🔍 QUERY: "${fullQueryText}" — ${matches.length} result(s)\n`];
163
+
164
+ // ── Phase 1: Signature cards (always emitted, ~40 tokens each) ────────────────
165
+ for (let i = 0; i < matches.length; i++) {
166
+ const { score, chunk } = matches[i];
167
+ lines.push(`${'─'.repeat(50)}`);
168
+ lines.push(`#${i + 1} · **${chunk.name}** [${chunk.node_type}]`);
169
+ lines.push(`📄 ${chunk.file_path}:${chunk.start_line}–${chunk.end_line} · ID: \`${chunk.id}\` · RRF: ${score.toFixed(4)}`);
170
+ if (chunk.docstring) lines.push(`💬 ${chunk.docstring.slice(0, 120).replace(/\n/g, ' ')}`);
171
+
172
+ if (include_topology) {
173
+ const deps = (db.graph.dependencies[chunk.file_path] || []).slice(0, 3);
174
+ const usedBy = (db.graph.importedBy[chunk.file_path] || []).slice(0, 3);
175
+ if (deps.length) lines.push(`⬇️ Deps: ${deps.map(depSignature).join(' | ')}`);
176
+ if (usedBy.length) lines.push(`⬆️ Used by: ${usedBy.join(', ')}`);
177
+ if (chunk.calls?.length) lines.push(`🔗 Calls: ${chunk.calls.slice(0, 6).join(', ')}`);
178
+ }
179
+ lines.push(`↩️ Expand body: get_chunk("${chunk.id}")`);
180
+ }
181
+
182
+ // ── Phase 2: Code snippets — fill token budget in rank order ──────────────
183
+ const CHARS_PER_TOKEN = 4;
184
+ const MAX_SNIPPET_CHARS = token_budget != null ? 0 : 1500; // default: show rank-1 snippet
185
+ let remainingChars = token_budget != null ? token_budget * CHARS_PER_TOKEN : MAX_SNIPPET_CHARS;
186
+
187
+ lines.push(`\n${'═'.repeat(50)}`);
188
+ lines.push(`CODE BODIES (budget: ${token_budget != null ? token_budget + ' tok' : '1500 chars rank-1'})\n`);
189
+
190
+ for (const { chunk } of matches) {
191
+ if (remainingChars <= 0) break;
192
+ const snippet = (chunk.code_snippet || '').slice(0, remainingChars);
193
+ if (!snippet) continue;
194
+ lines.push(`### ${chunk.name} — ${chunk.file_path}`);
195
+ lines.push('```\n' + snippet + '\n```\n');
196
+ remainingChars -= snippet.length;
197
+ }
198
+
199
+ return { content: [{ type: "text", text: lines.join("\n") }] };
200
+ } catch (err) {
201
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
202
+ }
203
+ }
204
+ );
205
+
206
+ server.tool(
207
+ "get_chunk",
208
+ "Returns the full source body of a specific indexed code chunk by ID. Use AFTER search_code when you need more than the signature.",
209
+ { chunk_id: z.string().describe("The chunk ID shown in search_code results (e.g. \"abc123def456\").") },
210
+ async ({ chunk_id }) => {
211
+ try {
212
+ const chunk = db.chunks.get(chunk_id);
213
+ if (!chunk) return { content: [{ type: "text", text: `Chunk '${chunk_id}' not found. Run search_code to get valid IDs.` }] };
214
+ const parts = [
215
+ `# ${chunk.name}`,
216
+ `**File:** \`${chunk.file_path}\` · **Lines:** ${chunk.start_line}–${chunk.end_line} · **Type:** ${chunk.node_type}`,
217
+ ];
218
+ if (chunk.docstring) parts.push(`**Doc:** ${chunk.docstring}`);
219
+ const deps = db.graph.dependencies[chunk.file_path] || [];
220
+ const usedBy = db.graph.importedBy[chunk.file_path] || [];
221
+ if (deps.length) parts.push(`⬇️ Imports: ${deps.join(', ')}`);
222
+ if (usedBy.length) parts.push(`⬆️ Used by: ${usedBy.join(', ')}`);
223
+ if (chunk.calls?.length) parts.push(`🔗 Calls: ${chunk.calls.join(', ')}`);
224
+ parts.push('', '```', chunk.code_snippet, '```');
225
+ return { content: [{ type: "text", text: parts.join('\n') }] };
226
+ } catch (err) {
227
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
228
+ }
229
+ }
230
+ );
231
+
232
+ server.tool(
233
+ "get_file_skeleton",
234
+ "Returns ONLY the function and class signatures of a file. Use this to quickly understand a file's structure without consuming thousands of tokens reading the full code.",
235
+ { file_path: z.string().describe("Relative path to the file (e.g., 'src/app.ts')") },
236
+ async ({ file_path }) => {
237
+ try {
238
+ const absolutePath = resolve(PROJECT_ROOT, file_path);
239
+ // Guard against path traversal attacks
240
+ const safeRoot = path.normalize(PROJECT_ROOT);
241
+ if (!path.normalize(absolutePath).startsWith(safeRoot + path.sep) &&
242
+ path.normalize(absolutePath) !== safeRoot) {
243
+ throw new Error("Access denied: path is outside the project root.");
244
+ }
245
+ if (!fs.existsSync(absolutePath)) throw new Error("File not found.");
246
+
247
+ const content = fs.readFileSync(absolutePath, 'utf-8');
248
+ const ext = path.extname(absolutePath);
249
+ const parser = getParserForFile(ext);
250
+
251
+ if (!parser) return { content: [{ type: "text", text: "Language not supported for AST parsing." }] };
252
+
253
+ const tree = parser.parse(content);
254
+ const skeleton = extractFileSkeleton(tree.rootNode, content);
255
+
256
+ return { content: [{ type: "text", text: `# Skeleton: ${file_path}\n\n${skeleton || "_No semantic signatures found_"}` }] };
257
+ } catch (err) {
258
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
259
+ }
260
+ }
261
+ );
262
+
263
+ server.tool(
264
+ "get_call_graph",
265
+ "Finds all code chunks across the entire repository that invoke a specific function. CRITICAL for safe refactoring.",
266
+ { target_function: z.string().describe("The exact name of the function being called (e.g., 'validateToken')") },
267
+ async ({ target_function }) => {
268
+ try {
269
+ const callers = [];
270
+ for (const chunk of db.chunks.values()) {
271
+ if (chunk.calls && chunk.calls.includes(target_function)) {
272
+ callers.push(`- [${chunk.node_type}] \`${chunk.name}\` in \`${chunk.file_path}\` (lines ${chunk.start_line}-${chunk.end_line})`);
273
+ }
274
+ }
275
+
276
+ if (callers.length === 0) {
277
+ return { content: [{ type: "text", text: `✅ Safe to modify: No local functions found calling '${target_function}'.` }] };
278
+ }
279
+
280
+ const md = [
281
+ `# ⚠️ Cross-File Call Graph: \`${target_function}\``,
282
+ `The following ${callers.length} function(s) depend on this method. You must review them before modifying the target's signature:`,
283
+ ...callers
284
+ ].join('\n');
285
+
286
+ return { content: [{ type: "text", text: md }] };
287
+ } catch (err) {
288
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
289
+ }
290
+ }
291
+ );
292
+
293
+ const transport = new StdioServerTransport();
294
+ await server.connect(transport);
295
+ process.stderr.write("✅ graph-indexer MCP server running (v1.0.0).\n");
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "graph-indexer",
3
+ "version": "1.0.0",
4
+ "description": "Zero-DB, Air-gapped, In-Memory Graph Indexer MCP Server with Hybrid RRF Search and Cross-Language Topology",
5
+ "type": "module",
6
+ "main": "core-engine.mjs",
7
+ "exports": {
8
+ ".": "./core-engine.mjs"
9
+ },
10
+ "bin": {
11
+ "idx-index": "indexer.mjs",
12
+ "idx-watch": "watch-daemon.mjs",
13
+ "idx-mcp": "mcp-server.mjs"
14
+ },
15
+ "files": [
16
+ "*.mjs",
17
+ "PROMPT.md",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/MaquinaTech/graph-indexer.git"
24
+ },
25
+ "scripts": {
26
+ "start": "node ./mcp-server.mjs",
27
+ "test": "node tests/eval-harness.mjs --index ./code-index.json",
28
+ "mcp:index": "node indexer.mjs --repo .",
29
+ "mcp:watch": "node watch-daemon.mjs",
30
+ "mcp:start": "node mcp-server.mjs"
31
+ },
32
+ "dependencies": {
33
+ "@modelcontextprotocol/sdk": "1.29.0",
34
+ "chokidar": "5.0.0",
35
+ "ignore": "5.3.2",
36
+ "tree-sitter": "0.21.1",
37
+ "tree-sitter-css": "0.21.1",
38
+ "tree-sitter-go": "0.21.0",
39
+ "tree-sitter-javascript": "0.21.4",
40
+ "tree-sitter-php": "0.23.1",
41
+ "tree-sitter-python": "0.21.0",
42
+ "tree-sitter-rust": "0.21.0",
43
+ "tree-sitter-typescript": "0.21.2",
44
+ "zod": "3.23.8"
45
+ },
46
+ "keywords": [
47
+ "mcp",
48
+ "model-context-protocol",
49
+ "indexer",
50
+ "ast",
51
+ "tree-sitter",
52
+ "rag",
53
+ "hybrid-search",
54
+ "rrf",
55
+ "call-graph",
56
+ "ollama"
57
+ ],
58
+ "author": {
59
+ "name": "MaquinaTech",
60
+ "url": "https://github.com/MaquinaTech",
61
+ "email": "nicolopezdelerma@gmail.com"
62
+ },
63
+ "license": "GPL-3.0-only",
64
+ "engines": {
65
+ "node": ">=18.0.0"
66
+ },
67
+ "bugs": {
68
+ "url": "https://github.com/MaquinaTech/graph-indexer/issues"
69
+ },
70
+ "homepage": "https://github.com/MaquinaTech/graph-indexer",
71
+ "funding": {
72
+ "type": "github",
73
+ "url": "https://github.com/sponsors/MaquinaTech"
74
+ },
75
+ "publishConfig": {
76
+ "access": "public"
77
+ },
78
+ "optionalDependencies": {
79
+ "hnswlib-node": "^3.0.0"
80
+ }
81
+ }