@znt/mcp 1.0.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.
- package/README.md +55 -0
- package/index.js +680 -0
- package/package.json +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Znt MCP Adapter (@znt/mcp)
|
|
2
|
+
|
|
3
|
+
Официальный адаптер **Model Context Protocol (MCP)** для системы локального понимания кодовых баз и графового анализа **Знаток (Znt)**.
|
|
4
|
+
|
|
5
|
+
Позволяет внешним ИИ-агентам и LLM-клиентам (Claude Desktop, Antigravity IDE, Cursor, Windsurf, Zed и др.) осуществлять умный гибридный поиск по смыслу, получать оглавления файлов, строить графы вызовов и трассировать зависимости с 0ms LLM оверхедом и максимальной экономией контекстных токенов.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## ⚡ Быстрый старт (npx)
|
|
10
|
+
|
|
11
|
+
Не требуется предварительная сборка или ручная установка. Для подключения добавьте адаптер в ваш конфигурационный файл MCP (например, `~/.gemini/config/mcp_config.json` или `claude_desktop_config.json`):
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"znt-mcp": {
|
|
17
|
+
"command": "npx",
|
|
18
|
+
"args": ["-y", "@znt/mcp"],
|
|
19
|
+
"env": {
|
|
20
|
+
"ZNT_API_URL": "http://localhost:8080"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 🛠 Доступные инструменты (MCP Tools)
|
|
30
|
+
|
|
31
|
+
Адаптер предоставляет 6 специализированных инструментов с автоматическим отслеживанием экономии контекста:
|
|
32
|
+
|
|
33
|
+
| Инструмент | Описание |
|
|
34
|
+
| :--- | :--- |
|
|
35
|
+
| `znatok_semantic_search` | **Гибридный поиск (BM25 + Векторы + RRF)** по кодовой базе. Находит узлы, архитектурные роли (`controller`, `service`, `repository`), аннотации и связи. |
|
|
36
|
+
| `znatok_find_similar` | **Поиск аналогов и дубликатов**. Находит схожие реализации, существующие абстракции и паттерны по фрагменту кода. |
|
|
37
|
+
| `znatok_get_subgraph` | **Графовый анализ и трассировка вызовов**. Возвращает подграф зависимостей или трассировку потока данных от узла A к узлу B (`from` ➔ `to`) в формате Mermaid / JSON. |
|
|
38
|
+
| `znatok_file_outline` | **Семантический атлас (оглавление) файла**. Мгновенно отдает список всех символов (функции, структуры, методы) с номерами строк и их ролями. |
|
|
39
|
+
| `znatok_server_logs` | **Логи сервера Znt**. Мониторинг событий индексации и состояния графа в реальном времени. |
|
|
40
|
+
| `znatok_mcp_stats` | **Статистика экономии токенов**. Показывает количество сэкономленных токенов контекста, метрики вызовов и коэффициент оптимизации (`savings_ratio`). |
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 🚀 Особенности работы
|
|
45
|
+
|
|
46
|
+
1. **Прямой доступ к SQLite БД**: Адаптер напрямую читает локальные базы данных `.znt/graph.db` и `.znt/analysis.db` через встроенный модуль `node:sqlite`, обеспечивая микросекундный отклик.
|
|
47
|
+
2. **Интеграция с Znt Core API**: Для векторного семантического поиска и генеративного теггирования адаптер взаимодействует с HTTP API сервера Znt (`ZNT_API_URL`).
|
|
48
|
+
3. **Usage Tracking**: Каждая операция логируется в `.znt/mcp_usage.db`, рассчитывая объем сэкономленного контекста по сравнению с прямым прочтением исходных файлов.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 📋 Системные требования
|
|
53
|
+
|
|
54
|
+
* **Node.js** версии **v22.5.0** или новее.
|
|
55
|
+
* Индексированный проект с папкой `.znt` в корневом каталоге репозитория.
|
package/index.js
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import {
|
|
5
|
+
CallToolRequestSchema,
|
|
6
|
+
ErrorCode,
|
|
7
|
+
ListToolsRequestSchema,
|
|
8
|
+
McpError
|
|
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
|
+
|
|
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
|
+
}
|
|
140
|
+
|
|
141
|
+
return Math.ceil(totalBytes / 4);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Extract all involved file paths from tool results or metadata
|
|
146
|
+
*/
|
|
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;
|
|
153
|
+
|
|
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);
|
|
166
|
+
|
|
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;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Alternative-cost multiplier table for active tools (fallback).
|
|
191
|
+
*/
|
|
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
|
+
};
|
|
212
|
+
}
|
|
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) {}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
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);
|
|
246
|
+
|
|
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
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
// ═════════════════════════════════════════════════════════════════
|
|
276
|
+
// Server Initialization
|
|
277
|
+
// ═════════════════════════════════════════════════════════════════
|
|
278
|
+
|
|
279
|
+
const server = new Server(
|
|
280
|
+
{ name: 'znt-mcp-server', version: '1.1.0' },
|
|
281
|
+
{ capabilities: { tools: {} } }
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
// ─── Register Tools (ListTools) ──────────────────────────────────
|
|
285
|
+
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: 'Глубина вложенности графа входящих вызовов ("где вызывается"), по умолчанию 3' },
|
|
297
|
+
callees_level: { type: 'number', description: 'Глубина вложенности графа исходящих вызовов ("где вызывают"), по умолчанию 3' },
|
|
298
|
+
include_code: { type: 'boolean', description: 'Включать ли исходный код (фрагмент узла) в результаты поиска' },
|
|
299
|
+
max_code_lines: { type: 'number', description: 'Максимальное количество строк исходного кода (по умолчанию 30)' },
|
|
300
|
+
role: { type: 'string', description: 'Фильтр по архитектурной роли компонента (например: "controller", "repository", "service", "model")' },
|
|
301
|
+
type: { type: 'string', description: 'Фильтр по типу узла AST (например: "function", "struct", "class", "interface")' },
|
|
302
|
+
file_pattern: { type: 'string', description: 'Шаблон/маска пути файла для фильтрации (например: "pkg/semantic/*" или "*.go")' },
|
|
303
|
+
hybrid: { type: 'boolean', description: 'Использовать гибридный поиск (RRF: BM25/Лексика + Векторы, по умолчанию true)' }
|
|
304
|
+
},
|
|
305
|
+
required: ['query']
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: 'znatok_find_similar',
|
|
310
|
+
description: 'Ищет элементы кода в базе знаний проекта, которые являются семантически близкими по назначению к заданному фрагменту текста или сигнатуре. Помогает находить дубликаты, аналогичные методы или связанные абстракции. Возвращает список объектов с относительными путями файлов и структурой графа.',
|
|
311
|
+
inputSchema: {
|
|
312
|
+
type: 'object',
|
|
313
|
+
properties: {
|
|
314
|
+
query: { type: 'string', description: 'Текстовый фрагмент или описание функции/класса для поиска аналогов' },
|
|
315
|
+
limit: { type: 'number', description: 'Максимальное количество возвращаемых элементов (по умолчанию 10)' },
|
|
316
|
+
callers_level: { type: 'number', description: 'Глубина вложенности входящего графа вызовов ("где вызывается"), по умолчанию 3' },
|
|
317
|
+
callees_level: { type: 'number', description: 'Глубина вложенности исходящего графа вызовов ("где вызывают"), по умолчанию 3' },
|
|
318
|
+
include_code: { type: 'boolean', description: 'Включать ли исходный код (фрагмент узла) в результаты поиска' },
|
|
319
|
+
max_code_lines: { type: 'number', description: 'Максимальное количество строк исходного кода (по умолчанию 30)' },
|
|
320
|
+
role: { type: 'string', description: 'Фильтр по архитектурной роли компонента (например: "controller", "repository", "service", "model")' },
|
|
321
|
+
type: { type: 'string', description: 'Фильтр по типу узла AST (например: "function", "struct", "class", "interface")' },
|
|
322
|
+
file_pattern: { type: 'string', description: 'Шаблон/маска пути файла для фильтрации (например: "pkg/semantic/*" или "*.go")' },
|
|
323
|
+
hybrid: { type: 'boolean', description: 'Использовать гибридный поиск (RRF: BM25/Лексика + Векторы, по умолчанию true)' }
|
|
324
|
+
},
|
|
325
|
+
required: ['query']
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
name: 'znatok_get_subgraph',
|
|
330
|
+
description: 'Возвращает ориентированный подграф (в формате Mermaid или JSON) вокруг заданного символа/файла (target) или вычисляет трассу вызовов между двумя точками (from -> to). Позволяет быстро визуализировать и исследовать архитектуру компонентов, цепочки вызовов и зависимости за 1 запрос без использования LLM.',
|
|
331
|
+
inputSchema: {
|
|
332
|
+
type: 'object',
|
|
333
|
+
properties: {
|
|
334
|
+
target: { type: 'string', description: 'Имя символа, функции, класса или относительный путь файла для построения подграфа окрестностей (например: "SemanticService" или "server.go")' },
|
|
335
|
+
from: { type: 'string', description: 'Стартовый символ/функция для поиска трассы вызовов (например: "main" или "handleSearch")' },
|
|
336
|
+
to: { type: 'string', description: 'Конечный символ/функция для поиска трассы вызовов (например: "GetNodeContent")' },
|
|
337
|
+
depth: { type: 'number', description: 'Глубина обхода подграфа (по умолчанию 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
|
+
},
|
|
352
|
+
required: ['path']
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
name: 'znatok_server_logs',
|
|
357
|
+
description: 'Считывает последние события и системные логи сервера Znt в реальном времени через WebSocket. Используется для диагностики состояния индексации проекта, отслеживания прогресса анализа и отладки работы фоновых процессов.',
|
|
358
|
+
inputSchema: {
|
|
359
|
+
type: 'object',
|
|
360
|
+
properties: {},
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
name: 'znatok_mcp_stats',
|
|
365
|
+
description: 'Возвращает подробную метрику и статистику работы MCP-сервера Znt: общее число вызовов, количество потраченных и сэкономленных токенов, коэффициент оптимизации (savings_ratio), среднее время выполнения запросов в миллисекундах и топ самых активных инструментов.',
|
|
366
|
+
inputSchema: {
|
|
367
|
+
type: 'object',
|
|
368
|
+
properties: {},
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ─── Helper for HTTP requests to Znt Core API ────────────────────
|
|
376
|
+
async function callZntApi(endpoint, method = 'GET', body = null) {
|
|
377
|
+
const apiUrl = process.env.ZNT_API_URL || 'http://localhost:8080';
|
|
378
|
+
const url = `${apiUrl}${endpoint}`;
|
|
379
|
+
const options = {
|
|
380
|
+
method,
|
|
381
|
+
headers: body ? { 'Content-Type': 'application/json' } : {},
|
|
382
|
+
};
|
|
383
|
+
if (body) {
|
|
384
|
+
options.body = JSON.stringify(body);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const response = await fetch(url, options);
|
|
388
|
+
if (!response.ok) {
|
|
389
|
+
throw new Error(`API call failed: ${response.status} ${response.statusText}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (response.headers.get('content-type')?.includes('text/event-stream')) {
|
|
393
|
+
const reader = response.body.getReader();
|
|
394
|
+
const decoder = new TextDecoder();
|
|
395
|
+
let text = '';
|
|
396
|
+
let accumulated = '';
|
|
397
|
+
while (true) {
|
|
398
|
+
const { done, value } = await reader.read();
|
|
399
|
+
if (done) break;
|
|
400
|
+
accumulated += decoder.decode(value, { stream: true });
|
|
401
|
+
const lines = accumulated.split('\n');
|
|
402
|
+
accumulated = lines.pop();
|
|
403
|
+
for (const line of lines) {
|
|
404
|
+
if (line.startsWith('data: ')) {
|
|
405
|
+
try {
|
|
406
|
+
const data = JSON.parse(line.slice(6));
|
|
407
|
+
if (data.type === 'token') {
|
|
408
|
+
text += data.content;
|
|
409
|
+
} else if (data.type === 'error') {
|
|
410
|
+
throw new Error(data.content);
|
|
411
|
+
}
|
|
412
|
+
} catch (err) {
|
|
413
|
+
if (!err.message.includes('JSON')) {
|
|
414
|
+
throw err;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return { content: text };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return response.json();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ═════════════════════════════════════════════════════════════════
|
|
427
|
+
// Tool Calls Router
|
|
428
|
+
// ═════════════════════════════════════════════════════════════════
|
|
429
|
+
|
|
430
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
431
|
+
const startTime = Date.now();
|
|
432
|
+
const toolName = request.params.name;
|
|
433
|
+
const inputTokens = estimateTokens(JSON.stringify(request.params.arguments || {}));
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
let result;
|
|
437
|
+
let sources = [];
|
|
438
|
+
|
|
439
|
+
switch (toolName) {
|
|
440
|
+
case 'znatok_find_similar':
|
|
441
|
+
case 'znatok_semantic_search': {
|
|
442
|
+
const query = request.params.arguments?.query || request.params.arguments?.text;
|
|
443
|
+
if (!query) {
|
|
444
|
+
result = 'Query parameter is required.';
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
const limit = Math.min(Math.max(1, parseInt(request.params.arguments?.limit, 10) || 10), 100);
|
|
448
|
+
const callersLevel = Math.min(Math.max(0, parseInt(request.params.arguments?.callers_level ?? request.params.arguments?.caller_level, 10) ?? 3), 10);
|
|
449
|
+
const calleesLevel = Math.min(Math.max(0, parseInt(request.params.arguments?.callees_level ?? request.params.arguments?.callee_level, 10) ?? 3), 10);
|
|
450
|
+
const includeCode = Boolean(request.params.arguments?.include_code);
|
|
451
|
+
const maxCodeLines = parseInt(request.params.arguments?.max_code_lines, 10) || 30;
|
|
452
|
+
const role = request.params.arguments?.role || '';
|
|
453
|
+
const nodeType = request.params.arguments?.type || '';
|
|
454
|
+
const filePattern = request.params.arguments?.file_pattern || '';
|
|
455
|
+
const hybrid = request.params.arguments?.hybrid !== false;
|
|
456
|
+
|
|
457
|
+
const apiParams = new URLSearchParams({
|
|
458
|
+
q: query,
|
|
459
|
+
limit: limit.toString(),
|
|
460
|
+
callers_level: callersLevel.toString(),
|
|
461
|
+
callees_level: calleesLevel.toString(),
|
|
462
|
+
include_code: includeCode.toString(),
|
|
463
|
+
max_code_lines: maxCodeLines.toString(),
|
|
464
|
+
hybrid: hybrid.toString(),
|
|
465
|
+
});
|
|
466
|
+
if (role) apiParams.set('role', role);
|
|
467
|
+
if (nodeType) apiParams.set('type', nodeType);
|
|
468
|
+
if (filePattern) apiParams.set('file_pattern', filePattern);
|
|
469
|
+
|
|
470
|
+
const rawResults = await callZntApi(`/api/search?${apiParams.toString()}`);
|
|
471
|
+
let processed = Array.isArray(rawResults) ? rawResults.map(item => {
|
|
472
|
+
const relFile = toRelativePath(item.file || item.file_path || item.filePath);
|
|
473
|
+
const resItem = {
|
|
474
|
+
...item,
|
|
475
|
+
file: relFile
|
|
476
|
+
};
|
|
477
|
+
if (includeCode && !resItem.code && resItem.file && resItem.start_line && resItem.end_line) {
|
|
478
|
+
let endLine = resItem.end_line;
|
|
479
|
+
if (maxCodeLines > 0 && (endLine - resItem.start_line + 1) > maxCodeLines) {
|
|
480
|
+
endLine = resItem.start_line + maxCodeLines - 1;
|
|
481
|
+
}
|
|
482
|
+
resItem.code = getSourceSnippet(resItem.file, resItem.start_line, endLine);
|
|
483
|
+
}
|
|
484
|
+
return resItem;
|
|
485
|
+
}) : rawResults;
|
|
486
|
+
if (Array.isArray(processed)) {
|
|
487
|
+
processed = processed.slice(0, limit);
|
|
488
|
+
}
|
|
489
|
+
result = processed;
|
|
490
|
+
sources = ['core_api:search'];
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
case 'znatok_get_subgraph': {
|
|
495
|
+
const target = request.params.arguments?.target || '';
|
|
496
|
+
const from = request.params.arguments?.from || '';
|
|
497
|
+
const to = request.params.arguments?.to || '';
|
|
498
|
+
const depth = parseInt(request.params.arguments?.depth, 10) || 2;
|
|
499
|
+
const maxNodes = parseInt(request.params.arguments?.max_nodes, 10) || 30;
|
|
500
|
+
const format = request.params.arguments?.format || 'mermaid';
|
|
501
|
+
|
|
502
|
+
const queryParams = new URLSearchParams();
|
|
503
|
+
if (target) queryParams.set('target', target);
|
|
504
|
+
if (from) queryParams.set('from', from);
|
|
505
|
+
if (to) queryParams.set('to', to);
|
|
506
|
+
queryParams.set('depth', depth.toString());
|
|
507
|
+
queryParams.set('max_nodes', maxNodes.toString());
|
|
508
|
+
if (format) queryParams.set('format', format);
|
|
509
|
+
|
|
510
|
+
const resData = await callZntApi(`/api/graph/subgraph?${queryParams.toString()}`);
|
|
511
|
+
result = resData;
|
|
512
|
+
sources = ['core_api:subgraph'];
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
case 'znatok_file_outline': {
|
|
517
|
+
const filePath = request.params.arguments?.path || request.params.arguments?.target_path || request.params.arguments?.file || request.params.arguments?.file_path || '';
|
|
518
|
+
if (!filePath) {
|
|
519
|
+
result = 'Path parameter is required.';
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
const includeCode = Boolean(request.params.arguments?.include_code);
|
|
523
|
+
|
|
524
|
+
const queryParams = new URLSearchParams({
|
|
525
|
+
path: filePath,
|
|
526
|
+
include_code: includeCode.toString(),
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
const resData = await callZntApi(`/api/file/outline?${queryParams.toString()}`);
|
|
530
|
+
result = resData;
|
|
531
|
+
sources = ['core_api:file_outline'];
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
case 'znatok_server_logs': {
|
|
536
|
+
const wsUrl = (process.env.ZNT_API_URL || 'http://localhost:8080').replace(/^http/, 'ws') + '/ws';
|
|
537
|
+
let WebSocketClient = globalThis.WebSocket;
|
|
538
|
+
if (!WebSocketClient) {
|
|
539
|
+
try {
|
|
540
|
+
WebSocketClient = (await import('ws')).default;
|
|
541
|
+
} catch (err) {
|
|
542
|
+
// ignore
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (!WebSocketClient) {
|
|
547
|
+
result = 'WebSocket client is not available in current Node environment.';
|
|
548
|
+
sources = ['core_api:websocket'];
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const logs = await new Promise((resolve) => {
|
|
553
|
+
const timeout = setTimeout(() => resolve('Log retrieval timed out.'), 3000);
|
|
554
|
+
let resolved = false;
|
|
555
|
+
const ws = new WebSocketClient(wsUrl);
|
|
556
|
+
|
|
557
|
+
ws.onopen = () => {};
|
|
558
|
+
ws.onmessage = (event) => {
|
|
559
|
+
if (resolved) return;
|
|
560
|
+
try {
|
|
561
|
+
const msg = JSON.parse(event.data);
|
|
562
|
+
if (msg.type === 'initial_state') {
|
|
563
|
+
clearTimeout(timeout);
|
|
564
|
+
resolved = true;
|
|
565
|
+
ws.close();
|
|
566
|
+
resolve(msg.logs || []);
|
|
567
|
+
}
|
|
568
|
+
} catch (err) {}
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
ws.onerror = (err) => {
|
|
572
|
+
if (!resolved) {
|
|
573
|
+
clearTimeout(timeout);
|
|
574
|
+
resolved = true;
|
|
575
|
+
resolve(`WebSocket Connection Error: ${err.message}`);
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
result = logs;
|
|
581
|
+
sources = ['core_api:websocket'];
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
case 'znatok_mcp_stats': {
|
|
586
|
+
let usageDb = null;
|
|
587
|
+
try {
|
|
588
|
+
usageDb = getUsageDb();
|
|
589
|
+
if (!usageDb) { result = 'Usage tracking database not available.'; break; }
|
|
590
|
+
|
|
591
|
+
const totalCalls = usageDb.prepare('SELECT count(*) as count FROM mcp_usage_log').get().count;
|
|
592
|
+
const totalOutputTokens = usageDb.prepare('SELECT COALESCE(sum(output_tokens), 0) as total FROM mcp_usage_log').get().total;
|
|
593
|
+
const totalAltTokens = usageDb.prepare('SELECT COALESCE(sum(estimated_alt_tokens), 0) as total FROM mcp_usage_log').get().total;
|
|
594
|
+
const avgExecutionMs = usageDb.prepare('SELECT COALESCE(avg(execution_ms), 0) as avg FROM mcp_usage_log').get().avg;
|
|
595
|
+
|
|
596
|
+
const topTools = usageDb.prepare(`
|
|
597
|
+
SELECT tool_name,
|
|
598
|
+
count(*) as calls,
|
|
599
|
+
sum(output_tokens) as total_output,
|
|
600
|
+
sum(estimated_alt_tokens) as total_alt,
|
|
601
|
+
CAST(avg(execution_ms) AS INTEGER) as avg_ms,
|
|
602
|
+
CASE WHEN sum(output_tokens) > 0
|
|
603
|
+
THEN ROUND(CAST(sum(estimated_alt_tokens) AS REAL) / sum(output_tokens), 1)
|
|
604
|
+
ELSE 0 END as savings_ratio
|
|
605
|
+
FROM mcp_usage_log
|
|
606
|
+
GROUP BY tool_name
|
|
607
|
+
ORDER BY calls DESC
|
|
608
|
+
LIMIT 20
|
|
609
|
+
`).all();
|
|
610
|
+
|
|
611
|
+
const recentCalls = usageDb.prepare(`
|
|
612
|
+
SELECT timestamp, tool_name, output_tokens, estimated_alt_tokens, execution_ms
|
|
613
|
+
FROM mcp_usage_log
|
|
614
|
+
ORDER BY id DESC
|
|
615
|
+
LIMIT 10
|
|
616
|
+
`).all();
|
|
617
|
+
|
|
618
|
+
result = {
|
|
619
|
+
last_call_saved_tokens: lastCallSavedTokens,
|
|
620
|
+
session_saved_tokens: sessionSavedTokens,
|
|
621
|
+
session_output_tokens: sessionOutputTokens,
|
|
622
|
+
session_calls: sessionCallsCount,
|
|
623
|
+
session_start_time: sessionStartTime,
|
|
624
|
+
total_calls: totalCalls,
|
|
625
|
+
total_output_tokens: totalOutputTokens,
|
|
626
|
+
estimated_alternative_tokens: totalAltTokens,
|
|
627
|
+
total_saved_tokens: Math.max(0, totalAltTokens - totalOutputTokens),
|
|
628
|
+
savings_ratio: totalOutputTokens > 0 ? `${(totalAltTokens / totalOutputTokens).toFixed(1)}x` : 'N/A',
|
|
629
|
+
avg_execution_ms: Math.round(avgExecutionMs),
|
|
630
|
+
top_tools: topTools,
|
|
631
|
+
recent_calls: recentCalls
|
|
632
|
+
};
|
|
633
|
+
sources = ['mcp_usage.db'];
|
|
634
|
+
} catch (err) {
|
|
635
|
+
result = `Error fetching stats: ${err.message}`;
|
|
636
|
+
} finally {
|
|
637
|
+
if (usageDb) try { usageDb.close(); } catch (e) {}
|
|
638
|
+
}
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
default:
|
|
643
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ── Wrap result with _meta and log usage ──
|
|
647
|
+
return mcpResponse(toolName, result, {
|
|
648
|
+
executionMs: Date.now() - startTime,
|
|
649
|
+
inputTokens,
|
|
650
|
+
sources
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
} catch (err) {
|
|
654
|
+
if (err instanceof McpError) throw err;
|
|
655
|
+
|
|
656
|
+
// Log the failed call too
|
|
657
|
+
const executionMs = Date.now() - startTime;
|
|
658
|
+
logUsage(toolName, inputTokens, 0, executionMs, 0, false);
|
|
659
|
+
|
|
660
|
+
return {
|
|
661
|
+
content: [{ type: 'text', text: `Error executing tool ${toolName}: ${err.message}` }],
|
|
662
|
+
isError: true,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// ═════════════════════════════════════════════════════════════════
|
|
668
|
+
// Run server using StdioTransport
|
|
669
|
+
// ═════════════════════════════════════════════════════════════════
|
|
670
|
+
|
|
671
|
+
async function main() {
|
|
672
|
+
const transport = new StdioServerTransport();
|
|
673
|
+
await server.connect(transport);
|
|
674
|
+
console.error('Znt MCP Server v1.1.0 running on stdio (with usage tracking)');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
main().catch((error) => {
|
|
678
|
+
console.error('Fatal error running server:', error);
|
|
679
|
+
process.exit(1);
|
|
680
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@znt/mcp",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Model Context Protocol adapter for Znt",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"znt-mcp": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node index.js"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@modelcontextprotocol/sdk": "^1.0.1",
|
|
18
|
+
"ws": "^8.21.1",
|
|
19
|
+
"yaml": "^2.4.5"
|
|
20
|
+
}
|
|
21
|
+
}
|