@code-yeongyu/senpi 2026.10.10-4 → 2026.10.10-6

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 (130) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/bundle/chunks/{anthropic-messages-OE3QBX4P.js → anthropic-messages-DUULMQBF.js} +1 -1
  3. package/dist/bundle/chunks/{app-server-command-N3L2LNP7.js → app-server-command-GJ5NXQSY.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-3A3C5VVO.js → chunk-3OGEJMZ7.js} +1 -1
  5. package/dist/bundle/chunks/{chunk-J5MUMVWB.js → chunk-64ZVBCTL.js} +8 -7
  6. package/dist/bundle/chunks/{chunk-MP2DMTI7.js → chunk-C7LEZMWS.js} +124 -124
  7. package/dist/bundle/chunks/{chunk-ZSYRWQWG.js → chunk-JMI5OCIJ.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-ODYQK5XJ.js → chunk-TALS4X7S.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-7GVD54RM.js → chunk-U3IL5N76.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-H67EVDWG.js → chunk-URQSNRHZ.js} +1 -1
  11. package/dist/bundle/chunks/{chunk-H4UIUFU5.js → chunk-VGYIPYOK.js} +1 -1
  12. package/dist/bundle/chunks/{chunk-M2IHGL3M.js → chunk-VW74BGIC.js} +1 -1
  13. package/dist/bundle/chunks/{chunk-4TK7VCZZ.js → chunk-WKMUAG23.js} +1 -1
  14. package/dist/bundle/chunks/chunk-YG5UO3E4.js +7 -0
  15. package/dist/bundle/chunks/chunk-YV2XQFXG.js +14 -0
  16. package/dist/bundle/chunks/{chunk-Z7MMKKIY.js → chunk-ZYHIRFGT.js} +1 -1
  17. package/dist/bundle/chunks/{cli-main-CHDR33FZ.js → cli-main-SZ54SQLA.js} +1 -1
  18. package/dist/bundle/chunks/{help-fast-path-EHRZEBRO.js → help-fast-path-W5W3FYYM.js} +1 -1
  19. package/dist/bundle/chunks/{host-command-VATE7HRG.js → host-command-YG2FIDPN.js} +1 -1
  20. package/dist/bundle/chunks/{host-lifecycle-GC3GIR2N.js → host-lifecycle-5AV4JXDQ.js} +1 -1
  21. package/dist/bundle/chunks/{interactive-mode-RVEM62WX.js → interactive-mode-SCNDQ4R6.js} +1 -1
  22. package/dist/bundle/chunks/{list-tips-IIRFXBTO.js → list-tips-BXAA4FG5.js} +1 -1
  23. package/dist/bundle/chunks/{multi-session-host-CFND7CXH.js → multi-session-host-ZDZMPNP5.js} +7 -6
  24. package/dist/bundle/chunks/{package-manager-cli-NWNECBF7.js → package-manager-cli-4LMYHHKH.js} +1 -1
  25. package/dist/bundle/chunks/{rotation-stream-C5BM7TMM.js → rotation-stream-E3XKSST7.js} +1 -1
  26. package/dist/bundle/chunks/{rpc-mode-JQFUDLNB.js → rpc-mode-ZFNFREN5.js} +1 -1
  27. package/dist/bundle/chunks/{schedule-command-EQ4PQCRP.js → schedule-command-K6CTAB4T.js} +1 -1
  28. package/dist/bundle/chunks/{session-control-endpoint-HMB5T5VD.js → session-control-endpoint-MVSYBGM3.js} +1 -1
  29. package/dist/bundle/chunks/{session-picker-O7OESJWU.js → session-picker-TNXWY5QY.js} +1 -1
  30. package/dist/bundle/chunks/session-worker.js +224 -222
  31. package/dist/bundle/cli.js +1 -1
  32. package/dist/bundle/index.js +1 -1
  33. package/dist/bundle/rpc-entry.js +1 -1
  34. package/dist/bundle/runtime-manifest.json +1 -1
  35. package/dist/core/agent-session-runtime.d.ts +6 -0
  36. package/dist/core/agent-session-runtime.js +18 -0
  37. package/dist/core/agent-session.js +5 -2
  38. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +3 -2
  39. package/dist/core/extensions/builtin/anthropic-subscription/options.js +11 -2
  40. package/dist/core/extensions/builtin/anthropic-subscription/session-reattach.js +4 -1
  41. package/dist/core/extensions/builtin/anthropic-subscription/session-sync.js +3 -0
  42. package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +5 -0
  43. package/dist/core/extensions/builtin/anthropic-subscription/settings.js +11 -0
  44. package/dist/core/extensions/builtin/anthropic-subscription/stream.d.ts +1 -0
  45. package/dist/core/extensions/builtin/anthropic-subscription/stream.js +17 -1
  46. package/dist/core/extensions/builtin/compaction/lane-policy.d.ts +20 -12
  47. package/dist/core/extensions/builtin/compaction/lane-policy.js +39 -34
  48. package/dist/core/extensions/builtin/html-render/bootstrap.d.ts +15 -0
  49. package/dist/core/extensions/builtin/html-render/bootstrap.js +123 -0
  50. package/dist/core/extensions/builtin/html-render/images.d.ts +16 -0
  51. package/dist/core/extensions/builtin/html-render/images.js +155 -0
  52. package/dist/core/extensions/builtin/html-render/index.d.ts +3 -0
  53. package/dist/core/extensions/builtin/html-render/index.js +5 -0
  54. package/dist/core/extensions/builtin/html-render/tool.d.ts +19 -0
  55. package/dist/core/extensions/builtin/html-render/tool.js +74 -0
  56. package/dist/core/extensions/builtin/index.js +4 -0
  57. package/dist/core/extensions/builtin/mcp/auth/catalog-identity.d.ts +4 -0
  58. package/dist/core/extensions/builtin/mcp/auth/catalog-identity.js +41 -0
  59. package/dist/core/extensions/builtin/mcp/auth/legacy-lock.d.ts +3 -0
  60. package/dist/core/extensions/builtin/mcp/auth/legacy-lock.js +39 -0
  61. package/dist/core/extensions/builtin/mcp/auth/legacy-migration.d.ts +14 -0
  62. package/dist/core/extensions/builtin/mcp/auth/legacy-migration.js +91 -0
  63. package/dist/core/extensions/builtin/mcp/auth/oauth-refresh.js +1 -1
  64. package/dist/core/extensions/builtin/mcp/auth/token-store.d.ts +1 -0
  65. package/dist/core/extensions/builtin/mcp/auth/token-store.js +19 -90
  66. package/dist/core/extensions/builtin/mcp/catalog-cache.d.ts +7 -3
  67. package/dist/core/extensions/builtin/mcp/catalog-cache.js +61 -12
  68. package/dist/core/extensions/builtin/mcp/catalog.d.ts +3 -1
  69. package/dist/core/extensions/builtin/mcp/catalog.js +2 -0
  70. package/dist/core/extensions/builtin/mcp/connection-types.d.ts +2 -0
  71. package/dist/core/extensions/builtin/mcp/expose/call.d.ts +12 -0
  72. package/dist/core/extensions/builtin/mcp/expose/call.js +60 -0
  73. package/dist/core/extensions/builtin/mcp/expose/proxy.js +16 -3
  74. package/dist/core/extensions/builtin/mcp/expose/register.d.ts +3 -1
  75. package/dist/core/extensions/builtin/mcp/expose/register.js +41 -50
  76. package/dist/core/extensions/builtin/mcp/expose/session.d.ts +4 -0
  77. package/dist/core/extensions/builtin/mcp/expose/session.js +5 -1
  78. package/dist/core/extensions/builtin/mcp/expose/tier-b.js +15 -4
  79. package/dist/core/extensions/builtin/mcp/index.js +5 -26
  80. package/dist/core/extensions/builtin/mcp/invocation.d.ts +42 -0
  81. package/dist/core/extensions/builtin/mcp/invocation.js +118 -0
  82. package/dist/core/extensions/builtin/mcp/service-connection.d.ts +3 -0
  83. package/dist/core/extensions/builtin/mcp/service-connection.js +6 -0
  84. package/dist/core/extensions/builtin/mcp/service-register.d.ts +4 -0
  85. package/dist/core/extensions/builtin/mcp/service-register.js +45 -1
  86. package/dist/core/extensions/builtin/mcp/service-tools-changed.js +26 -3
  87. package/dist/core/extensions/builtin/mcp/service-types.d.ts +6 -0
  88. package/dist/core/extensions/builtin/mcp/service.js +83 -49
  89. package/dist/core/extensions/builtin/mcp/shared-connection.js +17 -4
  90. package/dist/core/extensions/builtin/mcp/sharing-policy.js +1 -0
  91. package/dist/core/extensions/builtin/mcp/startup-race.js +47 -9
  92. package/dist/core/extensions/builtin/permission-system/dispatch-metadata.d.ts +8 -0
  93. package/dist/core/extensions/builtin/permission-system/dispatch-metadata.js +23 -0
  94. package/dist/core/extensions/builtin/permission-system/dispatch-policy.d.ts +14 -0
  95. package/dist/core/extensions/builtin/permission-system/dispatch-policy.js +53 -0
  96. package/dist/core/extensions/builtin/permission-system/dispatch.d.ts +33 -0
  97. package/dist/core/extensions/builtin/permission-system/dispatch.js +106 -0
  98. package/dist/core/extensions/builtin/permission-system/index.js +64 -17
  99. package/dist/core/extensions/builtin/permission-system/prompt.d.ts +1 -1
  100. package/dist/core/extensions/builtin/permission-system/prompt.js +3 -1
  101. package/dist/core/extensions/builtin/permission-system/service.d.ts +11 -1
  102. package/dist/core/extensions/builtin/permission-system/service.js +14 -4
  103. package/dist/core/extensions/builtin/ttsr/detectors/collapse-near-duplicates.d.ts +1 -0
  104. package/dist/core/extensions/builtin/ttsr/detectors/collapse-near-duplicates.js +24 -5
  105. package/dist/index.d.ts +1 -1
  106. package/dist/index.js +1 -1
  107. package/dist/modes/interactive/interactive-mode.js +21 -4
  108. package/dist/modes/rpc/host-reservations.d.ts +1 -1
  109. package/dist/modes/rpc/host-reservations.js +9 -3
  110. package/dist/modes/rpc/rpc-client.js +14 -2
  111. package/dist/modes/rpc/rpc-request-deadline.d.ts +8 -0
  112. package/dist/modes/rpc/rpc-request-deadline.js +10 -0
  113. package/dist/modes/rpc/rpc-types.d.ts +7 -4
  114. package/dist/modes/rpc/session-binding.js +15 -1
  115. package/dist/modes/rpc/session-command-router.d.ts +1 -0
  116. package/dist/modes/rpc/session-command-router.js +16 -0
  117. package/dist/modes/rpc/session-registry-attach.js +8 -2
  118. package/dist/modes/rpc/session-registry.js +5 -7
  119. package/dist/modes/rpc/session-teardown.d.ts +6 -1
  120. package/dist/modes/rpc/session-teardown.js +26 -7
  121. package/dist/modes/rpc/session-worker-client.d.ts +2 -0
  122. package/dist/modes/rpc/session-worker-client.js +6 -0
  123. package/dist/modes/rpc/session-worker-protocol.d.ts +4 -0
  124. package/dist/modes/rpc/session-worker.js +6 -0
  125. package/dist/modes/rpc/worker-session-registry.js +6 -1
  126. package/docs/providers.md +3 -2
  127. package/docs/rpc.md +26 -10
  128. package/package.json +6 -6
  129. package/dist/bundle/chunks/chunk-RADHRQ4K.js +0 -7
  130. package/dist/bundle/chunks/chunk-USRAHAW4.js +0 -14
