@vellumai/assistant 0.12.2-staging.2 → 0.12.2-staging.3

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 (153) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/openapi.yaml +17 -5
  3. package/package.json +1 -1
  4. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  5. package/src/__tests__/config-schema.test.ts +1 -1
  6. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  7. package/src/__tests__/conversation-error.test.ts +4 -1
  8. package/src/__tests__/conversation-runtime-assembly.test.ts +6 -4
  9. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  10. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  11. package/src/__tests__/host-cu-proxy.test.ts +116 -18
  12. package/src/__tests__/list-all-apps.test.ts +40 -8
  13. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  14. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  15. package/src/__tests__/mcp-health-check.test.ts +32 -32
  16. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  17. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  18. package/src/__tests__/skills.test.ts +10 -0
  19. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  20. package/src/apps/app-store.ts +6 -5
  21. package/src/browser/operations.ts +2 -1
  22. package/src/browser/types.ts +7 -0
  23. package/src/browser/virtual-desktop-target.ts +34 -0
  24. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  25. package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
  26. package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
  27. package/src/calls/progress-narration.ts +15 -2
  28. package/src/calls/voice-control-protocol.ts +34 -4
  29. package/src/calls/voice-session-bridge.ts +23 -0
  30. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  31. package/src/cli/commands/browser.help.ts +43 -0
  32. package/src/cli/commands/browser.ts +52 -8
  33. package/src/cli/commands/plugins.ts +16 -5
  34. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  35. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  36. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  37. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  38. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  39. package/src/cli/lib/bundled-marketplace.json +13 -0
  40. package/src/cli/lib/install-from-github.ts +17 -18
  41. package/src/cli/lib/install-from-platform.ts +14 -9
  42. package/src/cli/lib/list-installed-plugins.ts +21 -35
  43. package/src/cli/lib/plugin-details.ts +21 -8
  44. package/src/cli/lib/uninstall-plugin.ts +59 -1
  45. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  46. package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
  47. package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
  48. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  49. package/src/config/feature-flag-registry.json +2 -2
  50. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  51. package/src/config/schemas/mcp.ts +9 -7
  52. package/src/config/schemas/monitoring.ts +9 -0
  53. package/src/config/schemas/voice.ts +2 -2
  54. package/src/config/skills.ts +13 -21
  55. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  56. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  57. package/src/daemon/conversation-client-surface.ts +26 -0
  58. package/src/daemon/conversation-runtime-assembly.ts +1 -1
  59. package/src/daemon/conversation-tool-setup.ts +31 -29
  60. package/src/daemon/host-cu-proxy.ts +51 -26
  61. package/src/daemon/host-proxy-preactivation.ts +2 -0
  62. package/src/daemon/mcp-reload-service.ts +7 -0
  63. package/src/daemon/message-types/sync.ts +1 -0
  64. package/src/daemon/providers-setup.ts +4 -0
  65. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  66. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  67. package/src/desktop/desktop-automation-lease.ts +270 -0
  68. package/src/desktop/desktop-browser-client.test.ts +413 -0
  69. package/src/desktop/desktop-browser-client.ts +430 -0
  70. package/src/desktop/desktop-browser-cursor.ts +40 -0
  71. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  72. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  73. package/src/desktop/desktop-browser-operations.ts +130 -0
  74. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  75. package/src/desktop/desktop-chrome-session.ts +58 -8
  76. package/src/desktop/desktop-dependencies.test.ts +29 -0
  77. package/src/desktop/desktop-dependencies.ts +26 -5
  78. package/src/desktop/desktop-display.ts +9 -0
  79. package/src/desktop/desktop-panel-config.ts +14 -1
  80. package/src/desktop/desktop-session-manager.test.ts +86 -3
  81. package/src/desktop/desktop-session-manager.ts +144 -24
  82. package/src/desktop/desktop-stream-bridge.ts +1 -1
  83. package/src/desktop/virtual-desktop-feature.ts +18 -0
  84. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  85. package/src/i18n/__tests__/i18n.test.ts +9 -5
  86. package/src/i18n/messages.ts +13 -4
  87. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  88. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  89. package/src/ipc/assistant-server.ts +15 -1
  90. package/src/ipc/cli-client.ts +13 -5
  91. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
  92. package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
  93. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  94. package/src/live-voice/__tests__/protocol.test.ts +11 -3
  95. package/src/live-voice/__tests__/session-controls.test.ts +34 -0
  96. package/src/live-voice/live-voice-metrics.ts +9 -0
  97. package/src/live-voice/live-voice-session.ts +3 -0
  98. package/src/live-voice/protocol.ts +10 -1
  99. package/src/live-voice/session-controls.ts +28 -0
  100. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  101. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  102. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  103. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  104. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  105. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  106. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  107. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  108. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  109. package/src/mcp/client.ts +64 -16
  110. package/src/mcp/credential-target.ts +105 -0
  111. package/src/mcp/effective-config.ts +42 -15
  112. package/src/mcp/manager.ts +61 -8
  113. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  114. package/src/mcp/mcp-auth-state.ts +38 -6
  115. package/src/mcp/mcp-oauth-provider.ts +101 -33
  116. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  117. package/src/monitoring/mount-watch.ts +348 -0
  118. package/src/monitoring/worker.ts +9 -0
  119. package/src/oauth/seed-providers.ts +11 -0
  120. package/src/onboarding/checkin-event.ts +2 -1
  121. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  122. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  123. package/src/plugins/external-plugin-loader.ts +49 -86
  124. package/src/plugins/installed-plugin-dirs.ts +6 -4
  125. package/src/plugins/mcp-servers.ts +9 -15
  126. package/src/plugins/mtime-cache.ts +2 -1
  127. package/src/plugins/user-loader.ts +2 -1
  128. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  129. package/src/providers/inference/endpoint-probe.ts +27 -12
  130. package/src/providers/openai/responses-provider.ts +9 -2
  131. package/src/providers/opencode/client.test.ts +167 -0
  132. package/src/providers/opencode/client.ts +73 -4
  133. package/src/runtime/AGENTS.md +4 -0
  134. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  135. package/src/runtime/http-server.ts +6 -6
  136. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  137. package/src/runtime/routes/browser-context.ts +39 -0
  138. package/src/runtime/routes/browser-routes.ts +16 -47
  139. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  140. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  141. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  142. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  143. package/src/runtime/routes/plugins-routes.ts +14 -1
  144. package/src/runtime/sync/resource-sync-events.ts +4 -0
  145. package/src/tools/browser/browser-execution.ts +44 -13
  146. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  147. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  148. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  149. package/src/tools/computer-use/definitions.ts +2 -3
  150. package/src/util/abort-reasons.ts +12 -1
  151. package/src/util/plugin-manifest.ts +203 -0
  152. package/src/desktop/desktop-feature.test.ts +0 -18
  153. package/src/desktop/desktop-feature.ts +0 -19
