返回 DeepSeek-Reasonix
capabilities-panel-actions.test.ts
根目录 / desktop / frontend / src / __tests__ / capabilities-panel-actions.test.ts
1 import { findButton, flush, installDom, setInputValue, waitFor } from "./capabilities-test-helpers";
2 import { readFileSync } from "node:fs";
3 import React from "react";
4 import { act } from "react";
5 import { createRoot } from "react-dom/client";
6 import { MCPServersSettingsPage, failureKind, mcpServerDraftJSON, parseMCPQuickDefinition, parseMCPServerJSON, summarizeServerError, withExplicitMCPClears } from "../components/CapabilitiesPanel";
7 import { slashCommandGroup, slashCommandKindTag, sortSlashCommandsForMenu } from "../components/SlashMenu";
8 import { selectToolsOnFirstCustomUse } from "../components/SubagentsPanel";
9 import type { AppBindings } from "../lib/bridge";
10 import { LocaleProvider, t } from "../lib/i18n";
11 import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
12 import type { MCPServerInput, Meta, ServerView, TabMeta } from "../lib/types";
13 import { installDesktopHostStub } from "./desktopHostStub";
14
15 function ok(value: unknown, message: string) {
16 if (!value) throw new Error(message);
17 }
18
19 {
20 const dom = installDom();
21 const rootEl = document.getElementById("root");
22 if (!rootEl) throw new Error("missing root");
23 const root = createRoot(rootEl);
24 const meta: Meta = { label: "test", ready: true, eventChannel: "mcp-registry-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
25 const tabs: TabMeta[] = [{
26 id: "tab-mcp-registry",
27 scope: "project",
28 workspaceRoot: "/tmp/reasonix-test",
29 workspaceName: "reasonix-test",
30 topicId: "topic-mcp-registry",
31 topicTitle: "Registry",
32 label: "Registry",
33 ready: true,
34 running: false,
35 mode: "normal",
36 toolApprovalMode: "auto",
37 active: true,
38 cwd: "/tmp/reasonix-test",
39 }];
40 let servers: ServerView[] = [];
41 let installed: MCPServerInput | null = null;
42 let registryCached = false;
43 let resolvedRegistryName = "";
44 const registryEntry = {
45 name: "io.example/demo",
46 suggestedName: "demo",
47 title: "Demo MCP",
48 description: "Registry demo server",
49 version: "1.0.0",
50 installable: true,
51 transport: "http",
52 args: [],
53 url: "https://mcp.example.test/mcp",
54 };
55 const appStubTable: AppBindings = ({
56 main: {
57 App: {
58 Meta: async () => meta,
59 ListTabs: async () => tabs,
60 MCPServers: async () => servers,
61 MCPMarketplace: async () => ({
62 cached: registryCached,
63 warning: registryCached ? "offline" : undefined,
64 servers: [registryEntry],
65 }),
66 MCPMarketplaceResolve: async (registryName) => {
67 resolvedRegistryName = registryName;
68 return registryEntry;
69 },
70 AddMCPServer: async (input) => {
71 installed = input;
72 servers = [{
73 name: input.name,
74 transport: input.transport,
75 status: "connected",
76 configured: true,
77 autoStart: true,
78 tools: 1,
79 prompts: 0,
80 resources: 0,
81 url: input.url,
82 }];
83 return 1;
84 },
85 InstallMCPServer: async (input) => {
86 const toolCount: number = await appStubTable.AddMCPServer(input);
87 return { name: input.name, state: "ready", toolCount, action: "none", message: "ready" };
88 },
89 } as Partial<AppBindings> as AppBindings,
90 },
91 }).main.App;
92 installDesktopHostStub(appStubTable);
93
94 await act(async () => {
95 root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
96 await flush();
97 });
98 await waitFor("registry browse action", () => Boolean(findButton("Browse registry")));
99 await act(async () => {
100 findButton("Browse registry")?.click();
101 await flush();
102 });
103 await waitFor("registry result", () => document.body.textContent?.includes("Demo MCP") ?? false);
104 await act(async () => {
105 findButton("Install")?.click();
106 await flush();
107 });
108 await waitFor("registry install", () => installed !== null && document.body.textContent?.includes("demo") === true);
109 const installedEntry = installed as MCPServerInput | null;
110 ok(installedEntry?.name === "demo" && installedEntry.transport === "http" && installedEntry.url === "https://mcp.example.test/mcp", "registry install converts the selected entry into the normal add-and-connect input");
111 ok(resolvedRegistryName === "io.example/demo", "registry install re-resolves current metadata by canonical name");
112
113 registryCached = true;
114 installed = null;
115 await act(async () => {
116 findButton("Browse registry")?.click();
117 await flush();
118 findButton("Search")?.click();
119 await flush();
120 });
121 await waitFor("cached registry warning", () => document.body.textContent?.includes("Showing cached results") ?? false);
122 const cachedInstall = findButton("Install");
123 ok(cachedInstall?.disabled === true, "cached Registry results must remain browse-only");
124 cachedInstall?.click();
125 await flush();
126 ok(installed === null, "cached Registry result must not be installed");
127
128 await act(async () => {
129 root.unmount();
130 });
131 dom.window.close();
132 }
133
134 const quickCommand = parseMCPQuickDefinition("npx -y chrome-devtools-mcp@latest");
135 ok(quickCommand.name === "chrome-devtools-mcp" && quickCommand.transport === "stdio", "quick install should derive a stable name and stdio transport from one command");
136
137 const quickFilesystem = parseMCPQuickDefinition('npx -y @modelcontextprotocol/server-filesystem "/srv/shared data"');
138 ok(quickFilesystem.name === "server-filesystem", "quick install name should come from the launcher package, not a trailing server argument");
139
140 const quickPythonModule = parseMCPQuickDefinition("python -m mcp_server_time --local-timezone=UTC");
141 ok(quickPythonModule.name === "mcp-server-time", "python module quick install should derive its name from the module");
142 const quickURL = parseMCPQuickDefinition("https://mcp.linear.app/mcp");
143 ok(quickURL.name === "mcp" && quickURL.transport === "http", "quick install should derive HTTP transport from a URL");
144 const quickJSON = parseMCPQuickDefinition(JSON.stringify({ custom: { command: "uvx", args: ["demo-mcp"] } }));
145 ok(quickJSON.name === "custom" && quickJSON.args[0] === "demo-mcp", "quick install should preserve advanced JSON definitions");
146
147 const completeMCPJSON = JSON.stringify({
148 admin: {
149 type: "streamable-http",
150 url: "https://mcp.example.test/api",
151 auto_start: false,
152 call_timeout_seconds: 45,
153 tool_timeout_seconds: { wipe: 120 },
154 trusted_read_only_tools: ["status"],
155 default_tools_approval_mode: "writes",
156 tools: { wipe: { approval_mode: "prompt" } },
157 approvals_reviewer: "auto_review",
158 },
159 });
160 const completeMCP = parseMCPServerJSON(completeMCPJSON);
161 ok(completeMCP.input.transport === "http", "streamable-http should normalize to http");
162 ok(completeMCP.input.autoStart === false, "advanced JSON should preserve auto_start=false");
163 ok(completeMCP.input.callTimeoutSeconds === 45 && completeMCP.input.toolTimeoutSeconds?.wipe === 120, "advanced JSON should preserve timeouts");
164 const completeMCPRoundTrip = parseMCPServerJSON(mcpServerDraftJSON(completeMCP.draft));
165 ok(completeMCPRoundTrip.input.transport === "http" && completeMCPRoundTrip.input.toolTimeoutSeconds?.wipe === 120, "Form/JSON switching should preserve connection fields");
166 const normalizedMCPJSON = mcpServerDraftJSON(completeMCP.draft);
167 ok(!normalizedMCPJSON.includes("trusted_read_only_tools"), "Form/JSON switching should drop the removed reader setting");
168 ok(!normalizedMCPJSON.includes("approval_mode") && !normalizedMCPJSON.includes("approvals_reviewer"), "Form/JSON switching should drop retired MCP approval settings");
169 let unsupportedMCPFieldRejected = false;
170 try {
171 parseMCPServerJSON(JSON.stringify({ admin: { command: "admin-mcp", unsupported: true } }));
172 } catch (error) {
173 unsupportedMCPFieldRejected = error instanceof Error && error.message === "unsupported";
174 }
175 ok(unsupportedMCPFieldRejected, "unsupported advanced JSON fields should fail explicitly");
176 const incompleteMCPJSON = JSON.stringify({ admin: { type: "stdio", command: "" } });
177 let incompleteMCPRejected = false;
178 try {
179 parseMCPServerJSON(incompleteMCPJSON);
180 } catch (error) {
181 incompleteMCPRejected = error instanceof Error && error.message === "required";
182 }
183 ok(incompleteMCPRejected, "submitting incomplete MCP JSON must still require a command or URL");
184 const incompleteMCPDraft = parseMCPServerJSON(incompleteMCPJSON, undefined, { allowIncomplete: true });
185 ok(incompleteMCPDraft.draft.name === "admin" && incompleteMCPDraft.draft.command === "", "mode switching may recover an incomplete MCP draft for form editing");
186 parseMCPServerJSON(JSON.stringify({ admin: { command: "admin-mcp", default_tools_approval_mode: "", approvals_reviewer: "" } }));
187 let nullToolTimeoutRejected = false;
188 try {
189 parseMCPServerJSON(JSON.stringify({ admin: { command: "admin-mcp", tool_timeout_seconds: { wipe: null } } }));
190 } catch (error) {
191 nullToolTimeoutRejected = error instanceof Error && error.message === "invalid";
192 }
193 ok(nullToolTimeoutRejected, "a null per-tool timeout must be rejected instead of silently clearing all timeouts");
194 const sparseEdit = withExplicitMCPClears(parseMCPServerJSON(JSON.stringify({ admin: { command: "admin-mcp" } })).input);
195 ok(sparseEdit.callTimeoutSeconds === 0, "editing an existing server with fields removed must clear the timeout");
196 ok(sparseEdit.autoStart === true && Object.keys(sparseEdit.toolTimeoutSeconds ?? { x: 1 }).length === 0, "removed timeout fields must clear");
197 ok(sparseEdit.env === null && sparseEdit.headers === null, "absent env/headers must stay preserve-on-absent because their values are never seeded into the editor");
198
199 const refusedRegistryError = [
200 'plugin "fs": read EOF: stderr:',
201 "npm error code ECONNREFUSED",
202 "npm error syscall connect",
203 "npm error FetchError: request to https://registry.npmjs.org/@modelcontextprotocol%2fserver-filesystem failed, reason: connect ECONNREFUSED 127.0.0.1:7890",
204 ].join("\n");
205 ok(
206 summarizeServerError(refusedRegistryError) === "fs: npm ECONNREFUSED · registry.npmjs.org → 127.0.0.1:7890",
207 "npm connection failures should identify both the registry and the refused endpoint",
208 );
209 const legacyNpmRefusedRegistryError = [
210 'plugin "fs": read EOF: stderr:',
211 "npm ERR! code ECONNREFUSED",
212 "npm ERR! syscall connect",
213 "npm ERR! FetchError: request to https://registry.npmjs.org/@modelcontextprotocol%2fserver-filesystem failed, reason: connect ECONNREFUSED 127.0.0.1:7890",
214 ].join("\n");
215 ok(
216 summarizeServerError(legacyNpmRefusedRegistryError) === "fs: npm ECONNREFUSED · registry.npmjs.org → 127.0.0.1:7890",
217 "legacy npm ERR! failures should identify both the registry and the refused endpoint",
218 );
219 const credentialedRegistryError =
220 'plugin "private": stderr: npm error code ECONNREFUSED npm error request to https://build-user:registry-secret@packages.example.test/npm failed, reason: connect ECONNREFUSED proxy.internal.test:8443';
221 const credentialedRegistrySummary = summarizeServerError(credentialedRegistryError);
222 ok(credentialedRegistrySummary.includes("packages.example.test → proxy.internal.test:8443"), "private registries should keep actionable hosts");
223 ok(!credentialedRegistrySummary.includes("build-user") && !credentialedRegistrySummary.includes("registry-secret"), "registry credentials must not appear in the summary");
224 ok(
225 failureKind({ ...server("failed"), error: refusedRegistryError }) === "network",
226 "npm connection refusal should be grouped as a network/proxy issue",
227 );
228
229 const subagentTools = [
230 { name: "read_file", description: "Read files" },
231 { name: "edit_file", description: "Edit files" },
232 { name: "bash", description: "Run commands" },
233 ];
234 const firstCustomSelection = selectToolsOnFirstCustomUse(new Set(), subagentTools, false);
235 ok(firstCustomSelection.size === subagentTools.length, "first custom-mode use should select every available tool");
236 const savedCustomSelection = selectToolsOnFirstCustomUse(new Set(["read_file", "edit_file"]), subagentTools, true);
237 ok(savedCustomSelection.size === 2 && !savedCustomSelection.has("bash"), "saved custom tool selections should be preserved");
238 ok(selectToolsOnFirstCustomUse(new Set(), subagentTools, true).size === 0, "returning to custom mode should preserve a deliberate empty selection");
239
240 const subagentsSource = readFileSync(new URL("../components/SubagentsPanel.tsx", import.meta.url), "utf8");
241 const subagentsStyles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
242 const customGroupIndex = subagentsSource.indexOf('aria-labelledby="subagents-custom-title"');
243 const builtinGroupIndex = subagentsSource.indexOf('aria-labelledby="subagents-builtin-title"');
244 ok(customGroupIndex >= 0 && builtinGroupIndex > customGroupIndex, "custom subagents should render before built-in subagents");
245 ok((subagentsSource.match(/className="subagents-profile-group"/g) ?? []).length === 2, "custom and built-in subagents should use separate sections");
246 ok(subagentsSource.includes('className="btn btn--small subagents-reset-override"'), "override status and reset should share one compact action");
247 ok(subagentsStyles.includes("repeat(2, minmax(0, 1fr)) 152px"), "built-in subagent pickers should use equal shrinkable columns and reserve one stable status column");
248 ok(subagentsSource.includes('className="settings-model-picker subagents-effort-picker"'), "effort and model overrides should share the same picker interaction pattern");
249 ok(subagentsSource.includes("<SubagentInvocation name={skill.name}"), "every subagent card should show its chat invocation affordance");
250 ok(subagentsSource.includes("onUseInChat(command)"), "subagent cards should send their slash command to the chat composer");
251
252 function server(status: ServerView["status"]): ServerView {
253 return {
254 name: "codegraph",
255 transport: "stdio",
256 status,
257 configured: true,
258 autoStart: true,
259 tier: "background",
260 tools: 0,
261 prompts: 0,
262 resources: 0,
263 };
264 }
265
266 const initializing = mcpServerLifecycleActions(server("initializing"));
267 ok(initializing.enabled, "initializing server should still be treated as enabled");
268 ok(!initializing.showRetryInRow, "initializing server should not expose retry until it fails");
269 ok(!initializing.canReconnect, "initializing server should not expose reconnect while already connecting");
270 ok(!initializing.canConnectNow, "initializing server should not use the deferred connect-now action");
271
272 const connected = mcpServerLifecycleActions(server("connected"));
273 ok(!connected.showRetryInRow, "connected server row should keep the toggle UI");
274 ok(connected.canReconnect, "connected server details should expose reconnect");
275
276 const manuallyConnected = mcpServerLifecycleActions({ ...server("connected"), autoStart: false, startIntent: "off", runtimeState: "ready" });
277 ok(manuallyConnected.enabled, "connected manual server should still render as enabled");
278 ok(!manuallyConnected.canConnectNow, "connected manual server should not expose connect-now");
279 ok(manuallyConnected.canReconnect, "connected manual server should expose reconnect");
280
281 const automaticIdle = mcpServerLifecycleActions({ ...server("deferred"), startIntent: "automatic" });
282 ok(!automaticIdle.canConnectNow, "automatic idle server should not look like a manual connector");
283 ok(!automaticIdle.canReconnect, "automatic idle server should wait for background connection or failure");
284
285 const failed = mcpServerLifecycleActions({ ...server("failed"), runtimeState: "issue" });
286 ok(failed.showRetryInRow, "failed server row should expose retry");
287
288 ok(mcpServerRetryableFromAvailableList(server("initializing")), "connecting server should be included in available-list retry all");
289 ok(!mcpServerRetryableFromAvailableList({ ...server("deferred"), startIntent: "automatic" }), "healthy on-demand server should not be included in retry all");
290 ok(mcpServerRetryableFromAvailableList({ ...server("deferred"), startIntent: "automatic", action: "retry" }), "explicit retry action should remain available for an idle server");
291 ok(!mcpServerRetryableFromAvailableList(server("connected")), "connected server should be excluded from available-list retry all");
292 ok(!mcpServerRetryableFromAvailableList({ ...server("disabled"), startIntent: "off" }), "disabled server should be excluded from available-list retry all");
293 ok(!mcpServerRetryableFromAvailableList({ ...server("failed"), runtimeState: "issue" }), "failed server is handled by the failure banner retry all");
294
295
296 ok(
297 slashCommandKindTag({ name: "pwf:plan", description: "Plugin planning prompt.", kind: "custom", plugin: "pwf" }, t) === "plugin · pwf",
298 "slash menu identifies the canonical plugin command source",
299 );
300 ok(
301 slashCommandGroup({ name: "explore", description: "Explore in isolation.", kind: "subagent" }) === "subagents",
302 "slash menu groups isolated skills as subagents",
303 );
304 ok(
305 slashCommandGroup({ name: "plugins", description: "Manage plugins.", kind: "builtin", group: "management" }) === "management",
306 "slash menu honors backend-provided command groups",
307 );
308 ok(
309 slashCommandGroup({ name: "plugins", description: "Manage plugins.", kind: "builtin" }) === "management"
310 && slashCommandGroup({ name: "new", description: "New session.", kind: "builtin" }) === "actions",
311 "slash menu keeps a safe grouping fallback for older backends",
312 );
313 ok(
314 sortSlashCommandsForMenu([
315 { name: "plugins", description: "Manage plugins.", kind: "builtin", group: "management" },
316 { name: "explore", description: "Explore in isolation.", kind: "subagent", group: "subagents" },
317 { name: "new", description: "New session.", kind: "builtin", group: "actions" },
318 ]).map((command) => command.name).join(",") === "new,explore,plugins",
319 "slash menu keyboard order follows the visible group order",
320 );
321
322 console.log("capabilities panel MCP actions");
323
324 {
325 const dom = installDom();
326 const rootEl = document.getElementById("root");
327 if (!rootEl) throw new Error("missing root");
328 const root = createRoot(rootEl);
329 const meta: Meta = { label: "test", ready: true, eventChannel: "test-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
330 const tabs: TabMeta[] = [{
331 id: "tab-1",
332 scope: "project",
333 workspaceRoot: "/tmp/reasonix-test",
334 workspaceName: "reasonix-test",
335 topicId: "topic-1",
336 topicTitle: "Test",
337 label: "Test",
338 ready: true,
339 running: false,
340 mode: "normal",
341 toolApprovalMode: "auto",
342 active: true,
343 cwd: "/tmp/reasonix-test",
344 }];
345 let servers: ServerView[] = [{
346 name: "github",
347 transport: "stdio",
348 status: "connected",
349 configured: true,
350 autoStart: true,
351 tools: 2,
352 prompts: 0,
353 resources: 0,
354 toolList: [
355 { name: "issue_read", description: "Read issues.", readOnlyHint: true },
356 { name: "issue_write", description: "Write issues." },
357 { name: "broken_read", description: "Broken tool.", readOnlyHint: true, schemaError: "invalid input schema: bad nested type" },
358 ],
359 }];
360 installDesktopHostStub(({
361 main: {
362 App: {
363 Meta: async () => meta,
364 ListTabs: async () => tabs,
365 MCPServers: async () => servers,
366 } as Partial<AppBindings> as AppBindings,
367 },
368 }).main.App);
369
370 await act(async () => {
371 root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
372 await flush();
373 });
374 await waitFor("github server row", () => Boolean(document.querySelector(".cap-mcp-list-row__name")?.textContent?.includes("github")));
375 ok(Boolean(findButton("Remove server")), "configured MCP exposes a removal action directly in the server list");
376 ok(document.body.textContent?.includes("1 unavailable"), "server list summary reports one quarantined tool");
377 ok(!document.body.textContent?.includes("invalid input schema: bad nested type"), "server list keeps raw tool diagnostics out of the overview");
378
379 const openServer = document.querySelector<HTMLButtonElement>(".cap-mcp-list-row__main");
380 if (!openServer) throw new Error("missing MCP server details button");
381 await act(async () => {
382 openServer.click();
383 await flush();
384 });
385
386 await waitFor("unavailable tool", () => Boolean(document.querySelector(".cap-tool-hint--error")?.textContent?.includes("Unavailable")));
387 ok(document.body.textContent?.includes("invalid input schema: bad nested type"), "tool list shows the schema diagnostic");
388 ok(document.body.textContent?.includes("issue_read") ?? false, "server details list read-only MCP tools normally");
389 ok(document.body.textContent?.includes("issue_write") ?? false, "server details list write-capable MCP tools normally");
390 ok(!findButton("Pre-trust read-only (1)"), "MCP details do not expose a bulk pre-trust action");
391 ok(!findButton("Pre-trust"), "MCP details do not expose per-tool pre-trust actions");
392 ok(!findButton("Untrust"), "MCP details do not expose an untrust action");
393 ok(!document.querySelector(".cap-tool-trust"), "MCP details do not expose a separate trust state");
394 ok(!document.body.textContent?.includes("read-only trust"), "MCP details do not describe the removed trust workflow");
395
396 await act(async () => {
397 root.unmount();
398 });
399 dom.window.close();
400 }
401
402 {
403 const dom = installDom();
404 const rootEl = document.getElementById("root");
405 if (!rootEl) throw new Error("missing root");
406 const root = createRoot(rootEl);
407 const meta: Meta = { label: "test", ready: true, eventChannel: "authorize-mcp-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
408 const tabs: TabMeta[] = [{
409 id: "tab-authorize-mcp",
410 scope: "project",
411 workspaceRoot: "/tmp/reasonix-test",
412 workspaceName: "reasonix-test",
413 topicId: "topic-authorize-mcp",
414 topicTitle: "Authorize MCP",
415 label: "Authorize MCP",
416 ready: true,
417 running: false,
418 mode: "normal",
419 toolApprovalMode: "auto",
420 active: true,
421 cwd: "/tmp/reasonix-test",
422 }];
423 let servers: ServerView[] = [{
424 name: "github",
425 transport: "stdio",
426 status: "connected",
427 runtimeState: "ready",
428 configured: true,
429 source: "project",
430 configSource: "reasonix.toml",
431 autoStart: true,
432 tools: 3,
433 prompts: 0,
434 resources: 0,
435 toolList: [
436 { name: "issue_read", description: "Read issues.", readOnlyHint: true },
437 { name: "issue_write", description: "Write issues." },
438 { name: "wipe", description: "Delete data.", destructiveHint: true },
439 ],
440 }, {
441 name: "linear",
442 transport: "http",
443 status: "connected",
444 runtimeState: "ready",
445 configured: true,
446 source: "user",
447 autoStart: true,
448 tools: 1,
449 prompts: 0,
450 resources: 0,
451 toolList: [{ name: "get_issue", description: "Read an issue.", readOnlyHint: true }],
452 }];
453 installDesktopHostStub(({
454 main: {
455 App: {
456 Meta: async () => meta,
457 ListTabs: async () => tabs,
458 MCPServers: async () => servers,
459 } as Partial<AppBindings> as AppBindings,
460 },
461 }).main.App);
462
463 await act(async () => {
464 root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
465 await flush();
466 });
467 const refreshStatus = async () => {
468 const refresh = document.querySelector<HTMLButtonElement>('button[aria-label="Refresh MCP status"]');
469 if (!refresh) throw new Error("missing MCP status refresh action");
470 await act(async () => {
471 refresh.click();
472 await flush();
473 });
474 };
475 await waitFor("trusted project MCP", () => Boolean(document.querySelector('[data-status="connected"]')));
476 ok(document.body.textContent?.includes("This project"), "project MCP is grouped under This project");
477 ok(document.body.textContent?.includes("Global MCP"), "user-installed MCP is grouped by its global scope");
478 ok(document.body.textContent?.includes("Install once and use automatically in every Reasonix project."), "global MCP explains its cross-project availability");
479 ok(document.body.textContent?.includes("Project"), "project MCP row shows a project source badge");
480 ok(document.body.textContent?.includes("Declared by this project and available automatically."), "project MCP explains zero-confirmation availability");
481 ok(!findButton("Install and use"), "trusted project MCP has no install confirmation");
482 ok(!findButton("Authorize and connect"), "trusted project MCP has no authorization action");
483 ok(!findButton("Review changes"), "project MCP has no separate change-review workflow");
484 ok(!findButton("Refresh catalog"), "catalog maintenance is not part of the normal MCP workflow");
485 ok(!document.querySelector('[role="dialog"]'), "project MCP does not open a confirmation modal");
486
487 servers = servers.map((item) => ({
488 ...item,
489 status: "failed",
490 runtimeState: "issue",
491 error: "authentication required",
492 authStatus: "required",
493 authUrl: "https://mcp.example.test/authorize",
494 }));
495 await refreshStatus();
496 await waitFor("sign-in action", () => Boolean(findButton("Sign in")));
497 ok(!findButton("Review changes"), "OAuth failure does not expose a removed change-review action");
498 servers = servers.map((item) => ({
499 ...item,
500 status: "failed",
501 runtimeState: "issue",
502 error: "connection refused",
503 authStatus: "none",
504 authUrl: "",
505 }));
506 await refreshStatus();
507 await waitFor("ordinary retry action", () => Boolean(findButton("Retry")));
508 ok(!findButton("Review changes"), "ordinary startup failures keep only the retry action");
509
510 servers = servers.map((item) => ({
511 ...item,
512 status: "connected",
513 runtimeState: "ready",
514 error: "",
515 requiresLaunchApproval: false,
516 }));
517 await refreshStatus();
518 await waitFor("trusted project server row", () => Boolean(document.querySelector('[data-status="connected"]')));
519 await act(async () => {
520 (document.querySelector(".cap-mcp-list-row__main") as HTMLButtonElement | null)?.click();
521 await flush();
522 });
523 await waitFor("connected project server detail", () => Boolean(document.querySelector(".cap-mcp-subpage")));
524 ok(document.body.textContent?.includes("Current project · reasonix.toml"), "project MCP details show their configuration source");
525 ok(!findButton("Review changes"), "a trusted connected project server does not show a change alarm");
526 ok(!findButton("Revoke trust"), "normal MCP details do not expose a second authorization-management workflow");
527
528 await act(async () => {
529 root.unmount();
530 });
531 dom.window.close();
532 }
533
534 {
535 const dom = installDom();
536 const rootEl = document.getElementById("root");
537 if (!rootEl) throw new Error("missing root");
538 const root = createRoot(rootEl);
539 const meta: Meta = { label: "test", ready: true, eventChannel: "managed-mcp-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
540 const tabs: TabMeta[] = [{
541 id: "tab-managed-mcp",
542 scope: "project",
543 workspaceRoot: "/tmp/reasonix-test",
544 workspaceName: "reasonix-test",
545 topicId: "topic-managed-mcp",
546 topicTitle: "Managed MCP",
547 label: "Managed MCP",
548 ready: true,
549 running: false,
550 mode: "normal",
551 toolApprovalMode: "auto",
552 active: true,
553 cwd: "/tmp/reasonix-test",
554 }];
555 const servers: ServerView[] = [{
556 name: "helper",
557 transport: "http",
558 status: "connected",
559 configured: true,
560 managedByPlugin: "superpowers",
561 authConfigured: true,
562 autoStart: true,
563 tools: 1,
564 prompts: 0,
565 resources: 0,
566 toolList: [{ name: "echo", description: "Echo input", readOnlyHint: true }],
567 }];
568 installDesktopHostStub(({
569 main: {
570 App: {
571 Meta: async () => meta,
572 ListTabs: async () => tabs,
573 MCPServers: async () => servers,
574 } as Partial<AppBindings> as AppBindings,
575 },
576 }).main.App);
577
578 await act(async () => {
579 root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
580 await flush();
581 });
582 await waitFor("plugin-managed MCP row", () => Boolean(document.querySelector(".cap-mcp-list-row__name")?.textContent?.includes("helper")));
583 ok(document.body.textContent?.includes("Managed by plugin superpowers") ?? false, "plugin-managed MCP identifies its owner");
584
585 const openServer = document.querySelector<HTMLButtonElement>(".cap-mcp-list-row__main");
586 if (!openServer) throw new Error("missing plugin-managed MCP details button");
587 await act(async () => {
588 openServer.click();
589 await flush();
590 });
591 ok(!findButton("Remove server"), "plugin-managed MCP hides the misleading remove action");
592 ok(!findButton("Edit config"), "plugin-managed MCP hides direct config editing");
593 ok(!findButton("Clear auth"), "plugin-managed MCP hides auth persistence actions");
594 ok(!findButton("Pre-trust read-only (1)"), "plugin-managed MCP has no bulk pre-trust action");
595
596 ok(!findButton("View tools"), "standalone server details show tools without another disclosure step");
597 ok(document.body.textContent?.includes("echo") ?? false, "plugin-managed MCP details show its tools");
598 ok(!findButton("Pre-trust"), "plugin-managed MCP has no per-tool pre-trust action");
599
600 await act(async () => {
601 root.unmount();
602 });
603 dom.window.close();
604 }
605
606 {
607 const dom = installDom();
608 const rootEl = document.getElementById("root");
609 if (!rootEl) throw new Error("missing root");
610 const root = createRoot(rootEl);
611 const meta: Meta = { label: "test", ready: true, eventChannel: "runtime-mcp-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
612 const tabs: TabMeta[] = [{
613 id: "tab-runtime-mcp",
614 scope: "project",
615 workspaceRoot: "/tmp/reasonix-test",
616 workspaceName: "reasonix-test",
617 topicId: "topic-runtime-mcp",
618 topicTitle: "Runtime MCP",
619 label: "Runtime MCP",
620 ready: true,
621 running: false,
622 mode: "normal",
623 toolApprovalMode: "auto",
624 active: true,
625 cwd: "/tmp/reasonix-test",
626 }];
627 const servers: ServerView[] = [{
628 name: "runtime-only",
629 transport: "stdio",
630 status: "failed",
631 configured: false,
632 autoStart: false,
633 tools: 0,
634 prompts: 0,
635 resources: 0,
636 error: "command not found",
637 }];
638 installDesktopHostStub(({
639 main: {
640 App: {
641 Meta: async () => meta,
642 ListTabs: async () => tabs,
643 MCPServers: async () => servers,
644 } as Partial<AppBindings> as AppBindings,
645 },
646 }).main.App);
647
648 await act(async () => {
649 root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
650 await flush();
651 });
652 await waitFor("runtime-only failure row", () => Boolean(document.querySelector(".cap-mcp-list-row__name")?.textContent?.includes("runtime-only")));
653 const showDetails = document.querySelector<HTMLButtonElement>(".cap-mcp-list-row__main");
654 if (!showDetails) throw new Error("missing runtime-only failure details button");
655 await act(async () => {
656 showDetails.click();
657 await flush();
658 });
659 ok(document.body.textContent?.includes("command not found") ?? false, "runtime-only MCP detail preserves its failure diagnostic");
660 ok(!findButton("Remove server"), "runtime-only MCP failure hides an action the backend cannot persist");
661
662 await act(async () => {
663 root.unmount();
664 });
665 dom.window.close();
666 }
667
668 {
669 const dom = installDom();
670 const rootEl = document.getElementById("root");
671 if (!rootEl) throw new Error("missing root");
672 const root = createRoot(rootEl);
673 const meta: Meta = { label: "test", ready: true, eventChannel: "mcp-editor-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
674 const tabs: TabMeta[] = [{
675 id: "tab-mcp-editor",
676 scope: "project",
677 workspaceRoot: "/tmp/reasonix-test",
678 workspaceName: "reasonix-test",
679 topicId: "topic-mcp-editor",
680 topicTitle: "MCP editor",
681 label: "MCP editor",
682 ready: true,
683 running: false,
684 mode: "normal",
685 toolApprovalMode: "auto",
686 active: true,
687 cwd: "/tmp/reasonix-test",
688 }];
689 let addedInput: MCPServerInput | undefined;
690 let servers: ServerView[] = [
691 {
692 name: "github",
693 transport: "stdio",
694 status: "connected",
695 configured: true,
696 autoStart: true,
697 command: "github-mcp-server",
698 tools: 1,
699 prompts: 0,
700 resources: 0,
701 toolList: [{ name: "issue_read", description: "Read GitHub issues" }],
702 },
703 {
704 name: "yakit",
705 transport: "stdio",
706 status: "connected",
707 configured: true,
708 autoStart: true,
709 command: "yakit-mcp",
710 tools: 1,
711 prompts: 0,
712 resources: 0,
713 toolList: [{ name: "generate_yso_bytes", description: "Generate bytes" }],
714 },
715 ];
716 installDesktopHostStub(({
717 main: {
718 App: {
719 Meta: async () => meta,
720 ListTabs: async () => tabs,
721 MCPServers: async () => servers,
722 AddMCPServer: async (input: MCPServerInput) => {
723 addedInput = input;
724 servers = [...servers, {
725 name: input.name,
726 transport: input.transport,
727 status: "connected",
728 configured: true,
729 autoStart: true,
730 command: input.command,
731 args: input.args,
732 url: input.url,
733 tools: 0,
734 prompts: 0,
735 resources: 0,
736 }];
737 return 0;
738 },
739 InstallMCPServer: async (input: MCPServerInput) => {
740 addedInput = input;
741 servers = [...servers, {
742 name: input.name,
743 transport: input.transport,
744 status: "connected",
745 configured: true,
746 autoStart: true,
747 command: input.command,
748 args: input.args,
749 url: input.url,
750 tools: 0,
751 prompts: 0,
752 resources: 0,
753 }];
754 return { name: input.name, state: "ready", toolCount: 0, action: "none", message: "ready" };
755 },
756 } as Partial<AppBindings> as AppBindings,
757 },
758 }).main.App);
759
760 await act(async () => {
761 root.render(React.createElement(LocaleProvider, null, React.createElement(MCPServersSettingsPage)));
762 await flush();
763 });
764 await waitFor("MCP editor server rows", () => document.querySelectorAll(".cap-mcp-list-row__name").length === 2);
765 const search = document.querySelector<HTMLInputElement>('.cap-mcp-search input[type="search"]');
766 if (!search) throw new Error("missing MCP server search");
767 await act(async () => {
768 setInputValue(search, "generate_yso_bytes");
769 await flush();
770 });
771 ok(search.value === "generate_yso_bytes", "MCP search accepts the entered query");
772 await waitFor("filtered Yakit row", () => document.querySelectorAll(".cap-mcp-list-row__name").length === 1);
773 ok(document.querySelector(".cap-mcp-list-row__name")?.textContent === "yakit", "server search includes MCP tool names");
774
775 const addServer = findButton("Add server");
776 if (!addServer) throw new Error("missing Add server button");
777 await act(async () => {
778 addServer.click();
779 await flush();
780 });
781 const quickInstall = findButton("Quick install");
782 const manualSetup = findButton("Manual setup");
783 ok(quickInstall?.getAttribute("aria-selected") === "true" && Boolean(manualSetup) && Boolean(findButton("JSON")), "new server install defaults to quick install while keeping manual and JSON configuration in the same editor");
784 const definitionEditor = document.querySelector<HTMLTextAreaElement>(".cap-mcp-quick__input");
785 if (!definitionEditor) throw new Error("missing quick MCP install input");
786 ok(definitionEditor.placeholder.includes("chrome-devtools-mcp@latest"), "the default install path asks only for a command, URL, or JSON definition");
787 await act(async () => {
788 manualSetup?.click();
789 await flush();
790 });
791 ok(Boolean(document.querySelector(".cap-mcp-field--name input")) && Boolean(findButton("Advanced options")), "manual setup restores name, transport, and advanced configuration without leaving the install page");
792 ok(!addedInput, "opening the quick installer does not mutate MCP state");
793
794 await act(async () => {
795 root.unmount();
796 });
797 dom.window.close();
798 }
799
799 lines TYPESCRIPT