@code-yeongyu/senpi 2026.10.10-5 → 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 (93) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/bundle/chunks/{anthropic-messages-OE3QBX4P.js → anthropic-messages-DUULMQBF.js} +1 -1
  3. package/dist/bundle/chunks/{app-server-command-6FTH3TK6.js → app-server-command-GJ5NXQSY.js} +1 -1
  4. package/dist/bundle/chunks/{chunk-2UUTN6QT.js → chunk-C7LEZMWS.js} +97 -97
  5. package/dist/bundle/chunks/{chunk-ZSYRWQWG.js → chunk-JMI5OCIJ.js} +1 -1
  6. package/dist/bundle/chunks/{chunk-ODYQK5XJ.js → chunk-TALS4X7S.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-2PUAD5TD.js → chunk-VGYIPYOK.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-4TK7VCZZ.js → chunk-WKMUAG23.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-TIMLYJMK.js → chunk-YV2XQFXG.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-TZVDXVHB.js → chunk-ZYHIRFGT.js} +1 -1
  11. package/dist/bundle/chunks/{cli-main-UYTUBG64.js → cli-main-SZ54SQLA.js} +1 -1
  12. package/dist/bundle/chunks/{help-fast-path-EHRZEBRO.js → help-fast-path-W5W3FYYM.js} +1 -1
  13. package/dist/bundle/chunks/{host-command-TOVYSHYH.js → host-command-YG2FIDPN.js} +1 -1
  14. package/dist/bundle/chunks/{interactive-mode-KJIOCRS7.js → interactive-mode-SCNDQ4R6.js} +1 -1
  15. package/dist/bundle/chunks/{multi-session-host-TEFLJTTA.js → multi-session-host-ZDZMPNP5.js} +4 -3
  16. package/dist/bundle/chunks/{package-manager-cli-QSJSIHUR.js → package-manager-cli-4LMYHHKH.js} +1 -1
  17. package/dist/bundle/chunks/{rotation-stream-C5BM7TMM.js → rotation-stream-E3XKSST7.js} +1 -1
  18. package/dist/bundle/chunks/{rpc-mode-RUKK7VBF.js → rpc-mode-ZFNFREN5.js} +1 -1
  19. package/dist/bundle/chunks/{session-control-endpoint-GZDV2FOZ.js → session-control-endpoint-MVSYBGM3.js} +1 -1
  20. package/dist/bundle/chunks/{session-picker-6D4LC3KR.js → session-picker-TNXWY5QY.js} +1 -1
  21. package/dist/bundle/chunks/session-worker.js +158 -157
  22. package/dist/bundle/cli.js +1 -1
  23. package/dist/bundle/index.js +1 -1
  24. package/dist/bundle/rpc-entry.js +1 -1
  25. package/dist/bundle/runtime-manifest.json +1 -1
  26. package/dist/core/extensions/builtin/anthropic-subscription/auth-lane.js +3 -2
  27. package/dist/core/extensions/builtin/anthropic-subscription/options.js +11 -2
  28. package/dist/core/extensions/builtin/anthropic-subscription/session-reattach.js +4 -1
  29. package/dist/core/extensions/builtin/anthropic-subscription/session-sync.js +3 -0
  30. package/dist/core/extensions/builtin/anthropic-subscription/settings.d.ts +5 -0
  31. package/dist/core/extensions/builtin/anthropic-subscription/settings.js +11 -0
  32. package/dist/core/extensions/builtin/anthropic-subscription/stream.d.ts +1 -0
  33. package/dist/core/extensions/builtin/anthropic-subscription/stream.js +17 -1
  34. package/dist/core/extensions/builtin/compaction/lane-policy.d.ts +20 -12
  35. package/dist/core/extensions/builtin/compaction/lane-policy.js +39 -34
  36. package/dist/core/extensions/builtin/mcp/auth/catalog-identity.d.ts +4 -0
  37. package/dist/core/extensions/builtin/mcp/auth/catalog-identity.js +41 -0
  38. package/dist/core/extensions/builtin/mcp/auth/legacy-lock.d.ts +3 -0
  39. package/dist/core/extensions/builtin/mcp/auth/legacy-lock.js +39 -0
  40. package/dist/core/extensions/builtin/mcp/auth/legacy-migration.d.ts +14 -0
  41. package/dist/core/extensions/builtin/mcp/auth/legacy-migration.js +91 -0
  42. package/dist/core/extensions/builtin/mcp/auth/oauth-refresh.js +1 -1
  43. package/dist/core/extensions/builtin/mcp/auth/token-store.d.ts +1 -0
  44. package/dist/core/extensions/builtin/mcp/auth/token-store.js +19 -90
  45. package/dist/core/extensions/builtin/mcp/catalog-cache.d.ts +7 -3
  46. package/dist/core/extensions/builtin/mcp/catalog-cache.js +61 -12
  47. package/dist/core/extensions/builtin/mcp/catalog.d.ts +3 -1
  48. package/dist/core/extensions/builtin/mcp/catalog.js +2 -0
  49. package/dist/core/extensions/builtin/mcp/connection-types.d.ts +2 -0
  50. package/dist/core/extensions/builtin/mcp/expose/call.d.ts +12 -0
  51. package/dist/core/extensions/builtin/mcp/expose/call.js +60 -0
  52. package/dist/core/extensions/builtin/mcp/expose/proxy.js +16 -3
  53. package/dist/core/extensions/builtin/mcp/expose/register.d.ts +3 -1
  54. package/dist/core/extensions/builtin/mcp/expose/register.js +41 -50
  55. package/dist/core/extensions/builtin/mcp/expose/session.d.ts +4 -0
  56. package/dist/core/extensions/builtin/mcp/expose/session.js +5 -1
  57. package/dist/core/extensions/builtin/mcp/expose/tier-b.js +15 -4
  58. package/dist/core/extensions/builtin/mcp/index.js +5 -26
  59. package/dist/core/extensions/builtin/mcp/invocation.d.ts +42 -0
  60. package/dist/core/extensions/builtin/mcp/invocation.js +118 -0
  61. package/dist/core/extensions/builtin/mcp/service-connection.d.ts +3 -0
  62. package/dist/core/extensions/builtin/mcp/service-connection.js +6 -0
  63. package/dist/core/extensions/builtin/mcp/service-register.d.ts +4 -0
  64. package/dist/core/extensions/builtin/mcp/service-register.js +45 -1
  65. package/dist/core/extensions/builtin/mcp/service-tools-changed.js +26 -3
  66. package/dist/core/extensions/builtin/mcp/service-types.d.ts +6 -0
  67. package/dist/core/extensions/builtin/mcp/service.js +83 -49
  68. package/dist/core/extensions/builtin/mcp/shared-connection.js +17 -4
  69. package/dist/core/extensions/builtin/mcp/sharing-policy.js +1 -0
  70. package/dist/core/extensions/builtin/mcp/startup-race.js +47 -9
  71. package/dist/core/extensions/builtin/permission-system/dispatch-metadata.d.ts +8 -0
  72. package/dist/core/extensions/builtin/permission-system/dispatch-metadata.js +23 -0
  73. package/dist/core/extensions/builtin/permission-system/dispatch-policy.d.ts +14 -0
  74. package/dist/core/extensions/builtin/permission-system/dispatch-policy.js +53 -0
  75. package/dist/core/extensions/builtin/permission-system/dispatch.d.ts +33 -0
  76. package/dist/core/extensions/builtin/permission-system/dispatch.js +106 -0
  77. package/dist/core/extensions/builtin/permission-system/index.js +25 -4
  78. package/dist/core/extensions/builtin/permission-system/prompt.d.ts +1 -1
  79. package/dist/core/extensions/builtin/permission-system/prompt.js +3 -1
  80. package/dist/core/extensions/builtin/permission-system/service.d.ts +11 -1
  81. package/dist/core/extensions/builtin/permission-system/service.js +14 -4
  82. package/dist/core/extensions/builtin/ttsr/detectors/collapse-near-duplicates.d.ts +1 -0
  83. package/dist/core/extensions/builtin/ttsr/detectors/collapse-near-duplicates.js +24 -5
  84. package/dist/modes/rpc/rpc-client.js +14 -2
  85. package/dist/modes/rpc/rpc-request-deadline.d.ts +8 -0
  86. package/dist/modes/rpc/rpc-request-deadline.js +10 -0
  87. package/dist/modes/rpc/rpc-types.d.ts +7 -4
  88. package/dist/modes/rpc/session-binding.js +15 -1
  89. package/dist/modes/rpc/session-command-router.d.ts +1 -0
  90. package/dist/modes/rpc/session-command-router.js +16 -0
  91. package/docs/providers.md +3 -2
  92. package/docs/rpc.md +12 -6
  93. package/package.json +6 -6
