-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmcpConfig.js
More file actions
734 lines (685 loc) · 37 KB
/
Copy pathmcpConfig.js
File metadata and controls
734 lines (685 loc) · 37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
/*---------------------------------------------------------------------------------------------
* MCP config, tool naming, and approval policy — the PURE core of MCP support (see docs/MCP.md).
*
* Three jobs, all of them decided before any process is spawned or any tool is called:
* 1. WHICH servers exist — merge the user's setting with per-workspace `.levelcode/mcp.json`,
* keeping PROVENANCE, because the two are not equally trusted (below).
* 2. WHAT their tools are called — namespace to `server__tool`. This is a CORRECTNESS gate, not
* cosmetics: Anthropic requires ^[a-zA-Z0-9_-]{1,128}$ and OpenAI-shaped providers {1,64}, and
* nothing else in the pipeline validates names (providers/translate.js renames the field
* verbatim). A '/' or ':' fails the FIRST agent turn with an opaque provider 400 — and because
* the name is echoed into the stored transcript and re-serialized every later turn, one bad
* name poisons the whole conversation, not one request. So: take the stricter 64-char limit,
* sanitize, truncate stably, and never shadow a built-in tool.
* 3. WHETHER a call needs approval — default ask; only the user's allow-list may grant 'allow'.
*
* TRUST: a server entry names A PROCESS TO SPAWN. The user's setting is user-authored. A workspace
* file is REPO-authored — i.e. attacker-controlled for any repo you clone — so entries from it are
* marked source:'workspace' and MUST NOT be started without explicit consent — the trust-on-first-use
* gate at the bottom of this file (launchFingerprint / isLaunchTrusted / describeMcpLaunch), enforced
* by approveMcpLaunch in agent.js. For the same reason the
* user's setting WINS on a name collision: a repo can never shadow a server the user defined.
*
* Pure + dependency-free (node builtins `path` and `crypto` only) — file reading is injected as a
* readFile callback, so all of it
* is unit-testable (test/mcpConfig.test.js) without a filesystem, a child process, or the editor.
* Nothing here connects, spawns, or calls anything.
*--------------------------------------------------------------------------------------------*/
'use strict';
const path = require('path');
const crypto = require('crypto');
// The agent's built-in tools (agent.js TOOLS). An MCP tool may never shadow one of these.
const BUILTIN_TOOL_NAMES = [
'list_files', 'read_file', 'search', 'update_plan', 'edit_file', 'write_file',
'delete_file', 'run_command', 'read_command_output', 'ask_user', 'use_skill'
];
// Anthropic allows 128 chars, OpenAI-shaped providers 64. Take the stricter so one tool set works on
// every provider LevelCode supports.
const MAX_TOOL_NAME = 64;
const NAME_SEPARATOR = '__';
// Per-workspace config file, relative to each workspace folder root.
const WORKSPACE_CONFIG_PATH = ['.levelcode', 'mcp.json'];
// Bounds. Every tool schema rides EVERY turn, so an unbounded server would quietly eat the context
// window; and a config with 500 servers is a mistake, not a use case.
const MAX_SERVERS = 20;
const MAX_TOOLS_PER_SERVER = 64;
// A tool DESCRIPTION is a third-party string that rides every turn and is read by the model — both a
// context cost and the prompt-injection surface of docs/MCP.md G4. Bound it; we cannot sanitize meaning.
const MAX_TOOL_DESC = 1024;
// ---- 1. server config ------------------------------------------------------------------------
/** Accept both `{ mcpServers: {…} }` (the ecosystem convention) and a bare `{ name: {…} }` map. */
function serverMapOf(parsed) {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return {}; }
const inner = parsed.mcpServers;
if (inner && typeof inner === 'object' && !Array.isArray(inner)) { return inner; }
return parsed;
}
// Keys that must never be copied out of untrusted JSON: assigning `__proto__` invokes the prototype
// setter rather than creating a property, and `constructor`/`prototype` are the usual companions.
// See safeCopy.
const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'];
/**
* Shallow-copy a map that came from untrusted JSON — a repo-authored `.levelcode/mcp.json` env block,
* or a server-supplied `inputSchema`. JSON.parse creates a REAL own `__proto__` key, so a plain
* Object.assign would hand it to the prototype setter instead of copying it. The string-value check in
* normalizeServer already rejects the classic object-valued payload, which makes today's safety
* incidental — this makes it structural.
*
* The unsafe keys (`__proto__`/`constructor`/`prototype`) are DROPPED outright — never copied to the
* output — so none can reach the prototype setter. Note this is a drop, not a rescue: a key literally
* named `__proto__` does not survive into the result. That is deliberate. Such a name is meaningless as
* an env var and unused as a top-level JSON-Schema keyword, so losing it costs nothing, whereas copying
* it would be exactly the pollution we are guarding against. Deliberately a normal object, not
* Object.create(null): later code (and tests) may reasonably call hasOwnProperty on it.
*/
function safeCopy(raw) {
const out = {};
for (const k of Object.keys(raw || {})) {
if (UNSAFE_KEYS.indexOf(k) !== -1) { continue; } // dropped, not copied — see the doc above
out[k] = raw[k];
}
return out;
}
/** Validate one entry. Returns a server object, or a string describing why it was rejected. */
function normalizeServer(name, raw, source, origin) {
if (!name || typeof name !== 'string') { return 'server name must be a non-empty string'; }
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return 'entry must be an object'; }
if (!raw.command || typeof raw.command !== 'string') { return 'missing "command"'; }
if (raw.args != null && (!Array.isArray(raw.args) || raw.args.some((a) => typeof a !== 'string'))) {
return '"args" must be an array of strings';
}
if (raw.env != null && (typeof raw.env !== 'object' || Array.isArray(raw.env)
|| Object.values(raw.env).some((v) => typeof v !== 'string'))) {
return '"env" must be an object of strings';
}
return {
name: name,
command: raw.command,
args: raw.args ? raw.args.slice() : [],
env: raw.env ? safeCopy(raw.env) : {},
source: source, // 'settings' (user-authored) | 'workspace' (repo-authored, untrusted)
origin: origin // human label for the consent card / problem messages
};
}
/**
* Merge the user's MCP server setting with any per-workspace `.levelcode/mcp.json`.
*
* @param {{settings?:object, folders?:Array<{name:string, root:string}>,
* readFile?:(absPath:string)=>(string|null)}} opts
* @returns {{ servers: Array<object>, problems: Array<{level:string, message:string}> }}
*/
function loadServerConfig(opts) {
const o = opts || {};
const servers = [];
const problems = [];
const byName = new Map();
const add = (map, source, origin) => {
for (const key of Object.keys(map || {})) {
if (byName.has(key)) {
// Settings are added first and therefore win — a repo must not be able to redefine a
// server the user already trusts (it would inherit that trust with a new command line).
problems.push({ level: 'warn', message: 'ignored duplicate server "' + key + '" from ' + origin + ' (already defined in ' + byName.get(key).origin + ')' });
continue;
}
if (servers.length >= MAX_SERVERS) {
problems.push({ level: 'warn', message: 'ignored server "' + key + '" from ' + origin + ' (over the ' + MAX_SERVERS + '-server cap)' });
continue;
}
const s = normalizeServer(key, map[key], source, origin);
if (typeof s === 'string') { problems.push({ level: 'error', message: 'server "' + key + '" in ' + origin + ': ' + s }); continue; }
byName.set(key, s);
servers.push(s);
}
};
// 1. User setting first — see the precedence note above. A MISSING setting is an empty map, not an
// empty wrapper: {mcpServers: undefined} would fall through serverMapOf's bare-map branch and get
// reported as a phantom server literally named "mcpServers" — a spurious error for every user who
// has no MCP config at all.
const settingsRaw = (o.settings && typeof o.settings === 'object' && !Array.isArray(o.settings)) ? o.settings : null;
add(settingsRaw ? serverMapOf(settingsRaw) : {}, 'settings', 'settings');
// 2. Then each workspace folder's file.
for (const f of (Array.isArray(o.folders) ? o.folders : [])) {
if (!f || !f.root) { continue; }
const abs = path.join(f.root, ...WORKSPACE_CONFIG_PATH);
const label = (f.name || path.basename(f.root)) + '/' + WORKSPACE_CONFIG_PATH.join('/');
let raw = null;
try { raw = o.readFile ? o.readFile(abs) : null; } catch { raw = null; }
if (!raw || !String(raw).trim()) { continue; }
let parsed = null;
try { parsed = JSON.parse(String(raw)); }
catch (e) { problems.push({ level: 'error', message: 'could not parse ' + label + ': ' + ((e && e.message) || e) }); continue; }
add(serverMapOf(parsed), 'workspace', label);
}
return { servers, problems };
}
/**
* The value of a VS Code setting as authored BY THE USER — its global (user-settings) tier only,
* deliberately ignoring the workspace and workspace-folder tiers.
*
* The whole MCP trust model rests on "user-authored = trusted, repo-authored = untrusted", and I had it
* half-right: `.levelcode/mcp.json` is gated, but I missed that VS Code SETTINGS have a repo-authored
* tier too — a committed `.vscode/settings.json` (or a folder in a `.code-workspace`) can set
* `levelcode.ai.mcp.servers`, and a plain `cfg.get()` returns that merged value. Trusting it would spawn
* arbitrary processes on clone-and-open — the exact RCE the model exists to prevent (PR #31 review).
*
* These settings are ALSO declared `application`-scoped in package.json, which already makes VS Code drop
* any workspace value. This is the belt to that suspenders: the spawn decision is too dangerous to rest
* on a declarative manifest guard alone, so the trust boundary is enforced here too, at the point of use,
* and survives a scope regression. Takes a `getConfiguration().inspect(key)` result so it stays pure and
* unit-testable off the editor.
*
* @param {{globalValue?:any}|undefined|null} info a VS Code inspect() result
* @param {any} fallback returned when the user has not set it (workspace/folder values are NOT a fallback)
*/
function userScopedSetting(info, fallback) {
if (!info || info.globalValue === undefined) { return fallback; }
return info.globalValue;
}
// ---- 2. tool naming --------------------------------------------------------------------------
/**
* Stable 6-char tag (djb2) so a truncated name is identical every run — it lives in the transcript.
* Takes the LAST 6 base-36 digits, not the first: the low-order digits are the ones that actually vary
* between similar inputs, and slicing from the left collapsed `…zzzA` and `…zzzB` onto the same tag.
*/
function shortHash(s) {
let h = 5381;
for (let i = 0; i < s.length; i++) { h = ((h * 33) ^ s.charCodeAt(i)) >>> 0; }
return h.toString(36).padStart(6, '0').slice(-6);
}
/** Reduce one segment to the legal alphabet; never returns empty. */
function sanitizeSegment(raw, fallback) {
const s = String(raw == null ? '' : raw).replace(/[^A-Za-z0-9_-]/g, '_');
return s.length ? s : fallback;
}
/**
* `server__tool`, guaranteed to match ^[A-Za-z0-9_-]{1,64}$ — the intersection of every provider's
* rule. Over-long names truncate with a stable hash tag rather than a counter, because the name is
* re-serialized on every later turn and must not change between runs.
*/
function namespaceToolName(server, tool) {
const full = sanitizeSegment(server, 'server') + NAME_SEPARATOR + sanitizeSegment(tool, 'tool');
if (full.length <= MAX_TOOL_NAME) { return full; }
const tag = shortHash(String(server) + '\u0000' + String(tool));
return full.slice(0, MAX_TOOL_NAME - tag.length - 1) + '_' + tag;
}
/**
* Could `name` have come out of namespaceToolName? The guard for anything that PERSISTS a tool name —
* today, "Always allow" writing a key into `levelcode.ai.mcp.toolPolicy`.
*
* It lives here, beside the function whose output it describes, because the caller was hand-rolling its
* own regex, and two copies of one naming rule is how they drift apart.
*
* namespaceToolName emits exactly two SHAPES, and this accepts those two and nothing else:
*
* 1. `server__tool` — the separator survives whenever the joined name fits the cap.
* 2. `<57 chars>_<6-char hash>` — the truncated form, always exactly MAX_TOOL_NAME long.
*
* Shape 2 is why a plain "must contain `__`" test is wrong: when a server's name ALONE reaches the cap,
* the cut lands inside that first segment and the result carries no separator at all —
*
* namespaceToolName('s'.repeat(70), 'tool') -> 'sss…sss_a1b2c3' // 64 chars, no '__'
*
* — so requiring one rejects a name this module itself produced, and "Always allow" then silently does
* nothing for that server. Checking the two shapes keeps that case legal while still refusing a bare
* `read_file` or `x`.repeat(64), which namespacing can never emit and which would only sit inert in the
* policy map.
*
* UNSAFE_KEYS is rejected EXPLICITLY rather than left to fall out of the shape rules, so the protection
* does not depend on an unrelated rule keeping a particular form.
*/
const LEGAL_NAME = /^[A-Za-z0-9_-]+$/;
// Shape 1. At least one character on EACH side of a separator, because sanitizeSegment never returns
// empty — so `abc__` and `__abc` are not names this module can emit.
//
// A regex rather than the obvious `indexOf('__') > 0` test, and that difference is not cosmetic: a
// server legitimately NAMED `__a` yields `__a__b`, whose FIRST separator sits at index 0. The
// index test rejects it; backtracking here finds the separator at index 3 and accepts.
const SHAPE_NAMESPACED = /^[A-Za-z0-9_-]+__[A-Za-z0-9_-]+$/;
// Shape 2. shortHash is base36, lower-case, padded to 6, and truncation always lands exactly at the cap.
const SHAPE_TRUNCATED = /_[0-9a-z]{6}$/;
function isNamespacedToolName(name) {
if (typeof name !== 'string') { return false; }
if (name.length === 0 || name.length > MAX_TOOL_NAME) { return false; }
if (UNSAFE_KEYS.indexOf(name) !== -1) { return false; }
if (!LEGAL_NAME.test(name)) { return false; }
return SHAPE_NAMESPACED.test(name)
|| (name.length === MAX_TOOL_NAME && SHAPE_TRUNCATED.test(name));
}
/**
* Assign a final, unique, provider-legal name to every (server, tool) pair — the last line of defence
* before names reach the wire. Collisions (with a built-in, or between two servers whose names
* sanitize alike) get a numeric suffix rather than being dropped.
*
* @param {Array<{server:string, tool:string}>} pairs
* @returns {{ tools: Array<{server:string, tool:string, name:string}>, problems: Array<object> }}
*/
function assignToolNames(pairs, opts) {
const reserved = (opts && opts.reserved) || BUILTIN_TOOL_NAMES;
const taken = new Set(reserved);
const perServer = new Map();
const tools = [];
const problems = [];
for (const p of (Array.isArray(pairs) ? pairs : [])) {
if (!p || !p.tool || typeof p.tool !== 'string') { continue; }
const count = (perServer.get(p.server) || 0) + 1;
perServer.set(p.server, count);
if (count > MAX_TOOLS_PER_SERVER) {
problems.push({ level: 'warn', message: 'server "' + p.server + '" exposes more than ' + MAX_TOOLS_PER_SERVER + ' tools; "' + p.tool + '" and the rest were dropped' });
continue;
}
const base = namespaceToolName(p.server, p.tool);
let name = base;
let n = 2;
while (taken.has(name)) {
const suffix = '_' + n;
name = base.slice(0, MAX_TOOL_NAME - suffix.length) + suffix;
n++;
}
if (name !== base) {
problems.push({ level: 'warn', message: 'tool name "' + base + '" was already taken; exposed as "' + name + '"' });
}
taken.add(name);
tools.push({ server: p.server, tool: p.tool, name });
}
return { tools, problems };
}
/**
* A tool schema the providers will actually accept. MCP says `inputSchema` is a JSON Schema of type
* "object", but a server can send anything; a non-object top-level schema is a provider 400, which the
* agent would surface as an opaque failure on turn one. Normalize rather than trust, and copy safely —
* this is server-supplied JSON, same reasoning as safeCopy's other caller.
*/
function schemaOf(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return { type: 'object', properties: {} }; }
const out = safeCopy(raw);
out.type = 'object';
if (!out.properties || typeof out.properties !== 'object' || Array.isArray(out.properties)) { out.properties = {}; }
return out;
}
/**
* A description the model can decide on, ALWAYS within MAX_TOOL_DESC. The cap is applied to the final
* string — the server's own text AND the fallback — because the fallback embeds `tool`, which is the
* server-chosen (untrusted) tool name: a server could send a giant name with a blank description and,
* if only the real-description branch were capped, blow past the bound anyway (PR #31 review). One cap
* at the exit covers every branch.
*/
function describeTool(spec, server, tool) {
const raw = typeof spec.description === 'string' ? spec.description.trim() : '';
const desc = raw || ('The "' + tool + '" tool from the "' + server + '" MCP server (no description provided).');
return desc.length > MAX_TOOL_DESC ? desc.slice(0, MAX_TOOL_DESC - 1) + '…' : desc;
}
/**
* Turn the tool lists of connected servers into (a) agent TOOLS entries and (b) the routing table the
* agent uses to send a call back to the right server. This is the whole translation layer: MCP's
* `{name, description, inputSchema}` is our `{name, description, input_schema}` — a field rename, per
* docs/MCP.md — plus the naming/capping that makes it safe to put on the wire.
*
* Pure: takes plain data (`{name, tools}`), not live handles, so it is unit-testable without spawning.
*
* @param {Array<{name:string, tools:Array<object>}>} servers
* @returns {{ tools: Array<object>, routes: Map<string,{server:string, tool:string, annotations:object|null}>,
* problems: Array<object> }}
*/
function buildAgentTools(servers, opts) {
const specs = new Map();
const pairs = [];
for (const s of (Array.isArray(servers) ? servers : [])) {
if (!s || typeof s.name !== 'string' || !s.name || !Array.isArray(s.tools)) { continue; }
for (const t of s.tools) {
if (!t || typeof t.name !== 'string' || !t.name) { continue; }
const key = s.name + '\u0000' + t.name;
if (specs.has(key)) { continue; } // a server that lists the same tool twice
specs.set(key, t);
pairs.push({ server: s.name, tool: t.name });
}
}
// assignToolNames may DROP entries (the per-server cap) so its output is a subsequence, not a 1:1
// row-for-row mapping — correlate by (server, tool) rather than by index.
const assigned = assignToolNames(pairs, opts);
const tools = [];
const routes = new Map();
for (const a of assigned.tools) {
const spec = specs.get(a.server + '\u0000' + a.tool) || {};
tools.push({
name: a.name,
description: describeTool(spec, a.server, a.tool),
input_schema: schemaOf(spec.inputSchema)
});
routes.set(a.name, {
server: a.server,
tool: a.tool,
// Kept for classifyMcpTool, which may only ever TIGHTEN on them (they are server-supplied).
annotations: (spec.annotations && typeof spec.annotations === 'object') ? spec.annotations : null
});
}
return { tools, routes, problems: assigned.problems };
}
/**
* Per-server counts of the tools ACTUALLY exposed, taken from the routes buildAgentTools emitted — i.e.
* AFTER the per-server cap and junk-skipping. The startup chip uses this rather than the raw tools/list
* length, so its per-server numbers reflect what the agent can really call and SUM to the run's real
* total: a server that lists 100 tools but is capped to 64 must read `(64)`, not `(100)`, or the chip
* contradicts its own allowed/total denominator (PR #31 review).
*
* @param {Map<string,{server:string}>} routes the routes map from buildAgentTools
* @returns {Map<string, number>} server name → exposed tool count
*/
function toolCountsByServer(routes) {
const counts = new Map();
if (!routes || typeof routes.values !== 'function') { return counts; }
for (const r of routes.values()) {
if (!r || typeof r.server !== 'string') { continue; }
counts.set(r.server, (counts.get(r.server) || 0) + 1);
}
return counts;
}
// ---- 3. approval policy ----------------------------------------------------------------------
/**
* Does this MCP tool call need the approval card?
*
* Default is ASK — an MCP tool is third-party code, so autopilot deliberately does NOT relax it (only
* the user's explicit allow-list does). Server-supplied annotations are UNTRUSTED and may therefore
* only push toward asking, never toward allowing: a `destructiveHint` overrides an allow-list entry
* (worst case, one extra prompt), while a `readOnlyHint` grants nothing on its own.
*
* `policyCanAllow` answers a question the callers kept getting wrong (PR #31 review): would adding
* this tool to the allow-list actually grant it? For every ordinary refusal, yes. For a `destructiveHint`
* refusal, NO — a server hint may only tighten, so the allow-list cannot override it. Callers use this to
* avoid (a) counting a destructive-but-allow-listed tool as "allow-listed" in the startup chip, and
* (b) telling the model to allow-list a tool that allow-listing can never enable.
*
* @param {string} name the namespaced tool name (server__tool)
* @param {object} [policy] user map, e.g. { 'github__list_issues': 'allow', '*': 'ask' }
* @param {object} [annotations] the server's own hints for this tool (untrusted)
* @returns {{ approve: 'ask'|'allow', reason: string, policyCanAllow: boolean }}
*/
function classifyMcpTool(name, policy, annotations) {
if (annotations && annotations.destructiveHint === true) {
return { approve: 'ask', reason: 'the server marks this tool destructive', policyCanAllow: false };
}
const p = policy || {};
const exact = p[name];
if (exact === 'allow') { return { approve: 'allow', reason: 'allow-listed by you', policyCanAllow: true }; }
if (exact === 'ask') { return { approve: 'ask', reason: 'set to ask by you', policyCanAllow: true }; }
const star = p['*'];
if (star === 'allow') { return { approve: 'allow', reason: 'allow-listed by you (*)', policyCanAllow: true }; }
return { approve: 'ask', reason: 'third-party tool (default)', policyCanAllow: true };
}
/**
* The agent-facing explanation for a refused MCP call in a build with no approval card (S3). It lives
* HERE, beside classifyMcpTool, on purpose: the PR #31 review caught this message telling the model to
* allow-list a destructive tool that allow-listing can never enable — the message had drifted from the
* policy. Keeping both in one module (and unit-testing this off the editor) is what stops the drift
* recurring. Branches solely on the verdict, so it cannot disagree with the classifier.
*
* @param {string} name the namespaced tool name
* @param {{reason:string, policyCanAllow:boolean}} verdict from classifyMcpTool (a non-'allow' one)
* @returns {string}
*/
function explainMcpRefusal(name, verdict) {
// Reached only when there is NO interactive approval to fall back on (a headless run, a test harness).
// With a webview present, S4 shows the per-call card instead of this message.
const head = 'ERROR: the MCP tool "' + name + '" is not approved to run (' + verdict.reason + '). ';
const fix = verdict.policyCanAllow
? 'No interactive approval is available here, so the only way to permit it is for the USER to add '
+ '"' + name + '": "allow" to the "levelcode.ai.mcp.toolPolicy" setting. '
: 'Such tools always require per-call approval and CANNOT be enabled through the allow-list, so '
+ 'there is no way to run it in this non-interactive context. ';
return head + fix + 'Do NOT retry it in this run — continue without it, or tell the user what you needed it for.';
}
// A tool call's arguments can be large, and the approval card must not be blown open by one. See
// describeMcpCall — the card is capped, the full args still reach the server if approved.
const MAX_ARG_CHARS = 2000;
/** Pretty, bounded JSON for the args shown on the approval card. Never throws (circular/huge input). */
function previewArgs(args) {
if (args == null) { return ''; }
let text;
try { text = JSON.stringify(args, null, 2); }
catch { try { text = String(args); } catch { text = '[unserializable arguments]'; } }
if (text == null) { return ''; }
return text.length > MAX_ARG_CHARS ? text.slice(0, MAX_ARG_CHARS - 1) + '…' : text;
}
/**
* Shape an MCP call for the approval card (S4) — exactly what the user reads before deciding.
*
* Unlike the debug log (G4), the arguments are shown in FULL here, only length-capped. That is not an
* oversight: the card is ephemeral UI shown to the person who owns the credentials, and seeing the real
* arguments — the repo it will touch, the id it will delete — IS the decision. Redacting them would make
* the prompt meaningless. Nothing here is persisted; the card is not the transcript.
*
* `canAllowAlways` is false for a destructive tool: a server-marked-destructive tool can never be moved
* to the allow-list (classifyMcpTool tightens on it), so the card must not offer a button that would do
* nothing. Derived from the same annotation the classifier reads, so the two cannot disagree.
*
* @param {string} name namespaced tool name (server__tool)
* @param {*} args the arguments the model produced for this call
* @param {{server?:string, tool?:string, annotations?:object}} [route]
* @returns {{server:string, tool:string, argsText:string, destructive:boolean, canAllowAlways:boolean}}
*/
// ---- G1: trust-on-first-use for repo-authored servers ----------------------
// A `.levelcode/mcp.json` entry names a process to spawn, and the file is attacker-controlled for any
// repo you clone. These four functions are the launch gate: fingerprint what would be spawned, compare
// it to what this workspace has already trusted, and describe it for the consent card.
/**
* A stable fingerprint of what a server entry would actually EXECUTE.
*
* Trust is remembered against this, not against the server's NAME, so a repo cannot be granted consent
* for `npx @modelcontextprotocol/server-filesystem` and then quietly swap in `sh -c 'curl … | sh'` under
* the same name — the fingerprint changes and the user is asked again.
*
* `env` is included, and that is not padding: `NODE_OPTIONS=--require /tmp/evil.js` turns an innocent
* `node` command into arbitrary code execution without touching command or args. Keys are sorted so an
* unrelated reordering of the JSON does not spuriously revoke trust.
*
* SHA-256, NOT the shortHash used for tool-name truncation. shortHash is a 32-bit djb2 variant emitted
* as 6 base36 chars — a ~2^31 space, and it is not collision-resistant by design or intent. Here the
* attacker both KNOWS the trusted value (they authored the command that earned trust) and controls the
* replacement, so they need a second preimage — measured at ~6.8M candidate hashes/sec on one core,
* i.e. roughly five minutes of offline work to forge a malicious command that inherits trust. A
* truncation helper is the wrong tool for an authorization decision; the cost of a real hash here is
* one call per server per run.
*/
/**
* The launch material, normalized ONCE — what would be executed, in canonical form.
*
* Shared by launchFingerprint and describeMcpLaunch on purpose. They were normalizing separately, and
* they drifted: the fingerprint learned to survive a non-array `args` while the card kept calling
* `.map` on it and threw. A consent card and the trust record it produces must describe the same thing,
* so they read it from the same place.
*
* MALFORMED SHAPES COLLAPSE TO `null`, which is the same value an ABSENT field gets. That is
* deliberate and conservative: normalizeServer rejects a non-array `args` or non-object `env` long
* before a server reaches the gate, so neither is reachable here, and "no usable args" is the honest
* reading of both. The command itself always differentiates. (An earlier comment claimed malformed and
* absent stayed distinct — they do not, and the tests assert the collapse.)
*
* Env pairs stay STRUCTURAL — [[k, v]] sorted — never joined into "k=v". Joining is ambiguous:
* { 'a': 'b=c' } and { 'a=b': 'c' } both flatten to "a=b=c", a collision handed over for free in the
* one place collisions are the threat.
*/
function launchMaterial(server) {
const s = server || {};
const env = (s.env && typeof s.env === 'object' && !Array.isArray(s.env)) ? s.env : null;
return {
command: String(s.command || ''),
args: Array.isArray(s.args) ? s.args.map(String) : null,
env: env ? Object.keys(env).sort().map((k) => [k, String(env[k])]) : null
};
}
function launchFingerprint(server) {
return crypto.createHash('sha256')
.update(JSON.stringify(launchMaterial(server)), 'utf8')
.digest('hex');
}
/**
* Has THIS workspace already approved launching exactly this server?
*
* `store` is a plain `{ serverName: fingerprint }` map held in workspaceState, so trust is per-workspace
* by construction: approving a server in one repo says nothing about another repo that happens to
* declare a server by the same name.
*/
function isLaunchTrusted(server, store) {
if (!server || !server.name) { return false; }
const known = store && store[server.name];
return typeof known === 'string' && known === launchFingerprint(server);
}
/** Record trust for one server. Pure: returns the new store, so the caller owns persistence. */
function rememberLaunchTrust(server, store) {
const next = safeCopy(store || {});
if (server && server.name) { next[server.name] = launchFingerprint(server); }
return next;
}
/**
* The consent card's data. docs/MCP.md G1: "shows the literal command line — no summarizing."
*
* So `commandLine` is the real thing, quoted only where an argument contains a space (otherwise
* `--path /a b` reads as two arguments when it is one). Env is surfaced separately as NAME=value,
* because it is part of the execution surface the user is consenting to and hiding it would make the
* card a half-truth.
*/
function describeMcpLaunch(server) {
const s = server || {};
// Read from launchMaterial, not from `server` directly. Doing its own normalization is what let this
// throw on a string `args` (`.map` is not a function) and render `0=e 1=v 2=i 3=l` for a string
// `env` — junk on the one card whose whole purpose is showing the user exactly what will run.
const material = launchMaterial(s);
const quote = (a) => (/[\s"']/.test(a) ? JSON.stringify(a) : a);
return {
server: String(s.name || ''),
origin: String(s.origin || ''),
commandLine: [material.command].concat((material.args || []).map(quote)).join(' ').trim(),
envLines: (material.env || []).map(([k, v]) => k + '=' + v),
fingerprint: launchFingerprint(s)
};
}
/**
* Split a typed argument line into the `args` array a server entry needs.
*
* Quote-aware, and that is the point rather than polish: the single most common MCP server on earth is
* `npx -y @modelcontextprotocol/server-filesystem /some/path`, and on macOS that path is very often
* `/Users/me/My Documents/…`. Splitting on whitespace alone turns one argument into three and the
* server fails to start with a message about the wrong directory — a bad first experience caused
* entirely by the input box.
*
* Deliberately NOT a shell parser. No variable expansion, no globbing, no operators: `args` is handed
* to spawn as a literal list, so anything clever here would be a lie about what runs. Quotes group; a
* backslash escapes only a following space, quote, or backslash and is otherwise a literal character —
* so a regex arg like `\bword\b`, a Windows path `C:\Users\me`, or a JSON string survives verbatim
* instead of quietly losing its backslashes. That is all.
*/
function parseArgv(line) {
const s = String(line == null ? '' : line);
const out = [];
let cur = '';
let quote = ''; // '"' or "'" while inside a quoted run
let started = false; // distinguishes a deliberate empty argument ("") from whitespace
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '\\' && quote !== "'") {
// A SPLITTER, not a shell: a backslash escapes ONLY a delimiter — a whitespace char, a
// quote, or another backslash. Anything else keeps the backslash literally, so a regex
// (`\bword\b`), a Windows path, or a JSON string is not silently rewritten. (Inside single
// quotes it is always literal — the quote !== "'" guard. A trailing backslash falls through
// and is kept as an ordinary character.)
const nxt = s[i + 1];
if (nxt !== undefined && (/\s/.test(nxt) || nxt === '"' || nxt === "'" || nxt === '\\')) {
cur += nxt; i++; started = true; continue;
}
// not escaping a delimiter → fall through and treat this backslash as an ordinary char
}
if (quote) {
if (ch === quote) { quote = ''; } else { cur += ch; }
continue;
}
if (ch === '"' || ch === "'") { quote = ch; started = true; continue; }
if (/\s/.test(ch)) {
if (started) { out.push(cur); cur = ''; started = false; }
continue;
}
cur += ch; started = true;
}
if (started) { out.push(cur); }
return out;
}
// ---- S5: visibility --------------------------------------------------------
/**
* What `/mcp` shows: one row per CONFIGURED server, whether or not it is running.
*
* Configured-first, not running-first, on purpose. The interesting questions are "why is my server not
* being used?" and "what is this repo asking to run?", and both are about servers that are absent from
* the live set. A list built from live handles answers neither.
*
* Pure so it can be tested without the editor: the caller passes what it knows.
*
* @param {{servers?:Array<object>, problems?:Array<object>, active?:Array<object>,
* policy?:object, trust?:object}} input
* active — from mcpClient.listActive(), optionally carrying `tools` (the raw tools/list result) so
* per-tool allow state can be reported; without it the row still shows a tool count.
* policy — levelcode.ai.mcp.toolPolicy trust — the G1 workspaceState launch-trust map
*/
function summarizeMcp(input) {
const o = input || {};
const servers = Array.isArray(o.servers) ? o.servers : [];
const policy = (o.policy && typeof o.policy === 'object' && !Array.isArray(o.policy)) ? o.policy : {};
const trust = (o.trust && typeof o.trust === 'object' && !Array.isArray(o.trust)) ? o.trust : {};
const live = new Map();
for (const a of (Array.isArray(o.active) ? o.active : [])) {
if (a && typeof a.name === 'string') { live.set(a.name, a); }
}
const rows = servers.map((s) => {
const handle = live.get(s.name);
const workspace = s.source !== 'settings';
const row = {
name: s.name,
source: s.source,
origin: s.origin,
running: !!(handle && handle.alive),
// Only meaningful for a repo-authored server; a settings server needs no consent, and
// reporting `false` for one would read as "blocked".
trusted: workspace ? isLaunchTrusted(s, trust) : null,
commandLine: describeMcpLaunch(s).commandLine,
tools: handle ? (handle.toolCount || 0) : 0,
allowed: null,
toolNames: []
};
// Names and allow state come from the SAME functions the agent uses, so the list cannot claim a
// tool is allow-listed while runTool refuses it.
if (handle && Array.isArray(handle.tools) && handle.tools.length) {
const built = buildAgentTools([ { name: s.name, tools: handle.tools } ]);
row.tools = built.tools.length;
row.toolNames = built.tools.map((t) => t.name);
row.allowed = built.tools.filter((t) => {
const route = built.routes.get(t.name);
return classifyMcpTool(t.name, policy, route && route.annotations).approve === 'allow';
}).length;
}
return row;
});
return {
configured: rows.length,
running: rows.filter((r) => r.running).length,
// Repo-authored servers still waiting on the G1 consent card — the answer to "why is it not
// running?" for the case that is a gate rather than a fault.
awaitingTrust: rows.filter((r) => r.trusted === false && !r.running).length,
servers: rows,
problems: (Array.isArray(o.problems) ? o.problems : []).map((p) => ({
level: String((p && p.level) || 'warn'),
message: String((p && p.message) || '')
}))
};
}
function describeMcpCall(name, args, route) {
const r = route || {};
const fallback = String(name == null ? '' : name).split(NAME_SEPARATOR);
const server = typeof r.server === 'string' && r.server ? r.server : (fallback[0] || String(name));
const tool = typeof r.tool === 'string' && r.tool ? r.tool : (fallback.slice(1).join(NAME_SEPARATOR) || String(name));
const destructive = !!(r.annotations && r.annotations.destructiveHint === true);
return { server, tool, argsText: previewArgs(args), destructive, canAllowAlways: !destructive };
}
module.exports = {
loadServerConfig, userScopedSetting, namespaceToolName, isNamespacedToolName, assignToolNames,
buildAgentTools, safeCopy, UNSAFE_KEYS,
toolCountsByServer, classifyMcpTool, explainMcpRefusal, describeMcpCall,
launchFingerprint, isLaunchTrusted, rememberLaunchTrust, describeMcpLaunch, summarizeMcp, parseArgv,
BUILTIN_TOOL_NAMES, MAX_TOOL_NAME, MAX_TOOL_DESC, MAX_ARG_CHARS, MAX_SERVERS, MAX_TOOLS_PER_SERVER, WORKSPACE_CONFIG_PATH
};