@@ -1,9 +1,9 @@
1
1
  import { getAgentDir } from "../../../../config.js";
2
2
  import { getToolSearchService, getToolSearchServiceForExtension, resetToolSearchServiceForTests, ToolSearchService, } from "../tool-search/service.js";
3
+ import { mcpCredentialIdentity } from "./auth/catalog-identity.js";
3
4
  import { resolveAuthMode } from "./auth/context.js";
4
- import { getValidCachedServer, readMcpCatalogCache } from "./catalog-cache.js";
5
+ import { cachedCatalogNeedsRefresh, getValidCachedServer, readMcpCatalogCache } from "./catalog-cache.js";
5
6
  import { loadMcpConfig, mergeExtensionMcpServers, visitSpawnableMcpServers } from "./config.js";
6
- import { collectAllPages } from "./expose/pagination.js";
7
7
  import { cleanupMcpOutputArtifacts, McpOutputArtifacts } from "./guard/output-guard.js";
8
8
  import { HostMcpRegistry } from "./host-registry.js";
9
9
  import { refreshMcpInstructionsForSession } from "./instructions.js";
@@ -15,7 +15,7 @@ import { registerMcpServiceDirectTools } from "./service-register.js";
15
15
  import { buildMcpServerSnapshot } from "./service-snapshot.js";
