Skip to content

Commit 1029bf7

Browse files
committed
feat: add MCP server test button in Settings UI
- Add projects.mcp.test ORPC endpoint - Add MCPServerManager.testServer() that spawns server, fetches tools, closes - Add Test button with spinner, success (shows tools), and error states - 30 second timeout for test operations
1 parent 337e4ac commit 1029bf7

File tree

7 files changed

+184
-23
lines changed

7 files changed

+184
-23
lines changed

src/browser/components/Settings/sections/ProjectSettingsSection.tsx

Lines changed: 94 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import React, { useCallback, useEffect, useState } from "react";
22
import { useAPI } from "@/browser/contexts/API";
33
import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
4-
import { Trash2 } from "lucide-react";
4+
import { Trash2, Play, Loader2, CheckCircle, XCircle } from "lucide-react";
5+
6+
type TestResult = { success: true; tools: string[] } | { success: false; error: string };
57

68
export const ProjectSettingsSection: React.FC = () => {
79
const { api } = useAPI();
@@ -11,6 +13,8 @@ export const ProjectSettingsSection: React.FC = () => {
1113
const [servers, setServers] = useState<Record<string, string>>({});
1214
const [loading, setLoading] = useState(false);
1315
const [error, setError] = useState<string | null>(null);
16+
const [testingServer, setTestingServer] = useState<string | null>(null);
17+
const [testResults, setTestResults] = useState<Map<string, TestResult>>(new Map());
1418

1519
const refresh = useCallback(async () => {
1620
if (!api || !projectPath) return;
@@ -50,6 +54,33 @@ export const ProjectSettingsSection: React.FC = () => {
5054
[api, projectPath, refresh]
5155
);
5256

57+
const handleTest = useCallback(
58+
async (name: string) => {
59+
if (!api || !projectPath) return;
60+
setTestingServer(name);
61+
// Clear previous result for this server
62+
setTestResults((prev) => {
63+
const next = new Map(prev);
64+
next.delete(name);
65+
return next;
66+
});
67+
try {
68+
const result = await api.projects.mcp.test({ projectPath, name });
69+
setTestResults((prev) => new Map(prev).set(name, result));
70+
} catch (err) {
71+
setTestResults((prev) =>
72+
new Map(prev).set(name, {
73+
success: false,
74+
error: err instanceof Error ? err.message : "Test failed",
75+
})
76+
);
77+
} finally {
78+
setTestingServer(null);
79+
}
80+
},
81+
[api, projectPath]
82+
);
83+
5384
if (!projectPath) {
5485
return (
5586
<p className="text-muted-foreground text-sm">
@@ -77,26 +108,69 @@ export const ProjectSettingsSection: React.FC = () => {
77108
)}
78109

79110
<ul className="space-y-2">
80-
{Object.entries(servers).map(([name, command]) => (
81-
<li
82-
key={name}
83-
className="border-border-medium/60 bg-secondary/30 flex items-start justify-between rounded-md border px-3 py-2"
84-
>
85-
<div className="space-y-1">
86-
<div className="font-medium">{name}</div>
87-
<div className="text-muted-foreground text-xs break-all">{command}</div>
88-
</div>
89-
<button
90-
type="button"
91-
onClick={() => void handleRemove(name)}
92-
className="text-muted-foreground hover:text-destructive flex items-center gap-1 text-xs"
93-
aria-label={`Remove MCP server ${name}`}
94-
disabled={loading}
111+
{Object.entries(servers).map(([name, command]) => {
112+
const isTesting = testingServer === name;
113+
const testResult = testResults.get(name);
114+
return (
115+
<li
116+
key={name}
117+
className="border-border-medium/60 bg-secondary/30 rounded-md border px-3 py-2"
95118
>
96-
<Trash2 className="h-4 w-4" /> Remove
97-
</button>
98-
</li>
99-
))}
119+
<div className="flex items-start justify-between">
120+
<div className="min-w-0 flex-1 space-y-1">
121+
<div className="font-medium">{name}</div>
122+
<div className="text-muted-foreground text-xs break-all">{command}</div>
123+
</div>
124+
<div className="ml-2 flex shrink-0 items-center gap-2">
125+
<button
126+
type="button"
127+
onClick={() => void handleTest(name)}
128+
className="text-muted-foreground hover:text-accent flex items-center gap-1 text-xs disabled:opacity-50"
129+
aria-label={`Test MCP server ${name}`}
130+
disabled={loading || isTesting}
131+
>
132+
{isTesting ? (
133+
<Loader2 className="h-4 w-4 animate-spin" />
134+
) : (
135+
<Play className="h-4 w-4" />
136+
)}
137+
{isTesting ? "Testing…" : "Test"}
138+
</button>
139+
<button
140+
type="button"
141+
onClick={() => void handleRemove(name)}
142+
className="text-muted-foreground hover:text-destructive flex items-center gap-1 text-xs"
143+
aria-label={`Remove MCP server ${name}`}
144+
disabled={loading}
145+
>
146+
<Trash2 className="h-4 w-4" /> Remove
147+
</button>
148+
</div>
149+
</div>
150+
{/* Test result display */}
151+
{testResult && (
152+
<div
153+
className={`mt-2 flex items-start gap-1.5 text-xs ${testResult.success ? "text-green-500" : "text-destructive"}`}
154+
>
155+
{testResult.success ? (
156+
<>
157+
<CheckCircle className="mt-0.5 h-3 w-3 shrink-0" />
158+
<span>
159+
{testResult.tools.length} tools: {testResult.tools.slice(0, 5).join(", ")}
160+
{testResult.tools.length > 5 && ` (+${testResult.tools.length - 5} more)`}
161+
</span>
162+
</>
163+
) : (
164+
<>
165+
<XCircle className="mt-0.5 h-3 w-3 shrink-0" />
166+
<span>{testResult.error}</span>
167+
</>
168+
)}
169+
</div>
170+
)}
171+
</li>
172+
);
173+
})}
100174
</ul>
101175
</div>
102176
);

src/common/orpc/schemas.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ export { SecretSchema } from "./schemas/secrets";
3939
export { MuxProviderOptionsSchema } from "./schemas/providerOptions";
4040

4141
// MCP schemas
42-
export { MCPServerMapSchema, MCPAddParamsSchema, MCPRemoveParamsSchema } from "./schemas/mcp";
42+
export {
43+
MCPServerMapSchema,
44+
MCPAddParamsSchema,
45+
MCPRemoveParamsSchema,
46+
MCPTestParamsSchema,
47+
MCPTestResultSchema,
48+
} from "./schemas/mcp";
4349

4450
// Terminal schemas
4551
export {

src/common/orpc/schemas/api.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@ import {
1515
} from "./terminal";
1616
import { BashToolResultSchema, FileTreeNodeSchema } from "./tools";
1717
import { FrontendWorkspaceMetadataSchema, WorkspaceActivitySnapshotSchema } from "./workspace";
18-
import { MCPAddParamsSchema, MCPRemoveParamsSchema, MCPServerMapSchema } from "./mcp";
18+
import {
19+
MCPAddParamsSchema,
20+
MCPRemoveParamsSchema,
21+
MCPServerMapSchema,
22+
MCPTestParamsSchema,
23+
MCPTestResultSchema,
24+
} from "./mcp";
1925

2026
// Re-export telemetry schemas
2127
export { telemetry, TelemetryEventSchema } from "./telemetry";
@@ -130,6 +136,10 @@ export const projects = {
130136
input: MCPRemoveParamsSchema,
131137
output: ResultSchema(z.void(), z.string()),
132138
},
139+
test: {
140+
input: MCPTestParamsSchema,
141+
output: MCPTestResultSchema,
142+
},
133143
},
134144
secrets: {
135145
get: {

src/common/orpc/schemas/mcp.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,13 @@ export const MCPRemoveParamsSchema = z.object({
1212
projectPath: z.string(),
1313
name: z.string(),
1414
});
15+
16+
export const MCPTestParamsSchema = z.object({
17+
projectPath: z.string(),
18+
name: z.string(),
19+
});
20+
21+
export const MCPTestResultSchema = z.discriminatedUnion("success", [
22+
z.object({ success: z.literal(true), tools: z.array(z.string()) }),
23+
z.object({ success: z.literal(false), error: z.string() }),
24+
]);

src/node/orpc/router.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,12 @@ export const router = (authToken?: string) => {
185185
.handler(({ context, input }) =>
186186
context.mcpConfigService.removeServer(input.projectPath, input.name)
187187
),
188+
test: t
189+
.input(schemas.projects.mcp.test.input)
190+
.output(schemas.projects.mcp.test.output)
191+
.handler(({ context, input }) =>
192+
context.mcpServerManager.testServer(input.projectPath, input.name)
193+
),
188194
},
189195
},
190196
nameGeneration: {

src/node/services/mcpServerManager.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import { MCPStdioTransport } from "@/node/services/mcpStdioTransport";
55
import type { MCPServerMap } from "@/common/types/mcp";
66
import type { Runtime } from "@/node/runtime/Runtime";
77
import type { MCPConfigService } from "@/node/services/mcpConfigService";
8+
import { createRuntime } from "@/node/runtime/runtimeFactory";
9+
10+
const TEST_TIMEOUT_MS = 30_000;
11+
12+
export type MCPTestResult = { success: true; tools: string[] } | { success: false; error: string };
813

914
interface MCPServerInstance {
1015
name: string;
@@ -71,6 +76,57 @@ export class MCPServerManager {
7176
this.workspaceServers.delete(workspaceId);
7277
}
7378

79+
/**
80+
* Test an MCP server configuration by spawning it, fetching tools, then closing.
81+
* Used by the Settings UI to verify a server works before relying on it.
82+
*/
83+
async testServer(projectPath: string, name: string): Promise<MCPTestResult> {
84+
const servers = await this.configService.listServers(projectPath);
85+
const command = servers?.[name];
86+
if (!command) {
87+
return { success: false, error: `Server "${name}" not found in configuration` };
88+
}
89+
90+
const runtime = createRuntime({ type: "local", srcBaseDir: projectPath });
91+
const timeoutPromise = new Promise<MCPTestResult>((resolve) =>
92+
setTimeout(() => resolve({ success: false, error: "Connection timed out" }), TEST_TIMEOUT_MS)
93+
);
94+
95+
const testPromise = (async (): Promise<MCPTestResult> => {
96+
let transport: MCPStdioTransport | null = null;
97+
try {
98+
log.debug("[MCP] Testing server", { name, command });
99+
const execStream = await runtime.exec(command, {
100+
cwd: projectPath,
101+
timeout: TEST_TIMEOUT_MS / 1000,
102+
});
103+
104+
transport = new MCPStdioTransport(execStream);
105+
await transport.start();
106+
const client = await experimental_createMCPClient({ transport });
107+
const tools = await client.tools();
108+
const toolNames = Object.keys(tools);
109+
await client.close();
110+
await transport.close();
111+
log.info("[MCP] Test successful", { name, tools: toolNames });
112+
return { success: true, tools: toolNames };
113+
} catch (error) {
114+
const message = error instanceof Error ? error.message : String(error);
115+
log.warn("[MCP] Test failed", { name, error: message });
116+
if (transport) {
117+
try {
118+
await transport.close();
119+
} catch {
120+
// ignore cleanup errors
121+
}
122+
}
123+
return { success: false, error: message };
124+
}
125+
})();
126+
127+
return Promise.race([testPromise, timeoutPromise]);
128+
}
129+
74130
private collectTools(instances: Map<string, MCPServerInstance>): Record<string, Tool> {
75131
const aggregated: Record<string, Tool> = {};
76132
for (const instance of instances.values()) {

tests/ipc/mcpConfig.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ describeIntegration("MCP project configuration", () => {
7575
});
7676

7777
describeIntegration("MCP server integration with model", () => {
78-
7978
test.concurrent(
8079
"MCP tools are available to the model",
8180
async () => {

0 commit comments

Comments
 (0)