@@ -11,16 +11,27 @@ import { existsSync, rmSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { beforeEach, describe, expect, jest, mock, test } from "bun:test";
13
13
 
14
+ const start = jest.fn(async () => []);
15
+ const stop = jest.fn(async () => {});
16
+ const publishMcpChanged = jest.fn();
17
+ const realResourceSync =
18
+ await import("../../runtime/sync/resource-sync-events.js");
19
+
14
20
  mock.module("../manager.js", () => ({
15
21
  getMcpServerManager: () => ({
16
- start: jest.fn(async () => []),
17
- stop: jest.fn(async () => {}),
22
+ start,
23
+ stop,
18
24
  callTool: jest.fn(),
19
25
  getClient: () => undefined,
20
26
  }),
21
27
  stopMcpServerManager: jest.fn(async () => {}),
22
28
  }));
23
29
 
30
+ mock.module("../../runtime/sync/resource-sync-events.js", () => ({
31
+ ...realResourceSync,
32
+ publishMcpChanged,
33
+ }));
34
+
24
35
  mock.module("../mcp-header-store.js", () => ({
25
36
  migrateLegacyMcpHeaders: async () => {},
26
37
  }));
@@ -36,6 +47,11 @@ const { reloadMcpServers } = await import("../../daemon/mcp-reload-service.js");
36
47
  const signalPath = () => join(getSignalsDir(), MCP_RELOAD_SIGNAL_FILE);
37
48
 
38
49
  beforeEach(() => {
50
+ start.mockReset();
51
+ start.mockResolvedValue([]);
52
+ stop.mockReset();
53
+ stop.mockResolvedValue(undefined);
54
+ publishMcpChanged.mockReset();
39
55
  rmSync(signalPath(), { force: true });
40
56
  });
41
57
 
@@ -51,4 +67,25 @@ describe("reloadMcpServers", () => {
51
67
  "servers the config no longer lists.",
52
68
  ).toBe(true);
53
69
  });