16
16
  import { refreshMcpToolsOnListChanged, subscribeMcpToolsChanged } from "./service-tools-changed.js";
17
17
  import { resolveSkillMcpServer } from "./skill-server.js";
18
- import { MCP_ATTACH_SETTLE_TIMEOUT_MS, McpDeferredAttach, raceMcpStartupConnect, resolveMcpStartupTimeoutMs, shouldRaceMcpStartup, } from "./startup-race.js";
18
+ import { MCP_ATTACH_SETTLE_TIMEOUT_MS, McpDeferredAttach, raceMcpStartupConnect, shouldRaceMcpStartup, } from "./startup-race.js";
19
19
  import { safeTimer } from "./wrap.js";
20
20
  export { registerToolsPreservingActiveSet } from "./active-set.js";
21
21
  export class McpService {
@@ -86,7 +86,8 @@ export class McpService {
86
86
  projectTrusted: sessionOptions.projectTrusted ?? ctx.isProjectTrusted(),
87
87
  });
88
88
  mergeExtensionMcpServers(config, ctx.getRegisteredMcpServers?.() ?? []);
89
- this.#config = config;
89
+ // Equivalent peer attachment must not retire another session's configuration fence.
90
+ this.#config = JSON.stringify(this.#config) === JSON.stringify(config) ? this.#config : config;
90
91
  const binding = _pi === undefined || this.#releasedSessions.has(_pi) ? undefined : this.#bind(_pi, ctx);
91
92
  this.#authAgentDir = sessionOptions.agentDir;
92
93
  this.#authEnv = sessionOptions.env;
@@ -373,7 +374,8 @@ export class McpService {
373
374
  }
374
375
  getConnection(name) {
375
376
  const key = this.#connectionKeysByName.get(name);
376
- return key === undefined ? undefined : this.#connections.get(key)?.connection;
377
+ const entry = key === undefined ? undefined : this.#connections.get(key);
378
+ return entry?.credentialsCurrent?.() === false ? undefined : entry?.connection;
377
379
  }
