@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.
Files changed (89) hide show
  1. package/dist/automation/scheduler.d.ts +3 -0
  2. package/dist/automation/scheduler.js +21 -0
  3. package/dist/context/manager.d.ts +8 -2
  4. package/dist/context/manager.js +20 -4
  5. package/dist/context/notes.d.ts +39 -0
  6. package/dist/context/notes.js +314 -0
  7. package/dist/credentials/access.d.ts +9 -1
  8. package/dist/credentials/access.js +15 -4
  9. package/dist/credentials/store.d.ts +11 -0
  10. package/dist/credentials/store.js +44 -9
  11. package/dist/credentials/types.d.ts +4 -0
  12. package/dist/credentials/types.js +4 -0
  13. package/dist/credentials/use-credential-tool.js +12 -2
  14. package/dist/engine/engine-workspace-authority.js +10 -3
  15. package/dist/engine/engine.d.ts +8 -4
  16. package/dist/engine/engine.js +115 -15
  17. package/dist/engine/prompt-cache-diagnostics.js +10 -1
  18. package/dist/engine/run-goal.js +7 -3
  19. package/dist/engine/run-tooling.d.ts +3 -0
  20. package/dist/engine/run-tooling.js +25 -23
  21. package/dist/engine/run-types.d.ts +20 -0
  22. package/dist/engine/run-workspace.js +5 -21
  23. package/dist/engine/subagent-spawner.d.ts +3 -0
  24. package/dist/engine/subagent-spawner.js +48 -17
  25. package/dist/engine/turn-loop.d.ts +14 -4
  26. package/dist/engine/turn-loop.js +120 -28
  27. package/dist/engine/types.d.ts +19 -0
  28. package/dist/index.d.ts +4 -4
  29. package/dist/index.js +2 -2
  30. package/dist/links/index.d.ts +1 -0
  31. package/dist/links/index.js +1 -0
  32. package/dist/links/link-action-tool.d.ts +2 -1
  33. package/dist/links/link-action-tool.js +80 -44
  34. package/dist/links/status.d.ts +53 -0
  35. package/dist/links/status.js +175 -0
  36. package/dist/llm/prompt-cache.d.ts +35 -3
  37. package/dist/llm/prompt-cache.js +63 -3
  38. package/dist/llm/providers/openai.d.ts +3 -0
  39. package/dist/llm/providers/openai.js +57 -14
  40. package/dist/prompt/section-loader.js +1 -0
  41. package/dist/prompt/sections/browser.md +4 -2
  42. package/dist/prompt/sections/context-notes.md +9 -0
  43. package/dist/protocol/background-result-wakeup.d.ts +8 -1
  44. package/dist/protocol/background-result-wakeup.js +76 -38
  45. package/dist/protocol/chat-session-manager.d.ts +7 -1
  46. package/dist/protocol/chat-session-manager.js +44 -8
  47. package/dist/protocol/chat-session.d.ts +2 -0
  48. package/dist/protocol/chat-session.js +4 -1
  49. package/dist/protocol/server.d.ts +4 -0
  50. package/dist/protocol/server.js +355 -62
  51. package/dist/protocol/session-message-result.d.ts +12 -0
  52. package/dist/protocol/session-message-result.js +42 -0
  53. package/dist/protocol/session-message-workspace.d.ts +21 -0
  54. package/dist/protocol/session-message-workspace.js +57 -0
  55. package/dist/protocol/types.d.ts +4 -0
  56. package/dist/session/session-manager.js +8 -7
  57. package/dist/session/session-message.d.ts +17 -2
  58. package/dist/session/transcript.d.ts +17 -0
  59. package/dist/session/transcript.js +271 -16
  60. package/dist/settings/schema.d.ts +9 -0
  61. package/dist/settings/schema.js +4 -0
  62. package/dist/themes/paths.js +20 -1
  63. package/dist/tool-system/browser-bridge.d.ts +21 -1
  64. package/dist/tool-system/browser-discovery.d.ts +6 -0
  65. package/dist/tool-system/browser-discovery.js +17 -0
  66. package/dist/tool-system/builtin/agent.js +23 -9
  67. package/dist/tool-system/builtin/browser-tools.js +30 -9
  68. package/dist/tool-system/builtin/context-notes.d.ts +12 -0
  69. package/dist/tool-system/builtin/context-notes.js +188 -0
  70. package/dist/tool-system/builtin/index.js +50 -3
  71. package/dist/tool-system/builtin/mcp-tools.d.ts +5 -3
  72. package/dist/tool-system/builtin/mcp-tools.js +10 -10
  73. package/dist/tool-system/builtin/send-message-to-session.js +19 -3
  74. package/dist/tool-system/builtin/tool-search.js +15 -3
  75. package/dist/tool-system/context.d.ts +9 -0
  76. package/dist/tool-system/executor.js +5 -3
  77. package/dist/tool-system/mcp-compat.d.ts +3 -0
  78. package/dist/tool-system/mcp-compat.js +51 -0
  79. package/dist/tool-system/mcp-manager.d.ts +27 -26
  80. package/dist/tool-system/mcp-manager.js +273 -111
  81. package/dist/tool-system/mcp-workspace.d.ts +18 -0
  82. package/dist/tool-system/mcp-workspace.js +56 -0
  83. package/dist/tool-system/permission.d.ts +6 -0
  84. package/dist/tool-system/permission.js +45 -9
  85. package/dist/tool-system/plan-mode-allowlist.js +5 -0
  86. package/dist/tool-system/sandbox/seatbelt.js +71 -2
  87. package/dist/tool-system/session-tool-host.js +9 -1
  88. package/dist/types.d.ts +4 -2
  89. 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.inputSchema ?? {
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
- const results = await Promise.allSettled(entries.map(([name, config]) => this.connect(name, config)));
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
- * Coalesces concurrent calls for the same `name`: an already-connected server
487
- * returns immediately, and a connect already in flight for this name is
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
- if (this.connections.has(name)) {
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(name);
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(name);
545
+ const p = this.performConnect(name, config, workspace).finally(() => {
546
+ this.connecting.delete(key);
503
547
  });
504
- this.connecting.set(name, p);
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
- if (this.connections.has(name)) {
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 = new Client({ name: "code-shell", version: "0.1.0" }, { capabilities: {} });
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
- // MCPManager pool has no per-session cwd, and MCP-referenced credentials
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.connections.set(name, { client, serverName: name, transport });
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.disconnect(name);
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
- // Register with an executor that calls the MCP server
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.disconnect(serverName);
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
- await Promise.all([...this.connections.keys()].map((name) => this.disconnect(name)));
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
- const conn = this.connections.get(name);
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.delete(name);
685
- for (const toolName of this.registeredToolsByServer.get(name) ?? []) {
686
- this.toolRegistry.unregisterTool(toolName);
687
- logger.info("mcp.tool_unregistered", { server: name, tool: toolName });
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.keys()];
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.connections.get(serverName);
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({ name: toolName, arguments: stripInternalToolArgs(args) }, undefined, signal ? { signal } : undefined);
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
- return wrapMcpOutput(serverName, toolName, body);
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] : [...this.connections.keys()];
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.connections.get(serverName);
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
+ }