From 6b74b9f11547b698ce2e1f39a5faeb78cc64e0be Mon Sep 17 00:00:00 2001 From: Charles Howard <96023061+charlesrhoward@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:25:47 -0400 Subject: [PATCH 1/4] feat: add saved MCP connection tests and permission controls --- app/api/mcp-servers/[id]/test/route.ts | 26 ++ .../settings/mcp-servers-page-client.tsx | 9 +- .../settings/mcp-servers/connection-test.tsx | 64 +++ components/settings/mcp-servers/dialogs.tsx | 9 + components/settings/mcp-servers/helpers.ts | 10 +- .../mcp-servers/permissions-editor.tsx | 103 +++++ .../settings/mcp-servers/server-card.tsx | 6 +- lib/connections/mcp-tools.ts | 20 +- lib/mcp-servers/chat.ts | 55 +-- lib/mcp-servers/diagnostic-result.test.ts | 38 ++ lib/mcp-servers/diagnostic-result.ts | 59 +++ lib/mcp-servers/diagnostics.test.ts | 267 +++++++++++++ lib/mcp-servers/diagnostics.ts | 130 ++++++ lib/mcp-servers/normalization.ts | 11 + lib/mcp-servers/policy.test.ts | 114 ++++++ lib/mcp-servers/policy.ts | 51 +++ tests/db/mcp-servers-chat.test.ts | 11 + tests/e2e/mcp-permissions-diagnostics.spec.ts | 376 ++++++++++++++++++ tests/e2e/mcp-servers-settings.spec.ts | 4 +- tests/unit/mcp-server-test-route.test.ts | 71 ++++ 20 files changed, 1389 insertions(+), 45 deletions(-) create mode 100644 app/api/mcp-servers/[id]/test/route.ts create mode 100644 components/settings/mcp-servers/connection-test.tsx create mode 100644 components/settings/mcp-servers/permissions-editor.tsx create mode 100644 lib/mcp-servers/diagnostic-result.test.ts create mode 100644 lib/mcp-servers/diagnostic-result.ts create mode 100644 lib/mcp-servers/diagnostics.test.ts create mode 100644 lib/mcp-servers/diagnostics.ts create mode 100644 lib/mcp-servers/policy.test.ts create mode 100644 lib/mcp-servers/policy.ts create mode 100644 tests/e2e/mcp-permissions-diagnostics.spec.ts create mode 100644 tests/unit/mcp-server-test-route.test.ts 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); + async function testConnection() { + setTesting(true); + setResult(null); + setError(null); + try { + const response = await fetch(`/api/mcp-servers/${server.id}/test`, { method: "POST" }); + 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 (data.serverUpdatedAt && data.serverUpdatedAt !== server.updatedAt) { + setError("The saved settings changed. Refresh the page, then test again."); + return; + } + setResult(data); + onResult(data); + } catch { + setError("The test could not complete. Check your connection and try again."); + } finally { + 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 }))} + />} +