@znt/mcp 1.0.8 → 1.1.1

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/index.js +78 -713
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -57,7 +57,7 @@
57
57
  * `target` *(string)*: Имя символа, функции или класса в проекте (например `"Server.runFullScan"`). При указании вектор берется из БД напрямую **без обращения к LLM**.
58
58
  * `query` *(string)*: Текстовое описание или код для поиска аналогов (используется, если `target` не задан).
59
59
  * `limit` *(number)*: Максимальное число результатов (по умолчанию `10`).
60
- * `callers_level` / `callees_level` *(number)*: Глубина связанных вызовов (по умолчанию `3`).
60
+ * `edge_types` *(string)*: Фильтр связей для ранжирования (`"call,inherits,implements"`).
61
61
  * `include_code` *(boolean)* / `max_code_lines` *(number)*: Параметры включения кода (по умолчанию `30` строк).
62
62
  * `role` / `type` / `file_pattern` *(string)*: Фильтры по архитектуре и путям файлов.
63
63
 
package/index.js CHANGED
@@ -1,764 +1,129 @@
1
1
  #!/usr/bin/env node
2
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
3
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
2
4
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
6
  import {
5
7
  CallToolRequestSchema,
6
- ErrorCode,
7
8
  ListToolsRequestSchema,
8
- McpError
9
9
  } from '@modelcontextprotocol/sdk/types.js';
10
- import fs from 'node:fs';
11
- import path from 'node:path';
12
- import { DatabaseSync } from 'node:sqlite';
13
- import { fileURLToPath } from 'node:url';
14
10
 
15
- // ─── Helper: find Znt state/database folder ─────────────────────
16
- function findZntDir() {
17
- let currentDir = process.cwd();
18
- while (currentDir) {
19
- const candidate = path.join(currentDir, '.znt');
20
- if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
21
- return candidate;
22
- }
23
- const parent = path.dirname(currentDir);
24
- if (parent === currentDir) {
25
- break;
26
- }
27
- currentDir = parent;
28
- }
29
-
30
- // Fallback: check relative to the script location (parent of znt_mcp folder)
31
- try {
32
- const scriptDir = path.dirname(fileURLToPath(import.meta.url));
33
- const projectRoot = path.resolve(scriptDir, '..');
34
- const candidate = path.join(projectRoot, '.znt');
35
- if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
36
- return candidate;
37
- }
38
- } catch (err) {
39
- // Ignore error
40
- }
41
-
42
- return path.resolve(process.cwd(), '.znt');
43
- }
44
-
45
- function toRelativePath(filePath) {
46
- if (!filePath || typeof filePath !== 'string') return filePath;
47
- const projectRoot = path.dirname(findZntDir());
48
- if (filePath.startsWith(projectRoot)) {
49
- const rel = path.relative(projectRoot, filePath);
50
- return rel ? rel : filePath;
51
- }
52
- return filePath;
53
- }
54
-
55
- // ─── Helper: Source snippet ──────────────────────────────────────
56
- function getSourceSnippet(filePath, startLine, endLine) {
57
- try {
58
- const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(path.dirname(findZntDir()), filePath);
59
- if (!fs.existsSync(absolutePath)) return `File not found: ${filePath}`;
60
- const lines = fs.readFileSync(absolutePath, 'utf8').split('\n');
61
- const start = Math.max(1, startLine) - 1;
62
- const end = Math.min(lines.length, endLine);
63
- return lines.slice(start, end).join('\n');
64
- } catch (err) {
65
- return `Error reading file: ${err.message}`;
66
- }
67
- }
68
-
69
- // ═════════════════════════════════════════════════════════════════
70
- // Usage Tracking & Metrics State
71
- // ═════════════════════════════════════════════════════════════════
72
-
73
- const sessionStartTime = new Date().toISOString();
74
- let sessionOutputTokens = 0;
75
- let sessionSavedTokens = 0;
76
- let sessionCallsCount = 0;
77
- let lastCallSavedTokens = 0;
78
-
79
- /**
80
- * Opens (or creates) the MCP usage tracking SQLite database.
81
- * Stored alongside graph.db in .znt/mcp_usage.db
82
- */
83
- function getUsageDb() {
84
- const zntDir = findZntDir();
85
- const usageDbPath = path.join(zntDir, 'mcp_usage.db');
86
- try {
87
- const db = new DatabaseSync(usageDbPath);
88
- db.exec(`
89
- CREATE TABLE IF NOT EXISTS mcp_usage_log (
90
- id INTEGER PRIMARY KEY AUTOINCREMENT,
91
- timestamp TEXT NOT NULL,
92
- tool_name TEXT NOT NULL,
93
- input_tokens INTEGER DEFAULT 0,
94
- output_tokens INTEGER DEFAULT 0,
95
- execution_ms INTEGER DEFAULT 0,
96
- estimated_alt_tokens INTEGER DEFAULT 0,
97
- cache_hit INTEGER DEFAULT 0
98
- )
99
- `);
100
- return db;
101
- } catch (err) {
102
- console.error('Failed to open usage tracking database:', err);
103
- return null;
104
- }
105
- }
106
-
107
- /**
108
- * Rough token estimation: ~4 chars per token.
109
- */
110
- function estimateTokens(text) {
111
- if (!text) return 0;
112
- const str = typeof text === 'string' ? text : JSON.stringify(text);
113
- return Math.ceil(str.length / 4);
114
- }
115
-
116
- /**
117
- * Helper to compute total file tokens for unique file paths on disk
118
- */
119
- function getFilesTotalTokens(filePaths) {
120
- if (!Array.isArray(filePaths) || filePaths.length === 0) return 0;
121
- const projectRoot = path.dirname(findZntDir());
122
- const uniquePaths = new Set();
123
- let totalBytes = 0;
124
-
125
- for (const fp of filePaths) {
126
- if (!fp || typeof fp !== 'string') continue;
127
- const absPath = path.isAbsolute(fp) ? fp : path.resolve(projectRoot, fp);
128
- if (!uniquePaths.has(absPath) && fs.existsSync(absPath)) {
129
- uniquePaths.add(absPath);
130
- try {
131
- const stat = fs.statSync(absPath);
132
- if (stat.isFile()) {
133
- totalBytes += stat.size;
134
- }
135
- } catch (err) {
136
- // Ignore stat error
137
- }
138
- }
139
- }
11
+ // ─── Configuration ───────────────────────────────────────────────
12
+ const ZNT_API_URL = (process.env.ZNT_API_URL || 'http://localhost:8080').replace(/\/$/, '');
140
13
 
141
- return Math.ceil(totalBytes / 4);
142
- }
14
+ let clientInstance = null;
15
+ let clientPromise = null;
143
16
 
