@znt/mcp 1.0.8 → 1.1.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/index.js +78 -713
- package/package.json +1 -1
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
|
-
// ───
|
|
16
|
-
|
|
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
|
-
|
|
142
|
-
|
|
14
|
+
let clientInstance = null;
|
|
15
|
+
let clientPromise = null;
|
|
143
16
|
|
|
144
17
|
/**
|
|
145
|
-
*
|
|
18
|
+
* Establishes an SSE connection to the native Znt Go MCP Server
|
|
146
19
|
*/
|
|
147
|
-
function
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
*
|
|
44
|
+
* Returns active connected client or initiates a new connection
|
|
191
45
|
*/
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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-
|
|
65
|
+
{ name: 'znt-mcp-adapter', version: '1.2.0' },
|
|
281
66
|
{ capabilities: { tools: {} } }
|
|
282
67
|
);
|
|
283
68
|
|
|
284
|
-
// ───
|
|
69
|
+
// ─── Dynamic Tools Listing ───────────────────────────────────────
|
|
285
70
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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: [{
|
|
746
|
-
|
|
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
|
|
127
|
+
console.error('Fatal error running Znt MCP adapter:', error);
|
|
763
128
|
process.exit(1);
|
|
764
129
|
});
|