@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
@@ -2,22 +2,23 @@
2
2
  * Routes for browser tab management commands.
3
3
  *
4
4
  * Exposes `browser_tabs` so CLI commands can list, create, select, and
5
- * close browser tabs via the Chrome extension backend.
5
+ * close browser tabs in the Chrome extension or streamed desktop browser.
6
6
  */
7
7
 
8
8
  import { z } from "zod";
9
9
 
10
- import { findConversation } from "../../daemon/conversation-registry.js";
10
+ import { shouldUseVirtualDesktopBrowser } from "../../browser/virtual-desktop-target.js";
11
11
  import { HostBrowserProxy } from "../../daemon/host-browser-proxy.js";
12
+ import { executeDesktopBrowserTabs } from "../../desktop/desktop-browser-operations.js";
13
+ import { normalizeBrowserMode } from "../../tools/browser/browser-mode.js";
12
14
  import { getCdpClient } from "../../tools/browser/cdp-client/factory.js";
13
15
  import {
14
16
  clearPinnedTab,
15
17
  clearPinnedTabByTabId,
16
18
  setPinnedTab,
17
19
  } from "../../tools/browser/pinned-tabs.js";
18
- import type { ToolContext } from "../../tools/types.js";
19
20
  import { LOCAL_PRINCIPALS } from "../auth/route-policy.js";
20
- import { browserCliConversationKey } from "./browser-routes.js";
21
+ import { resolveBrowserContext } from "./browser-context.js";
21
22
  import { BadRequestError } from "./errors.js";
22
23
  import { parseBody } from "./parse-body.js";
23
24
  import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
