@nexrall/code-core 1.4.66 → 1.4.67
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/dist/agent/agentTypes.d.ts +6 -2
- package/dist/agent/agentTypes.d.ts.map +1 -1
- package/dist/agent/agentTypes.js +13 -4
- package/dist/agent/askOnce.d.ts +12 -0
- package/dist/agent/askOnce.d.ts.map +1 -0
- package/dist/agent/askOnce.js +41 -0
- package/dist/agent/compaction.d.ts +244 -0
- package/dist/agent/compaction.d.ts.map +1 -0
- package/dist/agent/compaction.js +976 -0
- package/dist/agent/fileLocks.d.ts +34 -0
- package/dist/agent/fileLocks.d.ts.map +1 -0
- package/dist/agent/fileLocks.js +114 -0
- package/dist/agent/hooks.d.ts +324 -0
- package/dist/agent/hooks.d.ts.map +1 -0
- package/dist/agent/hooks.js +1228 -0
- package/dist/agent/iterationPolicy.d.ts +121 -0
- package/dist/agent/iterationPolicy.d.ts.map +1 -0
- package/dist/agent/iterationPolicy.js +297 -0
- package/dist/agent/lifecycleHost.d.ts +55 -0
- package/dist/agent/lifecycleHost.d.ts.map +1 -0
- package/dist/agent/lifecycleHost.js +294 -0
- package/dist/agent/loop.d.ts +11 -491
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +417 -3031
- package/dist/agent/planMode.d.ts.map +1 -1
- package/dist/agent/planMode.js +1 -0
- package/dist/agent/sharedTasks.d.ts +7 -0
- package/dist/agent/sharedTasks.d.ts.map +1 -1
- package/dist/agent/sharedTasks.js +16 -0
- package/dist/agent/subAgentBudget.d.ts +65 -0
- package/dist/agent/subAgentBudget.d.ts.map +1 -0
- package/dist/agent/subAgentBudget.js +269 -0
- package/dist/agent/subTask.d.ts +6 -0
- package/dist/agent/subTask.d.ts.map +1 -0
- package/dist/agent/subTask.js +713 -0
- package/dist/agent/subTaskSupport.d.ts +156 -0
- package/dist/agent/subTaskSupport.d.ts.map +1 -0
- package/dist/agent/subTaskSupport.js +409 -0
- package/dist/agent/toolDescriptions.d.ts +3 -0
- package/dist/agent/toolDescriptions.d.ts.map +1 -0
- package/dist/agent/toolDescriptions.js +116 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +17 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/mcp/client.d.ts +104 -0
- package/dist/mcp/client.d.ts.map +1 -1
- package/dist/mcp/client.js +136 -2
- package/dist/mcp/httpClient.d.ts +17 -1
- package/dist/mcp/httpClient.d.ts.map +1 -1
- package/dist/mcp/httpClient.js +120 -19
- package/dist/mcp/manager.d.ts +77 -2
- package/dist/mcp/manager.d.ts.map +1 -1
- package/dist/mcp/manager.js +275 -9
- package/dist/mcp/sseClient.d.ts +7 -1
- package/dist/mcp/sseClient.d.ts.map +1 -1
- package/dist/mcp/sseClient.js +45 -1
- package/dist/mcp/stats.d.ts +41 -0
- package/dist/mcp/stats.d.ts.map +1 -0
- package/dist/mcp/stats.js +108 -0
- package/dist/permissions/destructive.d.ts +2 -0
- package/dist/permissions/destructive.d.ts.map +1 -1
- package/dist/permissions/destructive.js +6 -2
- package/dist/permissions/destructiveTokens.d.ts +5 -0
- package/dist/permissions/destructiveTokens.d.ts.map +1 -1
- package/dist/permissions/destructiveTokens.js +9 -3
- package/dist/permissions/modePolicy.d.ts.map +1 -1
- package/dist/permissions/modePolicy.js +5 -2
- package/dist/permissions/rules.d.ts +4 -1
- package/dist/permissions/rules.d.ts.map +1 -1
- package/dist/permissions/rules.js +29 -0
- package/dist/types.d.ts +45 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/mcp/manager.js
CHANGED
|
@@ -41,17 +41,40 @@ const client_1 = require("./client");
|
|
|
41
41
|
const httpClient_1 = require("./httpClient");
|
|
42
42
|
const sseClient_1 = require("./sseClient");
|
|
43
43
|
const index_1 = require("../plugins/index");
|
|
44
|
+
const stats_1 = require("./stats");
|
|
44
45
|
// ─── McpManager ───────────────────────────────────────────────────────────────
|
|
45
46
|
class McpManager {
|
|
46
47
|
constructor() {
|
|
47
48
|
this._clients = new Map();
|
|
48
49
|
// Map: prefixed tool name → server name
|
|
49
50
|
this._toolMap = new Map();
|
|
51
|
+
// Prompt metadata per server, fetched on first use (the `/` menu, `/mcp prompts`
|
|
52
|
+
// and positional→named argument mapping all read it). Cleared whenever the
|
|
53
|
+
// client is dropped, so a reconnect never serves a dead session's list.
|
|
54
|
+
this._promptCache = new Map();
|
|
50
55
|
// Config + last connection error per server (for status UI, incl. failures)
|
|
51
56
|
this._configs = new Map();
|
|
52
57
|
this._errors = new Map();
|
|
53
58
|
// Servers that returned 401 → need OAuth. Value = WWW-Authenticate challenge header.
|
|
54
59
|
this._needsAuth = new Map();
|
|
60
|
+
// Names the user has switched off. Kept per-manager (not just in the file) so a
|
|
61
|
+
// toggle takes effect in THIS session, not at the next start.
|
|
62
|
+
this._disabled = new Set();
|
|
63
|
+
/** Per-tool call counts and timings — the "is it slow or broken?" answer (spec §42). */
|
|
64
|
+
this.stats = new stats_1.ToolStats();
|
|
65
|
+
// The human-facing elicitation handler (set by the host that owns a UI). Null means
|
|
66
|
+
// "nobody can be asked" — transports then auto-decline, so a server never hangs.
|
|
67
|
+
this._elicitationHandler = null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Route every server's elicitation (`elicitation/create`) through this handler —
|
|
71
|
+
* including servers that connect later, which is why it is also re-applied in
|
|
72
|
+
* connectAll/enable. Pass null to restore auto-decline.
|
|
73
|
+
*/
|
|
74
|
+
setElicitationHandler(handler) {
|
|
75
|
+
this._elicitationHandler = handler;
|
|
76
|
+
for (const client of this._clients.values())
|
|
77
|
+
client.setElicitationHandler(handler);
|
|
55
78
|
}
|
|
56
79
|
/** Pick the transport for a server config: stdio (command), Streamable HTTP, or legacy SSE. */
|
|
57
80
|
static makeClient(name, cfg) {
|
|
@@ -73,24 +96,38 @@ class McpManager {
|
|
|
73
96
|
path.join(os.homedir(), '.nexrall', 'mcp.json'),
|
|
74
97
|
path.join(workDir, '.nexrall', 'mcp.json'),
|
|
75
98
|
];
|
|
99
|
+
// A list, so the two scopes UNION rather than override: switching a server off in
|
|
100
|
+
// the project must add to whatever the user file already silences.
|
|
101
|
+
const disabled = new Set();
|
|
76
102
|
for (const loc of locations) {
|
|
77
103
|
try {
|
|
78
104
|
const raw = fs.readFileSync(loc, 'utf-8');
|
|
79
105
|
const cfg = JSON.parse(raw);
|
|
80
106
|
Object.assign(merged.mcpServers, cfg.mcpServers ?? {});
|
|
107
|
+
for (const name of cfg.disabledServers ?? [])
|
|
108
|
+
disabled.add(name);
|
|
81
109
|
}
|
|
82
110
|
catch {
|
|
83
111
|
// File doesn't exist or invalid JSON — skip
|
|
84
112
|
}
|
|
85
113
|
}
|
|
114
|
+
if (disabled.size)
|
|
115
|
+
merged.disabledServers = [...disabled].sort();
|
|
86
116
|
return merged;
|
|
87
117
|
}
|
|
88
118
|
/** Connect to all configured servers. Errors per-server are logged, not thrown. */
|
|
89
119
|
async connectAll(config) {
|
|
120
|
+
this._disabled = new Set(config.disabledServers ?? []);
|
|
90
121
|
const entries = Object.entries(config.mcpServers);
|
|
91
122
|
await Promise.all(entries.map(async ([name, serverCfg]) => {
|
|
92
123
|
this._configs.set(name, serverCfg);
|
|
124
|
+
// Switched off by the user: remember it (so /mcp can list it) and do not spawn.
|
|
125
|
+
if (this._disabled.has(name)) {
|
|
126
|
+
this._errors.delete(name);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
93
129
|
const client = McpManager.makeClient(name, serverCfg);
|
|
130
|
+
client.setElicitationHandler(this._elicitationHandler);
|
|
94
131
|
try {
|
|
95
132
|
await client.connect();
|
|
96
133
|
this._clients.set(name, client);
|
|
@@ -161,7 +198,14 @@ class McpManager {
|
|
|
161
198
|
// Enforce a timeout — a hung MCP server would otherwise block the agent loop forever.
|
|
162
199
|
const MCP_TOOL_TIMEOUT_MS = 60000;
|
|
163
200
|
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`MCP tool "${prefixedName}" timed out after ${MCP_TOOL_TIMEOUT_MS / 1000}s`)), MCP_TOOL_TIMEOUT_MS));
|
|
164
|
-
|
|
201
|
+
// Timing covers the whole path the user waits on (server round-trip AND the
|
|
202
|
+
// race against the 60s timeout / abort), which is what "how long did that take"
|
|
203
|
+
// means from the outside. The tool name recorded is the PREFIXED one, so the
|
|
204
|
+
// stats of two servers with a same-named tool never merge.
|
|
205
|
+
const startedAt = Date.now();
|
|
206
|
+
this.stats.begin();
|
|
207
|
+
const timed = (p) => p.then((v) => { this.stats.ok(prefixedName, Date.now() - startedAt); return v; }, (e) => { this.stats.fail(prefixedName, Date.now() - startedAt, e.message || String(e)); throw e; });
|
|
208
|
+
const racers = [timed(client.callTool(toolName, input)), timed(timeoutPromise)];
|
|
165
209
|
if (abortSignal) {
|
|
166
210
|
let pollId;
|
|
167
211
|
const abortPromise = new Promise((_, reject) => {
|
|
@@ -172,7 +216,7 @@ class McpManager {
|
|
|
172
216
|
}
|
|
173
217
|
}, 100);
|
|
174
218
|
});
|
|
175
|
-
racers.push(abortPromise);
|
|
219
|
+
racers.push(timed(abortPromise));
|
|
176
220
|
// Clearing the poll on whichever branch wins avoids leaving a live interval
|
|
177
221
|
// (and the closure it holds) running for the rest of the process lifetime —
|
|
178
222
|
// the same leak-prevention pattern client.ts uses for its own abort poll.
|
|
@@ -180,6 +224,97 @@ class McpManager {
|
|
|
180
224
|
}
|
|
181
225
|
return Promise.race(racers);
|
|
182
226
|
}
|
|
227
|
+
/** Resources across connected, enabled servers. One server failing never hides the others. */
|
|
228
|
+
async listResources(server) {
|
|
229
|
+
const out = [];
|
|
230
|
+
for (const [name, client] of this._clients) {
|
|
231
|
+
if (server && name !== server)
|
|
232
|
+
continue;
|
|
233
|
+
for (const r of await client.listResources())
|
|
234
|
+
out.push({ ...r, server: name });
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
/** Read one resource. Throws a clear error for an unknown server; bounded by the tool timeout. */
|
|
239
|
+
async readResource(server, uri) {
|
|
240
|
+
const client = this._clients.get(server);
|
|
241
|
+
if (!client)
|
|
242
|
+
throw new Error(`No connected MCP server named "${server}". Connected: ${[...this._clients.keys()].join(', ') || '(none)'}`);
|
|
243
|
+
let timer;
|
|
244
|
+
const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`Reading ${uri} timed out after 60s`)), 60000); });
|
|
245
|
+
try {
|
|
246
|
+
return await Promise.race([client.readResource(uri), timeout]);
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
if (timer)
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Prompt metadata for one connected server. Fetched once per connection and
|
|
255
|
+
* cached — including an empty list (server has no prompts capability), so the
|
|
256
|
+
* `/` menu does not re-ask a server on every keystroke; reconnecting clears
|
|
257
|
+
* the entry. Returns [] for a server that is not connected.
|
|
258
|
+
*/
|
|
259
|
+
async promptsFor(server) {
|
|
260
|
+
const cached = this._promptCache.get(server);
|
|
261
|
+
if (cached)
|
|
262
|
+
return cached;
|
|
263
|
+
const client = this._clients.get(server);
|
|
264
|
+
if (!client)
|
|
265
|
+
return [];
|
|
266
|
+
const list = await client.listPrompts();
|
|
267
|
+
this._promptCache.set(server, list);
|
|
268
|
+
return list;
|
|
269
|
+
}
|
|
270
|
+
/** Prompts across connected, enabled servers, tagged with the server they came from. */
|
|
271
|
+
async listPrompts(server) {
|
|
272
|
+
const out = [];
|
|
273
|
+
for (const name of this._clients.keys()) {
|
|
274
|
+
if (server && name !== server)
|
|
275
|
+
continue;
|
|
276
|
+
for (const p of await this.promptsFor(name))
|
|
277
|
+
out.push({ ...p, server: name });
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Expand one prompt to text. Positional values map, in order, onto the
|
|
283
|
+
* arguments the server declared in prompts/list — the same positional form
|
|
284
|
+
* Claude Code accepts (`/mcp__server__prompt first second`). Validation runs
|
|
285
|
+
* here, before the request, so mistakes read as sentences instead of raw
|
|
286
|
+
* JSON-RPC errors; the request itself is bounded by the same 60s timeout the
|
|
287
|
+
* resource reader uses.
|
|
288
|
+
*/
|
|
289
|
+
async getPrompt(server, name, values = []) {
|
|
290
|
+
const client = this._clients.get(server);
|
|
291
|
+
if (!client)
|
|
292
|
+
throw new Error(`No connected MCP server named "${server}". Connected: ${[...this._clients.keys()].join(', ') || '(none)'}`);
|
|
293
|
+
const list = await this.promptsFor(server);
|
|
294
|
+
const meta = list.find((p) => p.name === name);
|
|
295
|
+
if (!meta) {
|
|
296
|
+
throw new Error(`No MCP prompt named "${name}" on server "${server}"${list.length ? ` (it offers: ${list.map((p) => p.name).join(', ')})` : ''}.`);
|
|
297
|
+
}
|
|
298
|
+
const declared = meta.arguments ?? [];
|
|
299
|
+
if (values.length > declared.length) {
|
|
300
|
+
throw new Error(`MCP prompt "${server}:${name}" takes ${declared.length} argument(s)${declared.length ? ` (${declared.map((a) => a.name).join(', ')})` : ''} — got ${values.length}.`);
|
|
301
|
+
}
|
|
302
|
+
const args = {};
|
|
303
|
+
declared.forEach((a, i) => { const v = values[i]; if (v !== undefined && v !== '')
|
|
304
|
+
args[a.name] = v; });
|
|
305
|
+
const missing = declared.filter((a) => a.required && args[a.name] === undefined);
|
|
306
|
+
if (missing.length)
|
|
307
|
+
throw new Error(`MCP prompt "${server}:${name}" is missing required argument(s): ${missing.map((a) => a.name).join(', ')}.`);
|
|
308
|
+
let timer;
|
|
309
|
+
const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error(`MCP prompt ${server}:${name} timed out after 60s`)), 60000); });
|
|
310
|
+
try {
|
|
311
|
+
return await Promise.race([client.getPrompt(name, args), timeout]);
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
if (timer)
|
|
315
|
+
clearTimeout(timer);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
183
318
|
/** True if this tool name belongs to an MCP server. */
|
|
184
319
|
isMcpTool(name) {
|
|
185
320
|
return this._toolMap.has(name);
|
|
@@ -195,23 +330,103 @@ class McpManager {
|
|
|
195
330
|
return cfg.type;
|
|
196
331
|
return cfg?.url && !cfg?.command ? 'http' : 'stdio';
|
|
197
332
|
}
|
|
198
|
-
/** Returns per-server status for UI display, including failed / auth-required
|
|
333
|
+
/** Returns per-server status for UI display, including failed / auth-required / disabled. */
|
|
199
334
|
getStatus() {
|
|
200
335
|
const out = [];
|
|
201
336
|
for (const [name, client] of this._clients) {
|
|
202
|
-
out.push({ name, transport: McpManager.transportOf(this._configs.get(name)), tools: client.tools, connected: true });
|
|
337
|
+
out.push({ name, transport: McpManager.transportOf(this._configs.get(name)), tools: client.tools, connected: true, disabled: false });
|
|
203
338
|
}
|
|
204
|
-
for (const
|
|
339
|
+
for (const name of this._configs.keys()) {
|
|
205
340
|
if (this._clients.has(name))
|
|
206
341
|
continue;
|
|
207
|
-
|
|
342
|
+
const disabled = this._disabled.has(name);
|
|
343
|
+
const error = this._errors.get(name);
|
|
344
|
+
// A disabled server that never connected has no error to report — say nothing
|
|
345
|
+
// rather than repeat "disconnected" as if it were a failure.
|
|
346
|
+
if (disabled && !error) {
|
|
347
|
+
out.push({ name, transport: McpManager.transportOf(this._configs.get(name)), tools: [], connected: false, disabled: true });
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
out.push({
|
|
351
|
+
name, transport: McpManager.transportOf(this._configs.get(name)), tools: [],
|
|
352
|
+
connected: false, disabled, error, needsAuth: this._needsAuth.has(name),
|
|
353
|
+
});
|
|
208
354
|
}
|
|
209
|
-
return out;
|
|
355
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
210
356
|
}
|
|
211
357
|
/** The WWW-Authenticate challenge a server returned on 401 (empty if none/unknown). */
|
|
212
358
|
authChallenge(name) {
|
|
213
359
|
return this._needsAuth.get(name) ?? '';
|
|
214
360
|
}
|
|
361
|
+
/** True when the user has switched this server off for the session. */
|
|
362
|
+
isDisabled(name) {
|
|
363
|
+
return this._disabled.has(name);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Switch a server off in THIS session: drop its client and forget its tool map, so
|
|
367
|
+
* the next turn's tool list no longer offers it. The caller persists the choice
|
|
368
|
+
* (McpManager.setServerDisabled) — this method deliberately does not touch disk.
|
|
369
|
+
*/
|
|
370
|
+
disable(name) {
|
|
371
|
+
const existed = this._configs.has(name);
|
|
372
|
+
this._disabled.add(name);
|
|
373
|
+
this._dropClient(name);
|
|
374
|
+
return existed;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Switch a server back on and connect it, without a restart. Resolves to an error
|
|
378
|
+
* message when the connection fails, so the caller can print it and leave the
|
|
379
|
+
* server listed as broken instead of silently reporting success.
|
|
380
|
+
*/
|
|
381
|
+
async enable(name) {
|
|
382
|
+
this._disabled.delete(name);
|
|
383
|
+
const cfg = this._configs.get(name);
|
|
384
|
+
if (!cfg)
|
|
385
|
+
return `no server named "${name}" is configured`;
|
|
386
|
+
this._dropClient(name);
|
|
387
|
+
const client = McpManager.makeClient(name, cfg);
|
|
388
|
+
client.setElicitationHandler(this._elicitationHandler);
|
|
389
|
+
try {
|
|
390
|
+
await client.connect();
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
const msg = err.message;
|
|
394
|
+
if (err instanceof httpClient_1.McpOAuthRequiredError)
|
|
395
|
+
this._needsAuth.set(name, err.wwwAuthenticate);
|
|
396
|
+
this._errors.set(name, msg);
|
|
397
|
+
return msg;
|
|
398
|
+
}
|
|
399
|
+
this._clients.set(name, client);
|
|
400
|
+
this._errors.delete(name);
|
|
401
|
+
this._needsAuth.delete(name);
|
|
402
|
+
for (const tool of client.tools)
|
|
403
|
+
this._toolMap.set(`${name}__${tool.name}`, name);
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
/** Disconnect one server for the session (its config stays; `enable` brings it back). */
|
|
407
|
+
disconnect(name) {
|
|
408
|
+
const had = this._clients.has(name) || this._errors.has(name);
|
|
409
|
+
this._dropClient(name);
|
|
410
|
+
this._errors.set(name, 'disconnected');
|
|
411
|
+
return had;
|
|
412
|
+
}
|
|
413
|
+
/** Forgets one server's client and tools, leaving its config and status alone. */
|
|
414
|
+
_dropClient(name) {
|
|
415
|
+
const client = this._clients.get(name);
|
|
416
|
+
if (client) {
|
|
417
|
+
try {
|
|
418
|
+
client.disconnect();
|
|
419
|
+
}
|
|
420
|
+
catch { /* best effort */ }
|
|
421
|
+
}
|
|
422
|
+
this._clients.delete(name);
|
|
423
|
+
for (const key of [...this._toolMap.keys()]) {
|
|
424
|
+
if (this._toolMap.get(key) === name)
|
|
425
|
+
this._toolMap.delete(key);
|
|
426
|
+
}
|
|
427
|
+
this._promptCache.delete(name);
|
|
428
|
+
this.stats.reset();
|
|
429
|
+
}
|
|
215
430
|
// ─── Config file persistence (for the management UI) ──────────────────────────
|
|
216
431
|
/** Path to an mcp.json for the given scope. */
|
|
217
432
|
static configPath(workDir, scope) {
|
|
@@ -222,7 +437,11 @@ class McpManager {
|
|
|
222
437
|
static readConfigFile(p) {
|
|
223
438
|
try {
|
|
224
439
|
const cfg = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
225
|
-
|
|
440
|
+
// `disabledServers` has to survive the read: this is the object that gets written
|
|
441
|
+
// BACK by addServer/setServerDisabled/removeServer, and dropping the list here
|
|
442
|
+
// silently un-disabled every server but the one being toggled (found by a test
|
|
443
|
+
// that switched two off and watched the first reconnect).
|
|
444
|
+
return { mcpServers: cfg.mcpServers ?? {}, disabledServers: cfg.disabledServers };
|
|
226
445
|
}
|
|
227
446
|
catch {
|
|
228
447
|
return { mcpServers: {} };
|
|
@@ -239,17 +458,62 @@ class McpManager {
|
|
|
239
458
|
cfg.mcpServers[name] = server;
|
|
240
459
|
McpManager.writeConfigFile(p, cfg);
|
|
241
460
|
}
|
|
461
|
+
/**
|
|
462
|
+
* Switch a server off/on in the config FILE, so the choice survives a restart.
|
|
463
|
+
* Written to the project scope even when the server came from elsewhere (a plugin
|
|
464
|
+
* or the user file), because the project is the only scope this session is sure it
|
|
465
|
+
* may edit — and `disabledServers` is a name list, so it works for all three.
|
|
466
|
+
* Returns the file that was written.
|
|
467
|
+
*/
|
|
468
|
+
static setServerDisabled(workDir, name, disabled, scope = 'project') {
|
|
469
|
+
const p = McpManager.configPath(workDir, scope);
|
|
470
|
+
const cfg = McpManager.readConfigFile(p);
|
|
471
|
+
const current = new Set(cfg.disabledServers ?? []);
|
|
472
|
+
if (disabled)
|
|
473
|
+
current.add(name);
|
|
474
|
+
else
|
|
475
|
+
current.delete(name);
|
|
476
|
+
cfg.disabledServers = [...current].sort();
|
|
477
|
+
if (cfg.disabledServers.length === 0)
|
|
478
|
+
delete cfg.disabledServers;
|
|
479
|
+
McpManager.writeConfigFile(p, cfg);
|
|
480
|
+
return p;
|
|
481
|
+
}
|
|
482
|
+
/** Where a server's definition comes from, for messages that have to be honest about it. */
|
|
483
|
+
static serverScope(workDir, name) {
|
|
484
|
+
const project = McpManager.readConfigFile(McpManager.configPath(workDir, 'project'));
|
|
485
|
+
if (project.mcpServers[name])
|
|
486
|
+
return 'project';
|
|
487
|
+
const user = McpManager.readConfigFile(McpManager.configPath(workDir, 'user'));
|
|
488
|
+
if (user.mcpServers[name])
|
|
489
|
+
return 'user';
|
|
490
|
+
try {
|
|
491
|
+
if ((0, index_1.pluginMcpServers)(workDir)[name])
|
|
492
|
+
return 'plugin';
|
|
493
|
+
}
|
|
494
|
+
catch { /* plugin scan is best-effort */ }
|
|
495
|
+
return 'none';
|
|
496
|
+
}
|
|
242
497
|
/** Remove a server from whichever scope(s) define it. Returns true if anything was removed. */
|
|
243
498
|
static removeServer(workDir, name) {
|
|
244
499
|
let removed = false;
|
|
245
500
|
for (const scope of ['project', 'user']) {
|
|
246
501
|
const p = McpManager.configPath(workDir, scope);
|
|
247
502
|
const cfg = McpManager.readConfigFile(p);
|
|
503
|
+
let touched = false;
|
|
248
504
|
if (cfg.mcpServers[name]) {
|
|
249
505
|
delete cfg.mcpServers[name];
|
|
250
|
-
|
|
506
|
+
touched = true;
|
|
251
507
|
removed = true;
|
|
252
508
|
}
|
|
509
|
+
if (cfg.disabledServers?.includes(name)) {
|
|
510
|
+
cfg.disabledServers = cfg.disabledServers.filter((n) => n !== name);
|
|
511
|
+
if (cfg.disabledServers.length === 0)
|
|
512
|
+
delete cfg.disabledServers;
|
|
513
|
+
touched = true;
|
|
514
|
+
}
|
|
515
|
+
if (touched)
|
|
516
|
+
McpManager.writeConfigFile(p, cfg);
|
|
253
517
|
}
|
|
254
518
|
return removed;
|
|
255
519
|
}
|
|
@@ -261,6 +525,8 @@ class McpManager {
|
|
|
261
525
|
this._configs.clear();
|
|
262
526
|
this._errors.clear();
|
|
263
527
|
this._needsAuth.clear();
|
|
528
|
+
this._disabled.clear();
|
|
529
|
+
this.stats.reset();
|
|
264
530
|
}
|
|
265
531
|
}
|
|
266
532
|
exports.McpManager = McpManager;
|
package/dist/mcp/sseClient.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { McpServerConfig, McpToolDefinition, McpTransport } from './client';
|
|
1
|
+
import type { McpServerConfig, McpToolDefinition, McpTransport, McpResource, McpPrompt, McpElicitationHandler } from './client';
|
|
2
2
|
export declare class McpSseClient implements McpTransport {
|
|
3
3
|
private readonly config;
|
|
4
4
|
private _nextId;
|
|
@@ -9,10 +9,16 @@ export declare class McpSseClient implements McpTransport {
|
|
|
9
9
|
private _closed;
|
|
10
10
|
private readonly _pending;
|
|
11
11
|
private _endpointWaiter;
|
|
12
|
+
private _elicitationHandler;
|
|
12
13
|
readonly name: string;
|
|
13
14
|
constructor(name: string, config: McpServerConfig);
|
|
14
15
|
get tools(): McpToolDefinition[];
|
|
16
|
+
setElicitationHandler(handler: McpElicitationHandler | null): void;
|
|
15
17
|
connect(): Promise<void>;
|
|
18
|
+
listResources(): Promise<McpResource[]>;
|
|
19
|
+
readResource(uri: string): Promise<string>;
|
|
20
|
+
listPrompts(): Promise<McpPrompt[]>;
|
|
21
|
+
getPrompt(name: string, args: Record<string, string>): Promise<string>;
|
|
16
22
|
callTool(name: string, args: Record<string, unknown>): Promise<string>;
|
|
17
23
|
disconnect(): void;
|
|
18
24
|
/** Open the GET event stream and wait for the server's `endpoint` event. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sseClient.d.ts","sourceRoot":"","sources":["../../src/mcp/sseClient.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"sseClient.d.ts","sourceRoot":"","sources":["../../src/mcp/sseClient.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,iBAAiB,EAAE,YAAY,EAAE,WAAW,EAAE,SAAS,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AA6BhI,qBAAa,YAAa,YAAW,YAAY;IAYrB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAXjD,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,MAAM,CAA2B;IACzC,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,MAAM,CAAgC;IAC9C,OAAO,CAAC,OAAO,CAAM;IACrB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA0H;IACnJ,OAAO,CAAC,eAAe,CAAoE;IAC3F,OAAO,CAAC,mBAAmB,CAAsC;IACjE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBAEV,IAAI,EAAE,MAAM,EAAmB,MAAM,EAAE,eAAe;IAIlE,IAAI,KAAK,IAAI,iBAAiB,EAAE,CAE/B;IAED,qBAAqB,CAAC,OAAO,EAAE,qBAAqB,GAAG,IAAI,GAAG,IAAI;IAE5D,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAaxB,aAAa,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IAOvC,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAI1C,WAAW,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC;IAOnC,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC;IAMtE,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC;IAc5E,UAAU,IAAI,IAAI;IAalB,4EAA4E;YAC9D,WAAW;IAoCzB,OAAO,CAAC,eAAe;IAYvB,oFAAoF;IACpF,OAAO,CAAC,KAAK;IAWb,OAAO,CAAC,mBAAmB;IAQ3B,OAAO,CAAC,cAAc;IAqDtB,OAAO,CAAC,IAAI;YAgBE,OAAO;IAIrB,6EAA6E;YAC/D,YAAY;CAwB3B"}
|
package/dist/mcp/sseClient.js
CHANGED
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.McpSseClient = void 0;
|
|
7
7
|
const node_fetch_1 = __importDefault(require("node-fetch"));
|
|
8
|
+
const client_1 = require("./client");
|
|
8
9
|
const httpClient_1 = require("./httpClient");
|
|
9
10
|
const REQUEST_TIMEOUT_MS = 30000;
|
|
10
11
|
const ENDPOINT_TIMEOUT_MS = 15000;
|
|
@@ -19,24 +20,53 @@ class McpSseClient {
|
|
|
19
20
|
this._closed = false;
|
|
20
21
|
this._pending = new Map();
|
|
21
22
|
this._endpointWaiter = null;
|
|
23
|
+
this._elicitationHandler = null;
|
|
22
24
|
this.name = name;
|
|
23
25
|
}
|
|
24
26
|
get tools() {
|
|
25
27
|
return this._tools;
|
|
26
28
|
}
|
|
29
|
+
setElicitationHandler(handler) { this._elicitationHandler = handler; }
|
|
27
30
|
async connect() {
|
|
28
31
|
if (!this.config.url)
|
|
29
32
|
throw new Error(`MCP server "${this.name}" has no url (sse transport requires one).`);
|
|
30
33
|
await this._openStream();
|
|
31
34
|
await this._rpc('initialize', {
|
|
32
35
|
protocolVersion: '2024-11-05',
|
|
33
|
-
capabilities:
|
|
36
|
+
capabilities: (0, client_1.elicitationCapability)(this.config.bareElicitationCapability),
|
|
34
37
|
clientInfo: { name: 'nexrall-code', version: '1.0' },
|
|
35
38
|
});
|
|
36
39
|
await this._notify('notifications/initialized');
|
|
37
40
|
const res = (await this._rpc('tools/list', {}));
|
|
38
41
|
this._tools = res?.tools ?? [];
|
|
39
42
|
}
|
|
43
|
+
async listResources() {
|
|
44
|
+
try {
|
|
45
|
+
const res = (await this._rpc('resources/list', {}));
|
|
46
|
+
return Array.isArray(res?.resources) ? res.resources : [];
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return []; /* server has no resources capability */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async readResource(uri) {
|
|
53
|
+
return (0, client_1.flattenResourceContents)(await this._rpc('resources/read', { uri }));
|
|
54
|
+
}
|
|
55
|
+
async listPrompts() {
|
|
56
|
+
try {
|
|
57
|
+
const res = (await this._rpc('prompts/list', {}));
|
|
58
|
+
return Array.isArray(res?.prompts) ? res.prompts : [];
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return []; /* server has no prompts capability */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async getPrompt(name, args) {
|
|
65
|
+
const params = { name };
|
|
66
|
+
if (Object.keys(args).length)
|
|
67
|
+
params.arguments = args;
|
|
68
|
+
return (0, client_1.flattenPromptMessages)(await this._rpc('prompts/get', params));
|
|
69
|
+
}
|
|
40
70
|
async callTool(name, args) {
|
|
41
71
|
const result = (await this._rpc('tools/call', { name, arguments: args }));
|
|
42
72
|
if (!result)
|
|
@@ -159,6 +189,19 @@ class McpSseClient {
|
|
|
159
189
|
catch {
|
|
160
190
|
return; // non-JSON frame
|
|
161
191
|
}
|
|
192
|
+
// A server→client REQUEST (elicitation) — answer it over the POST channel.
|
|
193
|
+
// Checked first: server request ids are their own namespace and could collide
|
|
194
|
+
// with one of ours.
|
|
195
|
+
if (typeof msg.method === 'string' && msg.id != null) {
|
|
196
|
+
const method = msg.method;
|
|
197
|
+
const params = msg.params;
|
|
198
|
+
const reqId = msg.id;
|
|
199
|
+
void (async () => {
|
|
200
|
+
const reply = await (0, client_1.serverRequestReply)(this.name, this._elicitationHandler, method, params);
|
|
201
|
+
await this._postMessage({ jsonrpc: '2.0', id: reqId, ...reply });
|
|
202
|
+
})().catch(() => { });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
162
205
|
if (typeof msg.id === 'number' && this._pending.has(msg.id)) {
|
|
163
206
|
const { resolve, reject, timer } = this._pending.get(msg.id);
|
|
164
207
|
clearTimeout(timer);
|
|
@@ -167,6 +210,7 @@ class McpSseClient {
|
|
|
167
210
|
reject(new Error(`MCP error ${msg.error.code}: ${msg.error.message}`));
|
|
168
211
|
else
|
|
169
212
|
resolve(msg.result ?? null);
|
|
213
|
+
return;
|
|
170
214
|
}
|
|
171
215
|
// Server-initiated notifications (no matching id) are ignored.
|
|
172
216
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export interface ToolStat {
|
|
2
|
+
calls: number;
|
|
3
|
+
failures: number;
|
|
4
|
+
/** Total wall time across every call, for an average that ignores the outlier size. */
|
|
5
|
+
totalMs: number;
|
|
6
|
+
/** The most recent call's duration — what a user means by "how long does it take". */
|
|
7
|
+
lastMs: number;
|
|
8
|
+
lastError?: string;
|
|
9
|
+
/** Epoch ms of the last call, so a stale number can be recognised as stale. */
|
|
10
|
+
lastAt: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ServerTotals {
|
|
13
|
+
calls: number;
|
|
14
|
+
failures: number;
|
|
15
|
+
/** Calls that neither succeeded nor failed — still in flight or abandoned. */
|
|
16
|
+
pending: number;
|
|
17
|
+
}
|
|
18
|
+
export declare class ToolStats {
|
|
19
|
+
private readonly _byTool;
|
|
20
|
+
private _pending;
|
|
21
|
+
/** A call has started. Pair with exactly one ok()/fail(). */
|
|
22
|
+
begin(): void;
|
|
23
|
+
ok(name: string, ms: number, now?: number): void;
|
|
24
|
+
fail(name: string, ms: number, error: string, now?: number): void;
|
|
25
|
+
/** Everything recorded for one tool, or undefined if it was never called. */
|
|
26
|
+
get(name: string): ToolStat | undefined;
|
|
27
|
+
/** Stats for every tool whose name starts with `<server>__`, keyed by the bare tool name. */
|
|
28
|
+
forServer(server: string, prefix?: string): Array<{
|
|
29
|
+
tool: string;
|
|
30
|
+
stat: ToolStat;
|
|
31
|
+
}>;
|
|
32
|
+
forServerTotals(server: string, prefix?: string): ServerTotals;
|
|
33
|
+
/** Forgets one tool's history — so a fixed server stops showing old failures. */
|
|
34
|
+
reset(name?: string): void;
|
|
35
|
+
private _entry;
|
|
36
|
+
}
|
|
37
|
+
/** "240ms" / "1.2s" — a latency reading, not a stopwatch. */
|
|
38
|
+
export declare function formatMs(ms: number): string;
|
|
39
|
+
/** One line per tool, or '' when the server was never called (nothing to say yet). */
|
|
40
|
+
export declare function describeStats(stat: ToolStat | undefined, avg?: boolean): string;
|
|
41
|
+
//# sourceMappingURL=stats.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stats.d.ts","sourceRoot":"","sources":["../../src/mcp/stats.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,uFAAuF;IACvF,OAAO,EAAE,MAAM,CAAC;IAChB,sFAAsF;IACtF,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+EAA+E;IAC/E,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA+B;IACvD,OAAO,CAAC,QAAQ,CAAK;IAErB,6DAA6D;IAC7D,KAAK,IAAI,IAAI;IAIb,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,IAAI;IAUpD,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,IAAI;IAWrE,6EAA6E;IAC7E,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS;IAIvC,6FAA6F;IAC7F,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,SAAO,GAAG,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,QAAQ,CAAA;KAAE,CAAC;IAYjF,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,SAAO,GAAG,YAAY;IAS5D,iFAAiF;IACjF,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI;IAK1B,OAAO,CAAC,MAAM;CAKf;AAED,6DAA6D;AAC7D,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAI3C;AAED,sFAAsF;AACtF,wBAAgB,aAAa,CAAC,IAAI,EAAE,QAAQ,GAAG,SAAS,EAAE,GAAG,UAAO,GAAG,MAAM,CAO5E"}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Per-tool call statistics for MCP servers (spec §42 "Tool latency / failures").
|
|
3
|
+
//
|
|
4
|
+
// WHY: when an MCP tool call returns nothing useful, the user's only question is
|
|
5
|
+
// "is this server slow, or broken?" — and the answer existed nowhere. The manager
|
|
6
|
+
// saw every call (it is the only path a tool call can take) but kept no record, so
|
|
7
|
+
// a flaky server looked exactly like a healthy one until it failed outright.
|
|
8
|
+
//
|
|
9
|
+
// Kept in its own file, and kept PURE (the caller measures the elapsed time), so the
|
|
10
|
+
// bookkeeping is unit-testable without spawning an MCP server. Nothing here is a
|
|
11
|
+
// promise about the future: the numbers are "since this session started", which is
|
|
12
|
+
// exactly the window a user is reasoning about.
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.ToolStats = void 0;
|
|
15
|
+
exports.formatMs = formatMs;
|
|
16
|
+
exports.describeStats = describeStats;
|
|
17
|
+
class ToolStats {
|
|
18
|
+
constructor() {
|
|
19
|
+
this._byTool = new Map();
|
|
20
|
+
this._pending = 0;
|
|
21
|
+
}
|
|
22
|
+
/** A call has started. Pair with exactly one ok()/fail(). */
|
|
23
|
+
begin() {
|
|
24
|
+
this._pending++;
|
|
25
|
+
}
|
|
26
|
+
ok(name, ms, now = Date.now()) {
|
|
27
|
+
this._pending = Math.max(0, this._pending - 1);
|
|
28
|
+
const s = this._entry(name);
|
|
29
|
+
s.calls++;
|
|
30
|
+
s.totalMs += Math.max(0, ms);
|
|
31
|
+
s.lastMs = Math.max(0, ms);
|
|
32
|
+
s.lastAt = now;
|
|
33
|
+
delete s.lastError;
|
|
34
|
+
}
|
|
35
|
+
fail(name, ms, error, now = Date.now()) {
|
|
36
|
+
this._pending = Math.max(0, this._pending - 1);
|
|
37
|
+
const s = this._entry(name);
|
|
38
|
+
s.calls++;
|
|
39
|
+
s.failures++;
|
|
40
|
+
s.totalMs += Math.max(0, ms);
|
|
41
|
+
s.lastMs = Math.max(0, ms);
|
|
42
|
+
s.lastError = error;
|
|
43
|
+
s.lastAt = now;
|
|
44
|
+
}
|
|
45
|
+
/** Everything recorded for one tool, or undefined if it was never called. */
|
|
46
|
+
get(name) {
|
|
47
|
+
return this._byTool.get(name);
|
|
48
|
+
}
|
|
49
|
+
/** Stats for every tool whose name starts with `<server>__`, keyed by the bare tool name. */
|
|
50
|
+
forServer(server, prefix = '__') {
|
|
51
|
+
const head = server + prefix;
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const [name, stat] of this._byTool) {
|
|
54
|
+
if (!name.startsWith(head))
|
|
55
|
+
continue;
|
|
56
|
+
out.push({ tool: name.slice(head.length), stat });
|
|
57
|
+
}
|
|
58
|
+
// Slowest / most used first: that is the row a user is looking for.
|
|
59
|
+
out.sort((a, b) => b.stat.calls - a.stat.calls || a.tool.localeCompare(b.tool));
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
forServerTotals(server, prefix = '__') {
|
|
63
|
+
const rows = this.forServer(server, prefix);
|
|
64
|
+
return {
|
|
65
|
+
calls: rows.reduce((n, r) => n + r.stat.calls, 0),
|
|
66
|
+
failures: rows.reduce((n, r) => n + r.stat.failures, 0),
|
|
67
|
+
pending: this._pending,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Forgets one tool's history — so a fixed server stops showing old failures. */
|
|
71
|
+
reset(name) {
|
|
72
|
+
if (name === undefined) {
|
|
73
|
+
this._byTool.clear();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this._byTool.delete(name);
|
|
77
|
+
}
|
|
78
|
+
_entry(name) {
|
|
79
|
+
let s = this._byTool.get(name);
|
|
80
|
+
if (!s) {
|
|
81
|
+
s = { calls: 0, failures: 0, totalMs: 0, lastMs: 0, lastAt: 0 };
|
|
82
|
+
this._byTool.set(name, s);
|
|
83
|
+
}
|
|
84
|
+
return s;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
exports.ToolStats = ToolStats;
|
|
88
|
+
/** "240ms" / "1.2s" — a latency reading, not a stopwatch. */
|
|
89
|
+
function formatMs(ms) {
|
|
90
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
91
|
+
return '—';
|
|
92
|
+
if (ms < 1000)
|
|
93
|
+
return `${Math.round(ms)}ms`;
|
|
94
|
+
return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)}s`;
|
|
95
|
+
}
|
|
96
|
+
/** One line per tool, or '' when the server was never called (nothing to say yet). */
|
|
97
|
+
function describeStats(stat, avg = true) {
|
|
98
|
+
if (!stat || stat.calls === 0)
|
|
99
|
+
return '';
|
|
100
|
+
const parts = [`${stat.calls} call${stat.calls === 1 ? '' : 's'}`];
|
|
101
|
+
if (stat.failures)
|
|
102
|
+
parts.push(`${stat.failures} failed`);
|
|
103
|
+
parts.push(`${avg && stat.calls > 1 ? 'avg' : 'last'} ${formatMs(avg && stat.calls > 1 ? stat.totalMs / stat.calls : stat.lastMs)}`);
|
|
104
|
+
if (stat.lastError)
|
|
105
|
+
parts.push(stat.lastError.split('\n')[0].slice(0, 80));
|
|
106
|
+
return parts.join(' · ');
|
|
107
|
+
}
|
|
108
|
+
//# sourceMappingURL=stats.js.map
|