378
380
  async reconnectServer(name) {
379
381
  const entry = this.#entryForName(name);
@@ -431,13 +433,19 @@ export class McpService {
431
433
  const hadConnectionsBeforeSync = this.#connections.size > 0;
432
434
  this.#refreshActiveSetWhenNoTools = Object.keys(config.servers).length > 0 || hadConnectionsBeforeSync;
433
435
  const wanted = new Map();
436
+ const credentialIdentities = new Map();
434
437
  visitSpawnableMcpServers(config, (name, server) => {
435
438
  wanted.set(name, server);
439
+ if (server.config !== undefined) {
440
+ credentialIdentities.set(name, mcpCredentialIdentity(server.config, name, options.agentDir, options.env));
441
+ }
436
442
  });
437
443
  const disposals = [];
438
444
  for (const entry of this.#connections.values()) {
439
445
  const server = wanted.get(entry.name);
440
- const key = server?.configHash === undefined ? undefined : `${entry.name}\0${server.configHash}`;
446
+ const key = server?.configHash === undefined
447
+ ? undefined
448
+ : `${entry.name}\0${server.configHash}\0${credentialIdentities.get(entry.name) ?? "unknown"}`;
441
449
  if (key === entry.key)
442
450
  continue;
443
451
  this.#connections.delete(entry.key);
@@ -449,7 +457,9 @@ export class McpService {
449
457
  for (const [name, server] of wanted) {
450
458
  if (server.config === undefined || server.configHash === undefined)
451
459
  continue;
452
- const key = `${name}\0${server.configHash}`;
460
+ const serverConfig = server.config;
461
+ const credentialIdentity = credentialIdentities.get(name);
462
+ const key = `${name}\0${server.configHash}\0${credentialIdentity ?? "unknown"}`;
453
463
  if (this.#connections.has(key))
454
464
  continue;
455
465
  const entry = createMcpSessionConnection({
@@ -460,13 +470,27 @@ export class McpService {
460
470
  name,
461
471
  configHash: server.configHash,
462
472
  config: server.config,
473
+ credentialIdentity,
474
+ credentialsCurrent: () => mcpCredentialIdentity(serverConfig, name, options.agentDir, options.env) === credentialIdentity,
475
+ onCredentialsChanged: async () => {
476
+ if (this.#disposed || this.#config !== config || this.#entryForName(name) !== entry)
477
+ return;
478
+ await this.#syncFromConfig(config, options, true, binding);
479
+ for (const live of this.#liveBindings())
480
+ await this.#registerDirectTools(live);
481
+ },
463
482
  session: options,
464
483
  cwd: this.#sessionContext?.cwd ?? process.cwd(),
465
484
  ui: () => this.getMcpElicitationUi(),
466
485
  artifacts: this.#outputArtifacts,
467
- shouldReconnect: (current) => !this.#disposed && this.#entryForName(name) === current,
486
+ shouldReconnect: (current) => !this.#disposed &&
487
+ this.#entryForName(name) === current &&
488
+ this.#config?.servers[name]?.state === "enabled" &&
489
+ this.#config.servers[name]?.configHash === current.configHash,
468
490
  });
469
- const cachedCatalog = useCache ? getValidCachedServer(cache, name, server.configHash) : undefined;
491
+ const cachedCatalog = useCache && credentialIdentity !== undefined
492
+ ? getValidCachedServer(cache, name, server.configHash, credentialIdentity)
493
+ : undefined;
470
494
  entry.cachedCatalog = cachedCatalog;
471
495
  this.#connections.set(key, entry);
472
496
  this.#connectionKeysByName.set(name, key);
@@ -475,9 +499,11 @@ export class McpService {
475
499
  // the background past the deadline (eager/keep-alive always; a cold lazy
476
500
  // server that has no cached catalog also races so a slow/wedged server
477
501
  // never gates attachSession -> before_agent_start -> the first turn).
478
- // A cached lazy server needs no startup connect: its tools come from the
479
- // cache and it connects on demand at tool-call time.
480
- if (shouldRaceMcpStartup(server.config.lifecycle) || cachedCatalog === undefined) {
502
+ // A fresh cached lazy server connects only on demand. An aged matching
503
+ // catalog remains usable while its background refresh runs.
504
+ if (shouldRaceMcpStartup(server.config.lifecycle) ||
505
+ cachedCatalog === undefined ||
506
+ cachedCatalogNeedsRefresh(cachedCatalog)) {
481
507
  connects.push(raceMcpStartupConnect({
482
508
  entry,
483
509
  pi: binding?.pi,
@@ -510,7 +536,7 @@ export class McpService {
510
536
  // A later attach does not supersede this catalog: it registers in every live session. Skip it only
511
537
  // once the service is gone or this connection was replaced (#2524 review).
512
538
  shouldRefreshTools: () => !this.#disposed && this.#entryForName(name) === entry,
513
- deadlineMs: resolveMcpStartupTimeoutMs(server.config.startupTimeoutMs),
539
+ deadlineMs: 0,
514
540
  onDeferred: (settled) => this.#deferredAttach.track(settled),
515
541
  }));
516
542
  }
@@ -553,6 +579,16 @@ export class McpService {
553
579
  binding.registration = await registerMcpServiceDirectTools(binding.pi, config, this.#connections.values(), toolSearchService, {
554
580
  refreshActiveSetWhenEmpty: this.#refreshActiveSetWhenNoTools,
555
581
  onRegistered: (entry, identity) => binding.registeredIdentities.set(entry.key, identity),
582
+ contextRequired: binding.context.mode !== undefined,
583
+ sessionManager: binding.context.sessionManager,
584
+ isCurrent: (entry) => !this.#disposed &&
585
+ this.#config === config &&
586
+ this.#bindings.get(binding.pi) === binding &&
587
+ this.#entryForName(entry.name) === entry,
588
+ publishCurrent: async () => {
589
+ if (this.#bindings.get(binding.pi) === binding)
590
+ await this.#registerDirectTools(binding);
591
+ },
556
592
  });
557
593
  const ctx = binding.context;
558
594
  if (ctx.mode === "rpc") {
@@ -571,9 +607,17 @@ export class McpService {
571
607
  return ((binding === undefined ? undefined : this.#toolSearchFor(binding)?.maybeRehydrateFromHistory(messages)) ?? []);
572
608
  }
573
609
  #serverSnapshot(name) {
610
+ const entry = this.#entryForName(name);
611
+ const config = this.#config?.servers[name]?.config;
574
612
  const connection = this.getConnection(name);
575
613
  connection?.refreshCapturedDiagnostics();
576
- return buildMcpServerSnapshot(name, this.#config?.servers[name], connection, this.#entryForName(name));
614
+ const snapshot = buildMcpServerSnapshot(name, this.#config?.servers[name], connection, entry);
615
+ if (config !== undefined &&
616
+ entry?.connection.state === "needs_auth" &&
617
+ mcpCredentialIdentity(config, name, entry.agentDir, this.#authEnv) === undefined) {
618
+ return { ...snapshot, lifecycleState: "needs_auth", lastError: entry.connection.lastError?.message ?? null };
619
+ }
620
+ return snapshot;
577
621
  }
578
622
  async #captureWireStatus(sessionId) {
579
623
  const config = this.#config;
@@ -594,42 +638,19 @@ export class McpService {
594
638
  }
595
639
  async #captureWireStatusServer(name, server) {
596
640
  const entry = this.#entryForName(name);
597
- const connection = entry?.connection;
641
+ const connection = this.getConnection(name);
598
642
  const connected = connection?.state === "connected";
599
- const cached = entry?.cachedCatalog;
600
- let tools = cached?.tools ?? [];
601
- let resources = cached?.resources ?? [];
602
- let resourceTemplates = [];
643
+ const needsAuth = !connected && this.#serverSnapshot(name).lifecycleState === "needs_auth";
644
+ const cached = entry?.credentialsCurrent?.() === false ? undefined : entry?.cachedCatalog;
645
+ const tools = cached?.tools ?? [];
646
+ const resources = cached?.resources ?? [];
647
+ const resourceTemplates = cached?.resourceTemplates ?? [];
603
648
  let serverInfo = null;
604
649
  if (connected && connection !== undefined) {
605
650
  const client = connection.client;
606
651
  const version = client.getServerVersion();
607
652
  if (version !== undefined)
608
653
  serverInfo = mapWireServerInfo(version);
609
- try {
610
- tools = (await collectAllPages((cursor) => client.listTools(cursor === undefined ? {} : { cursor }))).items;
611
- }
612
- catch (error) {
613
- if (!(error instanceof Error))
614
- throw error;
615
- tools = cached?.tools ?? [];
616
- }
617
- try {
618
- resources = (await collectAllPages((cursor) => client.listResources(cursor === undefined ? {} : { cursor }))).items;
619
- }
620
- catch (error) {
621
- if (!(error instanceof Error))
622
- throw error;
623
- resources = cached?.resources ?? [];
624
- }
625
- try {
626
- resourceTemplates = (await collectAllPages((cursor) => client.listResourceTemplates(cursor === undefined ? {} : { cursor }))).items;
627
- }
628
- catch (error) {
629
- if (!(error instanceof Error))
630
- throw error;
631
- resourceTemplates = [];
632
- }
633
654
  }
634
655
  return {
635
656
  name,
@@ -637,10 +658,16 @@ export class McpService {
637
658
  tools: tools.map(mapWireTool),
638
659
  resources: resources.map(mapWireResource),
639
660
  resourceTemplates: resourceTemplates.map(mapWireResourceTemplate),
640
- authStatus: wireAuthStatus(entry, server),
661
+ authStatus: wireAuthStatus(entry, server, this.#authEnv),
641
662
  ...(connection?.state === undefined && server?.state === undefined
642
663
  ? {}
643
- : { status: connection?.state ?? server?.state }),
664
+ : {
665
+ status: needsAuth
666
+ ? "needs_auth"
667
+ : entry?.startupCatalogClaim?.ownsRegistration()
668
+ ? "connecting"
669
+ : (connection?.state ?? server?.state),
670
+ }),
644
671
  };
645
672
  }
646
673
  #entryForName(name) {
@@ -651,6 +678,9 @@ export class McpService {
651
678
  this.#mcpInstructions = instructions;
652
679
  }
653
680
  getMcpInstructions() {
681
+ if ([...this.#connections.values()].some((entry) => entry.credentialsCurrent?.() === false)) {
682
+ refreshMcpInstructionsForSession(this);
683
+ }
654
684
  return this.#mcpInstructions;
655
685
  }
656
686
  setMcpElicitationUiProvider(provider) {
@@ -690,10 +720,11 @@ export class McpService {
690
720
  }
691
721
  /** Live auth status without fetching catalogs or exposing credentials. */
692
722
  getServerAuthStatus(name) {
693
- return wireAuthStatus(this.#entryForName(name), this.#config?.servers[name]);
723
+ return wireAuthStatus(this.#entryForName(name), this.#config?.servers[name], this.#authEnv);
694
724
  }
695
725
  getCachedInstructions(name) {
696
- return this.#entryForName(name)?.cachedCatalog?.instructions;
726
+ const entry = this.#entryForName(name);
727
+ return entry?.credentialsCurrent?.() === false ? undefined : entry?.cachedCatalog?.instructions;
697
728
  }
698
729
  /** Resolved `settings.nativeToolSearch` (auto | true | false | undefined).
699
730
  * Drives the native provider tool-search adapter gate. */
@@ -701,7 +732,7 @@ export class McpService {
701
732
  return this.#config?.settings.nativeToolSearch;
702
733
  }
703
734
  }
704
- function wireAuthStatus(entry, server) {
735
+ function wireAuthStatus(entry, server, env) {
705
736
  const mode = entry?.authPlan?.mode ?? (server?.config === undefined ? "none" : resolveAuthMode(server.config));
706
737
  switch (mode) {
707
738
  case "none":
@@ -709,7 +740,10 @@ function wireAuthStatus(entry, server) {
709
740
  case "bearer":
710
741
  return entry?.connection.state === "needs_auth" ? "notLoggedIn" : "bearerToken";
711
742
  case "oauth":
712
- return entry?.authPlan?.provider?.tokens() === undefined ? "notLoggedIn" : "oAuth";
743
+ return server?.config === undefined ||
744
+ mcpCredentialIdentity(server.config, server.name, entry?.agentDir, env) === undefined
745
+ ? "notLoggedIn"
746
+ : "oAuth";
713
747
  default:
714
748
  return assertNever(mode);
715
749
  }
@@ -149,14 +149,27 @@ export class SharedMcpConnection {
149
149
  }
150
150
  async #collectCatalog() {
151
151
  const generation = this.connection.generation;
152
- const catalog = await collectServerCatalogForCache(this.connection, this.#options.config, this.#options.configHash);
153
- if (generation !== this.connection.generation || this.#disposed)
152
+ const ownsCatalog = () => generation === this.connection.generation &&
153
+ !this.#disposed &&
154
+ [...this.leases].some((lease) => lease.options.credentialsCurrent?.() !== false);
155
+ const catalog = await collectServerCatalogForCache(this.connection, this.#options.config, this.#options.configHash, this.#options.credentialIdentity);
156
+ if (!ownsCatalog())
154
157
  return catalog;
155
158
  if (this.#cacheGeneration !== generation) {
156
159
  this.#cacheGeneration = generation;
157
- await writeMcpCachedServer(this.#options.agentDir, this.#options.serverName, catalog);
160
+ try {
161
+ await writeMcpCachedServer(this.#options.agentDir, this.#options.serverName, catalog, ownsCatalog);
162
+ }
163
+ catch (error) {
164
+ for (const lease of this.leases) {
165
+ lease.options.logger.warn("Failed to persist MCP catalog cache", {
166
+ error: error instanceof Error ? error.message : String(error),
167
+ });
168
+ }
169
+ }
158
170
  }
159
- await ensureMcpResourceSubscriptions(this.connection.client, catalog.resources);
171
+ if (ownsCatalog())
172
+ await ensureMcpResourceSubscriptions(this.connection.client, catalog.resources);
160
173
  return catalog;
161
174
  }
162
175
  async dispose() {
@@ -21,6 +21,7 @@ export function sharedMcpKey(options, agentDir) {
21
21
  const { config, env } = options;
22
22
  const bearer = config.bearerTokenEnv;
23
23
  const identity = {
24
+ credentialIdentity: options.credentialIdentity,
24
25
  agentDir,
25
26
  name: options.serverName,
26
27
  type: config.type,
@@ -64,6 +64,7 @@ export async function raceMcpStartupConnect(options) {
64
64
  const claim = {
65
65
  cachedCatalog: entry.cachedCatalog,
66
66
  ownsRegistration: () => pi !== undefined && options.shouldRefreshTools(),
67
+ settled: () => connect,
67
68
  };
68
69
  entry.startupCatalogClaim = claim;
69
70
  const releaseClaim = () => {
@@ -76,8 +77,22 @@ export async function raceMcpStartupConnect(options) {
76
77
  return;
77
78
  options.onDeferred(connect.then(() => refreshMcpToolsAfterStartupRace(options)));
78
79
  }
79
- export async function connectAndRefreshMcpCatalog(entry, serverConfig) {
80
- if (serverConfig === undefined)
80
+ const catalogRefreshes = new WeakMap();
81
+ export function connectAndRefreshMcpCatalog(entry, serverConfig) {
82
+ const pending = catalogRefreshes.get(entry);
83
+ if (pending !== undefined)
84
+ return pending;
85
+ const refresh = refreshConnectedMcpCatalog(entry, serverConfig);
86
+ catalogRefreshes.set(entry, refresh);
87
+ const finish = () => {
88
+ if (catalogRefreshes.get(entry) === refresh)
89
+ catalogRefreshes.delete(entry);
90
+ };
91
+ void refresh.then(finish, finish);
92
+ return refresh;
93
+ }
94
+ async function refreshConnectedMcpCatalog(entry, serverConfig) {
95
+ if (serverConfig === undefined || entry.isCurrent?.() === false)
81
96
  return;
82
97
  try {
83
98
  await entry.authPlan?.refresh?.ensureFresh();
@@ -90,24 +105,47 @@ export async function connectAndRefreshMcpCatalog(entry, serverConfig) {
90
105
  }
91
106
  throw error;
92
107
  }
108
+ if (entry.credentialsCurrent?.() === false) {
109
+ await entry.onCredentialsChanged?.();
110
+ return;
111
+ }
93
112
  await connectMcpServer(entry.connection, entry.logger);
94
- if (entry.connection.state !== "connected")
113
+ if (entry.connection.state !== "connected" || entry.isCurrent?.() === false)
95
114
  return;
115
+ const generation = entry.connection.generation;
116
+ const ownsCatalog = () => entry.isCurrent?.() !== false &&
117
+ entry.credentialsCurrent?.() !== false &&
118
+ entry.connection.state === "connected" &&
119
+ entry.connection.generation === generation;
96
120
  if (entry.connection instanceof SharedMcpLease) {
97
- entry.cachedCatalog = await entry.connection.catalog();
121
+ const catalog = await entry.connection.catalog();
122
+ if (!ownsCatalog()) {
123
+ if (entry.credentialsCurrent?.() === false)
124
+ await entry.onCredentialsChanged?.();
125
+ return;
126
+ }
127
+ entry.cachedCatalog = catalog;
128
+ entry.catalogGeneration = generation;
98
129
  entry.cacheRefreshedAfterConnect = true;
99
130
  return;
100
131
  }
101
- if (entry.cacheRefreshedAfterConnect)
132
+ if (entry.cacheRefreshedAfterConnect && entry.catalogGeneration === generation)
102
133
  return;
103
- entry.cacheRefreshedAfterConnect = true;
104
134
  try {
105
- const catalog = await collectServerCatalogForCache(entry.connection, serverConfig, entry.configHash);
135
+ const catalog = await collectServerCatalogForCache(entry.connection, serverConfig, entry.configHash, entry.credentialIdentity);
136
+ if (!ownsCatalog()) {
137
+ if (entry.credentialsCurrent?.() === false)
138
+ await entry.onCredentialsChanged?.();
139
+ return;
140
+ }
106
141
  entry.cachedCatalog = catalog;
107
- await writeMcpCachedServer(entry.agentDir, entry.name, catalog);
142
+ entry.catalogGeneration = generation;
143
+ entry.cacheRefreshedAfterConnect = true;
144
+ await writeMcpCachedServer(entry.agentDir, entry.name, catalog, ownsCatalog);
108
145
  // Per-resource subscriptions (todo 39): only when the server declares
109
146
  // resources.subscribe; best-effort, failures are non-fatal.
110
- await ensureMcpResourceSubscriptions(entry.connection.client, catalog.resources ?? []);
147
+ if (ownsCatalog())
148
+ await ensureMcpResourceSubscriptions(entry.connection.client, catalog.resources ?? []);
111
149
  }
112
150
  catch (error) {
113
151
  entry.logger.warn("Failed to refresh MCP catalog cache", {
@@ -0,0 +1,8 @@
1
+ import type { ExtensionAPI, ExtensionContext, ToolCallEvent } from "../../types.ts";
2
+ import { type DispatchAuthorizer, type DispatchIdentity } from "./dispatch.ts";
3
+ type IdentityForInput = (input: Record<string, unknown>) => DispatchIdentity | undefined;
4
+ export declare function registerDispatchIdentity(parameters: object, identity: IdentityForInput): void;
5
+ export declare function getDispatchIdentity(parameters: object, input: Record<string, unknown>): DispatchIdentity | undefined;
6
+ export declare function prepareMcpDispatchApproval(pi: Pick<ExtensionAPI, "getAllTools">, event: ToolCallEvent, ctx: ExtensionContext, authorizer: DispatchAuthorizer): (() => void) | undefined;
7
+ export {};
8
+ //# sourceMappingURL=dispatch-metadata.d.ts.map
@@ -0,0 +1,23 @@
1
+ import { prepareDispatchApproval } from "./dispatch.js";
2
+ // ToolInfo retains the registered schema object. Its identity scopes metadata
3
+ // to the offered definition, without adding private fields to provider schemas.
4
+ const identities = new WeakMap();
5
+ export function registerDispatchIdentity(parameters, identity) {
6
+ identities.set(parameters, identity);
7
+ }
8
+ export function getDispatchIdentity(parameters, input) {
9
+ return identities.get(parameters)?.(input);
10
+ }
11
+ export function prepareMcpDispatchApproval(pi, event, ctx, authorizer) {
12
+ // Other tools retain their existing preflight path, including calls while
13
+ // the extension runtime is still starting and cannot list tools (#2617).
14
+ if (!event.toolName.startsWith("mcp_"))
15
+ return undefined;
16
+ const tool = pi.getAllTools().find((candidate) => candidate.name === event.toolName);
17
+ const identity = tool === undefined ? undefined : getDispatchIdentity(tool.parameters, event.input);
18
+ if (identity === undefined)
19
+ return undefined;
20
+ const request = { ...event, identity };
21
+ return prepareDispatchApproval(ctx.sessionManager, request, authorizer.policy(request, ctx));
22
+ }
23
+ //# sourceMappingURL=dispatch-metadata.js.map
@@ -0,0 +1,14 @@
1
+ import type { ExtensionAPI, ExtensionContext, ToolCallEvent, ToolCallEventResult } from "../../types.ts";
2
+ import type { DispatchAuthorizer } from "./dispatch.ts";
3
+ import { type ParserRegistry } from "./parsers.ts";
4
+ import type { PermissionService } from "./service.ts";
5
+ import type { PermissionPresetName } from "./types.ts";
6
+ export interface DispatchPermissionState {
7
+ readonly service: PermissionService | null;
8
+ readonly parsers: ParserRegistry | null;
9
+ readonly preset: PermissionPresetName | null;
10
+ readonly setupError: string | null;
11
+ }
12
+ /** Use the permission extension's live parser, evaluator and approval handler. */
13
+ export declare function createDispatchAuthorizer(pi: Pick<ExtensionAPI, "getAllTools">, stateFor: (ctx: ExtensionContext) => DispatchPermissionState, authorize: (event: ToolCallEvent, ctx: ExtensionContext, signal?: AbortSignal) => Promise<ToolCallEventResult | undefined>): DispatchAuthorizer;
14
+ //# sourceMappingURL=dispatch-policy.d.ts.map
@@ -0,0 +1,53 @@
1
+ import { toolOwnedPermissionRequests } from "./parsers.js";
2
+ /** Use the permission extension's live parser, evaluator and approval handler. */
3
+ export function createDispatchAuthorizer(pi, stateFor, authorize) {
4
+ return {
5
+ policy(request, ctx) {
6
+ const state = stateFor(ctx);
7
+ if (state.setupError !== null)
8
+ throw new Error(`Permission setup failed: ${state.setupError}`);
9
+ const { service, parsers } = state;
10
+ if (!service || !parsers)
11
+ throw new Error("Permission authorizer is not initialized");
12
+ const requests = parsers.has(request.toolName)
13
+ ? parsers.parse(request.toolName, request.input, ctx.cwd)
14
+ : (toolOwnedPermissionRequests(pi.getAllTools(), request.toolName, request.input, ctx.cwd) ??
15
+ parsers.parse(request.toolName, request.input, ctx.cwd));
16
+ let action = "allow";
17
+ const decisions = requests.map((permission) => {
18
+ const rules = permission.patterns.map((pattern) => service.dispatchDecision(permission.permission, permission.ruleAliases ?? pattern, {
19
+ presetBound: state.preset === "auto",
20
+ }));
21
+ for (const decision of rules) {
22
+ if (decision.action === "deny")
23
+ action = "deny";
24
+ else if (decision.action === "ask" && !permission.autoApproveAsk && action !== "deny")
25
+ action = "ask";
26
+ }
27
+ return { permission, rules };
28
+ });
29
+ return {
30
+ action,
31
+ fingerprint: JSON.stringify([
32
+ ctx.sessionManager.getSessionId(),
33
+ ctx.cwd,
34
+ state.preset === "auto",
35
+ decisions,
36
+ ]),
37
+ };
38
+ },
39
+ async ask(request, ctx, signal) {
40
+ signal?.throwIfAborted();
41
+ const result = await authorize({
42
+ type: "tool_call",
43
+ toolCallId: request.toolCallId,
44
+ toolName: request.toolName,
45
+ input: request.input,
46
+ ...(request.parentToolCallId === undefined ? {} : { parentToolCallId: request.parentToolCallId }),
47
+ }, ctx, signal);
48
+ if (result?.block)
49
+ throw new Error(result.reason ?? "Permission request was rejected.");
50
+ },
51
+ };
52
+ }
53
+ //# sourceMappingURL=dispatch-policy.js.map
@@ -0,0 +1,33 @@
1
+ import type { ExtensionContext } from "../../types.ts";
2
+ export interface DispatchIdentity {
3
+ readonly owner: object;
4
+ readonly metadata: string;
5
+ readonly operation?: string;
6
+ }
7
+ export interface DispatchRequest {
8
+ readonly toolCallId: string;
9
+ readonly parentToolCallId?: string;
10
+ readonly toolName: string;
11
+ readonly input: Record<string, unknown>;
12
+ readonly identity: DispatchIdentity;
13
+ }
14
+ export interface DispatchPolicy {
15
+ readonly action: "allow" | "ask" | "deny";
16
+ readonly fingerprint: string;
17
+ }
18
+ export interface DispatchAuthorizer {
19
+ policy(request: DispatchRequest, ctx: ExtensionContext): DispatchPolicy;
20
+ ask(request: DispatchRequest, ctx: ExtensionContext, signal: AbortSignal | undefined): Promise<void>;
21
+ }
22
+ /** Retirement cannot remove the authorizer installed by a newer extension load. */
23
+ export declare function registerDispatchAuthorizer(session: object, authorizer: DispatchAuthorizer): () => void;
24
+ export declare function hasDispatchAuthorizer(session: object): boolean;
25
+ /** Keep the policy and operation that were actually presented, not their post-reply replacements. */
26
+ export declare function prepareDispatchApproval(session: object, request: DispatchRequest, policy: DispatchPolicy): () => void;
27
+ export declare function forgetDispatchApproval(input: object): void;
28
+ /**
29
+ * Resolve live permission after MCP readiness. The returned synchronous fence must
30
+ * pass immediately before dispatch; a false result requires preparation again.
31
+ */
32
+ export declare function authorizeToolDispatch(session: object, request: DispatchRequest, ctx: ExtensionContext, signal: AbortSignal | undefined): Promise<() => boolean>;
33
+ //# sourceMappingURL=dispatch.d.ts.map
@@ -0,0 +1,106 @@
1
+ import { DeniedError } from "./types.js";
2
+ const authorizers = new WeakMap();
3
+ // AgentSession passes this validated object unchanged from preflight to execution.
4
+ // Identical calls, including calls with duplicate provider IDs, get distinct objects.
5
+ const approvals = new WeakMap();
6
+ /** Retirement cannot remove the authorizer installed by a newer extension load. */
7
+ export function registerDispatchAuthorizer(session, authorizer) {
8
+ const previous = authorizers.get(session);
9
+ const registration = { authorizer, retired: new AbortController() };
10
+ authorizers.set(session, registration);
11
+ previous?.retired.abort(new Error("Permission authorizer was retired"));
12
+ return () => {
13
+ // Retain the retired registration so required authority cannot become
14
+ // indistinguishable from a session where enforcement was never loaded.
15
+ registration.retired.abort(new Error("Permission authorizer was retired"));
16
+ };
17
+ }
18
+ export function hasDispatchAuthorizer(session) {
19
+ return authorizers.has(session);
20
+ }
21
+ /** Keep the policy and operation that were actually presented, not their post-reply replacements. */
22
+ export function prepareDispatchApproval(session, request, policy) {
23
+ const registration = authorizers.get(session);
24
+ if (!registration)
25
+ throw new Error("Permission authorizer is unavailable");
26
+ registration.retired.signal.throwIfAborted();
27
+ const approval = {
28
+ registration,
29
+ toolCallId: request.toolCallId,
30
+ toolName: request.toolName,
31
+ input: JSON.stringify(request.input),
32
+ identity: { ...request.identity },
33
+ policy: { ...policy },
34
+ };
35
+ return () => {
36
+ registration.retired.signal.throwIfAborted();
37
+ if (authorizers.get(session) !== registration)
38
+ throw new Error("Permission authorizer was retired");
39
+ approvals.set(request.input, approval);
40
+ };
41
+ }
42
+ export function forgetDispatchApproval(input) {
43
+ approvals.delete(input);
44
+ }
45
+ function matches(approval, registration, request, policy) {
46
+ return (approval?.registration === registration &&
47
+ approval.toolCallId === request.toolCallId &&
48
+ approval.toolName === request.toolName &&
49
+ approval.input === JSON.stringify(request.input) &&
50
+ approval.identity.owner === request.identity.owner &&
51
+ approval.identity.metadata === request.identity.metadata &&
52
+ approval.policy.action === policy.action &&
53
+ approval.policy.fingerprint === policy.fingerprint);
54
+ }
55
+ /** Await only this caller's approval; aborting it does not retire another caller's authority. */
56
+ async function awaitApproval(pending, signal) {
57
+ if (!signal)
58
+ return pending;
59
+ let onAbort = () => { };
60
+ const aborted = new Promise((_resolve, reject) => {
61
+ onAbort = () => reject(signal.reason);
62
+ signal.addEventListener("abort", onAbort, { once: true });
63
+ if (signal.aborted)
64
+ onAbort();
65
+ });
66
+ try {
67
+ await Promise.race([pending, aborted]);
68
+ }
69
+ finally {
70
+ signal.removeEventListener("abort", onAbort);
71
+ }
72
+ }
73
+ /**
74
+ * Resolve live permission after MCP readiness. The returned synchronous fence must
75
+ * pass immediately before dispatch; a false result requires preparation again.
76
+ */
77
+ export async function authorizeToolDispatch(session, request, ctx, signal) {
78
+ signal?.throwIfAborted();
79
+ const registration = authorizers.get(session);
80
+ if (!registration)
81
+ throw new Error("Permission authorizer is unavailable");
82
+ registration.retired.signal.throwIfAborted();
83
+ const policy = registration.authorizer.policy(request, ctx);
84
+ if (policy.action === "deny")
85
+ throw new DeniedError(["*"]);
86
+ if (policy.action === "ask" && !matches(approvals.get(request.input), registration, request, policy)) {
87
+ const approve = prepareDispatchApproval(session, request, policy);
88
+ const waitSignal = signal === undefined ? registration.retired.signal : AbortSignal.any([signal, registration.retired.signal]);
89
+ waitSignal.throwIfAborted();
90
+ await awaitApproval(registration.authorizer.ask(request, ctx, waitSignal), waitSignal);
91
+ signal?.throwIfAborted();
92
+ approve();
93
+ }
94
+ const approvedInput = JSON.stringify(request.input);
95
+ return () => {
96
+ if (signal?.aborted || registration.retired.signal.aborted || authorizers.get(session) !== registration)
97
+ return false;
98
+ if (JSON.stringify(request.input) !== approvedInput)
99
+ return false;
100
+ const current = registration.authorizer.policy(request, ctx);
101
+ if (current.action === "deny")
102
+ return false;
103
+ return current.action === "allow" || matches(approvals.get(request.input), registration, request, current);
104
+ };
105
+ }
106
+ //# sourceMappingURL=dispatch.js.map