@@ -26,6 +27,8 @@ const BrowserTabsParams = z.object({
26
27
  command: z.enum(["list", "select", "new", "close"]),
27
28
  sessionId: z.string().min(1).default("default"),
28
29
  conversationId: z.string().min(1).optional(),
30
+ desktop: z.boolean().optional(),
31
+ browserMode: z.string().optional(),
29
32
  tabId: z.number().optional(),
30
33
  url: z.string().optional(),
31
34
  // Route tab operations to a specific extension client in multi-client
@@ -33,33 +36,73 @@ const BrowserTabsParams = z.object({
33
36
  targetClientId: z.string().min(1).optional(),
34
37
  });
35
38
 
36
- async function handleBrowserTabs({ body = {} }: RouteHandlerArgs) {
37
- const { command, sessionId, conversationId, tabId, url, targetClientId } =
38
- parseBody(BrowserTabsParams, body);
39
-
40
- const conversation = conversationId
41
- ? findConversation(conversationId)
42
- : undefined;
43
- const resolvedConversationId = conversation
44
- ? conversationId!
45
- : browserCliConversationKey(sessionId);
46
-
47
- const context = {
48
- workingDir: process.cwd(),
49
- conversationId: resolvedConversationId,
50
- trustClass: conversation?.trustContext?.trustClass ?? "unknown",
51
- transportInterface: conversation?.transportInterface,
52
- } as unknown as ToolContext;
39
+ async function handleBrowserTabs({
40
+ body = {},
41
+ headers = {},
42
+ abortSignal,
43
+ }: RouteHandlerArgs) {
44
+ const {
45
+ command,
46
+ sessionId,
47
+ conversationId,
48
+ tabId,
49
+ url,
50
+ targetClientId,
51
+ desktop,
52
+ browserMode,
53
+ } = parseBody(BrowserTabsParams, body);
53
54
 
54
- const cdpOptions = { mode: "extension" as const, targetClientId };
55
+ const context = await resolveBrowserContext(
56
+ conversationId,
57
+ sessionId,
58
+ headers,
59
+ abortSignal,
60
+ );
61
+ const resolvedConversationId = context.conversationId;
62
+ const mode = normalizeBrowserMode(browserMode);
63
+ if ("error" in mode) {
64
+ throw new BadRequestError(mode.error);
65
+ }
66
+ if (
67
+ shouldUseVirtualDesktopBrowser(
68
+ desktop,
69
+ { browser_mode: browserMode, target_client_id: targetClientId },
70
+ context,
71
+ )
72
+ ) {
73
+ if (
74
+ targetClientId ||
75
+ (mode.mode !== "auto" && mode.mode !== "cdp-inspect")
76
+ ) {
77
+ throw new BadRequestError(
78
+ "--virtual-desktop cannot target a personal browser client",
79
+ );
80
+ }
81
+ const result = await executeDesktopBrowserTabs(
82
+ { command, tabId, url },
83
+ context,
84
+ );
85
+ if (result.isError) {
86
+ throw new BadRequestError(result.content);
87
+ }
88
+ return JSON.parse(result.content);
89
+ }
55
90
 
56
- // Every tabs command pins extension mode. Absorb a brief extension SSE
57
- // reconnect blip so a flapping connection doesn't surface as a hard
58
- // "no Chrome Extension connected" error.
59
- await HostBrowserProxy.instance.waitForExtensionClient(
60
- context.sourceActorPrincipalId,
91
+ const cdpOptions = {
92
+ mode:
93
+ targetClientId || mode.mode === "auto"
94
+ ? ("extension" as const)
95
+ : mode.mode,
61
96
  targetClientId,
62
- );
97
+ };
98
+
99
+ // Absorb brief reconnects before dispatching to the extension.
100
+ if (cdpOptions.mode === "extension") {
101
+ await HostBrowserProxy.instance.waitForExtensionClient(
102
+ context.sourceActorPrincipalId,
103
+ targetClientId,
104
+ );
105
+ }
63
106
 
64
107
  if (command === "list") {
65
108
  const cdp = getCdpClient(context, cdpOptions);
@@ -185,7 +228,7 @@ export const ROUTES: RouteDefinition[] = [
185
228
  handler: handleBrowserTabs,
186
229
  summary: "Manage browser tabs",
187
230
  description:
188
- "List, create, select, or close browser tabs via the Chrome extension backend.",
231
+ "List, create, select, or close browser tabs in the Chrome extension or streamed desktop browser.",
189
232
  tags: ["browser"],
190
233
  requestBody: BrowserTabsParams,
191
234
  responseBody: z.object({
@@ -14,6 +14,7 @@ import { ROUTES } from "./desktop-setup-routes.js";
14
14
  import { NotFoundError } from "./errors.js";
15
15
 
16
16
  const originalContainerized = process.env.IS_CONTAINERIZED;
17
+ const originalPlatform = process.env.IS_PLATFORM;
17
18
  const status = spyOn(desktopDependencyInstaller, "getStatus");
18
19
  const start = spyOn(desktopDependencyInstaller, "start");
19
20
 
@@ -24,6 +25,7 @@ afterAll(() => {
24
25
 
25
26
  beforeEach(() => {
26
27
  process.env.IS_CONTAINERIZED = "true";
28
+ process.env.IS_PLATFORM = "true";
27
29
  status.mockReturnValue({ state: "required" });
28
30
  start.mockReturnValue({ state: "installing", stage: "packages" });
29
31
  });
@@ -34,6 +36,11 @@ afterEach(() => {
34
36
  } else {
35
37
  process.env.IS_CONTAINERIZED = originalContainerized;
36
38
  }
39
+ if (originalPlatform === undefined) {
40
+ delete process.env.IS_PLATFORM;
41
+ } else {
42
+ process.env.IS_PLATFORM = originalPlatform;
43
+ }
37
44
  setOverridesForTesting({});
38
45
  status.mockClear();
39
46
  start.mockClear();
@@ -60,7 +67,15 @@ describe("desktop setup route feature gate", () => {
60
67
  expect(start).not.toHaveBeenCalled();
61
68
  });
62
69
 
63
- test(`${route.method} serves setup only with the flag enabled in a container`, () => {
70
+ test(`${route.method} rejects self-hosted Docker assistants without inspecting or installing dependencies`, () => {
71
+ process.env.IS_PLATFORM = "false";
72
+ setOverridesForTesting({ "assistant-desktop": true });
73
+ expect(() => route.handler({})).toThrow(NotFoundError);
74
+ expect(status).not.toHaveBeenCalled();
75
+ expect(start).not.toHaveBeenCalled();
76
+ });
77
+
78
+ test(`${route.method} serves setup only for enabled platform containers`, () => {
64
79
  setOverridesForTesting({ "assistant-desktop": true });
65
80
  expect(route.handler({})).toEqual(
66
81
  route.method === "GET"
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
 
3
3
  import { getConfig } from "../../config/loader.js";
4
4
  import { desktopDependencyInstaller } from "../../desktop/desktop-dependencies.js";
5
- import { isAssistantDesktopEnabled } from "../../desktop/desktop-feature.js";
5
+ import { isVirtualDesktopEnabled } from "../../desktop/virtual-desktop-feature.js";
6
6
  import { GATEWAY_PRINCIPALS } from "../auth/route-policy.js";
7
7
  import { NotFoundError } from "./errors.js";
8
8
  import type { RouteDefinition } from "./types.js";
@@ -19,8 +19,10 @@ export const ROUTES: RouteDefinition[] = ["GET", "POST"].map((method) => ({
19
19
  method,
20
20
  policy: { requiredScopes: [], allowedPrincipalTypes: GATEWAY_PRINCIPALS },
21
21
  handler: () => {
22
- if (!isAssistantDesktopEnabled(getConfig())) {
23
- throw new NotFoundError("Desktop is not available on this assistant");
22
+ if (!isVirtualDesktopEnabled(getConfig())) {
23
+ throw new NotFoundError(
24
+ "Virtual desktop is available only on enabled platform-hosted assistants",
25
+ );
24
26
  }
25
27
  return method === "GET"
26
28
  ? desktopDependencyInstaller.getStatus()
@@ -28,8 +30,8 @@ export const ROUTES: RouteDefinition[] = ["GET", "POST"].map((method) => ({
28
30
  },
29
31
  summary:
30
32
  method === "GET"
31
- ? "Get desktop setup status"
32
- : "Install desktop components",
33
+ ? "Get virtual desktop setup status"
34
+ : "Install virtual desktop components",
33
35
  tags: ["desktop"],
34
36
  responseBody: statusSchema,
35
37
  }));
@@ -17,7 +17,12 @@ import { z } from "zod";
17
17
  import type { McpServerConfig } from "../../config/schemas/mcp.js";
18
18
  import { estimateToolDefinitionTokens } from "../../context/token-estimator.js";
19
19
  import { reloadMcpServers } from "../../daemon/mcp-reload-service.js";
20
- import { McpClient } from "../../mcp/client.js";
20
+ import {
21
+ mcpOAuthCredentialKey,
22
+ resolveMcpOAuthCredentialTarget,
23
+ workspaceMcpOAuthCredentialTarget,
24
+ } from "../../mcp/credential-target.js";
25
+ import { readEffectiveMcpConfig } from "../../mcp/effective-config.js";
21
26
  import { getMcpServerManager } from "../../mcp/manager.js";
22
27
  import { orchestrateMcpOAuthConnect } from "../../mcp/mcp-auth-orchestrator.js";
23
28
  import { getMcpAuthState } from "../../mcp/mcp-auth-state.js";
@@ -74,9 +79,7 @@ function persistWorkspaceMcpConfig(config: {
74
79
  try {
75
80
  saveWorkspaceMcpConfig(config);
76
81
  } catch (err) {
77
- throw new InternalError(
78
- err instanceof Error ? err.message : String(err),
79
- );
82
+ throw new InternalError(err instanceof Error ? err.message : String(err));
80
83
  }
81
84
  }
82
85
 
@@ -91,8 +94,9 @@ async function handleMcpAuthStart({
91
94
  }> {
92
95
  const { serverId } = parseBody(McpServerIdParams, body);
93
96
 
94
- const servers = loadWorkspaceMcpConfig().servers;
95
- const serverConfig = servers[serverId];
97
+ const serverConfig = readEffectiveMcpConfig(loadWorkspaceMcpConfig()).servers[
98
+ serverId
99
+ ];
96
100
 
97
101
  if (!serverConfig) {
98
102
  throw new BadRequestError(`MCP server "${serverId}" not configured`);
@@ -105,10 +109,29 @@ async function handleMcpAuthStart({
105
109
  );
106
110
  }
107
111
 
112
+ const credentialTarget = resolveMcpOAuthCredentialTarget(
113
+ serverId,
114
+ serverConfig,
115
+ );
116
+ if (!credentialTarget) {
117
+ throw new BadRequestError(
118
+ `OAuth only supported for sse/streamable-http transports (server "${serverId}" uses ${transport.type})`,
119
+ );
120
+ }
121
+
122
+ try {
123
+ mcpOAuthCredentialKey(credentialTarget, "tokens");
124
+ } catch {
125
+ throw new BadRequestError(
126
+ `MCP server "${serverId}" has an invalid transport URL`,
127
+ );
128
+ }
129
+
108
130
  let result: { auth_url: string; already_authenticated?: boolean };
109
131
  try {
110
132
  result = await orchestrateMcpOAuthConnect({
111
133
  serverId,
134
+ credentialTarget,
112
135
  transport: {
113
136
  url: transport.url,
114
137
  type: transport.type,
@@ -135,7 +158,19 @@ function handleMcpAuthStatus({
135
158
  | { status: "complete" }
136
159
  | { status: "error"; error: string } {
137
160
  const { serverId } = pathParams as { serverId: string };
138
- const state = getMcpAuthState(serverId);
161
+ const serverConfig = readEffectiveMcpConfig(loadWorkspaceMcpConfig()).servers[
162
+ serverId
163
+ ];
164
+ const target = serverConfig
165
+ ? resolveMcpOAuthCredentialTarget(serverId, serverConfig)
166
+ : null;
167
+ let targetKey: string | undefined;
168
+ try {
169
+ targetKey = target ? mcpOAuthCredentialKey(target, "tokens") : undefined;
170
+ } catch {
171
+ targetKey = undefined;
172
+ }
173
+ const state = targetKey ? getMcpAuthState(serverId, targetKey) : null;
139
174
 
140
175
  if (state === null) {
141
176
  throw new NotFoundError(`No active OAuth flow for server "${serverId}"`);
@@ -170,53 +205,6 @@ function handleMcpReload(_args: { body?: Record<string, unknown> }): {
170
205
  return { ok: true };
171
206
  }
172
207
 
173
- // ---------------------------------------------------------------------------
174
- // Health check helper
175
- // ---------------------------------------------------------------------------
176
-
177
- const HEALTH_CHECK_TIMEOUT_MS = 10_000;
178
-
179
- async function checkMachineReadableHealth(
180
- serverId: string,
181
- config: McpServerConfig,
182
- timeoutMs = HEALTH_CHECK_TIMEOUT_MS,
183
- ): Promise<string> {
184
- const client = new McpClient(serverId);
185
- try {
186
- await Promise.race([
187
- client.connect(config.transport),
188
- new Promise<never>((_, reject) => {
189
- const t = setTimeout(() => reject(new Error("timeout")), timeoutMs);
190
- if (typeof t === "object" && "unref" in t) {
191
- t.unref();
192
- }
193
- }),
194
- ]);
195
-
196
- if (client.isConnected) {
197
- await client.disconnect();
198
- return "connected";
199
- }
200
-
201
- const err = client.lastError;
202
- if (err) {
203
- if (err.message.includes("timeout")) {
204
- return "error";
205
- }
206
- return "error";
207
- }
208
-
209
- return "needs-auth";
210
- } catch {
211
- try {
212
- await client.disconnect();
213
- } catch {
214
- /* ignore */
215
- }
216
- return "error";
217
- }
218
- }
219
-
220
208
  // ---------------------------------------------------------------------------
221
209
  // List
222
210
  // ---------------------------------------------------------------------------
@@ -271,12 +259,19 @@ async function handleMcpList(_args: {
271
259
  const configEntries = (
272
260
  Object.entries(servers) as [string, McpServerConfig][]
273
261
  ).filter(([, config]) => config && typeof config === "object");
262
+ const manager = getMcpServerManager();
274
263
 
275
264
  const workspaceEntries: McpServerEntry[] = await Promise.all(
276
265
  configEntries.map(async ([id, config]) => {
277
- const status = await checkMachineReadableHealth(id, config);
266
+ const runtimeState = manager.getServerState(id, "workspace");
267
+ const status =
268
+ runtimeState === "connected" || runtimeState === "needs-auth"
269
+ ? runtimeState
270
+ : "error";
278
271
  const hasOAuth =
279
- config.transport.type !== "stdio" ? await hasMcpOAuthTokens(id) : false;
272
+ config.transport.type !== "stdio"
273
+ ? await hasMcpOAuthTokens(workspaceMcpOAuthCredentialTarget(id))
274
+ : false;
280
275
 
281
276
  // Check credential store for stored static auth headers
282
277
  const storedHeaders = await getMcpHeaders(id);
@@ -316,21 +311,23 @@ async function handleMcpList(_args: {
316
311
  );
317
312
 
318
313
  return {
319
- servers: [...workspaceEntries, ...listPluginServerEntries(configEntries)],
314
+ servers: [
315
+ ...workspaceEntries,
316
+ ...(await listPluginServerEntries(configEntries)),
317
+ ],
320
318
  };
321
319
  }
322
320
 
323
321
  /**
324
322
  * Project plugin-declared servers onto listing entries.
325
323
  *
326
- * Nothing here touches the credential store or the network: a plugin
327
- * server carries no auth state the assistant owns, and its status comes
328
- * from the manager's in-memory client rather than a probe (see
329
- * {@link PLUGIN_SERVER_STATUS}).
324
+ * OAuth presence is read from the plugin's scoped credential identity. Static
325
+ * workspace headers are never consulted, no network probe runs, and status
326
+ * comes from the manager's in-memory client (see {@link PLUGIN_SERVER_STATUS}).
330
327
  */
331
- function listPluginServerEntries(
328
+ async function listPluginServerEntries(
332
329
  configEntries: [string, McpServerConfig][],
333
- ): McpServerEntry[] {
330
+ ): Promise<McpServerEntry[]> {
334
331
  const { servers, issues } = readPluginMcpServers();
335
332
  for (const issue of issues) {
336
333
  log.warn(
@@ -349,36 +346,51 @@ function listPluginServerEntries(
349
346
  const workspaceIds = new Set(configEntries.map(([id]) => id));
350
347
  const manager = getMcpServerManager();
351
348
 
352
- return servers
353
- .filter((server) => {
354
- if (!workspaceIds.has(server.id)) {
355
- return true;
356
- }
357
- log.warn(
358
- { plugin: server.pluginName, serverId: server.id },
359
- "Plugin MCP server shadowed by a workspace server of the same id; skipping",
360
- );
361
- return false;
362
- })
363
- .map((server) => {
364
- const { headers: _stripped, ...safeTransport } = server.config
365
- .transport as Record<string, unknown>;
366
- return {
367
- id: server.id,
368
- status: manager.getClient(server.id)?.isConnected
369
- ? "connected"
370
- : PLUGIN_SERVER_STATUS,
371
- transport: safeTransport as McpServerEntry["transport"],
372
- // A plugin server has no assistant-owned credentials. Resolving
373
- // these against `mcp:<id>:*` would report, and could disclose, a
374
- // workspace credential that happens to share the id.
375
- hasOAuth: false,
376
- hasStaticAuth: false,
377
- authType: "none" as const,
378
- source: "plugin" as const,
379
- pluginName: server.pluginName,
380
- };
381
- });
349
+ return Promise.all(
350
+ servers
351
+ .filter((server) => {
352
+ if (!workspaceIds.has(server.id)) {
353
+ return true;
354
+ }
355
+ log.warn(
356
+ { plugin: server.pluginName, serverId: server.id },
357
+ "Plugin MCP server shadowed by a workspace server of the same id; skipping",
358
+ );
359
+ return false;
360
+ })
361
+ .map(async (server) => {
362
+ const { headers: _stripped, ...safeTransport } = server.config
363
+ .transport as Record<string, unknown>;
364
+ let hasOAuth = false;
365
+ const credentialTarget = resolveMcpOAuthCredentialTarget(
366
+ server.id,
367
+ server.config,
368
+ );
369
+ if (credentialTarget) {
370
+ try {
371
+ hasOAuth = await hasMcpOAuthTokens(credentialTarget);
372
+ } catch (err) {
373
+ log.warn(
374
+ { err, plugin: server.pluginName, serverId: server.id },
375
+ "Plugin MCP OAuth credential identity could not be resolved",
376
+ );
377
+ }
378
+ }
379
+ return {
380
+ id: server.id,
381
+ status:
382
+ manager.getServerState(server.id, "plugin") === "connected"
383
+ ? "connected"
384
+ : PLUGIN_SERVER_STATUS,
385
+ transport: safeTransport as McpServerEntry["transport"],
386
+ hasOAuth,
387
+ hasStaticAuth: false,
388
+ authType: "none" as const,
389
+ source: "plugin" as const,
390
+ pluginName: server.pluginName,
391
+ };
392
+ }),
393
+ );
382
394
  }
383
395
 
384
396
  // ---------------------------------------------------------------------------
@@ -687,7 +699,7 @@ export const ROUTES: RouteDefinition[] = [
687
699
  },
688
700
  summary: "List MCP servers with health status",
689
701
  description:
690
- "Returns configured MCP servers with live health-check results (connected, needs auth, error).",
702
+ "Returns configured MCP servers with recorded runtime status without opening new connections.",
691
703
  tags: ["internal"],
692
704
  responseBody: z.object({
693
705
  servers: z.array(
@@ -80,6 +80,7 @@ import {
80
80
  PluginDirectoryNotFoundError,
81
81
  } from "../../cli/lib/toggle-plugin.js";
82
82
  import {
83
+ PLUGIN_UNINSTALL_WARNING_KEYS,
83
84
  PluginNotInstalledError,
84
85
  uninstallPlugin,
85
86
  } from "../../cli/lib/uninstall-plugin.js";
@@ -255,6 +256,14 @@ const pluginUninstallResponseSchema = z.object({
255
256
  .describe(
256
257
  "Absolute path that was removed on the assistant host. Useful for audit logs and confirmation toasts.",
257
258
  ),
259
+ warnings: z
260
+ .array(
261
+ z.literal(PLUGIN_UNINSTALL_WARNING_KEYS.MCP_OAUTH_CREDENTIALS_UNCHECKED),
262
+ )
263
+ .optional()
264
+ .describe(
265
+ "Stable keys for non-fatal cleanup limitations that should be localized at the presentation edge.",
266
+ ),
258
267
  });
259
268
 
260
269
  const pluginDetailsResponseSchema = z.object({
@@ -1134,7 +1143,11 @@ async function handleUninstallPlugin({
1134
1143
  const result = await uninstallPlugin({ name: rawName });
1135
1144
  await reconcilePluginSourcesNow();
1136
1145
  publishPluginsChanged(getOriginClientId(headers));
1137
- return { name: result.name, target: result.target };
1146
+ return {
1147
+ name: result.name,
1148
+ target: result.target,
1149
+ ...(result.warnings && { warnings: result.warnings }),
1150
+ };
1138
1151
  } catch (err) {
1139
1152
  if (err instanceof InvalidPluginNameError) {
1140
1153
  throw new BadRequestError(err.message);
@@ -152,6 +152,10 @@ export function publishPluginsChanged(originClientId?: string): void {
152
152
  void publishSyncInvalidation([SYNC_TAGS.pluginsList], originClientId);
153
153
  }
154
154
 
155
+ export function publishMcpChanged(): void {
156
+ void publishSyncInvalidation([SYNC_TAGS.mcpList]);
157
+ }
158
+
155
159
  /**
156
160
  * Invalidate the activation-checklist progress resource on every client.
157
161
  *