144
17
  /**
145
- * Extract all involved file paths from tool results or metadata
18
+ * Establishes an SSE connection to the native Znt Go MCP Server
146
19
  */
147
- function extractInvolvedFiles(toolName, data, meta) {
148
- const files = [];
149
- if (meta && meta.targetFile) {
150
- files.push(meta.targetFile);
151
- }
152
- if (!data) return files;
20
+ async function connectClient() {
21
+ const sseUrl = new URL(`${ZNT_API_URL}/mcp/sse`);
22
+ const transport = new SSEClientTransport(sseUrl);
23
+ const client = new Client(
24
+ { name: 'znt-mcp-adapter', version: '1.2.0' },
25
+ { capabilities: {} }
26
+ );
27
+
28
+ client.onerror = () => {
29
+ clientInstance = null;
30
+ clientPromise = null;
31
+ };
153
32
 
154
- if (Array.isArray(data)) {
155
- for (const item of data) {
156
- if (item && typeof item === 'object') {
157
- if (item.file) files.push(item.file);
158
- if (item.file_path) files.push(item.file_path);
159
- if (item.filePath) files.push(item.filePath);
160
- }
161
- }
162
- } else if (typeof data === 'object') {
163
- if (data.file) files.push(data.file);
164
- if (data.file_path) files.push(data.file_path);
165
- if (data.filePath) files.push(data.filePath);
33
+ client.onclose = () => {
34
+ clientInstance = null;
35
+ clientPromise = null;
36
+ };
166
37
 
167
- if (Array.isArray(data.result)) {
168
- for (const item of data.result) {
169
- if (item && typeof item === 'object') {
170
- if (item.file) files.push(item.file);
171
- if (item.file_path) files.push(item.file_path);
172
- if (item.filePath) files.push(item.filePath);
173
- }
174
- }
175
- }
176
- if (Array.isArray(data.nodes)) {
177
- for (const node of data.nodes) {
178
- if (node && typeof node === 'object') {
179
- if (node.file) files.push(node.file);
180
- if (node.file_path) files.push(node.file_path);
181
- if (node.filePath) files.push(node.filePath);
182
- }
183
- }
184
- }
185
- }
186
- return files;
38
+ await client.connect(transport);
39
+ clientInstance = client;
40
+ return client;
187
41
  }
188
42
 
189
43
  /**
190
- * Alternative-cost multiplier table for active tools (fallback).
44
+ * Returns active connected client or initiates a new connection
191
45
  */
192
- const ALT_COST_MAP = {
193
- znatok_semantic_search: { description: 'поиск по векторам и лексике + контекст', multiplier: 10 },
194
- znatok_find_similar: { description: 'поиск семантически похожих элементов', multiplier: 10 },
195
- znatok_get_subgraph: { description: 'трассировка и подграф вызовов в 1 запрос', multiplier: 25 },
196
- znatok_file_outline: { description: 'полный анатомический оглавление-атлас файла', multiplier: 15 },
197
- znatok_server_logs: { description: 'ручное подключение к WebSocket', multiplier: 3 },
198
- znatok_mcp_stats: { description: 'данные доступны только через MCP', multiplier: 1 },
199
- };
200
-
201
- function estimateAlternativeCost(toolName, outputTokens, data, meta) {
202
- const involvedFiles = extractInvolvedFiles(toolName, data, meta);
203
- const fileTokens = getFilesTotalTokens(involvedFiles);
204
-
205
- if (fileTokens > 0) {
206
- const ratio = outputTokens > 0 ? (fileTokens / outputTokens).toFixed(1) : '1.0';
207
- return {
208
- tokens: fileTokens,
209
- description: `объем ${involvedFiles.length} затрагиваемых файлов целиком`,
210
- savings_ratio: `${ratio}x`
211
- };
46
+ async function getZntClient() {
47
+ if (clientInstance) {
48
+ return clientInstance;
212
49
  }
213
-
214
- const alt = ALT_COST_MAP[toolName];
215
- if (!alt) return { tokens: outputTokens, description: 'сопоставимо с прямым подходом', savings_ratio: '1.0x' };
216
-
217
- return {
218
- tokens: Math.ceil(outputTokens * alt.multiplier),
219
- description: alt.description,
220
- savings_ratio: `${alt.multiplier}.0x`
221
- };
222
- }
223
-
224
- function logUsage(toolName, inputTokens, outputTokens, executionMs, alternativeTokens, cacheHit = false) {
225
- let usageDb = null;
226
- try {
227
- usageDb = getUsageDb();
228
- if (!usageDb) return;
229
- usageDb.prepare(`
230
- INSERT INTO mcp_usage_log (timestamp, tool_name, input_tokens, output_tokens, execution_ms, estimated_alt_tokens, cache_hit)
231
- VALUES (?, ?, ?, ?, ?, ?, ?)
232
- `).run(new Date().toISOString(), toolName, inputTokens, outputTokens, executionMs, alternativeTokens, cacheHit ? 1 : 0);
233
- } catch (err) {
234
- console.error('Failed to log usage:', err);
235
- } finally {
236
- if (usageDb) try { usageDb.close(); } catch (e) {}
50
+ if (clientPromise) {
51
+ return clientPromise;
237
52
  }
238
- }
239
53
 
240
- function mcpResponse(toolName, data, meta = {}) {
241
- const resultText = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
242
- const outputTokens = estimateTokens(resultText);
243
- const inputTokens = meta.inputTokens || 0;
244
- const executionMs = meta.executionMs || 0;
245
- const altCost = estimateAlternativeCost(toolName, outputTokens, data, meta);
54
+ clientPromise = connectClient().catch((err) => {
55
+ clientInstance = null;
56
+ clientPromise = null;
57
+ throw new Error(`Failed to connect to Znt Go server at ${ZNT_API_URL}/mcp. Make sure Znt backend is running (go run main.go). Detail: ${err.message}`);
58
+ });
246
59
 
247
- const callSavedTokens = Math.max(0, altCost.tokens - outputTokens);
248
- lastCallSavedTokens = callSavedTokens;
249
- sessionOutputTokens += outputTokens;
250
- sessionSavedTokens += callSavedTokens;
251
- sessionCallsCount += 1;
252
-
253
- logUsage(toolName, inputTokens, outputTokens, executionMs, altCost.tokens, meta.cacheHit || false);
254
-
255
- return {
256
- content: [{
257
- type: 'text',
258
- text: JSON.stringify({
259
- _meta: {
260
- tool: toolName,
261
- response_tokens: outputTokens,
262
- execution_ms: executionMs,
263
- last_call_saved_tokens: callSavedTokens,
264
- session_saved_tokens: sessionSavedTokens,
265
- alternative_cost: altCost,
266
- data_sources: meta.sources || []
267
- },
268
- result: data
269
- }, null, 2)
270
- }]
271
- };
60
+ return clientPromise;
272
61
  }
273
62
 
274
-
275
- // ═════════════════════════════════════════════════════════════════
276
- // Server Initialization
277
- // ═════════════════════════════════════════════════════════════════
278
-
63
+ // ─── MCP Server Setup (STDIO) ────────────────────────────────────
279
64
  const server = new Server(
280
- { name: 'znt-mcp-server', version: '1.1.0' },
65
+ { name: 'znt-mcp-adapter', version: '1.2.0' },
281
66
  { capabilities: { tools: {} } }
282
67
  );
283
68
 
284
- // ─── Register Tools (ListTools) ──────────────────────────────────
69
+ // ─── Dynamic Tools Listing ───────────────────────────────────────
285
70
  server.setRequestHandler(ListToolsRequestSchema, async () => {
286
- return {
287
- tools: [
288
- {
289
- name: 'znatok_semantic_search',
290
- description: 'Выполняет векторный семантический поиск элементов кода (функций, классов, модулей) по естественному языку и описанию их назначения в проекте. Позволяет находить узлы графа и их взаимосвязи ("где вызывается" и "где вызывают") с возможностью глубокой детализации. Возвращает совпадения с коэффициентами сходства (score), аннотациями, относительным путем к файлу и графом вызовов.',
291
- inputSchema: {
292
- type: 'object',
293
- properties: {
294
- query: { type: 'string', description: 'Поисковый запрос на естественном языке (например: "обработка HTTP запросов в сервере")' },
295
- limit: { type: 'number', description: 'Максимальное количество результатов поиска (по умолчанию 10)' },
296
- callers_level: { type: 'number', description: 'Глубина вложенности графа входящих вызовов ("где вызывается"), по умолчанию 0' },
297
- callees_level: { type: 'number', description: 'Глубина вложенности графа исходящих вызовов ("где вызывают"), по умолчанию 0' },
298
- compact: { type: 'boolean', description: 'Возвращать только компактный DTO {name, file, start_line, end_line, summary, role, type, score} без графа связей' },
299
- include_code: { type: 'boolean', description: 'Включать ли исходный код (фрагмент узла) в результаты поиска' },
300
- max_code_lines: { type: 'number', description: 'Максимальное количество строк исходного кода (по умолчанию 30)' },
301
- role: { type: 'string', description: 'Фильтр по архитектурной роли компонента (например: "controller", "repository", "service", "model")' },
302
- type: { type: 'string', description: 'Фильтр по типу узла AST (например: "function", "struct", "class", "interface")' },
303
- file_pattern: { type: 'string', description: 'Шаблон/маска пути файла для фильтрации (например: "pkg/semantic/*" или "*.go")' },
304
- mode: { type: 'string', description: 'Режим поиска: "hybrid" (RRF гибридный), "lexical" (точный BM25/FTS5), "vector" (чисто векторный). По умолчанию "hybrid"' }
305
- },
306
- required: ['query']
307
- },
308
- },
309
- {
310
- name: 'znatok_find_similar',
311
- description: 'Ищет аналогичные элементы кода и дубликаты на основе комбинированного мультифакторного анализа (векторное косинусное сходство, графовые соседи Jaccard и совпадение типов AST/ролей). Позволяет передавать как имя существующего символа (target), так и фрагмент текста/кода (query).',
312
- inputSchema: {
313
- type: 'object',
314
- properties: {
315
- target: { type: 'string', description: 'Имя существующего символа, функции или класса в проекте для поиска аналогов (например: "Server.runFullScan"). Если указано, вектор берется напрямую из БД без запроса к LLM.' },
316
- query: { type: 'string', description: 'Текстовый фрагмент или описание функции/класса для поиска аналогов (используется, если target не задан)' },
317
- limit: { type: 'number', description: 'Максимальное количество возвращаемых элементов (по умолчанию 10)' },
318
- callers_level: { type: 'number', description: 'Глубина вложенности входящего графа вызовов ("где вызывается"), по умолчанию 3' },
319
- callees_level: { type: 'number', description: 'Глубина вложенности исходящего графа вызовов ("где вызывают"), по умолчанию 3' },
320
- include_code: { type: 'boolean', description: 'Включать ли исходный код (фрагмент узла) в результаты поиска' },
321
- max_code_lines: { type: 'number', description: 'Максимальное количество строк исходного кода (по умолчанию 30)' },
322
- role: { type: 'string', description: 'Фильтр по архитектурной роли компонента (например: "controller", "repository", "service", "model")' },
323
- type: { type: 'string', description: 'Фильтр по типу узла AST (например: "function", "struct", "class", "interface")' },
324
- file_pattern: { type: 'string', description: 'Шаблон/маска пути файла для фильтрации (например: "pkg/semantic/*" или "*.go")' }
325
- }
326
- },
327
- },
328
- {
329
- name: 'znatok_get_subgraph',
330
- description: 'Возвращает ориентированный подграф (в формате Mermaid или JSON) вокруг заданного символа/файла (target) или ищет цепочку вызовов между двумя точками (from + to). Режим target: строит окрестности символа на заданную глубину. Режим трассировки (from + to): DFS запускается от `to` вверх по входящим рёбрам и ищет пути, в которых встречается `from`. Ограничение: если from вызывает to не напрямую, а через промежуточный узел, рёбра могут не отобразиться — в таком случае edges будет пустым и вернутся два изолированных узла.',
331
- inputSchema: {
332
- type: 'object',
333
- properties: {
334
- target: { type: 'string', description: 'Имя символа, функции, класса или относительный путь файла для построения подграфа окрестностей (например: "SemanticService" или "server.go"). Используется только если from/to не заданы.' },
335
- from: { type: 'string', description: 'Фильтр: символ/функция, которая должна встречаться в путях вызовов, найденных от `to`. Алгоритм ищет пути где from предшествует to по цепочке. Требует совместного указания с `to`.' },
336
- to: { type: 'string', description: 'Точка старта трассировки — символ/функция, от которой DFS идёт вверх по входящим рёбрам (PredecessorMap). Именно `to` является началом обхода, а не концом. Требует совместного указания с `from`.' },
337
- depth: { type: 'number', description: 'Глубина обхода подграфа (по умолчанию 2). В режиме трассировки используется как depth*2.' },
338
- max_nodes: { type: 'number', description: 'Максимальное количество узлов подграфа (по умолчанию 30)' },
339
- format: { type: 'string', description: 'Формат ответа: "mermaid" для диаграммы в Markdown или "json" (по умолчанию "mermaid")' }
340
- }
341
- },
342
- },
343
- {
344
- name: 'znatok_file_outline',
345
- description: 'Возвращает полную семантическую карту-атлас (оглавление) файла: 100% объявленных символов (функций, методов, структур, классов, интерфейсов), упорядоченных по номеру строки (start_line ASC), с их архитектурными ролями, диапазонами строк и семантическими описаниями. Позволяет мгновенно понять анатомию любого большого файла за 1 запрос без замусоривания контекста.',
346
- inputSchema: {
347
- type: 'object',
348
- properties: {
349
- path: { type: 'string', description: 'Относительный или абсолютный путь к целевому файлу (например: "internal/engine/server.go")' },
350
- include_code: { type: 'boolean', description: 'Включать ли фрагменты/сигнатуры исходного кода для каждого символа' },
351
- format: { type: 'string', description: 'Формат ответа: "text" (сверхкомпактный атлас строк, экономит до 75% токенов) или "json"' }
352
- },
353
- required: ['path']
354
- },
355
- },
356
- {
357
- name: 'znatok_server_logs',
358
- description: 'Считывает последние события и системные логи сервера Znt в реальном времени через WebSocket. Используется для диагностики состояния индексации проекта, отслеживания прогресса анализа и отладки работы фоновых процессов.',
359
- inputSchema: {
360
- type: 'object',
361
- properties: {},
362
- },
363
- },
364
- {
365
- name: 'znatok_mcp_stats',
366
- description: 'Возвращает подробную метрику и статистику работы MCP-сервера Znt: общее число вызовов, количество потраченных и сэкономленных токенов, коэффициент оптимизации (savings_ratio), среднее время выполнения запросов в миллисекундах и топ самых активных инструментов.',
367
- inputSchema: {
368
- type: 'object',
369
- properties: {},
370
- },
371
- },
372
- ],
373
- };
374
- });
375
-
376
- // ─── Helper for HTTP requests to Znt Core API ────────────────────
377
- async function callZntApi(endpoint, method = 'GET', body = null) {
378
- const apiUrl = process.env.ZNT_API_URL || 'http://localhost:8080';
379
- const url = `${apiUrl}${endpoint}`;
380
- const options = {
381
- method,
382
- headers: body ? { 'Content-Type': 'application/json' } : {},
383
- };
384
- if (body) {
385
- options.body = JSON.stringify(body);
386
- }
387
-
388
- const response = await fetch(url, options);
389
- if (!response.ok) {
390
- throw new Error(`API call failed: ${response.status} ${response.statusText}`);
391
- }
392
-
393
- if (response.headers.get('content-type')?.includes('text/event-stream')) {
394
- const reader = response.body.getReader();
395
- const decoder = new TextDecoder();
396
- let text = '';
397
- let accumulated = '';
398
- while (true) {
399
- const { done, value } = await reader.read();
400
- if (done) break;
401
- accumulated += decoder.decode(value, { stream: true });
402
- const lines = accumulated.split('\n');
403
- accumulated = lines.pop();
404
- for (const line of lines) {
405
- if (line.startsWith('data: ')) {
406
- try {
407
- const data = JSON.parse(line.slice(6));
408
- if (data.type === 'token') {
409
- text += data.content;
410
- } else if (data.type === 'error') {
411
- throw new Error(data.content);
412
- }
413
- } catch (err) {
414
- // Ignore parse errors
415
- }
71
+ try {
72
+ const client = await getZntClient();
73
+ const res = await client.listTools();
74
+ return { tools: res.tools || [] };
75
+ } catch (err) {
76
+ // If backend is offline, return descriptive fallback status tool
77
+ return {
78
+ tools: [
79
+ {
80
+ name: 'znatok_server_offline',
81
+ description: `Znt backend is offline (${err.message}). Start server with 'go run main.go' to enable tools.`,
82
+ inputSchema: { type: 'object', properties: {} }
416
83
  }
417
- }
418
- }
419
- return { content: text };
420
- }
421
-
422
- const contentType = response.headers.get('content-type') || '';
423
- if (contentType.includes('text/plain')) {
424
- return response.text();
84
+ ]
85
+ };
425
86
  }
87
+ });
426
88
 
427
- return response.json();
428
- }
429
-
430
- // ═════════════════════════════════════════════════════════════════
431
- // Tool Calls Router
432
- // ═════════════════════════════════════════════════════════════════
433
-
89
+ // ─── Dynamic Tools Proxy Execution ──────────────────────────────
434
90
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
435
- const startTime = Date.now();
436
91
  const toolName = request.params.name;
437
- const inputTokens = estimateTokens(JSON.stringify(request.params.arguments || {}));
92
+ if (toolName === 'znatok_server_offline') {
93
+ return {
94
+ content: [{
95
+ type: 'text',
96
+ text: `Znt backend server is not running on ${ZNT_API_URL}. Please start Znt backend by running 'go run main.go' in workspace.`
97
+ }],
98
+ isError: true
99
+ };
100
+ }
438
101
 
439
102
  try {
440
- let result;
441
- let sources = [];
442
-
443
- switch (toolName) {
444
- case 'znatok_find_similar': {
445
- const target = request.params.arguments?.target || request.params.arguments?.target_symbol || '';
446
- const query = request.params.arguments?.query || request.params.arguments?.text || '';
447
- if (!target && !query) {
448
- result = 'Either target or query parameter is required.';
449
- break;
450
- }
451
- const limit = Math.min(Math.max(1, parseInt(request.params.arguments?.limit, 10) || 10), 100);
452
- const callersLevel = Math.min(Math.max(0, parseInt(request.params.arguments?.callers_level ?? request.params.arguments?.caller_level, 10) ?? 3), 10);
453
- const calleesLevel = Math.min(Math.max(0, parseInt(request.params.arguments?.callees_level ?? request.params.arguments?.callee_level, 10) ?? 3), 10);
454
- const includeCode = Boolean(request.params.arguments?.include_code);
455
- const maxCodeLines = parseInt(request.params.arguments?.max_code_lines, 10) || 30;
456
- const role = request.params.arguments?.role || '';
457
- const nodeType = request.params.arguments?.type || '';
458
- const filePattern = request.params.arguments?.file_pattern || '';
459
-
460
- const apiParams = new URLSearchParams({
461
- limit: limit.toString(),
462
- callers_level: callersLevel.toString(),
463
- callees_level: calleesLevel.toString(),
464
- include_code: includeCode.toString(),
465
- max_code_lines: maxCodeLines.toString(),
466
- });
467
- if (target) apiParams.set('target', target);
468
- if (query) apiParams.set('q', query);
469
- if (role) apiParams.set('role', role);
470
- if (nodeType) apiParams.set('type', nodeType);
471
- if (filePattern) apiParams.set('file_pattern', filePattern);
472
-
473
- const rawResults = await callZntApi(`/api/find_similar?${apiParams.toString()}`);
474
- let processed = Array.isArray(rawResults) ? rawResults.map(item => {
475
- const relFile = toRelativePath(item.file || item.file_path || item.filePath);
476
- const resItem = {
477
- ...item,
478
- file: relFile
479
- };
480
- if (includeCode && !resItem.code && resItem.file && resItem.start_line && resItem.end_line) {
481
- let endLine = resItem.end_line;
482
- if (maxCodeLines > 0 && (endLine - resItem.start_line + 1) > maxCodeLines) {
483
- endLine = resItem.start_line + maxCodeLines - 1;
484
- }
485
- resItem.code = getSourceSnippet(resItem.file, resItem.start_line, endLine);
486
- }
487
- return resItem;
488
- }) : rawResults;
489
- if (Array.isArray(processed)) {
490
- processed = processed.slice(0, limit);
491
- }
492
- result = processed;
493
- sources = ['core_api:find_similar'];
494
- break;
495
- }
496
-
497
- case 'znatok_semantic_search': {
498
- const query = request.params.arguments?.query || request.params.arguments?.text;
499
- if (!query) {
500
- result = 'Query parameter is required.';
501
- break;
502
- }
503
- const limit = Math.min(Math.max(1, parseInt(request.params.arguments?.limit, 10) || 10), 100);
504
- const callersLevelArg = request.params.arguments?.callers_level ?? request.params.arguments?.caller_level;
505
- const callersLevel = Math.min(Math.max(0, callersLevelArg !== undefined ? (parseInt(callersLevelArg, 10) || 0) : 0), 10);
506
- const calleesLevelArg = request.params.arguments?.callees_level ?? request.params.arguments?.callee_level;
507
- const calleesLevel = Math.min(Math.max(0, calleesLevelArg !== undefined ? (parseInt(calleesLevelArg, 10) || 0) : 0), 10);
508
- const compact = Boolean(request.params.arguments?.compact);
509
- const includeCode = Boolean(request.params.arguments?.include_code);
510
- const maxCodeLines = parseInt(request.params.arguments?.max_code_lines, 10) || 30;
511
- const role = request.params.arguments?.role || '';
512
- const nodeType = request.params.arguments?.type || '';
513
- const filePattern = request.params.arguments?.file_pattern || '';
514
- const mode = request.params.arguments?.mode || (request.params.arguments?.hybrid === false ? 'lexical' : 'hybrid');
515
-
516
- const apiParams = new URLSearchParams({
517
- q: query,
518
- limit: limit.toString(),
519
- callers_level: callersLevel.toString(),
520
- callees_level: calleesLevel.toString(),
521
- include_code: includeCode.toString(),
522
- max_code_lines: maxCodeLines.toString(),
523
- mode: mode,
524
- format: 'text'
525
- });
526
- if (compact) apiParams.set('compact', 'true');
527
- if (role) apiParams.set('role', role);
528
- if (nodeType) apiParams.set('type', nodeType);
529
- if (filePattern) apiParams.set('file_pattern', filePattern);
530
-
531
- const rawResults = await callZntApi(`/api/search?${apiParams.toString()}`);
532
- let processed = Array.isArray(rawResults) ? rawResults.map(item => {
533
- const relFile = toRelativePath(item.file || item.file_path || item.filePath);
534
- let codeSnippet = item.code;
535
- if (includeCode && !codeSnippet && relFile && item.start_line && item.end_line) {
536
- let endLine = item.end_line;
537
- if (maxCodeLines > 0 && (endLine - item.start_line + 1) > maxCodeLines) {
538
- endLine = item.start_line + maxCodeLines - 1;
539
- }
540
- codeSnippet = getSourceSnippet(relFile, item.start_line, endLine);
541
- }
542
-
543
- if (compact) {
544
- return {
545
- name: item.name,
546
- file: relFile,
547
- start_line: item.start_line,
548
- end_line: item.end_line,
549
- summary: item.summary,
550
- role: item.role,
551
- type: item.type,
552
- score: item.score,
553
- ...(codeSnippet ? { code: codeSnippet } : {})
554
- };
555
- }
556
-
557
- const resItem = {
558
- ...item,
559
- file: relFile
560
- };
561
- if (codeSnippet) {
562
- resItem.code = codeSnippet;
563
- }
564
- return resItem;
565
- }) : rawResults;
566
- if (Array.isArray(processed)) {
567
- processed = processed.slice(0, limit);
568
- }
569
- result = processed;
570
- sources = ['core_api:search'];
571
- break;
572
- }
573
-
574
- case 'znatok_get_subgraph': {
575
- const target = request.params.arguments?.target || '';
576
- const from = request.params.arguments?.from || '';
577
- const to = request.params.arguments?.to || '';
578
- const depth = parseInt(request.params.arguments?.depth, 10) || 2;
579
- const maxNodes = parseInt(request.params.arguments?.max_nodes, 10) || 30;
580
- const format = request.params.arguments?.format || 'mermaid';
581
-
582
- const queryParams = new URLSearchParams();
583
- if (target) queryParams.set('target', target);
584
- if (from) queryParams.set('from', from);
585
- if (to) queryParams.set('to', to);
586
- queryParams.set('depth', depth.toString());
587
- queryParams.set('max_nodes', maxNodes.toString());
588
- if (format) queryParams.set('format', format);
589
-
590
- const resData = await callZntApi(`/api/graph/subgraph?${queryParams.toString()}`);
591
- result = resData;
592
- sources = ['core_api:subgraph'];
593
- break;
594
- }
595
-
596
- case 'znatok_file_outline': {
597
- const filePath = request.params.arguments?.path || request.params.arguments?.target_path || request.params.arguments?.file || request.params.arguments?.file_path || '';
598
- if (!filePath) {
599
- result = 'Path parameter is required.';
600
- break;
601
- }
602
- const includeCode = Boolean(request.params.arguments?.include_code);
603
- const format = request.params.arguments?.format || '';
604
-
605
- const queryParams = new URLSearchParams({
606
- path: filePath,
607
- include_code: includeCode.toString(),
608
- });
609
- if (format) {
610
- queryParams.set('format', format);
611
- }
612
-
613
- const resData = await callZntApi(`/api/file/outline?${queryParams.toString()}`);
614
- result = resData;
615
- sources = ['core_api:file_outline'];
616
- break;
617
- }
618
-
619
- case 'znatok_server_logs': {
620
- const wsUrl = (process.env.ZNT_API_URL || 'http://localhost:8080').replace(/^http/, 'ws') + '/ws';
621
- let WebSocketClient = globalThis.WebSocket;
622
- if (!WebSocketClient) {
623
- try {
624
- WebSocketClient = (await import('ws')).default;
625
- } catch (err) {
626
- // ignore
627
- }
628
- }
629
-
630
- if (!WebSocketClient) {
631
- result = 'WebSocket client is not available in current Node environment.';
632
- sources = ['core_api:websocket'];
633
- break;
634
- }
635
-
636
- const logs = await new Promise((resolve) => {
637
- const timeout = setTimeout(() => resolve('Log retrieval timed out.'), 3000);
638
- let resolved = false;
639
- const ws = new WebSocketClient(wsUrl);
640
-
641
- ws.onopen = () => {};
642
- ws.onmessage = (event) => {
643
- if (resolved) return;
644
- try {
645
- const msg = JSON.parse(event.data);
646
- if (msg.type === 'initial_state') {
647
- clearTimeout(timeout);
648
- resolved = true;
649
- ws.close();
650
- resolve(msg.logs || []);
651
- }
652
- } catch (err) {}
653
- };
654
-
655
- ws.onerror = (err) => {
656
- if (!resolved) {
657
- clearTimeout(timeout);
658
- resolved = true;
659
- resolve(`WebSocket Connection Error: ${err.message}`);
660
- }
661
- };
662
- });
663
-
664
- result = logs;
665
- sources = ['core_api:websocket'];
666
- break;
667
- }
668
-
669
- case 'znatok_mcp_stats': {
670
- let usageDb = null;
671
- try {
672
- usageDb = getUsageDb();
673
- if (!usageDb) { result = 'Usage tracking database not available.'; break; }
674
-
675
- const totalCalls = usageDb.prepare('SELECT count(*) as count FROM mcp_usage_log').get().count;
676
- const totalOutputTokens = usageDb.prepare('SELECT COALESCE(sum(output_tokens), 0) as total FROM mcp_usage_log').get().total;
677
- const totalAltTokens = usageDb.prepare('SELECT COALESCE(sum(estimated_alt_tokens), 0) as total FROM mcp_usage_log').get().total;
678
- const avgExecutionMs = usageDb.prepare('SELECT COALESCE(avg(execution_ms), 0) as avg FROM mcp_usage_log').get().avg;
679
-
680
- const topTools = usageDb.prepare(`
681
- SELECT tool_name,
682
- count(*) as calls,
683
- sum(output_tokens) as total_output,
684
- sum(estimated_alt_tokens) as total_alt,
685
- CAST(avg(execution_ms) AS INTEGER) as avg_ms,
686
- CASE WHEN sum(output_tokens) > 0
687
- THEN ROUND(CAST(sum(estimated_alt_tokens) AS REAL) / sum(output_tokens), 1)
688
- ELSE 0 END as savings_ratio
689
- FROM mcp_usage_log
690
- GROUP BY tool_name
691
- ORDER BY calls DESC
692
- LIMIT 20
693
- `).all();
694
-
695
- const recentCalls = usageDb.prepare(`
696
- SELECT timestamp, tool_name, output_tokens, estimated_alt_tokens, execution_ms
697
- FROM mcp_usage_log
698
- ORDER BY id DESC
699
- LIMIT 10
700
- `).all();
701
-
702
- result = {
703
- last_call_saved_tokens: lastCallSavedTokens,
704
- session_saved_tokens: sessionSavedTokens,
705
- session_output_tokens: sessionOutputTokens,
706
- session_calls: sessionCallsCount,
707
- session_start_time: sessionStartTime,
708
- total_calls: totalCalls,
709
- total_output_tokens: totalOutputTokens,
710
- estimated_alternative_tokens: totalAltTokens,
711
- total_saved_tokens: Math.max(0, totalAltTokens - totalOutputTokens),
712
- savings_ratio: totalOutputTokens > 0 ? `${(totalAltTokens / totalOutputTokens).toFixed(1)}x` : 'N/A',
713
- avg_execution_ms: Math.round(avgExecutionMs),
714
- top_tools: topTools,
715
- recent_calls: recentCalls
716
- };
717
- sources = ['mcp_usage.db'];
718
- } catch (err) {
719
- result = `Error fetching stats: ${err.message}`;
720
- } finally {
721
- if (usageDb) try { usageDb.close(); } catch (e) {}
722
- }
723
- break;
724
- }
725
-
726
- default:
727
- throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`);
728
- }
729
-
730
- // ── Wrap result with _meta and log usage ──
731
- return mcpResponse(toolName, result, {
732
- executionMs: Date.now() - startTime,
733
- inputTokens,
734
- sources
103
+ const client = await getZntClient();
104
+ const res = await client.callTool({
105
+ name: toolName,
106
+ arguments: request.params.arguments || {}
735
107
  });
736
-
108
+ return res;
737
109
  } catch (err) {
738
- if (err instanceof McpError) throw err;
739
-
740
- // Log the failed call too
741
- const executionMs = Date.now() - startTime;
742
- logUsage(toolName, inputTokens, 0, executionMs, 0, false);
743
-
744
110
  return {
745
- content: [{ type: 'text', text: `Error executing tool ${toolName}: ${err.message}` }],
746
- isError: true,
111
+ content: [{
112
+ type: 'text',
113
+ text: `Error executing tool ${toolName} via Znt Go backend: ${err.message}`
114
+ }],
115
+ isError: true
747
116
  };
748
117
  }
749
118
  });
750
119
 
751
- // ═════════════════════════════════════════════════════════════════
752
- // Run server using StdioTransport
753
- // ═════════════════════════════════════════════════════════════════
754
-
120
+ // ─── Run Server over STDIO ───────────────────────────────────────
755
121
  async function main() {
756
122
  const transport = new StdioServerTransport();
757
123
  await server.connect(transport);
758
- console.error('Znt MCP Server v1.1.0 running on stdio (with usage tracking)');
759
124
  }
760
125
 
761
126
  main().catch((error) => {
762
- console.error('Fatal error running server:', error);
127
+ console.error('Fatal error running Znt MCP adapter:', error);
763
128
  process.exit(1);
764
129
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@znt/mcp",
3
- "version": "1.0.8",
3
+ "version": "1.1.1",
4
4
  "description": "Model Context Protocol adapter for Znt",
5
5
  "main": "index.js",
6
6
  "type": "module",