70
+
71
+ test("publishes status only after a slow reload settles", async () => {
72
+ let finishStop: (() => void) | undefined;
73
+ stop.mockImplementationOnce(
74
+ () =>
75
+ new Promise<void>((resolve) => {
76
+ finishStop = resolve;
77
+ }),
78
+ );
79
+
80
+ const reload = reloadMcpServers();
81
+ while (!finishStop) {
82
+ await Promise.resolve();
83
+ }
84
+ expect(publishMcpChanged).not.toHaveBeenCalled();
85
+
86
+ finishStop!();
87
+ await reload;
88
+
89
+ expect(publishMcpChanged).toHaveBeenCalledTimes(1);
90
+ });
54
91
  });
package/src/mcp/client.ts CHANGED
@@ -5,11 +5,18 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
5
5
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6
6
  import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
7
7
 
8
- import type { McpServerSource, McpTransport } from "../config/schemas/mcp.js";
9
- import { getSecureKeyAsync } from "../security/secure-keys.js";
8
+ import type {
9
+ McpServerSource,
10
+ McpTransport,
11
+ ResolvedMcpServerConfig,
12
+ } from "../config/schemas/mcp.js";
10
13
  import { getLogger } from "../util/logger.js";
14
+ import {
15
+ resolveMcpOAuthCredentialTarget,
16
+ workspaceMcpOAuthCredentialTarget,
17
+ } from "./credential-target.js";
11
18
  import { getMcpHeaders } from "./mcp-header-store.js";
12
- import { McpOAuthProvider } from "./mcp-oauth-provider.js";
19
+ import { hasMcpOAuthTokens, McpOAuthProvider } from "./mcp-oauth-provider.js";
13
20
 
14
21
  const log = getLogger("mcp-client");
15
22
 
