@cjhyy/code-shell-core 0.9.5 → 0.9.7
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/automation/scheduler.d.ts +3 -0
- package/dist/automation/scheduler.js +21 -0
- package/dist/context/manager.d.ts +8 -2
- package/dist/context/manager.js +20 -4
- package/dist/context/notes.d.ts +39 -0
- package/dist/context/notes.js +314 -0
- package/dist/credentials/access.d.ts +9 -1
- package/dist/credentials/access.js +15 -4
- package/dist/credentials/store.d.ts +11 -0
- package/dist/credentials/store.js +44 -9
- package/dist/credentials/types.d.ts +4 -0
- package/dist/credentials/types.js +4 -0
- package/dist/credentials/use-credential-tool.js +12 -2
- package/dist/engine/engine-workspace-authority.js +10 -3
- package/dist/engine/engine.d.ts +8 -4
- package/dist/engine/engine.js +115 -15
- package/dist/engine/prompt-cache-diagnostics.js +10 -1
- package/dist/engine/run-goal.js +7 -3
- package/dist/engine/run-tooling.d.ts +3 -0
- package/dist/engine/run-tooling.js +25 -23
- package/dist/engine/run-types.d.ts +20 -0
- package/dist/engine/run-workspace.js +5 -21
- package/dist/engine/subagent-spawner.d.ts +3 -0
- package/dist/engine/subagent-spawner.js +48 -17
- package/dist/engine/turn-loop.d.ts +14 -4
- package/dist/engine/turn-loop.js +120 -28
- package/dist/engine/types.d.ts +19 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/links/index.d.ts +1 -0
- package/dist/links/index.js +1 -0
- package/dist/links/link-action-tool.d.ts +2 -1
- package/dist/links/link-action-tool.js +80 -44
- package/dist/links/status.d.ts +53 -0
- package/dist/links/status.js +175 -0
- package/dist/llm/prompt-cache.d.ts +35 -3
- package/dist/llm/prompt-cache.js +63 -3
- package/dist/llm/providers/openai.d.ts +3 -0
- package/dist/llm/providers/openai.js +57 -14
- package/dist/prompt/section-loader.js +1 -0
- package/dist/prompt/sections/browser.md +4 -2
- package/dist/prompt/sections/context-notes.md +9 -0
- package/dist/protocol/background-result-wakeup.d.ts +8 -1
- package/dist/protocol/background-result-wakeup.js +76 -38
- package/dist/protocol/chat-session-manager.d.ts +7 -1
- package/dist/protocol/chat-session-manager.js +44 -8
- package/dist/protocol/chat-session.d.ts +2 -0
- package/dist/protocol/chat-session.js +4 -1
- package/dist/protocol/server.d.ts +4 -0
- package/dist/protocol/server.js +355 -62
- package/dist/protocol/session-message-result.d.ts +12 -0
- package/dist/protocol/session-message-result.js +42 -0
- package/dist/protocol/session-message-workspace.d.ts +21 -0
- package/dist/protocol/session-message-workspace.js +57 -0
- package/dist/protocol/types.d.ts +4 -0
- package/dist/session/session-manager.js +8 -7
- package/dist/session/session-message.d.ts +17 -2
- package/dist/session/transcript.d.ts +17 -0
- package/dist/session/transcript.js +271 -16
- package/dist/settings/schema.d.ts +9 -0
- package/dist/settings/schema.js +4 -0
- package/dist/themes/paths.js +20 -1
- package/dist/tool-system/browser-bridge.d.ts +21 -1
- package/dist/tool-system/browser-discovery.d.ts +6 -0
- package/dist/tool-system/browser-discovery.js +17 -0
- package/dist/tool-system/builtin/agent.js +23 -9
- package/dist/tool-system/builtin/browser-tools.js +30 -9
- package/dist/tool-system/builtin/context-notes.d.ts +12 -0
- package/dist/tool-system/builtin/context-notes.js +188 -0
- package/dist/tool-system/builtin/index.js +50 -3
- package/dist/tool-system/builtin/mcp-tools.d.ts +5 -3
- package/dist/tool-system/builtin/mcp-tools.js +10 -10
- package/dist/tool-system/builtin/send-message-to-session.js +19 -3
- package/dist/tool-system/builtin/tool-search.js +15 -3
- package/dist/tool-system/context.d.ts +9 -0
- package/dist/tool-system/executor.js +5 -3
- package/dist/tool-system/mcp-compat.d.ts +3 -0
- package/dist/tool-system/mcp-compat.js +51 -0
- package/dist/tool-system/mcp-manager.d.ts +27 -26
- package/dist/tool-system/mcp-manager.js +273 -111
- package/dist/tool-system/mcp-workspace.d.ts +18 -0
- package/dist/tool-system/mcp-workspace.js +56 -0
- package/dist/tool-system/permission.d.ts +6 -0
- package/dist/tool-system/permission.js +45 -9
- package/dist/tool-system/plan-mode-allowlist.js +5 -0
- package/dist/tool-system/sandbox/seatbelt.js +71 -2
- package/dist/tool-system/session-tool-host.js +9 -1
- package/dist/types.d.ts +4 -2
- package/package.json +1 -1
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Supports stdio and streamable-http transports.
|
|
5
5
|
*/
|
|
6
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
7
6
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
8
7
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
9
8
|
import { logger } from "../logging/logger.js";
|
|
@@ -15,6 +14,11 @@ import { randomUUID } from "node:crypto";
|
|
|
15
14
|
import { join } from "node:path";
|
|
16
15
|
import { diagnoseMcpStdioMissingCommand, previewPath } from "./mcp-stdio-diagnostics.js";
|
|
17
16
|
import { codeShellHome } from "../session/session-manager.js";
|
|
17
|
+
import { adaptMcpToolSchema, normalizeMcpToolArgs } from "./mcp-compat.js";
|
|
18
|
+
import { createWorkspaceMcpClient, mcpConnectionKey, mcpConnectionScope, mcpScopeAtCwd, } from "./mcp-workspace.js";
|
|
19
|
+
// Enumerable symbol survives the registry's per-call context copy. Models can
|
|
20
|
+
// neither supply it through JSON arguments nor mutate the original run scope.
|
|
21
|
+
const MCP_RUN_SCOPE = Symbol("mcpRunScope");
|
|
18
22
|
/**
|
|
19
23
|
* Read a required secret from `process.env` by NAME (Codex-style env-secret
|
|
20
24
|
* handling — the value is never persisted in MCP config). A referenced env var
|
|
@@ -339,12 +343,12 @@ export function stripInternalToolArgs(args) {
|
|
|
339
343
|
function toOpenAIToolName(name) {
|
|
340
344
|
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
341
345
|
}
|
|
342
|
-
export function buildRegisteredTool(serverName, tool) {
|
|
346
|
+
export function buildRegisteredTool(serverName, tool, implementation) {
|
|
343
347
|
const readOnly = tool.annotations?.readOnlyHint === true;
|
|
344
348
|
return {
|
|
345
349
|
name: toOpenAIToolName(`mcp_${serverName}_${tool.name}`),
|
|
346
350
|
description: `[${serverName}] ${tool.description ?? tool.name}`,
|
|
347
|
-
inputSchema: tool
|
|
351
|
+
inputSchema: adaptMcpToolSchema(implementation, tool) ?? {
|
|
348
352
|
type: "object",
|
|
349
353
|
properties: {},
|
|
350
354
|
},
|
|
@@ -362,20 +366,13 @@ export class MCPManager {
|
|
|
362
366
|
connections = new Map();
|
|
363
367
|
registeredToolsByServer = new Map();
|
|
364
368
|
desiredServerNames = null;
|
|
365
|
-
/**
|
|
366
|
-
* In-flight connect()s keyed by server name. When the broadcast config
|
|
367
|
-
* reload (server.ts forEachSession → every session's refreshRuntimeConfig)
|
|
368
|
-
* calls connectAll for the SAME `added` server on this ONE shared pool, K
|
|
369
|
-
* concurrent connect(name) calls would each start a fresh handshake because
|
|
370
|
-
* `connections.has(name)` only becomes true AFTER the handshake completes —
|
|
371
|
-
* a thundering herd of duplicate connections racing to set(). Coalescing by
|
|
372
|
-
* name here collapses them to a SINGLE underlying connection; the late
|
|
373
|
-
* callers await the same promise and return. Cleared in finally so a failed
|
|
374
|
-
* connect can be retried later.
|
|
375
|
-
*/
|
|
369
|
+
/** Concurrent handshakes share only an identical server + cwd + root scope. */
|
|
376
370
|
connecting = new Map();
|
|
377
371
|
/** Per-owner (engine) desired server sets — see reconcile()'s shared-pool note. */
|
|
378
372
|
desiredByOwner = new Map();
|
|
373
|
+
scopeByOwner = new Map();
|
|
374
|
+
connectionKeysByOwner = new Map();
|
|
375
|
+
connectionGeneration = 0;
|
|
379
376
|
constructor(toolRegistry) {
|
|
380
377
|
this.toolRegistry = toolRegistry;
|
|
381
378
|
MCPManager.instance = this;
|
|
@@ -386,16 +383,43 @@ export class MCPManager {
|
|
|
386
383
|
}
|
|
387
384
|
return MCPManager.instance;
|
|
388
385
|
}
|
|
386
|
+
/** Resolve generic MCP builtins through the current run, never another pool. */
|
|
387
|
+
static forContext(workspace) {
|
|
388
|
+
if (!workspace)
|
|
389
|
+
return MCPManager.getInstance();
|
|
390
|
+
const binding = workspace[MCP_RUN_SCOPE];
|
|
391
|
+
if (!binding)
|
|
392
|
+
throw new Error("MCP is not connected for the current run.");
|
|
393
|
+
binding.manager.scopeForContext(workspace);
|
|
394
|
+
return binding.manager;
|
|
395
|
+
}
|
|
389
396
|
/**
|
|
390
397
|
* Connect to all configured MCP servers and register their tools.
|
|
391
398
|
*/
|
|
392
|
-
async connectAll(servers, owner, onServerEvent) {
|
|
399
|
+
async connectAll(servers, owner, onServerEvent, workspace) {
|
|
400
|
+
const selectedWorkspace = workspace ?? this.scopeByOwner.get(owner);
|
|
401
|
+
const scope = mcpConnectionScope(selectedWorkspace);
|
|
402
|
+
if (selectedWorkspace) {
|
|
403
|
+
const previousContext = this.scopeByOwner.get(owner);
|
|
404
|
+
if (previousContext?.[MCP_RUN_SCOPE])
|
|
405
|
+
previousContext[MCP_RUN_SCOPE].active = false;
|
|
406
|
+
selectedWorkspace[MCP_RUN_SCOPE] = {
|
|
407
|
+
manager: this,
|
|
408
|
+
scope,
|
|
409
|
+
context: selectedWorkspace,
|
|
410
|
+
active: true,
|
|
411
|
+
generation: this.connectionGeneration,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
393
414
|
const enabledNames = this.enabledServerNames(servers);
|
|
394
415
|
// Register this owner's desired set up front (see reconcile's shared-pool
|
|
395
416
|
// note) so a later reconcile from ANOTHER session can't disconnect servers
|
|
396
417
|
// this session connected at run start.
|
|
397
418
|
if (owner !== undefined) {
|
|
398
419
|
this.desiredByOwner.set(owner, enabledNames);
|
|
420
|
+
if (selectedWorkspace)
|
|
421
|
+
this.scopeByOwner.set(owner, selectedWorkspace);
|
|
422
|
+
this.connectionKeysByOwner.set(owner, new Set([...enabledNames].map((name) => mcpConnectionKey(name, scope))));
|
|
399
423
|
this.desiredServerNames = this.unionDesired() ?? new Set();
|
|
400
424
|
}
|
|
401
425
|
else if (this.desiredByOwner.size === 0) {
|
|
@@ -411,9 +435,11 @@ export class MCPManager {
|
|
|
411
435
|
}
|
|
412
436
|
return true;
|
|
413
437
|
});
|
|
414
|
-
if (entries.length === 0)
|
|
438
|
+
if (entries.length === 0) {
|
|
439
|
+
await this.pruneUnusedScopedConnections();
|
|
415
440
|
return;
|
|
416
|
-
|
|
441
|
+
}
|
|
442
|
+
const results = await Promise.allSettled(entries.map(([name, config]) => this.connect(name, config, selectedWorkspace)));
|
|
417
443
|
for (let i = 0; i < results.length; i++) {
|
|
418
444
|
const result = results[i];
|
|
419
445
|
const server = entries[i][0];
|
|
@@ -432,6 +458,7 @@ export class MCPManager {
|
|
|
432
458
|
/* observers must not affect connection handling */
|
|
433
459
|
}
|
|
434
460
|
}
|
|
461
|
+
await this.pruneUnusedScopedConnections();
|
|
435
462
|
}
|
|
436
463
|
async reconcile(servers, owner) {
|
|
437
464
|
const enabledNames = this.enabledServerNames(servers);
|
|
@@ -460,11 +487,27 @@ export class MCPManager {
|
|
|
460
487
|
async unregisterOwner(owner) {
|
|
461
488
|
if (!this.desiredByOwner.delete(owner))
|
|
462
489
|
return;
|
|
490
|
+
const context = this.scopeByOwner.get(owner);
|
|
491
|
+
if (context?.[MCP_RUN_SCOPE])
|
|
492
|
+
context[MCP_RUN_SCOPE].active = false;
|
|
493
|
+
this.scopeByOwner.delete(owner);
|
|
494
|
+
this.connectionKeysByOwner.delete(owner);
|
|
463
495
|
const desired = this.unionDesired() ?? new Set();
|
|
464
496
|
this.desiredServerNames = desired;
|
|
465
497
|
const stale = this.listServers().filter((name) => !desired.has(name));
|
|
466
498
|
await Promise.all(stale.map((name) => this.disconnect(name)));
|
|
499
|
+
await this.pruneUnusedScopedConnections();
|
|
500
|
+
}
|
|
501
|
+
async pruneUnusedScopedConnections() {
|
|
502
|
+
const wanted = new Set([...this.connectionKeysByOwner.values()].flatMap((keys) => [...keys]));
|
|
503
|
+
const stale = [...this.connections.entries()].filter(([key, conn]) => conn.scope?.key && !wanted.has(key));
|
|
504
|
+
// Only owner-managed scopes are pruned. Direct SDK callers own their
|
|
505
|
+
// explicitly connected scope until disconnect()/disconnectAll().
|
|
506
|
+
await Promise.all(stale
|
|
507
|
+
.filter(([key]) => this.managedConnectionKeys.has(key))
|
|
508
|
+
.map(([key]) => this.disconnectConnection(key)));
|
|
467
509
|
}
|
|
510
|
+
managedConnectionKeys = new Set();
|
|
468
511
|
enabledServerNames(servers) {
|
|
469
512
|
return new Set(Object.entries(servers)
|
|
470
513
|
.filter(([, config]) => config.enabled !== false)
|
|
@@ -483,25 +526,26 @@ export class MCPManager {
|
|
|
483
526
|
/**
|
|
484
527
|
* Connect to a single MCP server.
|
|
485
528
|
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
* awaited rather than restarted (#5 — thundering-herd guard on the shared
|
|
489
|
-
* pool). The actual handshake lives in `performConnect`.
|
|
529
|
+
* Calls for the same server and canonical workspace scope share one pending
|
|
530
|
+
* handshake. Other workspace scopes always get their own transport.
|
|
490
531
|
*/
|
|
491
|
-
async connect(name, config) {
|
|
492
|
-
|
|
532
|
+
async connect(name, config, workspace) {
|
|
533
|
+
const key = mcpConnectionKey(name, this.scopeForContext(workspace));
|
|
534
|
+
if ([...this.connectionKeysByOwner.values()].some((keys) => keys.has(key)))
|
|
535
|
+
this.managedConnectionKeys.add(key);
|
|
536
|
+
if (this.connections.has(key)) {
|
|
493
537
|
logger.info("mcp.already_connected", { server: name });
|
|
494
538
|
return;
|
|
495
539
|
}
|
|
496
|
-
const inflight = this.connecting.get(
|
|
540
|
+
const inflight = this.connecting.get(key);
|
|
497
541
|
if (inflight) {
|
|
498
542
|
logger.info("mcp.connect_coalesced", { server: name });
|
|
499
543
|
return inflight;
|
|
500
544
|
}
|
|
501
|
-
const p = this.performConnect(name, config).finally(() => {
|
|
502
|
-
this.connecting.delete(
|
|
545
|
+
const p = this.performConnect(name, config, workspace).finally(() => {
|
|
546
|
+
this.connecting.delete(key);
|
|
503
547
|
});
|
|
504
|
-
this.connecting.set(
|
|
548
|
+
this.connecting.set(key, p);
|
|
505
549
|
return p;
|
|
506
550
|
}
|
|
507
551
|
/**
|
|
@@ -509,14 +553,17 @@ export class MCPManager {
|
|
|
509
553
|
* from `connect` so the coalescing/dedup logic stays in one place. Override
|
|
510
554
|
* `connect` (not this) in test doubles that want to count handshakes.
|
|
511
555
|
*/
|
|
512
|
-
async performConnect(name, config) {
|
|
513
|
-
|
|
556
|
+
async performConnect(name, config, workspace) {
|
|
557
|
+
const scope = this.scopeForContext(workspace);
|
|
558
|
+
const generation = this.connectionGeneration;
|
|
559
|
+
const key = mcpConnectionKey(name, scope);
|
|
560
|
+
if (this.connections.has(key)) {
|
|
514
561
|
logger.info("mcp.already_connected", { server: name });
|
|
515
562
|
return;
|
|
516
563
|
}
|
|
517
564
|
const transportType = inferTransportType(config);
|
|
518
565
|
logger.info("mcp.connecting", { server: name, transport: transportType });
|
|
519
|
-
const client =
|
|
566
|
+
const client = createWorkspaceMcpClient(scope);
|
|
520
567
|
let transport;
|
|
521
568
|
if (transportType === "stdio") {
|
|
522
569
|
if (!config.command) {
|
|
@@ -526,6 +573,7 @@ export class MCPManager {
|
|
|
526
573
|
command: config.command,
|
|
527
574
|
args: config.args,
|
|
528
575
|
env: buildStdioEnv(name, config),
|
|
576
|
+
...(scope.cwd ? { cwd: scope.cwd } : {}),
|
|
529
577
|
});
|
|
530
578
|
}
|
|
531
579
|
else if (transportType === "streamable-http" || transportType === "sse") {
|
|
@@ -533,7 +581,7 @@ export class MCPManager {
|
|
|
533
581
|
throw new Error(`MCP server "${name}": url is required for ${transportType} transport`);
|
|
534
582
|
}
|
|
535
583
|
// credentialRef resolves against user-scope credential access. The shared
|
|
536
|
-
//
|
|
584
|
+
// MCP-referenced credentials
|
|
537
585
|
// (e.g. a Figma token) remain user-global by design.
|
|
538
586
|
const access = getCredentialAccess();
|
|
539
587
|
const headers = buildHttpHeaders(name, { ...config, credentialRef: undefined });
|
|
@@ -587,11 +635,15 @@ export class MCPManager {
|
|
|
587
635
|
if (timeoutHandle)
|
|
588
636
|
clearTimeout(timeoutHandle);
|
|
589
637
|
}
|
|
590
|
-
this.
|
|
638
|
+
if (generation !== this.connectionGeneration) {
|
|
639
|
+
await client.close();
|
|
640
|
+
throw new Error("MCP manager closed during connection.");
|
|
641
|
+
}
|
|
642
|
+
this.connections.set(key, { client, serverName: name, transport, scope, config });
|
|
591
643
|
// Discover and register tools
|
|
592
|
-
await this.discoverTools(name, client);
|
|
644
|
+
await this.discoverTools(name, client, key);
|
|
593
645
|
if (this.desiredServerNames && !this.desiredServerNames.has(name)) {
|
|
594
|
-
await this.
|
|
646
|
+
await this.disconnectConnection(key);
|
|
595
647
|
return;
|
|
596
648
|
}
|
|
597
649
|
logger.info("mcp.connected", { server: name });
|
|
@@ -599,57 +651,15 @@ export class MCPManager {
|
|
|
599
651
|
/**
|
|
600
652
|
* Discover tools from an MCP server and register them.
|
|
601
653
|
*/
|
|
602
|
-
async discoverTools(serverName, client) {
|
|
654
|
+
async discoverTools(serverName, client, connectionKey = serverName) {
|
|
603
655
|
try {
|
|
604
656
|
const result = await client.listTools();
|
|
657
|
+
const connection = this.connections.get(connectionKey);
|
|
658
|
+
if (connection)
|
|
659
|
+
connection.tools = new Map(result.tools.map((tool) => [tool.name, tool]));
|
|
605
660
|
for (const tool of result.tools) {
|
|
606
|
-
const registered = buildRegisteredTool(serverName, tool);
|
|
607
|
-
|
|
608
|
-
this.toolRegistry.registerTool(registered, async (args, ctx) => {
|
|
609
|
-
const callResult = await client.callTool({
|
|
610
|
-
name: tool.name,
|
|
611
|
-
arguments: stripInternalToolArgs(args),
|
|
612
|
-
}, undefined, ctx?.signal ? { signal: ctx.signal } : undefined);
|
|
613
|
-
// Extract text + image content from the result. Image blobs
|
|
614
|
-
// are spilled to ~/.code-shell/mcp_images/ so they don't bloat
|
|
615
|
-
// the LLM message tree — same pattern as the GenerateImage
|
|
616
|
-
// tool — and the model sees a textual reference it can Read
|
|
617
|
-
// on a later turn if it needs the pixels. This sidesteps the
|
|
618
|
-
// "MCP returned a 5MB screenshot → token budget exploded"
|
|
619
|
-
// failure mode that bit Codex (issue #11845); we never put
|
|
620
|
-
// raw base64 image data in the result text. See
|
|
621
|
-
// TODO-week.md #9c + docs/research-cc-vs-codex-image-handling.md §B.
|
|
622
|
-
const parts = [];
|
|
623
|
-
if (Array.isArray(callResult.content)) {
|
|
624
|
-
for (const item of callResult.content) {
|
|
625
|
-
if (typeof item === "string") {
|
|
626
|
-
parts.push(item);
|
|
627
|
-
continue;
|
|
628
|
-
}
|
|
629
|
-
if (typeof item !== "object" || item === null)
|
|
630
|
-
continue;
|
|
631
|
-
if ("text" in item) {
|
|
632
|
-
parts.push(String(item.text));
|
|
633
|
-
continue;
|
|
634
|
-
}
|
|
635
|
-
if (item.type === "image") {
|
|
636
|
-
const block = item;
|
|
637
|
-
if (typeof block.data === "string" && block.data.length > 0) {
|
|
638
|
-
const note = await spillMcpImage(serverName, tool.name, block.data, block.mimeType ?? "image/png");
|
|
639
|
-
parts.push(note);
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
const body = parts.join("\n") || "(no output)";
|
|
645
|
-
// Trust boundary: MCP output is external content. Wrap it so the
|
|
646
|
-
// model sees an explicit reminder that the body comes from an
|
|
647
|
-
// untrusted server and any instructions inside are content, not
|
|
648
|
-
// commands. The marker is intentionally short so it doesn't bloat
|
|
649
|
-
// every tool result, but distinct enough that prompt-injected
|
|
650
|
-
// strings can't fake their way out.
|
|
651
|
-
return wrapMcpOutput(serverName, tool.name, body);
|
|
652
|
-
});
|
|
661
|
+
const registered = buildRegisteredTool(serverName, tool, client.getServerVersion?.()?.name);
|
|
662
|
+
this.toolRegistry.registerTool(registered, (args, ctx) => this.executeRegisteredTool(serverName, tool.name, args, ctx));
|
|
653
663
|
const set = this.registeredToolsByServer.get(serverName) ?? new Set();
|
|
654
664
|
set.add(registered.name);
|
|
655
665
|
this.registeredToolsByServer.set(serverName, set);
|
|
@@ -657,22 +667,177 @@ export class MCPManager {
|
|
|
657
667
|
}
|
|
658
668
|
}
|
|
659
669
|
catch (err) {
|
|
660
|
-
await this.
|
|
670
|
+
await this.disconnectConnection(connectionKey);
|
|
661
671
|
throw err;
|
|
662
672
|
}
|
|
663
673
|
}
|
|
674
|
+
scopeForContext(workspace) {
|
|
675
|
+
const binding = workspace?.[MCP_RUN_SCOPE];
|
|
676
|
+
if (binding &&
|
|
677
|
+
(binding.manager !== this ||
|
|
678
|
+
!binding.active ||
|
|
679
|
+
binding.generation !== this.connectionGeneration)) {
|
|
680
|
+
throw new Error("MCP run context is no longer active.");
|
|
681
|
+
}
|
|
682
|
+
const previous = binding?.scope;
|
|
683
|
+
// Legacy cwd-only contexts retain the root granted at run start when cd
|
|
684
|
+
// moves deeper. A later arbitrary directory never becomes a new root here.
|
|
685
|
+
if (workspace && !workspace.workspace && previous)
|
|
686
|
+
return mcpScopeAtCwd(previous, workspace.cwd);
|
|
687
|
+
return mcpConnectionScope(workspace);
|
|
688
|
+
}
|
|
689
|
+
findConnection(serverName, workspace) {
|
|
690
|
+
if (workspace)
|
|
691
|
+
return this.connections.get(mcpConnectionKey(serverName, this.scopeForContext(workspace)));
|
|
692
|
+
// Compatibility for direct SDK callers without a run context. Ambiguous
|
|
693
|
+
// workspace connections require the caller to provide its actual scope.
|
|
694
|
+
const candidates = [...this.connections.values()].filter((conn) => conn.serverName === serverName);
|
|
695
|
+
if (candidates.length > 1)
|
|
696
|
+
throw new Error(`MCP server "${serverName}" requires a workspace context.`);
|
|
697
|
+
return candidates[0];
|
|
698
|
+
}
|
|
699
|
+
async connectionForScope(serverName, workspace) {
|
|
700
|
+
let connection = this.findConnection(serverName, workspace);
|
|
701
|
+
if (connection)
|
|
702
|
+
return connection;
|
|
703
|
+
const binding = workspace?.[MCP_RUN_SCOPE];
|
|
704
|
+
const initial = binding?.scope;
|
|
705
|
+
if (workspace && initial) {
|
|
706
|
+
const scope = this.scopeForContext(workspace);
|
|
707
|
+
const sameRoots = (candidate) => JSON.stringify(candidate.roots) === JSON.stringify(initial.roots);
|
|
708
|
+
// Reconnect only a cwd change within this run's original authority. The
|
|
709
|
+
// configuration comes from a live server with exactly those same roots.
|
|
710
|
+
if (sameRoots(scope)) {
|
|
711
|
+
mcpScopeAtCwd(initial, workspace.cwd);
|
|
712
|
+
const source = [...this.connections.values()].find((conn) => conn.serverName === serverName && conn.scope && sameRoots(conn.scope) && conn.config);
|
|
713
|
+
if (source?.config) {
|
|
714
|
+
const key = mcpConnectionKey(serverName, scope);
|
|
715
|
+
for (const [owner, context] of this.scopeByOwner) {
|
|
716
|
+
if (context !== binding?.context)
|
|
717
|
+
continue;
|
|
718
|
+
const keys = this.connectionKeysByOwner.get(owner);
|
|
719
|
+
for (const oldKey of keys) {
|
|
720
|
+
if (this.connections.get(oldKey)?.serverName === serverName)
|
|
721
|
+
keys.delete(oldKey);
|
|
722
|
+
}
|
|
723
|
+
keys.add(key);
|
|
724
|
+
this.managedConnectionKeys.add(key);
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
await this.connect(serverName, source.config, workspace);
|
|
728
|
+
connection = this.findConnection(serverName, workspace);
|
|
729
|
+
if (connection)
|
|
730
|
+
return connection;
|
|
731
|
+
}
|
|
732
|
+
finally {
|
|
733
|
+
// The owner can close while the new transport is handshaking.
|
|
734
|
+
await this.pruneUnusedScopedConnections();
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
throw new Error(`MCP server "${serverName}" is not connected${workspace ? " for the current workspace" : ""}.`);
|
|
740
|
+
}
|
|
741
|
+
/** Refresh an engine's fork after pool discovery; retain all non-MCP tools. */
|
|
742
|
+
syncToolsToRegistry(registry, workspace, allowedServers) {
|
|
743
|
+
const available = new Set();
|
|
744
|
+
for (const serverName of this.listServers()) {
|
|
745
|
+
if (allowedServers && !allowedServers.has(serverName))
|
|
746
|
+
continue;
|
|
747
|
+
const conn = this.findConnection(serverName, workspace);
|
|
748
|
+
if (!conn)
|
|
749
|
+
continue;
|
|
750
|
+
for (const tool of conn.tools?.values() ?? []) {
|
|
751
|
+
const registered = buildRegisteredTool(serverName, tool, conn.client.getServerVersion?.()?.name);
|
|
752
|
+
// A project tool with the same name has its own contract and executor.
|
|
753
|
+
const existing = registry.getTool(registered.name);
|
|
754
|
+
if (existing && existing.source !== "mcp")
|
|
755
|
+
continue;
|
|
756
|
+
available.add(registered.name);
|
|
757
|
+
registry.registerTool(registered, (args, ctx) => this.executeRegisteredTool(serverName, tool.name, args, ctx));
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
for (const tool of registry.getToolDefinitions()) {
|
|
761
|
+
if (registry.getTool(tool.name)?.source === "mcp" && !available.has(tool.name))
|
|
762
|
+
registry.unregisterTool(tool.name);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
async executeRegisteredTool(serverName, toolName, args, ctx) {
|
|
766
|
+
const connection = await this.connectionForScope(serverName, ctx);
|
|
767
|
+
const tool = connection.tools?.get(toolName);
|
|
768
|
+
if (!tool)
|
|
769
|
+
throw new Error(`MCP tool "${toolName}" is not available in the current workspace.`);
|
|
770
|
+
const callResult = await connection.client.callTool({
|
|
771
|
+
name: toolName,
|
|
772
|
+
arguments: normalizeMcpToolArgs(connection.client.getServerVersion?.()?.name, tool, stripInternalToolArgs(args)),
|
|
773
|
+
}, undefined, ctx?.signal ? { signal: ctx.signal } : undefined);
|
|
774
|
+
// Extract text + image content from the result. Image blobs
|
|
775
|
+
// are spilled to ~/.code-shell/mcp_images/ so they don't bloat
|
|
776
|
+
// the LLM message tree — same pattern as the GenerateImage
|
|
777
|
+
// tool — and the model sees a textual reference it can Read
|
|
778
|
+
// on a later turn if it needs the pixels. This sidesteps the
|
|
779
|
+
// "MCP returned a 5MB screenshot → token budget exploded"
|
|
780
|
+
// failure mode that bit Codex (issue #11845); we never put
|
|
781
|
+
// raw base64 image data in the result text. See
|
|
782
|
+
// TODO-week.md #9c + docs/research-cc-vs-codex-image-handling.md §B.
|
|
783
|
+
const parts = [];
|
|
784
|
+
if (Array.isArray(callResult.content)) {
|
|
785
|
+
for (const item of callResult.content) {
|
|
786
|
+
if (typeof item === "string") {
|
|
787
|
+
parts.push(item);
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (typeof item !== "object" || item === null)
|
|
791
|
+
continue;
|
|
792
|
+
if ("text" in item) {
|
|
793
|
+
parts.push(String(item.text));
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
if (item.type === "image") {
|
|
797
|
+
const block = item;
|
|
798
|
+
if (typeof block.data === "string" && block.data.length > 0) {
|
|
799
|
+
const note = await spillMcpImage(serverName, toolName, block.data, block.mimeType ?? "image/png");
|
|
800
|
+
parts.push(note);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const body = parts.join("\n") || "(no output)";
|
|
806
|
+
// Trust boundary: MCP output is external content. Wrap it so the
|
|
807
|
+
// model sees an explicit reminder that the body comes from an
|
|
808
|
+
// untrusted server and any instructions inside are content, not
|
|
809
|
+
// commands. The marker is intentionally short so it doesn't bloat
|
|
810
|
+
// every tool result, but distinct enough that prompt-injected
|
|
811
|
+
// strings can't fake their way out.
|
|
812
|
+
const wrapped = wrapMcpOutput(serverName, toolName, body);
|
|
813
|
+
if (callResult.isError)
|
|
814
|
+
throw new Error(wrapped);
|
|
815
|
+
return wrapped;
|
|
816
|
+
}
|
|
664
817
|
/**
|
|
665
818
|
* Disconnect all MCP servers.
|
|
666
819
|
*/
|
|
667
820
|
async disconnectAll() {
|
|
668
|
-
|
|
821
|
+
this.connectionGeneration++;
|
|
822
|
+
await Promise.all([...this.connections.keys()].map((key) => this.disconnectConnection(key)));
|
|
669
823
|
this.desiredByOwner.clear();
|
|
824
|
+
this.scopeByOwner.clear();
|
|
825
|
+
this.connectionKeysByOwner.clear();
|
|
826
|
+
this.managedConnectionKeys.clear();
|
|
670
827
|
this.desiredServerNames = null;
|
|
671
828
|
}
|
|
672
829
|
async disconnect(name) {
|
|
673
|
-
|
|
830
|
+
await Promise.all([...this.connections.entries()]
|
|
831
|
+
.filter(([, conn]) => conn.serverName === name)
|
|
832
|
+
.map(([key]) => this.disconnectConnection(key)));
|
|
833
|
+
}
|
|
834
|
+
async disconnectConnection(key) {
|
|
835
|
+
const conn = this.connections.get(key);
|
|
674
836
|
if (!conn)
|
|
675
837
|
return;
|
|
838
|
+
this.connections.delete(key);
|
|
839
|
+
this.managedConnectionKeys.delete(key);
|
|
840
|
+
const name = conn.serverName;
|
|
676
841
|
try {
|
|
677
842
|
await conn.client.close();
|
|
678
843
|
logger.info("mcp.disconnected", { server: name });
|
|
@@ -681,32 +846,31 @@ export class MCPManager {
|
|
|
681
846
|
logger.warn("mcp.disconnect_error", { server: name, error: err.message });
|
|
682
847
|
}
|
|
683
848
|
finally {
|
|
684
|
-
this.connections.
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
849
|
+
if (![...this.connections.values()].some((other) => other.serverName === name)) {
|
|
850
|
+
for (const toolName of this.registeredToolsByServer.get(name) ?? []) {
|
|
851
|
+
this.toolRegistry.unregisterTool(toolName);
|
|
852
|
+
logger.info("mcp.tool_unregistered", { server: name, tool: toolName });
|
|
853
|
+
}
|
|
854
|
+
this.registeredToolsByServer.delete(name);
|
|
688
855
|
}
|
|
689
|
-
this.registeredToolsByServer.delete(name);
|
|
690
856
|
}
|
|
691
857
|
}
|
|
692
|
-
/**
|
|
693
|
-
* List connected servers.
|
|
694
|
-
*/
|
|
858
|
+
/** Names remain stable even when several isolated workspace transports exist. */
|
|
695
859
|
listServers() {
|
|
696
|
-
return [...this.connections.
|
|
860
|
+
return [...new Set([...this.connections.values()].map((conn) => conn.serverName))];
|
|
697
861
|
}
|
|
698
862
|
/**
|
|
699
863
|
* Call a tool on a specific MCP server.
|
|
700
864
|
*/
|
|
701
|
-
async callTool(serverName, toolName, args, signal) {
|
|
702
|
-
const conn = this.
|
|
703
|
-
if (!conn) {
|
|
704
|
-
throw new Error(`MCP server "${serverName}" is not connected.`);
|
|
705
|
-
}
|
|
865
|
+
async callTool(serverName, toolName, args, signal, workspace) {
|
|
866
|
+
const conn = await this.connectionForScope(serverName, workspace);
|
|
706
867
|
// Forward the run's abort signal so a user Stop cancels an in-flight MCP
|
|
707
868
|
// call promptly instead of blocking until the SDK's default request timeout.
|
|
708
869
|
// (The SDK still enforces its default timeout when no signal is provided.)
|
|
709
|
-
const result = await conn.client.callTool({
|
|
870
|
+
const result = await conn.client.callTool({
|
|
871
|
+
name: toolName,
|
|
872
|
+
arguments: normalizeMcpToolArgs(conn.client.getServerVersion?.()?.name, conn.tools?.get(toolName), stripInternalToolArgs(args)),
|
|
873
|
+
}, undefined, signal ? { signal } : undefined);
|
|
710
874
|
const parts = [];
|
|
711
875
|
if (Array.isArray(result.content)) {
|
|
712
876
|
for (const item of result.content) {
|
|
@@ -719,19 +883,20 @@ export class MCPManager {
|
|
|
719
883
|
}
|
|
720
884
|
}
|
|
721
885
|
const body = parts.join("\n") || "(no output)";
|
|
722
|
-
|
|
886
|
+
const wrapped = wrapMcpOutput(serverName, toolName, body);
|
|
887
|
+
if (result.isError)
|
|
888
|
+
throw new Error(wrapped);
|
|
889
|
+
return wrapped;
|
|
723
890
|
}
|
|
724
891
|
/**
|
|
725
892
|
* List resources from MCP servers.
|
|
726
893
|
*/
|
|
727
|
-
async listResources(serverName, signal) {
|
|
894
|
+
async listResources(serverName, signal, workspace) {
|
|
728
895
|
const results = [];
|
|
729
|
-
const servers = serverName ? [serverName] :
|
|
896
|
+
const servers = serverName ? [serverName] : this.listServers();
|
|
730
897
|
for (const name of servers) {
|
|
731
|
-
const conn = this.connections.get(name);
|
|
732
|
-
if (!conn)
|
|
733
|
-
continue;
|
|
734
898
|
try {
|
|
899
|
+
const conn = await this.connectionForScope(name, workspace);
|
|
735
900
|
// Forward the run's abort signal so a user Stop cancels promptly
|
|
736
901
|
// instead of waiting out the SDK's default request timeout (same as
|
|
737
902
|
// callTool). The SDK still enforces its default timeout when no signal.
|
|
@@ -754,11 +919,8 @@ export class MCPManager {
|
|
|
754
919
|
/**
|
|
755
920
|
* Read a resource from an MCP server.
|
|
756
921
|
*/
|
|
757
|
-
async readResource(serverName, uri, signal) {
|
|
758
|
-
const conn = this.
|
|
759
|
-
if (!conn) {
|
|
760
|
-
throw new Error(`MCP server "${serverName}" is not connected.`);
|
|
761
|
-
}
|
|
922
|
+
async readResource(serverName, uri, signal, workspace) {
|
|
923
|
+
const conn = await this.connectionForScope(serverName, workspace);
|
|
762
924
|
// Forward the run's abort signal so a user Stop cancels promptly (same as
|
|
763
925
|
// callTool); the SDK still enforces its default timeout when no signal.
|
|
764
926
|
const result = await conn.client.readResource({ uri }, signal ? { signal } : undefined);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { type Root } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import type { ToolContext } from "./context.js";
|
|
4
|
+
export type McpWorkspaceScope = Pick<ToolContext, "cwd" | "workspace">;
|
|
5
|
+
export interface McpConnectionScope {
|
|
6
|
+
key: string;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
roots: Root[];
|
|
9
|
+
}
|
|
10
|
+
/** Scope comes only from the host's ToolContext, never from tool arguments. */
|
|
11
|
+
export declare function mcpConnectionScope(scope?: McpWorkspaceScope): McpConnectionScope;
|
|
12
|
+
/** Roots stay immutable for the lifetime of a connection. Different root sets
|
|
13
|
+
* use different transports, so concurrent sessions never borrow each other's
|
|
14
|
+
* filesystem authority or depend on a racing roots/list_changed notification. */
|
|
15
|
+
export declare function createWorkspaceMcpClient(scope: McpConnectionScope): Client;
|
|
16
|
+
export declare function mcpConnectionKey(serverName: string, scope: McpConnectionScope): string;
|
|
17
|
+
/** Changing cwd may narrow execution location, but never expands negotiated roots. */
|
|
18
|
+
export declare function mcpScopeAtCwd(scope: McpConnectionScope, cwd: string): McpConnectionScope;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { ListRootsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { isAbsolute, relative } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { canonicalKey, canonicalPath } from "../workspace/canonical-key.js";
|
|
6
|
+
/** Scope comes only from the host's ToolContext, never from tool arguments. */
|
|
7
|
+
export function mcpConnectionScope(scope) {
|
|
8
|
+
if (!scope)
|
|
9
|
+
return { key: "", roots: [] };
|
|
10
|
+
if (!isAbsolute(scope.cwd))
|
|
11
|
+
throw new Error("MCP workspace cwd must be absolute");
|
|
12
|
+
const paths = scope.workspace ? scope.workspace.roots.map((root) => root.path) : [scope.cwd];
|
|
13
|
+
if (!paths.length || paths.some((path) => !isAbsolute(path))) {
|
|
14
|
+
throw new Error("MCP workspace roots must be nonempty absolute paths");
|
|
15
|
+
}
|
|
16
|
+
const cwd = canonicalPath(scope.cwd);
|
|
17
|
+
const roots = [...new Set(paths.map(canonicalPath))]
|
|
18
|
+
.sort()
|
|
19
|
+
.map((path) => ({ uri: pathToFileURL(path).href }));
|
|
20
|
+
return { key: JSON.stringify([cwd, roots.map((root) => root.uri)]), cwd, roots };
|
|
21
|
+
}
|
|
22
|
+
/** Roots stay immutable for the lifetime of a connection. Different root sets
|
|
23
|
+
* use different transports, so concurrent sessions never borrow each other's
|
|
24
|
+
* filesystem authority or depend on a racing roots/list_changed notification. */
|
|
25
|
+
export function createWorkspaceMcpClient(scope) {
|
|
26
|
+
const client = new Client({ name: "code-shell", version: "0.1.0" }, { capabilities: { roots: {} } });
|
|
27
|
+
const roots = scope.roots.map((root) => ({ ...root }));
|
|
28
|
+
client.setRequestHandler(ListRootsRequestSchema, async () => ({
|
|
29
|
+
roots: roots.map((root) => ({ ...root })),
|
|
30
|
+
}));
|
|
31
|
+
return client;
|
|
32
|
+
}
|
|
33
|
+
export function mcpConnectionKey(serverName, scope) {
|
|
34
|
+
return scope.key ? JSON.stringify([serverName, scope.key]) : serverName;
|
|
35
|
+
}
|
|
36
|
+
/** Changing cwd may narrow execution location, but never expands negotiated roots. */
|
|
37
|
+
export function mcpScopeAtCwd(scope, cwd) {
|
|
38
|
+
if (!isAbsolute(cwd))
|
|
39
|
+
throw new Error("MCP workspace cwd must be absolute");
|
|
40
|
+
const canonicalCwd = canonicalPath(cwd);
|
|
41
|
+
const inside = scope.roots.some((root) => {
|
|
42
|
+
const suffix = relative(canonicalKey(fileURLToPath(root.uri)), canonicalKey(canonicalCwd));
|
|
43
|
+
return (suffix === "" ||
|
|
44
|
+
(suffix !== ".." &&
|
|
45
|
+
!suffix.startsWith("../") &&
|
|
46
|
+
!suffix.startsWith("..\\") &&
|
|
47
|
+
!isAbsolute(suffix)));
|
|
48
|
+
});
|
|
49
|
+
if (!inside)
|
|
50
|
+
throw new Error("MCP cwd is outside this run's workspace roots; reconnect the run after switching workspace.");
|
|
51
|
+
return {
|
|
52
|
+
...scope,
|
|
53
|
+
cwd: canonicalCwd,
|
|
54
|
+
key: JSON.stringify([canonicalCwd, scope.roots.map((root) => root.uri)]),
|
|
55
|
+
};
|
|
56
|
+
}
|