diff --git a/app/api/mcp-servers/[id]/test/route.ts b/app/api/mcp-servers/[id]/test/route.ts new file mode 100644 index 00000000..709fba17 --- /dev/null +++ b/app/api/mcp-servers/[id]/test/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireUserId } from "@/lib/auth"; +import { testSavedMcpServer } from "@/lib/mcp-servers/diagnostics"; + +const defaultDeps = { requireUserId }; + +export function createMcpServerTestHandler(deps = defaultDeps) { + return async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } + ) { + const userId = await deps.requireUserId(); + if (userId instanceof Response) return userId; + const { id } = await params; + if (!z.string().uuid().safeParse(id).success) + return NextResponse.json({ error: "Invalid server id" }, { status: 400 }); + const result = await testSavedMcpServer(userId, id, request.signal); + return NextResponse.json(result ?? { error: "Server not found" }, { + status: result ? 200 : 404, + headers: { "Cache-Control": "no-store" }, + }); + }; +} + +export const POST = createMcpServerTestHandler(); diff --git a/components/settings/mcp-servers-page-client.tsx b/components/settings/mcp-servers-page-client.tsx index 4a5287dc..f4fcaf38 100644 --- a/components/settings/mcp-servers-page-client.tsx +++ b/components/settings/mcp-servers-page-client.tsx @@ -5,6 +5,7 @@ import useSWR from "swr"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { fetchJsonObject } from "@/lib/client-fetch"; +import type { McpDiagnosticResult } from "@/lib/mcp-servers/diagnostic-result"; import type { McpServer, McpServersResponse, FormState } from "./mcp-servers/types"; import { EMPTY_FORM } from "./mcp-servers/types"; @@ -30,6 +31,7 @@ export function McpServersPageClient() { const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [pageError, setPageError] = useState(null); + const [testResults, setTestResults] = useState>({}); const stats = useMemo( () => ({ @@ -160,10 +162,11 @@ export function McpServersPageClient() {
{servers.map((server) => ( setTestResults((current) => ({ ...current, [server.id]: result }))} /> ))}
@@ -180,6 +183,10 @@ export function McpServersPageClient() { onFormChange={setForm} onClose={closeDialog} onSave={() => void handleSave()} + toolNames={(() => { + const result = editingServer ? testResults[editingServer.id] : undefined; + return result?.status === "success" && result.serverUpdatedAt === editingServer?.updatedAt ? result.tools.map((tool) => tool.name) : []; + })()} /> void; +}) { + const [testing, setTesting] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const activeTest = useRef(null); + useEffect(() => () => activeTest.current?.abort(), []); + async function testConnection() { + const controller = new AbortController(); + activeTest.current = controller; + setTesting(true); + setResult(null); + setError(null); + try { + const response = await fetch(`/api/mcp-servers/${server.id}/test`, { method: "POST", signal: controller.signal }); + if (controller.signal.aborted) return; + if (!response.ok) { + setError(response.status === 401 ? "Sign in again to test this connection." : response.status === 404 ? "This server no longer exists. Refresh the page." : "The test could not complete. Try again."); + return; + } + const data = diagnosticResultSchema.parse(await response.json()); + if (controller.signal.aborted) return; + if (data.serverUpdatedAt && data.serverUpdatedAt !== server.updatedAt) { + setError("The saved settings changed. Refresh the page, then test again."); + return; + } + setResult(data); + onResult(data); + } catch { + if (!controller.signal.aborted) setError("The test could not complete. Check your connection and try again."); + } finally { + if (!controller.signal.aborted) setTesting(false); + } + } + if (server.transport === "stdio") return null; + const success = result?.status === "success" ? result : null; + const available = success?.tools.filter((tool) => tool.approval !== "deny") ?? []; + const automatic = available.filter((tool) => tool.approval !== "prompt").length; + const prompts = available.length - automatic; + return
+
+ +

Lists tools without running them.

+
+
+ {!result && !error && !testing &&

Not tested in this visit.

} + {error &&

{error}

} + {result?.status === "error" &&

{diagnosticMessages[result.code]}

} + {success && <> +

Connection test passed. {success.tools.length} {success.tools.length === 1 ? "tool" : "tools"} found.

+ {success.enabled ?

{automatic} available in workspace chat. Control approval required: {prompts}. Blocked: {success.tools.length - available.length}.

:

This server is disabled. Enable it to make its allowed tools available.

} + {success.tools.length === 0 &&

The server did not advertise any tools.

} + {success.tools.length > 0 &&
+ Discovered tools +
    {success.tools.map((tool) =>
  • {tool.name}: {tool.approval === "deny" ? "Blocked" : tool.approval === "prompt" ? "Control only: approval required" : "Runs automatically"}
  • )}
+
} + } + {result &&

Last test: {new Date(result.checkedAt).toLocaleString()}. Availability can change.

} +
+
; +} diff --git a/components/settings/mcp-servers/dialogs.tsx b/components/settings/mcp-servers/dialogs.tsx index f8924430..e8cd920b 100644 --- a/components/settings/mcp-servers/dialogs.tsx +++ b/components/settings/mcp-servers/dialogs.tsx @@ -26,6 +26,7 @@ import { } from "@/components/ui/alert-dialog"; import type { McpServer, FormState } from "./types"; import { KeyValueEditor } from "./key-value-editor"; +import { PermissionsEditor } from "./permissions-editor"; interface McpServerDialogProps { open: boolean; @@ -36,6 +37,7 @@ interface McpServerDialogProps { onFormChange: (updater: (current: FormState) => FormState) => void; onClose: (open: boolean) => void; onSave: () => void; + toolNames?: string[]; } export function McpServerDialog({ @@ -47,6 +49,7 @@ export function McpServerDialog({ onFormChange, onClose, onSave, + toolNames, }: McpServerDialogProps) { const dialogTitle = editingServer ? "Edit MCP server" : "Add MCP server"; const dialogDescription = editingServer @@ -194,6 +197,12 @@ export function McpServerDialog({ + {form.transport === "http" && onFormChange((current) => ({ ...current, extraText }))} + />} +