@@ -55,11 +62,12 @@ export interface McpCallResult {
55
62
  export class McpClient {
56
63
  readonly serverId: string;
57
64
  /**
58
- * Where this server was declared. Only a `workspace` server resolves
59
- * `mcp:<serverId>:tokens` / `mcp:<serverId>:headers` from the credential
60
- * store — see {@link McpServerSource} for why a plugin server must not.
65
+ * Where this server was declared. Workspace servers use their public id for
66
+ * OAuth and static-header identity. Plugin servers use their declaring owner,
67
+ * original key, and endpoint for OAuth and never read workspace headers.
61
68
  */
62
69
  readonly source: McpServerSource;
70
+ private readonly serverConfig: ResolvedMcpServerConfig | null;
63
71
  private client: Client;
64
72
  private transport:
65
73
  | StdioClientTransport
@@ -79,9 +87,18 @@ export class McpClient {
79
87
  return this.connected;
80
88
  }
81
89
 
82
- constructor(serverId: string, source: McpServerSource = "workspace") {
90
+ constructor(
91
+ serverId: string,
92
+ sourceOrConfig: McpServerSource | ResolvedMcpServerConfig = "workspace",
93
+ private readonly onUnexpectedClose?: () => void,
94
+ ) {
83
95
  this.serverId = serverId;
84
- this.source = source;
96
+ this.source =
97
+ typeof sourceOrConfig === "string"
98
+ ? sourceOrConfig
99
+ : sourceOrConfig.source;
100
+ this.serverConfig =
101
+ typeof sourceOrConfig === "string" ? null : sourceOrConfig;
85
102
  this.client = new Client({
86
103
  name: "vellum-assistant",
87
104
  version: "1.0.0",
@@ -96,6 +113,14 @@ export class McpClient {
96
113
  "MCP SDK transport error (non-fatal)",
97
114
  );
98
115
  };
116
+ this.client.onclose = () => {
117
+ const wasConnected = this.connected;
118
+ this.connected = false;
119
+ this.transport = null;
120
+ if (wasConnected) {
121
+ this.onUnexpectedClose?.();
122
+ }
123
+ };
99
124
  }
100
125
 
101
126
  async connect(transportConfig: McpTransport): Promise<void> {
@@ -106,21 +131,20 @@ export class McpClient {
106
131
  const isHttpTransport =
107
132
  transportConfig.type === "sse" ||
108
133
  transportConfig.type === "streamable-http";
109
- const usesStoredCredentials = this.source === "workspace";
134
+ const credentialTarget = this.resolveCredentialTarget(transportConfig);
135
+ this.oauthProvider = null;
110
136
 
111
137
  // For HTTP transports, only attach an OAuth provider if cached tokens
112
138
  // exist. This avoids triggering client registration during daemon
113
139
  // startup. If no tokens, try without auth: if the server requires it,
114
140
  // skip silently.
115
- if (isHttpTransport && usesStoredCredentials) {
116
- const cachedTokens = await getSecureKeyAsync(
117
- `mcp:${this.serverId}:tokens`,
118
- );
119
- if (cachedTokens) {
141
+ if (isHttpTransport && credentialTarget) {
142
+ if (await hasMcpOAuthTokens(credentialTarget)) {
120
143
  this.oauthProvider = new McpOAuthProvider(
121
144
  this.serverId,
122
145
  transportConfig.url,
123
146
  /* interactive */ false,
147
+ { credentialTarget },
124
148
  );
125
149
  }
126
150
  }
@@ -128,7 +152,7 @@ export class McpClient {
128
152
  // Resolve static auth headers from credential store, falling back to
129
153
  // any legacy headers in the transport config for backward compatibility.
130
154
  let effectiveConfig = transportConfig;
131
- if (isHttpTransport && usesStoredCredentials) {
155
+ if (isHttpTransport && this.source === "workspace") {
132
156
  const storedHeaders = await getMcpHeaders(this.serverId);
133
157
  if (storedHeaders) {
134
158
  effectiveConfig = {
@@ -184,10 +208,34 @@ export class McpClient {
184
208
  return;
185
209
  }
186
210
 
211
+ if (this.transport === null) {
212
+ this._lastError = new Error(
213
+ `MCP server "${this.serverId}" closed during initialization`,
214
+ );
215
+ return;
216
+ }
217
+
187
218
  this.connected = true;
188
219
  log.info({ serverId: this.serverId }, "MCP client connected");
189
220
  }
190
221
 
222
+ private resolveCredentialTarget(
223
+ transport: McpTransport,
224
+ ): ReturnType<typeof resolveMcpOAuthCredentialTarget> {
225
+ if (this.source === "workspace") {
226
+ return transport.type === "stdio"
227
+ ? null
228
+ : workspaceMcpOAuthCredentialTarget(this.serverId);
229
+ }
230
+ if (!this.serverConfig) {
231
+ return null;
232
+ }
233
+ return resolveMcpOAuthCredentialTarget(this.serverId, {
234
+ ...this.serverConfig,
235
+ transport,
236
+ });
237
+ }
238
+
191
239
  async listTools(): Promise<McpToolInfo[]> {
192
240
  if (!this.connected) {
193
241
  throw new Error(`MCP client "${this.serverId}" is not connected`);
@@ -278,12 +326,12 @@ export class McpClient {
278
326
  return;
279
327
  }
280
328
 
329
+ this.connected = false;
281
330
  try {
282
331
  await this.client.close();
283
332
  } catch (err) {
284
333
  log.warn({ err, serverId: this.serverId }, "Error closing MCP client");
285
334
  }
286
- this.connected = false;
287
335
  this.transport = null;
288
336
  log.info({ serverId: this.serverId }, "MCP client disconnected");
289
337
  }
@@ -0,0 +1,105 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import type {
4
+ McpTransport,
5
+ ResolvedMcpServerConfig,
6
+ } from "../config/schemas/mcp.js";
7
+
8
+ export const MCP_OAUTH_CREDENTIAL_LEAVES = [
9
+ "tokens",
10
+ "client_info",
11
+ "client_binding",
12
+ "discovery",
13
+ ] as const;
14
+
15
+ export type McpOAuthCredentialLeaf =
16
+ (typeof MCP_OAUTH_CREDENTIAL_LEAVES)[number];
17
+
18
+ export type McpOAuthCredentialTarget =
19
+ | {
20
+ readonly source: "workspace";
21
+ readonly serverId: string;
22
+ }
23
+ | {
24
+ readonly source: "plugin";
25
+ readonly pluginName: string;
26
+ readonly serverKey: string;
27
+ readonly transportType: "sse" | "streamable-http";
28
+ readonly url: string;
29
+ };
30
+
31
+ export function workspaceMcpOAuthCredentialTarget(
32
+ serverId: string,
33
+ ): McpOAuthCredentialTarget {
34
+ return { source: "workspace", serverId };
35
+ }
36
+
37
+ export function resolveMcpOAuthCredentialTarget(
38
+ serverId: string,
39
+ config: ResolvedMcpServerConfig,
40
+ ): McpOAuthCredentialTarget | null {
41
+ const transport = config.transport;
42
+ if (transport.type === "stdio") {
43
+ return null;
44
+ }
45
+ if (config.source === "workspace") {
46
+ return workspaceMcpOAuthCredentialTarget(serverId);
47
+ }
48
+ return pluginMcpOAuthCredentialTarget(
49
+ config.pluginName,
50
+ config.serverKey,
51
+ transport,
52
+ );
53
+ }
54
+
55
+ export function pluginMcpOAuthCredentialTarget(
56
+ pluginName: string,
57
+ serverKey: string,
58
+ transport: Extract<McpTransport, { type: "sse" | "streamable-http" }>,
59
+ ): McpOAuthCredentialTarget {
60
+ return {
61
+ source: "plugin",
62
+ pluginName,
63
+ serverKey,
64
+ transportType: transport.type,
65
+ url: transport.url,
66
+ };
67
+ }
68
+
69
+ export function mcpOAuthCredentialKey(
70
+ target: McpOAuthCredentialTarget,
71
+ leaf: McpOAuthCredentialLeaf,
72
+ ): string {
73
+ if (target.source === "workspace") {
74
+ return `mcp:${target.serverId}:${leaf}`;
75
+ }
76
+ return `${pluginMcpOAuthCredentialPrefix(target.pluginName, target.serverKey, target.transportType, target.url)}${leaf}`;
77
+ }
78
+
79
+ export function pluginMcpOAuthCredentialPrefix(
80
+ pluginName: string,
81
+ serverKey?: string,
82
+ transportType?: "sse" | "streamable-http",
83
+ rawUrl?: string,
84
+ ): string {
85
+ const pluginPrefix = `mcp-plugin/v1/${encodeSegment(pluginName)}/`;
86
+ if (serverKey === undefined) {
87
+ return pluginPrefix;
88
+ }
89
+ if (transportType === undefined || rawUrl === undefined) {
90
+ throw new Error("Plugin MCP credential endpoint is incomplete");
91
+ }
92
+ const endpoint = `${transportType}\n${canonicalUrlWithoutFragment(rawUrl)}`;
93
+ const endpointDigest = createHash("sha256").update(endpoint).digest("hex");
94
+ return `${pluginPrefix}${encodeSegment(serverKey)}/${endpointDigest}/`;
95
+ }
96
+
97
+ function encodeSegment(value: string): string {
98
+ return Buffer.from(value, "utf8").toString("base64url");
99
+ }
100
+
101
+ function canonicalUrlWithoutFragment(rawUrl: string): string {
102
+ const url = new URL(rawUrl);
103
+ url.hash = "";
104
+ return url.toString();
105
+ }
@@ -42,6 +42,25 @@ let lastBuiltPluginFingerprint: string | null = null;
42
42
  export function buildEffectiveMcpConfig(
43
43
  workspaceConfig?: McpConfig,
44
44
  ): ResolvedMcpConfig {
45
+ const result = resolveEffectiveMcpConfig(workspaceConfig, true);
46
+ lastBuiltPluginFingerprint = fingerprintServers(result.pluginServers);
47
+ return result.config;
48
+ }
49
+
50
+ /** Resolve the effective config without recording it as applied by runtime. */
51
+ export function readEffectiveMcpConfig(
52
+ workspaceConfig?: McpConfig,
53
+ ): ResolvedMcpConfig {
54
+ return resolveEffectiveMcpConfig(workspaceConfig, false).config;
55
+ }
56
+
57
+ function resolveEffectiveMcpConfig(
58
+ workspaceConfig: McpConfig | undefined,
59
+ reportIssues: boolean,
60
+ ): {
61
+ config: ResolvedMcpConfig;
62
+ pluginServers: ReturnType<typeof readPluginMcpServers>["servers"];
63
+ } {
45
64
  const base = workspaceConfig ?? McpConfigSchema.parse({});
46
65
  const servers: Record<string, ResolvedMcpServerConfig> = {};
47
66
  for (const [id, config] of Object.entries(base.servers)) {
@@ -49,30 +68,32 @@ export function buildEffectiveMcpConfig(
49
68
  }
50
69
 
51
70
  const { servers: pluginServers, issues } = readPluginMcpServers();
52
- for (const issue of issues) {
53
- log.warn(
54
- {
55
- plugin: issue.pluginName,
56
- ...(issue.serverKey && { serverKey: issue.serverKey }),
57
- },
58
- `Plugin MCP declaration problem: ${issue.message}`,
59
- );
71
+ if (reportIssues) {
72
+ for (const issue of issues) {
73
+ log.warn(
74
+ {
75
+ plugin: issue.pluginName,
76
+ ...(issue.serverKey && { serverKey: issue.serverKey }),
77
+ },
78
+ `Plugin MCP declaration problem: ${issue.message}`,
79
+ );
80
+ }
60
81
  }
61
82
 
62
83
  for (const server of pluginServers) {
63
84
  if (Object.hasOwn(servers, server.id)) {
64
- log.warn(
65
- { plugin: server.pluginName, serverId: server.id },
66
- "Plugin MCP server shadowed by a workspace server of the same id; skipping",
67
- );
85
+ if (reportIssues) {
86
+ log.warn(
87
+ { plugin: server.pluginName, serverId: server.id },
88
+ "Plugin MCP server shadowed by a workspace server of the same id; skipping",
89
+ );
90
+ }
68
91
  continue;
69
92
  }
70
93
  servers[server.id] = server.config;
71
94
  }
72
95
 
73
- lastBuiltPluginFingerprint = fingerprintPluginServers();
74
-
75
- return { ...base, servers };
96
+ return { config: { ...base, servers }, pluginServers };
76
97
  }
77
98
 
78
99
  /**
@@ -98,6 +119,12 @@ export function pluginMcpServersChangedSinceLastBuild(): boolean {
98
119
  */
99
120
  function fingerprintPluginServers(): string {
100
121
  const { servers } = readPluginMcpServers();
122
+ return fingerprintServers(servers);
123
+ }
124
+
125
+ function fingerprintServers(
126
+ servers: ReturnType<typeof readPluginMcpServers>["servers"],
127
+ ): string {
101
128
  return JSON.stringify(
102
129
  [...servers]
103
130
  .sort((a, b) => a.id.localeCompare(b.id))
@@ -15,9 +15,26 @@ export interface McpServerToolInfo {
15
15
  tools: McpToolInfo[];
16
16
  }
17
17
 
18
+ export type McpConnectionState =
19
+ | "connecting"
20
+ | "connected"
21
+ | "needs-auth"
22
+ | "error";
23
+
24
+ export type McpUnexpectedCloseHandler = () => void;
25
+
18
26
  export class McpServerManager {
19
27
  private clients = new Map<string, McpClient>();
20
- private serverConfigs = new Map<string, ResolvedMcpServerConfig>();
28
+ private connectionStates = new Map<
29
+ string,
30
+ { source: ResolvedMcpServerConfig["source"]; state: McpConnectionState }
31
+ >();
32
+
33
+ constructor(private onUnexpectedClose?: McpUnexpectedCloseHandler) {}
34
+
35
+ setUnexpectedCloseHandler(handler: McpUnexpectedCloseHandler): void {
36
+ this.onUnexpectedClose = handler;
37
+ }
21
38
 
22
39
  async start(config: ResolvedMcpConfig): Promise<McpServerToolInfo[]> {
23
40
  const results: McpServerToolInfo[] = [];
@@ -26,6 +43,10 @@ export class McpServerManager {
26
43
  `[MCP] Starting ${Object.keys(config.servers).length} server(s)...`,
27
44
  );
28
45
  for (const [serverId, serverConfig] of Object.entries(config.servers)) {
46
+ this.connectionStates.set(serverId, {
47
+ source: serverConfig.source,
48
+ state: "connecting",
49
+ });
29
50
  try {
30
51
  console.log(
31
52
  `[MCP] Starting server "${serverId}" (transport: ${serverConfig.transport.type})`,
@@ -39,20 +60,37 @@ export class McpServerManager {
39
60
  "HTTP transport: OAuth provider will be available if server requires authentication",
40
61
  );
41
62
  }
42
- // The server's own origin decides whether it may resolve
43
- // `mcp:<serverId>:*` from the credential store.
44
- const client = new McpClient(serverId, serverConfig.source);
63
+ const client = new McpClient(serverId, serverConfig, () => {
64
+ if (this.clients.get(serverId) !== client) {
65
+ return;
66
+ }
67
+ this.connectionStates.set(serverId, {
68
+ source: serverConfig.source,
69
+ state: "error",
70
+ });
71
+ this.onUnexpectedClose?.();
72
+ });
45
73
  await client.connect(serverConfig.transport);
46
74
 
47
75
  if (!client.isConnected) {
48
- // Server requires authentication — connect() logged guidance
76
+ this.connectionStates.set(serverId, {
77
+ source: serverConfig.source,
78
+ state: client.lastError ? "error" : "needs-auth",
79
+ });
49
80
  continue;
50
81
  }
51
82
 
52
83
  this.clients.set(serverId, client);
53
- this.serverConfigs.set(serverId, serverConfig);
54
84
 
55
85
  let tools = await client.listTools();
86
+ if (!client.isConnected || this.clients.get(serverId) !== client) {
87
+ this.clients.delete(serverId);
88
+ this.connectionStates.set(serverId, {
89
+ source: serverConfig.source,
90
+ state: "error",
91
+ });
92
+ continue;
93
+ }
56
94
  log.info(
57
95
  { serverId, toolCount: tools.length },
58
96
  "MCP server tools discovered",
@@ -71,7 +109,15 @@ export class McpServerManager {
71
109
  }
72
110
 
73
111
  results.push({ serverId, serverConfig, tools });
112
+ this.connectionStates.set(serverId, {
113
+ source: serverConfig.source,
114
+ state: "connected",
115
+ });
74
116
  } catch (err) {
117
+ this.connectionStates.set(serverId, {
118
+ source: serverConfig.source,
119
+ state: "error",
120
+ });
75
121
  console.error(`[MCP] Failed to connect to server "${serverId}":`, err);
76
122
  log.error({ err, serverId }, "Failed to connect to MCP server");
77
123
  // Clean up any partially-connected client
@@ -83,7 +129,6 @@ export class McpServerManager {
83
129
  /* ignore */
84
130
  }
85
131
  this.clients.delete(serverId);
86
- this.serverConfigs.delete(serverId);
87
132
  }
88
133
  }
89
134
  }
@@ -119,7 +164,7 @@ export class McpServerManager {
119
164
  );
120
165
  await Promise.all(disconnects);
121
166
  this.clients.clear();
122
- this.serverConfigs.clear();
167
+ this.connectionStates.clear();
123
168
  log.info("All MCP servers disconnected");
124
169
  }
125
170
 
@@ -139,6 +184,14 @@ export class McpServerManager {
139
184
  getClient(serverId: string): McpClient | undefined {
140
185
  return this.clients.get(serverId);
141
186
  }
187
+
188
+ getServerState(
189
+ serverId: string,
190
+ source: ResolvedMcpServerConfig["source"],
191
+ ): McpConnectionState | undefined {
192
+ const entry = this.connectionStates.get(serverId);
193
+ return entry?.source === source ? entry.state : undefined;
194
+ }
142
195
  }
143
196
 
144
197
  // Singleton instance
@@ -15,6 +15,11 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
15
15
 
16
16
  import { reloadMcpServers } from "../daemon/mcp-reload-service.js";
17
17
  import { getLogger } from "../util/logger.js";
18
+ import {
19
+ mcpOAuthCredentialKey,
20
+ type McpOAuthCredentialTarget,
21
+ workspaceMcpOAuthCredentialTarget,
22
+ } from "./credential-target.js";
18
23
  import {
19
24
  setMcpAuthComplete,
20
25
  setMcpAuthError,
@@ -46,8 +51,12 @@ export interface OrchestrateMcpOAuthConnectResult {
46
51
  export async function orchestrateMcpOAuthConnect(args: {
47
52
  serverId: string;
48
53
  transport: McpAuthTransportConfig;
54
+ credentialTarget?: McpOAuthCredentialTarget;
49
55
  }): Promise<OrchestrateMcpOAuthConnectResult> {
50
56
  const { serverId, transport } = args;
57
+ const credentialTarget =
58
+ args.credentialTarget ?? workspaceMcpOAuthCredentialTarget(serverId);
59
+ const targetKey = mcpOAuthCredentialKey(credentialTarget, "tokens");
51
60
 
52
61
  let capturedAuthUrl: string | undefined;
53
62
  const provider = new McpOAuthProvider(
@@ -55,6 +64,7 @@ export async function orchestrateMcpOAuthConnect(args: {
55
64
  transport.url,
56
65
  /* interactive */ false,
57
66
  {
67
+ credentialTarget,
58
68
  onAuthorizationUrl: (url) => {
59
69
  capturedAuthUrl = url;
60
70
  },
@@ -72,7 +82,10 @@ export async function orchestrateMcpOAuthConnect(args: {
72
82
  const { codePromise } = await provider.startCallbackServer();
73
83
 
74
84
  // Resolve effective headers: credential store takes precedence, then config
75
- const storedHeaders = await getMcpHeaders(serverId);
85
+ const storedHeaders =
86
+ credentialTarget.source === "workspace"
87
+ ? await getMcpHeaders(serverId)
88
+ : null;
76
89
  const effectiveHeaders = storedHeaders ?? transport.headers;
77
90
 
78
91
  // Build the MCP transport and client
@@ -126,7 +139,7 @@ export async function orchestrateMcpOAuthConnect(args: {
126
139
  // finishes cannot have its slot overwritten by stale completion writes
127
140
  // from the older attempt.
128
141
  const attemptId = crypto.randomUUID();
129
- setMcpAuthPending(serverId, capturedAuthUrl, attemptId);
142
+ setMcpAuthPending(serverId, capturedAuthUrl, attemptId, targetKey);
130
143
 
131
144
  // Fire-and-forget background tail — completes the token exchange once
132
145
  // the user approves in the browser.
@@ -153,7 +166,7 @@ export async function orchestrateMcpOAuthConnect(args: {
153
166
  }),
154
167
  ]);
155
168
  await mcpTransport.finishAuth(code);
156
- const applied = setMcpAuthComplete(serverId, attemptId);
169
+ const applied = setMcpAuthComplete(serverId, attemptId, targetKey);
157
170
  if (!applied) {
158
171
  log.info(
159
172
  { serverId, attemptId },
@@ -182,7 +195,7 @@ export async function orchestrateMcpOAuthConnect(args: {
182
195
  }
183
196
  } catch (err) {
184
197
  const message = err instanceof Error ? err.message : String(err);
185
- const applied = setMcpAuthError(serverId, message, attemptId);
198
+ const applied = setMcpAuthError(serverId, message, attemptId, targetKey);
186
199
  if (!applied) {
187
200
  log.info(
188
201
  { serverId, attemptId, error: message },