@@ -3,11 +3,13 @@ import type { Progress } from "@modelcontextprotocol/sdk/types.js";
3
3
  import type { TSchema } from "typebox";
4
4
  import type { ToolDefinition } from "../../../types.ts";
5
5
  import type { McpToolCatalogEntry } from "../catalog.ts";
6
+ import type { McpInvocation, McpInvocationError } from "../invocation.ts";
6
7
  export interface McpToolDetails {
7
8
  server: string;
8
9
  tool: string;
9
10
  preview?: string;
10
11
  progress?: Progress;
12
+ error?: McpInvocationError;
11
13
  }
12
14
  export type McpToolDefinition = ToolDefinition<TSchema, McpToolDetails | undefined, unknown>;
13
15
  type WarnFn = (message: string) => void;
@@ -25,6 +27,6 @@ export declare function mapMcpCatalogNames(entries: readonly McpToolCatalogEntry
25
27
  export declare function buildMcpToolDefinitions(entries: readonly McpToolCatalogEntry[], warn?: WarnFn): McpToolDefinition[];
26
28
  /** Full guarded call path for one catalog entry (lifecycle + retry + output
27
29
  * guard). Shared by direct/search tool definitions and the Tier-C proxy. */
28
- export declare function executeMcpCatalogEntry(entry: McpToolCatalogEntry, args: Record<string, unknown>, signal: AbortSignal | undefined, onUpdate: Parameters<McpToolDefinition["execute"]>[3]): Promise<AgentToolResult<McpToolDetails | undefined>>;
30
+ export declare function executeMcpCatalogEntry(entry: McpToolCatalogEntry, args: Record<string, unknown>, signal: AbortSignal | undefined, onUpdate: Parameters<McpToolDefinition["execute"]>[3], invocation?: McpInvocation): Promise<AgentToolResult<McpToolDetails | undefined>>;
29
31
  export {};
30
32
  //# sourceMappingURL=register.d.ts.map
@@ -1,8 +1,9 @@
1
1
  import { Text } from "@earendil-works/pi-tui";
2
+ import { forgetDispatchApproval } from "../../permission-system/dispatch.js";
3
+ import { registerDispatchIdentity } from "../../permission-system/dispatch-metadata.js";
2
4
  import { ToolExecError } from "../errors.js";
3
5
  import { applyMcpOutputGuard } from "../guard/output-guard.js";
4
- import { ensureMcpToolCallConnection, withMcpRetriableFailedSendRetry, withMcpSessionExpiryRetry } from "../health.js";
5
- import { runMcpConnectionLifecycleCall } from "../idle.js";
6
+ import { callMcpTool } from "./call.js";
6
7
  import { buildMcpToolNames, convertJsonSchemaToTypeBox, mapMcpToolResult, } from "./schema-compat.js";
7
8
  /** Pair each catalog entry with its stable, collision-resolved mcp tool name.
8
9
  * Shared by the full-tool builder and the Tier-B search catalog so names never
@@ -18,15 +19,21 @@ export function buildMcpToolDefinitions(entries, warn) {
18
19
  function createMcpToolDefinition(entry, name) {
19
20
  const converted = convertJsonSchemaToTypeBox(entry.schema);
20
21
  const label = `${entry.server}/${entry.tool}`;
22
+ registerDispatchIdentity(converted.schema, () => entry.invocation?.identity(entry));
21
23
  return {
22
24
  name,
23
25
  label,
24
26
  description: entry.description ?? `MCP tool ${label}`,
25
27
  parameters: converted.schema,
26
28
  executionMode: "parallel",
27
- async execute(_toolCallId, params, signal, onUpdate) {
29
+ async execute(toolCallId, params, signal, onUpdate, context) {
28
30
  const args = isRecord(params) ? params : {};
29
- return await executeMcpCatalogEntry(entry, args, signal, onUpdate);
31
+ return await executeMcpCatalogEntry(entry, args, signal, onUpdate, {
32
+ toolCallId,
33
+ toolName: name,
34
+ input: args,
35
+ context,
36
+ });
30
37
  },
31
38
  renderCall(args, theme) {
32
39
  return new Text(theme.fg("toolTitle", theme.bold(`${name} ${previewArgs(args)}`.trim())), 0, 0);
@@ -41,47 +48,39 @@ function createMcpToolDefinition(entry, name) {
41
48
  }
42
49
  /** Full guarded call path for one catalog entry (lifecycle + retry + output
43
50
  * guard). Shared by direct/search tool definitions and the Tier-C proxy. */
44
- export async function executeMcpCatalogEntry(entry, args, signal, onUpdate) {
45
- const label = `${entry.server}/${entry.tool}`;
46
- const result = await callMcpTool(entry, args, signal, onUpdate, label);
47
- const mapped = mapMcpToolResult(normalizeCallToolResult(result));
48
- if (!mapped.ok) {
49
- throw new ToolExecError(mapped.error.message, { phase: "call", serverName: entry.server });
50
- }
51
- const guarded = await applyMcpOutputGuard(mapped.content, {
52
- agentDir: entry.agentDir,
53
- artifacts: entry.artifacts,
54
- outputGuard: entry.outputGuard,
55
- server: entry.server,
56
- });
57
- const content = toAgentContent(guarded);
58
- return { content, details: { preview: previewContent(content), server: entry.server, tool: entry.tool } };
59
- }
60
- async function callMcpTool(entry, args, signal, onUpdate, label) {
51
+ export async function executeMcpCatalogEntry(entry, args, signal, onUpdate, invocation) {
61
52
  try {
62
- return await runMcpConnectionLifecycleCall(entry.connection, () => withMcpSessionExpiryRetry(entry.connection, async () => {
63
- await ensureMcpToolCallConnection(entry.connection, entry.ensureFresh);
64
- await entry.ensureConnected?.();
65
- return await withMcpRetriableFailedSendRetry(entry.connection, async () => {
66
- return await entry.connection.client.callTool({ name: entry.tool, arguments: args }, undefined, {
67
- onprogress: (progress) => {
68
- onUpdate?.({
69
- content: [{ type: "text", text: formatProgress(label, progress) }],
70
- details: { progress, server: entry.server, tool: entry.tool },
71
- });
72
- },
73
- signal,
74
- timeout: entry.requestTimeoutMs,
75
- });
53
+ const label = `${entry.server}/${entry.tool}`;
54
+ const outcome = await callMcpTool(entry, args, signal, onUpdate, label, invocation);
55
+ if (outcome.kind === "refused") {
56
+ const guarded = await applyMcpOutputGuard([{ type: "text", text: JSON.stringify({ error: outcome.error }) }], {
57
+ agentDir: entry.agentDir,
58
+ artifacts: entry.artifacts,
59
+ outputGuard: entry.outputGuard,
60
+ server: entry.server,
76
61
  });
77
- }));
78
- }
79
- catch (error) {
80
- throw new ToolExecError(`ToolExecError: ${errorLabel(error)}`, {
81
- cause: error,
82
- phase: "call",
83
- serverName: entry.server,
62
+ return {
63
+ content: toAgentContent(guarded),
64
+ details: { error: outcome.error, preview: outcome.error.kind, server: entry.server, tool: entry.tool },
65
+ };
66
+ }
67
+ const current = outcome.entry;
68
+ const mapped = mapMcpToolResult(normalizeCallToolResult(outcome.result));
69
+ if (!mapped.ok) {
70
+ throw new ToolExecError(mapped.error.message, { phase: "call", serverName: entry.server });
71
+ }
72
+ const guarded = await applyMcpOutputGuard(mapped.content, {
73
+ agentDir: current.agentDir,
74
+ artifacts: current.artifacts,
75
+ outputGuard: current.outputGuard,
76
+ server: current.server,
84
77
  });
78
+ const content = toAgentContent(guarded);
79
+ return { content, details: { preview: previewContent(content), server: entry.server, tool: entry.tool } };
80
+ }
81
+ finally {
82
+ if (invocation !== undefined)
83
+ forgetDispatchApproval(invocation.input);
85
84
  }
86
85
  }
87
86
  function normalizeCallToolResult(result) {
@@ -121,14 +120,6 @@ function previewArgs(args) {
121
120
  function isRecord(value) {
122
121
  return typeof value === "object" && value !== null && !Array.isArray(value);
123
122
  }
124
- function errorLabel(error) {
125
- return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
126
- }
127
- function formatProgress(label, progress) {
128
- const total = progress.total === undefined ? "" : `/${progress.total}`;
129
- const message = progress.message === undefined ? "" : ` ${progress.message}`;
130
- return `${label} progress ${progress.progress}${total}${message}`.trim();
131
- }
132
123
  function truncatePreview(value) {
133
124
  return value.length <= 120 ? value : `${value.slice(0, 117)}...`;
134
125
  }
@@ -4,6 +4,7 @@ import type { McpCachedServerCatalog } from "../catalog-cache.ts";
4
4
  import type { ResolvedMcpConfig } from "../config-schema.ts";
5
5
  import type { ServerConnection } from "../connection.ts";
6
6
  import type { McpOutputArtifacts } from "../guard/output-guard.ts";
7
+ import type { McpInvocationResolver } from "../invocation.ts";
7
8
  import { type McpLogger } from "../log.ts";
8
9
  import type { McpPromptServer } from "../prompts.ts";
9
10
  import { type McpResourceServer } from "../resources.ts";
@@ -22,8 +23,11 @@ export interface McpDirectRegistrationEntry {
22
23
  readonly cachedCatalog?: McpCachedServerCatalog;
23
24
  readonly ensureFresh?: () => Promise<void>;
24
25
  readonly ensureCachedToolConnected?: () => Promise<void>;
26
+ readonly invocation?: McpInvocationResolver;
25
27
  /** A startup connect still owns this server's catalog; its refresh registers it. */
26
28
  readonly startupCatalogPending?: boolean;
29
+ /** Service admission and registry updates never enumerate a remote server. */
30
+ readonly localCatalogOnly?: boolean;
27
31
  readonly onRegistered?: (identity: string) => void;
28
32
  }
29
33
  export declare function registerDirectMcpTools(pi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools" | "registerTool">, config: ResolvedMcpConfig, entries: Iterable<McpDirectRegistrationEntry>, toolSearchService: ToolSearchService, options?: McpCatalogRegistrationOptions): Promise<McpSessionRegistration | undefined>;
@@ -16,11 +16,14 @@ export async function registerDirectMcpTools(pi, config, entries, toolSearchServ
16
16
  if (server?.config === undefined)
17
17
  continue;
18
18
  const catalog = entry.cachedCatalog === undefined
19
- ? entry.connection.state === "connected" && entry.startupCatalogPending !== true
19
+ ? entry.connection.state === "connected" &&
20
+ entry.startupCatalogPending !== true &&
21
+ entry.localCatalogOnly !== true
20
22
  ? await collectToolCatalog(entry.name, entry.connection, server.config, {
21
23
  agentDir: entry.agentDir,
22
24
  artifacts: entry.artifacts,
23
25
  ensureFresh: entry.ensureFresh,
26
+ invocation: entry.invocation,
24
27
  outputGuard: config.settings.outputGuard,
25
28
  })
26
29
  : []
@@ -28,6 +31,7 @@ export async function registerDirectMcpTools(pi, config, entries, toolSearchServ
28
31
  agentDir: entry.agentDir,
29
32
  artifacts: entry.artifacts,
30
33
  ensureFresh: entry.ensureFresh,
34
+ invocation: entry.invocation,
31
35
  outputGuard: config.settings.outputGuard,
32
36
  });
33
37
  const identity = mcpRegistrationIdentity(catalog, entry.cachedCatalog);
@@ -6,6 +6,7 @@
6
6
  // MCP feeds its searchable documents and activation hook into that service.
7
7
  import { Text } from "@earendil-works/pi-tui";
8
8
  import { Type } from "typebox";
9
+ import { getDispatchIdentity, registerDispatchIdentity } from "../../permission-system/dispatch-metadata.js";
9
10
  import { deriveMcpRegistrationId } from "../../tool-search/engine/marker.js";
10
11
  import { registerToolsPreservingActiveSet } from "../active-set.js";
11
12
  import { createMcpProxyTool } from "./proxy.js";
@@ -37,6 +38,8 @@ export function registerMcpTierBTools(pi, input, toolSearchService, warn) {
37
38
  }));
38
39
  const fullDefs = buildMcpToolDefinitions(input.registeredEntries, warn);
39
40
  const fullByName = new Map(fullDefs.map((def) => [def.name, def]));
41
+ const promoted = promotedNamesByRegistrar.get(pi) ?? new Set();
42
+ promotedNamesByRegistrar.set(pi, promoted);
40
43
  const gatewayNames = [];
41
44
  for (const gateway of input.proxyGateways ?? []) {
42
45
  const tool = createMcpProxyTool(gateway.server, gateway.entries);
@@ -47,7 +50,11 @@ export function registerMcpTierBTools(pi, input, toolSearchService, warn) {
47
50
  pi.registerTool(tool);
48
51
  gatewayNames.push(tool.name);
49
52
  }
50
- const activeMcpNames = [...mapMcpCatalogNames(input.activeEntries).map(({ name }) => name), ...gatewayNames];
53
+ const activeMcpNames = [
54
+ ...mapMcpCatalogNames(input.activeEntries).map(({ name }) => name),
55
+ ...fullDefs.filter((def) => input.searchMode && promoted.has(def.name)).map((def) => def.name),
56
+ ...gatewayNames,
57
+ ];
51
58
  const managedNames = new Set([...fullDefs.map((def) => def.name), ...gatewayNames]);
52
59
  const previousManagedNames = managedNamesByRegistrar.get(pi) ?? new Set();
53
60
  managedNamesByRegistrar.set(pi, managedNames);
@@ -59,6 +66,8 @@ export function registerMcpTierBTools(pi, input, toolSearchService, warn) {
59
66
  const known = [...new Set(names.filter((name) => registeredNames.has(name)))];
60
67
  if (known.length === 0)
61
68
  return;
69
+ for (const name of known)
70
+ promoted.add(name);
62
71
  if (stubSwap)
63
72
  swapStubsToFull(pi, known, stubbed, fullByName);
64
73
  const current = pi.getActiveTools();
@@ -86,8 +95,6 @@ export function registerMcpTierBTools(pi, input, toolSearchService, warn) {
86
95
  // stubSwap: every search-mode tool is registered as a tiny stub and kept
87
96
  // active so the tools array is length-stable; direct tools stay full.
88
97
  const directActive = new Set(activeMcpNames);
89
- const promoted = promotedNamesByRegistrar.get(pi) ?? new Set();
90
- promotedNamesByRegistrar.set(pi, promoted);
91
98
  const toRegister = fullDefs.map((def) => {
92
99
  if (directActive.has(def.name) || promoted.has(def.name))
93
100
  return def;
@@ -135,13 +142,17 @@ function isLegacyMcpRegistrationName(name) {
135
142
  * definition and runs the call in the same turn, so the model never needs a
136
143
  * tool_search round trip; without a promotion hook it only points at tool_search. */
137
144
  export function buildMcpStubDefinition(name, promotion) {
145
+ const parameters = Type.Object({}, { additionalProperties: true });
146
+ if (promotion !== undefined) {
147
+ registerDispatchIdentity(parameters, (input) => getDispatchIdentity(promotion.full.parameters, input));
148
+ }
138
149
  return {
139
150
  name,
140
151
  label: name,
141
152
  description: promotion === undefined
142
153
  ? `Inactive MCP tool. Run tool_search to activate ${name}, then call it on your next turn.`
143
154
  : `Deferred MCP tool. Call ${name} with its real arguments; it activates and runs on this first call (tool_search lists its schema).`,
144
- parameters: Type.Object({}, { additionalProperties: true }),
155
+ parameters,
145
156
  executionMode: "parallel",
146
157
  async execute(toolCallId, params, signal, onUpdate, ctx) {
147
158
  if (promotion === undefined) {
@@ -8,7 +8,6 @@ import { registerMcpPromptCommands } from "./prompts.js";
8
8
  import { expandMcpResourceMentions } from "./resources.js";
9
9
  import { getMcpService, McpService } from "./service.js";
10
10
  import { parseSkillMcpDeclarations, skillActivationTargets, } from "./skills.js";
11
- import { MCP_ATTACH_SETTLE_TIMEOUT_MS } from "./startup-race.js";
12
11
  import { reportMcpAsyncError, safeEventBusOn, wrapAsync } from "./wrap.js";
13
12
  const MCP_BUILTIN_EXTENSION_PATH = "<builtin:mcp>";
14
13
  export function createMcpExtension(service, sessionOwned = true) {
@@ -91,16 +90,8 @@ export function createMcpExtension(service, sessionOwned = true) {
91
90
  }
92
91
  return undefined;
93
92
  });
94
- // Attach is SINGLE-FLIGHT. session_start handlers are dispatched
95
- // fire-and-forget, so a slow attach (a cold MCP server's boot + catalog
96
- // collection is awaited inside attachSession) can still be in flight when
97
- // before_agent_start fires. The old `attached` boolean was only set on
98
- // completion, so before_agent_start would start a SECOND concurrent attach —
99
- // which found the connection entries already created (still "connecting"),
100
- // collected an empty catalog, and registered no MCP tools for turn 1; the
101
- // first attach then landed the real registration turns later. Memoizing the
102
- // in-flight promise makes before_agent_start await the ORIGINAL attach, so
103
- // the first turn's payload deterministically carries the MCP tool set.
93
+ // Attach is single-flight: the prompt uses the original attach's bindings
94
+ // and known catalog, without waiting for deferred remote discovery.
104
95
  // session_start always starts a fresh attach (reloads must re-sync config).
105
96
  const attach = (event, ctx) => {
106
97
  attachedSessionId = ctx.sessionManager?.getSessionId?.();
@@ -117,11 +108,7 @@ export function createMcpExtension(service, sessionOwned = true) {
117
108
  const onSessionStart = wrapAsync("mcp.session_start", (event, ctx) => attach(event, ctx), sink);
118
109
  pi.on("session_start", (event, ctx) => {
119
110
  const work = onSessionStart(event, ctx);
120
- // session_start is dispatched serially inside interactive startup, so awaiting attach here
121
- // puts a cold server's boot and catalog handshake in front of the first frame: measured at
122
- // 254ms median of a 292ms dispatch on a real config, against 0.2ms with no servers. Attach
123
- // is single-flight via attachPromise + service.#attachQueue and before_agent_start awaits
124
- // it, so the first turn still carries the full tool set; only the first paint stops waiting.
111
+ // First paint does not wait for attach; prompt preparation reuses its promise.
125
112
  void work;
126
113
  });
127
114
  const onBeforeAgentStart = async (event, ctx) => {
@@ -148,16 +135,8 @@ export function createMcpExtension(service, sessionOwned = true) {
148
135
  for (const warning of warnings)
149
136
  createMcpLogger("skills").warn(warning);
150
137
  }
151
- // attachPromise resolves at the startup-race deadline, which leaves a slow
152
- // server still handshaking: assembling the prompt here would publish that
153
- // server's stale instructions - or none at all - for the whole session.
154
- // Await the attach's own completion signal instead, bounded; on timeout the
155
- // turn still goes out and the server's catalog lands on a later turn.
156
- if ((await service.whenAttachSettled()) === "timeout") {
157
- createMcpLogger("service").warn("MCP attach still settling at prompt build", {
158
- timeoutMs: MCP_ATTACH_SETTLE_TIMEOUT_MS,
159
- });
160
- }
138
+ // Use the known instructions now. Deferred catalog registration refreshes
139
+ // the service for subsequent turns instead of gating this provider request.
161
140
  const systemPrompt = injectMcpInstructions(service, event.systemPrompt);
162
141
  return systemPrompt === undefined ? undefined : { systemPrompt };
163
142
  }
@@ -0,0 +1,42 @@
1
+ import type { ExtensionToolContext } from "../../types.ts";
2
+ import { type DispatchIdentity } from "../permission-system/dispatch.ts";
3
+ import type { McpToolCatalogEntry } from "./catalog.ts";
4
+ import { type McpAsyncErrorSink } from "./wrap.ts";
5
+ export interface McpInvocation {
6
+ readonly toolCallId: string;
7
+ readonly toolName: string;
8
+ readonly input: Record<string, unknown>;
9
+ readonly context?: ExtensionToolContext;
10
+ }
11
+ export interface McpInvocationError {
12
+ readonly kind: "unavailable" | "schema_changed" | "permission_denied";
13
+ readonly server: string;
14
+ readonly tool: string;
15
+ readonly message: string;
16
+ readonly currentDefinition?: McpToolCatalogEntry["schema"];
17
+ }
18
+ export type McpPreparedInvocation = {
19
+ readonly kind: "ready";
20
+ readonly entry: McpToolCatalogEntry;
21
+ readonly isCurrent: () => boolean;
22
+ } | {
23
+ readonly kind: "refused";
24
+ readonly error: McpInvocationError;
25
+ };
26
+ export interface McpInvocationResolver {
27
+ identity(entry: McpToolCatalogEntry): DispatchIdentity;
28
+ resolve(entry: McpToolCatalogEntry, args: Record<string, unknown>, invocation: McpInvocation | undefined, signal: AbortSignal | undefined): Promise<McpPreparedInvocation>;
29
+ }
30
+ interface InvocationScope {
31
+ readonly owner: object;
32
+ readonly sink: McpAsyncErrorSink;
33
+ readonly configuration: string;
34
+ readonly contextRequired: boolean;
35
+ readonly sessionManager?: object;
36
+ readonly isCurrent: () => boolean;
37
+ readonly ready: () => Promise<void>;
38
+ readonly current: (tool: string) => McpToolCatalogEntry | undefined;
39
+ }
40
+ export declare function createMcpInvocationResolver(scope: InvocationScope): McpInvocationResolver;
41
+ export {};
42
+ //# sourceMappingURL=invocation.d.ts.map
@@ -0,0 +1,118 @@
1
+ import { Type } from "typebox";
2
+ import { Check } from "typebox/value";
3
+ import { authorizeToolDispatch, hasDispatchAuthorizer } from "../permission-system/dispatch.js";
4
+ import { getDispatchIdentity } from "../permission-system/dispatch-metadata.js";
5
+ import { TimeoutError } from "./errors.js";
6
+ import { safeTimer } from "./wrap.js";
7
+ export function createMcpInvocationResolver(scope) {
8
+ const identity = (entry) => ({
9
+ owner: scope.owner,
10
+ operation: JSON.stringify([entry.server, entry.tool]),
11
+ metadata: JSON.stringify([
12
+ scope.configuration,
13
+ entry.server,
14
+ entry.tool,
15
+ entry.schema,
16
+ entry.description,
17
+ entry.annotations,
18
+ ]),
19
+ });
20
+ return {
21
+ identity,
22
+ async resolve(offered, args, invocation, signal) {
23
+ const refuse = (kind, message, current) => ({
24
+ kind: "refused",
25
+ error: {
26
+ kind,
27
+ message,
28
+ server: offered.server,
29
+ tool: offered.tool,
30
+ currentDefinition: current?.schema,
31
+ },
32
+ });
33
+ signal?.throwIfAborted();
34
+ if (!scope.isCurrent())
35
+ return refuse("unavailable", "MCP session or server configuration was replaced.");
36
+ const ctx = invocation?.context;
37
+ if (scope.contextRequired &&
38
+ (ctx === undefined || (scope.sessionManager !== undefined && ctx.sessionManager !== scope.sessionManager))) {
39
+ return refuse("unavailable", "MCP invocation does not belong to this session.");
40
+ }
41
+ const deadline = new AbortController();
42
+ const timeout = safeTimer("invocation.catalog.deadline", offered.requestTimeoutMs, () => deadline.abort(new TimeoutError("MCP metadata was not ready before the request timeout.", {
43
+ phase: "catalog",
44
+ serverName: offered.server,
45
+ })), scope.sink);
46
+ const readySignal = signal === undefined ? deadline.signal : AbortSignal.any([signal, deadline.signal]);
47
+ const ready = scope.ready();
48
+ let abort = () => { };
49
+ const interrupted = new Promise((_resolve, reject) => {
50
+ abort = () => reject(readySignal.reason);
51
+ readySignal.addEventListener("abort", abort, { once: true });
52
+ if (readySignal.aborted)
53
+ abort();
54
+ });
55
+ try {
56
+ await Promise.race([ready, interrupted]);
57
+ }
58
+ finally {
59
+ clearTimeout(timeout);
60
+ readySignal.removeEventListener("abort", abort);
61
+ }
62
+ signal?.throwIfAborted();
63
+ if (!scope.isCurrent())
64
+ return refuse("unavailable", "MCP session or server configuration was replaced.");
65
+ const current = scope.current(offered.tool);
66
+ if (current === undefined)
67
+ return refuse("unavailable", "MCP tool is no longer available.");
68
+ // Validate the current raw schema, never the coercing preflight schema.
69
+ if (!Check(Type.Unsafe(current.schema), args)) {
70
+ return refuse("schema_changed", "Arguments do not match the current MCP tool schema.", current);
71
+ }
72
+ const preparedIdentity = identity(current);
73
+ let permissionFence;
74
+ let operationFence;
75
+ if (ctx !== undefined &&
76
+ invocation !== undefined &&
77
+ (scope.contextRequired || ctx.sessionManager !== undefined)) {
78
+ operationFence = () => {
79
+ const registered = ctx.tools.find((tool) => tool.name === invocation.toolName);
80
+ const registeredIdentity = registered === undefined ? undefined : getDispatchIdentity(registered.parameters, invocation.input);
81
+ return (registeredIdentity?.owner === preparedIdentity.owner &&
82
+ registeredIdentity.operation === preparedIdentity.operation);
83
+ };
84
+ if (!operationFence()) {
85
+ return refuse("unavailable", "MCP invocation name no longer identifies this operation.", current);
86
+ }
87
+ if (hasDispatchAuthorizer(ctx.sessionManager)) {
88
+ try {
89
+ permissionFence = await authorizeToolDispatch(ctx.sessionManager, { ...invocation, identity: preparedIdentity }, ctx, signal);
90
+ }
91
+ catch (error) {
92
+ signal?.throwIfAborted();
93
+ return refuse("permission_denied", error instanceof Error ? error.message : String(error), current);
94
+ }
95
+ }
96
+ else if (ctx.loadedExtensionPaths === undefined ||
97
+ ctx.loadedExtensionPaths.some((path) => path.includes("permission-system"))) {
98
+ return refuse("permission_denied", "Permission authorizer is unavailable.", current);
99
+ }
100
+ }
101
+ return {
102
+ kind: "ready",
103
+ entry: current,
104
+ isCurrent: () => {
105
+ if (signal?.aborted || !scope.isCurrent())
106
+ return false;
107
+ const latest = scope.current(offered.tool);
108
+ return (latest !== undefined &&
109
+ identity(latest).metadata === preparedIdentity.metadata &&
110
+ Check(Type.Unsafe(latest.schema), args) &&
111
+ (operationFence === undefined || operationFence()) &&
112
+ (permissionFence === undefined || permissionFence()));
113
+ },
114
+ };
115
+ },
116
+ };
117
+ }
118
+ //# sourceMappingURL=invocation.js.map
@@ -16,6 +16,9 @@ interface SessionConnectionOptions {
16
16
  readonly ui: McpElicitationUiProvider;
17
17
  readonly artifacts: McpOutputArtifacts;
18
18
  readonly shouldReconnect: (entry: McpConnectionEntry) => boolean;
19
+ readonly credentialIdentity?: string;
20
+ readonly credentialsCurrent?: () => boolean;
21
+ readonly onCredentialsChanged?: () => Promise<void>;
19
22
  }
20
23
  export declare function createMcpSessionConnection(options: SessionConnectionOptions): McpConnectionEntry;
21
24
  export declare function disposeEntryConnection(entry: McpConnectionEntry, registry: HostMcpRegistry, owner: object): Promise<void>;
@@ -20,6 +20,8 @@ export function createMcpSessionConnection(options) {
20
20
  for (const warning of detectLiteralBearerWarnings(name, config))
21
21
  logger.warn(warning);
22
22
  const connectionOptions = {
23
+ credentialIdentity: options.credentialIdentity,
24
+ credentialsCurrent: options.credentialsCurrent,
23
25
  authProvider: authPlan.provider,
24
26
  config,
25
27
  env: session.env,
@@ -39,6 +41,10 @@ export function createMcpSessionConnection(options) {
39
41
  artifacts: options.artifacts,
40
42
  authPlan,
41
43
  cacheRefreshedAfterConnect: false,
44
+ isCurrent: () => options.shouldReconnect(entry),
45
+ credentialIdentity: options.credentialIdentity,
46
+ credentialsCurrent: options.credentialsCurrent,
47
+ onCredentialsChanged: options.onCredentialsChanged,
42
48
  key,
43
49
  name,
44
50
  configHash: options.configHash,
@@ -7,6 +7,10 @@ type McpToolRegistrar = Pick<ExtensionAPI, "getActiveTools" | "setActiveTools" |
7
7
  export interface McpServiceDirectToolRegistrationOptions {
8
8
  readonly refreshActiveSetWhenEmpty?: boolean;
9
9
  readonly onRegistered?: (entry: McpConnectionEntry, identity: string) => void;
10
+ readonly isCurrent?: (entry: McpConnectionEntry) => boolean;
11
+ readonly contextRequired?: boolean;
12
+ readonly sessionManager?: object;
13
+ readonly publishCurrent?: () => Promise<void>;
10
14
  }
11
15
  export declare function registerMcpServiceDirectTools(pi: McpToolRegistrar, config: ResolvedMcpConfig, entries: Iterable<McpConnectionEntry>, toolSearchService: ToolSearchService, options?: McpServiceDirectToolRegistrationOptions): Promise<McpSessionRegistration | undefined>;
12
16
  export {};
@@ -1,15 +1,59 @@
1
+ import { cachedToolsToCatalogEntries } from "./catalog.js";
2
+ import { computeMcpExposurePolicy } from "./expose/policy.js";
1
3
  import { registerDirectMcpTools } from "./expose/session.js";
4
+ import { ensureMcpToolCallConnection } from "./health.js";
5
+ import { createMcpInvocationResolver } from "./invocation.js";
2
6
  import { connectAndRefreshMcpCatalog } from "./startup-race.js";
3
7
  export async function registerMcpServiceDirectTools(pi, config, entries, toolSearchService, options = {}) {
4
8
  return await registerDirectMcpTools(pi, config, [...entries].map((entry) => {
5
9
  const serverConfig = config.servers[entry.name]?.config;
6
10
  const claim = entry.startupCatalogClaim;
7
11
  const startupCatalogPending = claim?.ownsRegistration() === true;
12
+ const invocation = createMcpInvocationResolver({
13
+ owner: entry,
14
+ sink: { logger: entry.logger },
15
+ configuration: entry.configHash,
16
+ contextRequired: options.contextRequired === true,
17
+ sessionManager: options.sessionManager,
18
+ isCurrent: () => entry.isCurrent?.() !== false && options.isCurrent?.(entry) !== false,
19
+ ready: async () => {
20
+ await ensureMcpToolCallConnection(entry.connection, () => entry.authPlan?.refresh?.ensureFresh().then(() => undefined) ?? Promise.resolve());
21
+ await connectAndRefreshMcpCatalog(entry, serverConfig);
22
+ await entry.startupCatalogClaim?.settled();
23
+ if (entry.isCurrent?.() === false || options.isCurrent?.(entry) === false)
24
+ return;
25
+ await options.publishCurrent?.();
26
+ },
27
+ current: (tool) => {
28
+ if (serverConfig === undefined ||
29
+ entry.credentialsCurrent?.() === false ||
30
+ entry.cachedCatalog === undefined ||
31
+ !entry.cacheRefreshedAfterConnect ||
32
+ entry.catalogGeneration !== entry.connection.generation ||
33
+ entry.connection.state !== "connected") {
34
+ return undefined;
35
+ }
36
+ const current = cachedToolsToCatalogEntries(entry.name, entry.cachedCatalog, entry.connection, serverConfig.requestTimeoutMs, () => connectAndRefreshMcpCatalog(entry, serverConfig), {
37
+ agentDir: entry.agentDir,
38
+ artifacts: entry.artifacts,
39
+ ensureFresh: () => entry.authPlan?.refresh?.ensureFresh().then(() => undefined) ?? Promise.resolve(),
40
+ outputGuard: config.settings.outputGuard,
41
+ invocation,
42
+ });
43
+ return computeMcpExposurePolicy(current, serverConfig, config.settings).filteredEntries.find((candidate) => candidate.tool === tool);
44
+ },
45
+ });
8
46
  return {
9
47
  agentDir: entry.agentDir,
10
48
  artifacts: entry.artifacts,
11
- cachedCatalog: startupCatalogPending ? claim?.cachedCatalog : entry.cachedCatalog,
49
+ cachedCatalog: entry.credentialsCurrent?.() === false
50
+ ? undefined
51
+ : startupCatalogPending
52
+ ? claim?.cachedCatalog
53
+ : entry.cachedCatalog,
12
54
  startupCatalogPending,
55
+ localCatalogOnly: true,
56
+ invocation,
13
57
  onRegistered: (identity) => options.onRegistered?.(entry, identity),
14
58
  connection: entry.connection,
15
59
  ensureFresh: () => entry.authPlan?.refresh?.ensureFresh().then(() => undefined) ?? Promise.resolve(),
@@ -40,17 +40,33 @@ export async function refreshMcpToolsOnListChanged(entry, targets, config, conne
40
40
  const server = config.servers[entry.name];
41
41
  if (server?.config === undefined || entry.connection.state !== "connected")
42
42
  return;
43
+ if (entry.isCurrent?.() === false)
44
+ return;
45
+ if (entry.credentialsCurrent?.() === false) {
46
+ await entry.onCredentialsChanged?.();
47
+ return;
48
+ }
49
+ const generation = entry.connection.generation;
50
+ const ownsCatalog = () => entry.isCurrent?.() !== false &&
51
+ entry.credentialsCurrent?.() !== false &&
52
+ entry.connection.generation === generation &&
53
+ entry.connection.state === "connected";
43
54
  // The startup connect that still owns registration registers its refreshed catalog itself.
44
55
  if (connectOnly && entry.startupCatalogClaim?.ownsRegistration() === true)
45
56
  return;
46
57
  const registeredCatalog = entry.cachedCatalog;
47
58
  if (entry.connection instanceof SharedMcpLease) {
48
- entry.cachedCatalog = await entry.connection.catalog();
59
+ const refreshed = await entry.connection.catalog();
60
+ if (!ownsCatalog())
61
+ return;
62
+ entry.cachedCatalog = refreshed;
49
63
  }
50
64
  const catalog = await collectToolCatalog(entry.name, entry.connection, server.config, {
51
65
  agentDir: entry.agentDir,
52
66
  outputGuard: config.settings.outputGuard,
53
67
  });
68
+ if (!ownsCatalog())
69
+ return;
54
70
  const newNames = mapMcpCatalogNames(catalog).map(({ name }) => name);
55
71
  // Before the first refresh, the registered names are the catalog the startup pass registered.
56
72
  const knownNames = entry.knownToolNames ??
@@ -65,9 +81,14 @@ export async function refreshMcpToolsOnListChanged(entry, targets, config, conne
65
81
  // Registration reads entry.cachedCatalog. A shared lease refreshed it above; nothing else
66
82
  // refreshes a non-shared connection's catalog after its startup connect (#2188).
67
83
  if (!(entry.connection instanceof SharedMcpLease)) {
68
- entry.cachedCatalog = await collectServerCatalogForCache(entry.connection, server.config, entry.configHash);
69
- await writeMcpCachedServer(entry.agentDir, entry.name, entry.cachedCatalog);
84
+ const refreshed = await collectServerCatalogForCache(entry.connection, server.config, entry.configHash, entry.credentialIdentity);
85
+ if (!ownsCatalog())
86
+ return;
87
+ entry.cachedCatalog = refreshed;
88
+ await writeMcpCachedServer(entry.agentDir, entry.name, refreshed, ownsCatalog);
70
89
  }
90
+ if (!ownsCatalog())
91
+ return;
71
92
  // Tombstone removed tools BEFORE re-registration so the subsequent
72
93
  // setActiveTools (which excludes them) leaves the tombstones inactive.
73
94
  const seen = new Set(initial.map((target) => target.pi));
@@ -75,6 +96,8 @@ export async function refreshMcpToolsOnListChanged(entry, targets, config, conne
75
96
  let pending = stale;
76
97
  while (pending.length > 0) {
77
98
  for (const target of pending) {
99
+ if (!ownsCatalog())
100
+ return;
78
101
  try {
79
102
  for (const removed of diff.removed)
80
103
  target.pi.registerTool(buildMcpTombstoneDefinition(removed, entry.name));