@znt/mcp 1.1.1 → 2.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/AGENTS_EXAMPLE.md +50 -0
- package/README.md +459 -90
- package/index.js +18 -119
- package/package.json +30 -21
- package/src/credential-setup.js +726 -0
- package/src/metrics.js +121 -0
- package/src/server.js +30 -0
- package/src/tool-definitions.js +143 -0
- package/src/znt-tools.js +555 -0
package/src/znt-tools.js
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ZntClient,
|
|
3
|
+
ZntCoreNotInstalledError,
|
|
4
|
+
ZntRpcError,
|
|
5
|
+
ZntTransportError,
|
|
6
|
+
credentialReference,
|
|
7
|
+
getSetupStatus,
|
|
8
|
+
mergeConfig,
|
|
9
|
+
resolveManagedConfigPath,
|
|
10
|
+
setupManagedConfig,
|
|
11
|
+
} from "@znt/sdk-nodejs";
|
|
12
|
+
import { access, chmod, copyFile, mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
13
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
14
|
+
import { parse, stringify } from "yaml";
|
|
15
|
+
|
|
16
|
+
import { CredentialSetupServer } from "./credential-setup.js";
|
|
17
|
+
import { UsageMetrics } from "./metrics.js";
|
|
18
|
+
import { TOOL_NAMES } from "./tool-definitions.js";
|
|
19
|
+
|
|
20
|
+
const ZNT_CORE_VERSION = "1.2";
|
|
21
|
+
|
|
22
|
+
export class ZntTools {
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
const explicitConfig = options.configPath ?? process.env.ZNT_CONFIG;
|
|
25
|
+
this.customCoreBinary = options.coreBinaryPath ?? process.env.ZNT_CORE_BINARY;
|
|
26
|
+
this.configPath = resolve(explicitConfig ?? resolveManagedConfigPath(options.coreBinaryPath));
|
|
27
|
+
this.managedConfig = options.managedConfig ?? (explicitConfig === undefined);
|
|
28
|
+
this.client = options.client ?? new ZntClient({
|
|
29
|
+
configPath: this.configPath,
|
|
30
|
+
...(options.coreBinaryPath ? { coreBinaryPath: options.coreBinaryPath } : {}),
|
|
31
|
+
});
|
|
32
|
+
if (typeof this.client.setupManagedConfig !== "function") {
|
|
33
|
+
this.client.setupManagedConfig = (opts) => setupManagedConfig(opts, {
|
|
34
|
+
configPath: this.configPath,
|
|
35
|
+
coreBinaryPath: this.customCoreBinary,
|
|
36
|
+
client: this.client,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (typeof this.client.getSetupStatus !== "function") {
|
|
40
|
+
this.client.getSetupStatus = (opts) => getSetupStatus(opts, {
|
|
41
|
+
configPath: this.configPath,
|
|
42
|
+
coreBinaryPath: this.customCoreBinary,
|
|
43
|
+
client: this.client,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
this.metrics = options.metrics ?? new UsageMetrics();
|
|
47
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? envPositiveInteger("ZNT_MCP_REQUEST_TIMEOUT_MS", 30_000);
|
|
48
|
+
this.projectRoot = resolve(options.projectRoot ?? process.env.ZNT_PROJECT_ROOT ?? process.cwd());
|
|
49
|
+
this.coreVersion = options.coreVersion ?? ZNT_CORE_VERSION;
|
|
50
|
+
this.coreReady = false;
|
|
51
|
+
this.startPromise = undefined;
|
|
52
|
+
this.scanPromise = undefined;
|
|
53
|
+
this.setupGate = options.setupGate ?? (options.client === undefined);
|
|
54
|
+
this.setupReady = options.setupReady ?? false;
|
|
55
|
+
this.credentialSetup = options.credentialSetup ?? new CredentialSetupServer();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async initialize() {
|
|
59
|
+
return this.setupStatus(false);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
disconnect() {
|
|
63
|
+
this.client.disconnect();
|
|
64
|
+
void this.credentialSetup.close();
|
|
65
|
+
this.coreReady = false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async call(toolName, args = {}, options = {}) {
|
|
69
|
+
if (!TOOL_NAMES.has(toolName)) return errorResult(`Unknown tool: ${toolName}`);
|
|
70
|
+
const startedAt = performance.now();
|
|
71
|
+
try {
|
|
72
|
+
if (toolName === "setup") {
|
|
73
|
+
const data = await this.setup(args, options.signal);
|
|
74
|
+
await this.metrics.record(toolName, {}, data, Math.round(performance.now() - startedAt));
|
|
75
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
76
|
+
}
|
|
77
|
+
if (toolName === "setup_status") {
|
|
78
|
+
const data = await this.setupStatus(args.check_provider === true, options.signal);
|
|
79
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
80
|
+
}
|
|
81
|
+
if (toolName === "stats") {
|
|
82
|
+
const data = await this.metrics.snapshot();
|
|
83
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
84
|
+
}
|
|
85
|
+
if (this.setupGate && !this.setupReady) {
|
|
86
|
+
const setup = await this.setupStatus(true, options.signal);
|
|
87
|
+
if (setup.status !== "ready") {
|
|
88
|
+
return {
|
|
89
|
+
content: [{
|
|
90
|
+
type: "text", text: JSON.stringify({
|
|
91
|
+
...setup,
|
|
92
|
+
setup_status: setup.status,
|
|
93
|
+
status: "setup_required",
|
|
94
|
+
next_tool: "setup",
|
|
95
|
+
}, null, 2)
|
|
96
|
+
}]
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
this.setupReady = true;
|
|
100
|
+
}
|
|
101
|
+
await this.ensureCore();
|
|
102
|
+
const data = await this.execute(toolName, args, options.signal);
|
|
103
|
+
const text = formatToolOutput(toolName, data, args);
|
|
104
|
+
await this.metrics.record(toolName, args, data, Math.round(performance.now() - startedAt));
|
|
105
|
+
return { content: [{ type: "text", text }] };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof ZntTransportError) this.coreReady = false;
|
|
108
|
+
return errorResult(formatError(toolName, error));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async ensureCore() {
|
|
113
|
+
if (this.coreReady) return;
|
|
114
|
+
if (!this.startPromise) {
|
|
115
|
+
this.startPromise = this.startCoreWithInstall().then(async () => {
|
|
116
|
+
this.coreReady = true;
|
|
117
|
+
try {
|
|
118
|
+
const status = await this.client.status({ timeoutMs: 2_000 });
|
|
119
|
+
this.metrics.setProjectRoot(status.project_root);
|
|
120
|
+
} catch {
|
|
121
|
+
// Project metrics become persistent once a project root is available;
|
|
122
|
+
// failure to read status does not invalidate a working daemon.
|
|
123
|
+
}
|
|
124
|
+
}).finally(() => {
|
|
125
|
+
this.startPromise = undefined;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
await this.startPromise;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async startCoreWithInstall() {
|
|
132
|
+
try {
|
|
133
|
+
await this.client.startCore();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (!(error instanceof ZntCoreNotInstalledError)) throw error;
|
|
136
|
+
await this.client.downloadCore({ version: this.coreVersion });
|
|
137
|
+
await this.client.startCore();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async ensureCoreBinary() {
|
|
142
|
+
const expectedVersion = this.customCoreBinary ? undefined : this.coreVersion;
|
|
143
|
+
if (await this.client.isCoreInstalled(expectedVersion)) return;
|
|
144
|
+
await this.client.downloadCore({ version: this.coreVersion });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async setupStatus(checkProvider = false, signal) {
|
|
148
|
+
const status = await this.client.getSetupStatus({ configPath: this.configPath, checkProvider, signal });
|
|
149
|
+
return {
|
|
150
|
+
...status,
|
|
151
|
+
config_managed_by_mcp: this.managedConfig,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async setup(args = {}, signal) {
|
|
156
|
+
await this.ensureCoreBinary();
|
|
157
|
+
if (!this.managedConfig) {
|
|
158
|
+
const status = await this.setupStatus(args.check_provider !== false, signal);
|
|
159
|
+
return {
|
|
160
|
+
...status,
|
|
161
|
+
externally_managed: true,
|
|
162
|
+
message: status.status === "ready"
|
|
163
|
+
? "The configuration selected by ZNT_CONFIG is valid and was not modified."
|
|
164
|
+
: "The configuration selected by ZNT_CONFIG must be updated by its owner; MCP will not overwrite it.",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Interactive wizard mode: invoked with no configuration arguments or explicitly requesting interactive UI
|
|
169
|
+
const hasExplicitSettings = [
|
|
170
|
+
"mode",
|
|
171
|
+
"provider_url",
|
|
172
|
+
"model",
|
|
173
|
+
"embed_model",
|
|
174
|
+
"semantic_mode",
|
|
175
|
+
"description_language",
|
|
176
|
+
"exclude",
|
|
177
|
+
"languages",
|
|
178
|
+
"api_key",
|
|
179
|
+
"clear_api_key",
|
|
180
|
+
].some((key) => args[key] !== undefined);
|
|
181
|
+
|
|
182
|
+
if (!hasExplicitSettings || args.interactive === true) {
|
|
183
|
+
return this.openSetupWizard(args, signal);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return this.applySetupConfig(args, signal);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async openSetupWizard(args = {}, signal) {
|
|
190
|
+
let existing = {};
|
|
191
|
+
try {
|
|
192
|
+
existing = parse(await readFile(this.configPath, "utf8")) ?? {};
|
|
193
|
+
} catch {
|
|
194
|
+
// Configuration does not exist yet
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const defaults = parse(await this.client.getDefaultConfig());
|
|
198
|
+
const merged = mergeConfig(defaults, existing);
|
|
199
|
+
|
|
200
|
+
let currentStatus = {};
|
|
201
|
+
try {
|
|
202
|
+
currentStatus = await this.client.getSetupStatus({ configPath: this.configPath, checkProvider: false });
|
|
203
|
+
} catch {
|
|
204
|
+
// Ignore if status query fails during initial setup
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const initialData = {
|
|
208
|
+
mode: existing?.llm?.provider === "ollama" ? "ollama" : (existing?.llm?.provider === "" ? "bm25" : (existing?.llm?.provider || "openapi")),
|
|
209
|
+
provider_url: existing?.llm?.url || (existing?.llm?.provider === "ollama" ? "http://localhost:11434" : "https://openrouter.ai/api/v1"),
|
|
210
|
+
model: existing?.llm?.model || "",
|
|
211
|
+
embed_model: existing?.llm?.embed_model || "bge-m3",
|
|
212
|
+
semantic_mode: existing?.semantic?.mode || "fast",
|
|
213
|
+
description_language: existing?.semantic?.description_language || merged?.semantic?.description_language || "ru",
|
|
214
|
+
exclude: existing?.exclude || merged?.exclude || [],
|
|
215
|
+
languages: existing?.languages || merged?.languages || {},
|
|
216
|
+
has_credential: Boolean(currentStatus?.has_credential),
|
|
217
|
+
credential_source: currentStatus?.credential_source,
|
|
218
|
+
token_ref: currentStatus?.token_ref,
|
|
219
|
+
token_env: currentStatus?.token_env,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const setupUrl = await this.credentialSetup.open({
|
|
223
|
+
provider: initialData.provider_url,
|
|
224
|
+
initialData,
|
|
225
|
+
onSetup: async (payload) => {
|
|
226
|
+
await this.applySetupConfig({
|
|
227
|
+
mode: payload.mode,
|
|
228
|
+
provider_url: payload.provider_url,
|
|
229
|
+
model: payload.model,
|
|
230
|
+
embed_model: payload.embed_model,
|
|
231
|
+
semantic_mode: payload.semantic_mode,
|
|
232
|
+
description_language: payload.description_language,
|
|
233
|
+
exclude: payload.exclude,
|
|
234
|
+
languages: payload.languages,
|
|
235
|
+
...(payload.api_key ? { api_key: payload.api_key } : {}),
|
|
236
|
+
...(payload.clear_api_key ? { clear_api_key: true } : {}),
|
|
237
|
+
}, signal);
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const statusForResponse = currentStatus?.status ? currentStatus : await this.setupStatus(false, signal);
|
|
242
|
+
return {
|
|
243
|
+
status: "wizard_opened",
|
|
244
|
+
config_path: this.configPath,
|
|
245
|
+
setup_url: setupUrl,
|
|
246
|
+
instruction: "Opened setup wizard in browser. Configure provider, models, and project settings in the wizard.",
|
|
247
|
+
recommendations: statusForResponse.recommendations,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async applySetupConfig(args = {}, signal) {
|
|
252
|
+
await this.stopCoreForConfigChange();
|
|
253
|
+
this.setupReady = false;
|
|
254
|
+
|
|
255
|
+
let previousStatus;
|
|
256
|
+
try {
|
|
257
|
+
previousStatus = await this.client.getSetupStatus({ configPath: this.configPath, checkProvider: false });
|
|
258
|
+
} catch {
|
|
259
|
+
// Ignore
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const hasExistingCredential = Boolean(
|
|
263
|
+
previousStatus?.has_credential &&
|
|
264
|
+
previousStatus?.status === "ready" &&
|
|
265
|
+
(!args.provider_url || args.provider_url === previousStatus?.provider_url) &&
|
|
266
|
+
!args.clear_api_key
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const mode = args.mode ?? previousStatus?.mode ?? "openapi";
|
|
270
|
+
const isInteractiveOpenapi = mode === "openapi" && !args.api_key && args.credential_source !== "env" && !hasExistingCredential;
|
|
271
|
+
|
|
272
|
+
const status = await this.client.setupManagedConfig({
|
|
273
|
+
...args,
|
|
274
|
+
config_path: this.configPath,
|
|
275
|
+
check_provider: isInteractiveOpenapi ? false : (args.check_provider !== false),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (isInteractiveOpenapi || status.status === "credential_required") {
|
|
279
|
+
const providerUrl = args.provider_url ?? status.provider_url ?? "https://api.openai.com/v1";
|
|
280
|
+
const reference = credentialReference(providerUrl);
|
|
281
|
+
const setupUrl = await this.credentialSetup.open({
|
|
282
|
+
provider: providerUrl,
|
|
283
|
+
onCredential: async (credential) => {
|
|
284
|
+
if (typeof this.client.storeSecret === "function") {
|
|
285
|
+
await this.client.storeSecret(reference, credential);
|
|
286
|
+
}
|
|
287
|
+
if (typeof this.client.validateConfig === "function") {
|
|
288
|
+
await this.client.validateConfig(this.configPath, { checkProvider: true });
|
|
289
|
+
}
|
|
290
|
+
this.setupReady = true;
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
return {
|
|
294
|
+
status: "credential_required",
|
|
295
|
+
config_path: this.configPath,
|
|
296
|
+
credential_source: "keyring",
|
|
297
|
+
setup_url: setupUrl,
|
|
298
|
+
next: "Open setup_url, save the API key, then call setup_status with check_provider=true.",
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
this.setupReady = status.status === "ready";
|
|
303
|
+
return {
|
|
304
|
+
...status,
|
|
305
|
+
config_managed_by_mcp: this.managedConfig,
|
|
306
|
+
scan_required: this.setupReady,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async stopCoreForConfigChange() {
|
|
312
|
+
if (typeof this.client.stopCore !== "function") return;
|
|
313
|
+
try {
|
|
314
|
+
await this.client.stopCore({ timeoutMs: 2_000 });
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (!(error instanceof ZntTransportError)) throw error;
|
|
317
|
+
} finally {
|
|
318
|
+
this.coreReady = false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async execute(toolName, args, signal) {
|
|
323
|
+
const requestOptions = { timeoutMs: this.requestTimeoutMs, signal };
|
|
324
|
+
switch (toolName) {
|
|
325
|
+
case "search":
|
|
326
|
+
return this.client.semanticSearch(normalizeSearchArgs(args), requestOptions);
|
|
327
|
+
case "similar":
|
|
328
|
+
return this.client.findSimilar(normalizeSimilarArgs(args), requestOptions);
|
|
329
|
+
case "graph":
|
|
330
|
+
return this.client.getSubgraph(copyDefined(args, ["from", "to", "depth", "format", "edge_types"]), requestOptions);
|
|
331
|
+
case "outline":
|
|
332
|
+
return this.client.fileOutline({ file_path: args.path, include_code: args.include_code }, requestOptions);
|
|
333
|
+
case "logs":
|
|
334
|
+
return this.client.logs(copyDefined(args, ["stream_id", "after_id"]), requestOptions);
|
|
335
|
+
case "status":
|
|
336
|
+
return this.projectStatus(args, requestOptions);
|
|
337
|
+
case "scan":
|
|
338
|
+
return this.scanProject(args, requestOptions);
|
|
339
|
+
case "stats": {
|
|
340
|
+
try {
|
|
341
|
+
const status = await this.client.status({ timeoutMs: 2_000, signal });
|
|
342
|
+
this.metrics.setProjectRoot(status.project_root);
|
|
343
|
+
} catch {
|
|
344
|
+
// Session metrics remain available even without a workspace root.
|
|
345
|
+
}
|
|
346
|
+
return this.metrics.snapshot();
|
|
347
|
+
}
|
|
348
|
+
default:
|
|
349
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async projectStatus(args, requestOptions) {
|
|
354
|
+
const targetProject = await this.resolveProjectPath(args.project_path);
|
|
355
|
+
const [status, scan] = await Promise.all([
|
|
356
|
+
this.client.status(requestOptions),
|
|
357
|
+
this.client.scanStatus(requestOptions),
|
|
358
|
+
]);
|
|
359
|
+
const daemonProject = status.project_root || null;
|
|
360
|
+
const comparableDaemonProject = await canonicalizeExistingPath(daemonProject);
|
|
361
|
+
const isExactProject = samePath(targetProject, comparableDaemonProject);
|
|
362
|
+
const isCurrentProject = pathContains(comparableDaemonProject, targetProject);
|
|
363
|
+
return {
|
|
364
|
+
current_project: targetProject,
|
|
365
|
+
daemon_project: daemonProject,
|
|
366
|
+
is_current_project: isCurrentProject,
|
|
367
|
+
is_exact_project: isExactProject,
|
|
368
|
+
coverage: isExactProject ? "exact" : isCurrentProject ? "ancestor" : "none",
|
|
369
|
+
daemon_status: status.status,
|
|
370
|
+
has_index: isCurrentProject && Number(status.files || 0) > 0,
|
|
371
|
+
stats: {
|
|
372
|
+
files: Number(status.files || 0),
|
|
373
|
+
declarations: Number(status.declarations || 0),
|
|
374
|
+
functions: Number(status.functions || 0),
|
|
375
|
+
members: Number(status.members || 0),
|
|
376
|
+
dependencies: Number(status.dependencies || 0),
|
|
377
|
+
edges: Number(status.edges || 0),
|
|
378
|
+
},
|
|
379
|
+
scan,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async scanProject(args, requestOptions) {
|
|
384
|
+
if (this.scanPromise) {
|
|
385
|
+
return {
|
|
386
|
+
started: false,
|
|
387
|
+
reason: "scan_request_in_progress",
|
|
388
|
+
current_project: await this.resolveProjectPath(args.project_path),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
this.scanPromise = this.performScanProject(args, requestOptions);
|
|
393
|
+
try {
|
|
394
|
+
return await this.scanPromise;
|
|
395
|
+
} finally {
|
|
396
|
+
this.scanPromise = undefined;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async performScanProject(args, requestOptions) {
|
|
401
|
+
const targetProject = await this.resolveProjectPath(args.project_path);
|
|
402
|
+
const [before, progress] = await Promise.all([
|
|
403
|
+
this.client.status(requestOptions),
|
|
404
|
+
this.client.scanStatus(requestOptions),
|
|
405
|
+
]);
|
|
406
|
+
const daemonProject = before.project_root || null;
|
|
407
|
+
const comparableDaemonProject = await canonicalizeExistingPath(daemonProject);
|
|
408
|
+
if (pathContains(comparableDaemonProject, targetProject)) {
|
|
409
|
+
const exact = samePath(targetProject, comparableDaemonProject);
|
|
410
|
+
return {
|
|
411
|
+
started: false,
|
|
412
|
+
reason: exact ? "already_current_project" : "already_covered",
|
|
413
|
+
current_project: targetProject,
|
|
414
|
+
daemon_project: daemonProject,
|
|
415
|
+
daemon_status: before.status,
|
|
416
|
+
coverage: exact ? "exact" : "ancestor",
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (progress.status === "scanning") {
|
|
421
|
+
return {
|
|
422
|
+
started: false,
|
|
423
|
+
reason: "scan_in_progress",
|
|
424
|
+
current_project: targetProject,
|
|
425
|
+
daemon_project: daemonProject,
|
|
426
|
+
daemon_status: before.status,
|
|
427
|
+
scan: progress,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const scan = await this.client.scan({
|
|
432
|
+
file_path: targetProject,
|
|
433
|
+
language: args.language ?? "auto",
|
|
434
|
+
restart: false,
|
|
435
|
+
}, requestOptions);
|
|
436
|
+
this.metrics.setProjectRoot(targetProject);
|
|
437
|
+
return {
|
|
438
|
+
started: true,
|
|
439
|
+
previous_project: daemonProject,
|
|
440
|
+
current_project: targetProject,
|
|
441
|
+
scan,
|
|
442
|
+
next: "Call status until scan.status is completed or error.",
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async resolveProjectPath(projectPath) {
|
|
447
|
+
if (projectPath === undefined) return this.projectRoot;
|
|
448
|
+
const candidate = resolve(this.projectRoot, projectPath);
|
|
449
|
+
let details;
|
|
450
|
+
try {
|
|
451
|
+
details = await stat(candidate);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
throw new Error(`Project path is not accessible: ${candidate} (${error.message})`);
|
|
454
|
+
}
|
|
455
|
+
if (!details.isDirectory()) throw new Error(`Project path is not a directory: ${candidate}`);
|
|
456
|
+
return realpath(candidate);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function normalizeSearchArgs(args) {
|
|
461
|
+
return copyDefined({
|
|
462
|
+
...args,
|
|
463
|
+
type: args.type_boost,
|
|
464
|
+
role: args.role_boost,
|
|
465
|
+
file_path: args.file_pattern,
|
|
466
|
+
}, ["query", "mode", "limit", "callers_level", "callees_level", "compact", "include_code", "max_code_lines", "type", "role", "file_path"]);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function normalizeSimilarArgs(args) {
|
|
470
|
+
return copyDefined({
|
|
471
|
+
...args,
|
|
472
|
+
type: args.type_boost,
|
|
473
|
+
role: args.role_boost,
|
|
474
|
+
file_path: args.file_pattern,
|
|
475
|
+
}, ["target", "query", "limit", "include_code", "max_code_lines", "type", "role", "file_path", "edge_types"]);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function copyDefined(source, keys) {
|
|
479
|
+
return Object.fromEntries(keys.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function formatToolOutput(toolName, data, args) {
|
|
483
|
+
if (toolName === "search") return formatSearchResults(data, args.compact);
|
|
484
|
+
if (toolName === "outline") return data.formatted_text || JSON.stringify(data, null, 2);
|
|
485
|
+
if (toolName === "graph" && data.format === "text") return data.text || "No graph edges found.";
|
|
486
|
+
if (toolName === "graph" && data.format === "mermaid") return data.mermaid || "No graph edges found.";
|
|
487
|
+
return JSON.stringify(data, null, 2);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function formatSearchResults(results, compact) {
|
|
491
|
+
if (!Array.isArray(results) || !results.length) return "No matching components found.";
|
|
492
|
+
return results.map((result) => {
|
|
493
|
+
const lines = [
|
|
494
|
+
`[Score: ${Number(result.score || 0).toFixed(2)}] ${result.name} (${result.type})`,
|
|
495
|
+
result.role ? `Role: ${result.role}` : "",
|
|
496
|
+
`File: ${result.file} L:${result.start_line}-${result.end_line}`,
|
|
497
|
+
compact && result.summary ? `Summary: ${result.summary}` : "",
|
|
498
|
+
!compact && result.description ? `Description: ${result.description}` : "",
|
|
499
|
+
!compact ? formatRelations("Callers", result.callers) : "",
|
|
500
|
+
!compact ? formatRelations("Callees", result.callees) : "",
|
|
501
|
+
result.code ? `Code:\n${result.code}` : "",
|
|
502
|
+
].filter(Boolean);
|
|
503
|
+
return `${lines.join("\n")}\n---`;
|
|
504
|
+
}).join("\n");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function formatRelations(label, relations) {
|
|
508
|
+
if (!Array.isArray(relations) || !relations.length) return "";
|
|
509
|
+
return `${label}: ${relations.map((item) => item.name).join(", ")}`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function formatError(toolName, error) {
|
|
513
|
+
if (error instanceof ZntRpcError) {
|
|
514
|
+
const data = error.data === undefined ? "" : `\n${JSON.stringify(error.data, null, 2)}`;
|
|
515
|
+
return `Error executing ${toolName}: znt-core RPC ${error.code}: ${error.message}${data}`;
|
|
516
|
+
}
|
|
517
|
+
return `Error executing ${toolName}: ${error?.message ?? String(error)}`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function errorResult(message) {
|
|
521
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function envPositiveInteger(name, fallback) {
|
|
525
|
+
const value = Number(process.env[name]);
|
|
526
|
+
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function samePath(left, right) {
|
|
530
|
+
if (!left || !right) return false;
|
|
531
|
+
const normalize = (value) => {
|
|
532
|
+
const normalized = resolve(value);
|
|
533
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
534
|
+
};
|
|
535
|
+
return normalize(left) === normalize(right);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function pathContains(parent, candidate) {
|
|
539
|
+
if (!parent || !candidate) return false;
|
|
540
|
+
const normalize = (value) => {
|
|
541
|
+
const normalized = resolve(value);
|
|
542
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
543
|
+
};
|
|
544
|
+
const pathFromParent = relative(normalize(parent), normalize(candidate));
|
|
545
|
+
return pathFromParent === "" || (!pathFromParent.startsWith("..") && !isAbsolute(pathFromParent));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function canonicalizeExistingPath(value) {
|
|
549
|
+
if (!value) return null;
|
|
550
|
+
try {
|
|
551
|
+
return await realpath(resolve(value));
|
|
552
|
+
} catch {
|
|
553
|
+
return resolve(value);
|
|
554
|
+
}
|
|
555
|
+
}
|