Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/api/mcp-servers/[id]/test/route.ts
Original file line number Diff line number Diff line change
@@ -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();
9 changes: 8 additions & 1 deletion components/settings/mcp-servers-page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,6 +31,7 @@ export function McpServersPageClient() {
const [deleteTarget, setDeleteTarget] = useState<McpServer | null>(null);
const [deleting, setDeleting] = useState(false);
const [pageError, setPageError] = useState<string | null>(null);
const [testResults, setTestResults] = useState<Record<string, McpDiagnosticResult>>({});

const stats = useMemo(
() => ({
Expand Down Expand Up @@ -160,10 +162,11 @@ export function McpServersPageClient() {
<div className="space-y-3">
{servers.map((server) => (
<ServerCard
key={server.id}
key={`${server.id}:${server.updatedAt}`}
server={server}
onEdit={openEditDialog}
onDelete={setDeleteTarget}
onTestResult={(result) => setTestResults((current) => ({ ...current, [server.id]: result }))}
/>
))}
</div>
Expand All @@ -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) : [];
})()}
/>

<DeleteServerDialog
Expand Down
70 changes: 70 additions & 0 deletions components/settings/mcp-servers/connection-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { diagnosticMessages, diagnosticResultSchema, type McpDiagnosticResult } from "@/lib/mcp-servers/diagnostic-result";
import type { McpServer } from "./types";

export function ConnectionTest({ server, onResult }: {
server: McpServer;
onResult: (result: McpDiagnosticResult) => void;
}) {
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<McpDiagnosticResult | null>(null);
const [error, setError] = useState<string | null>(null);
const activeTest = useRef<AbortController | null>(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 <div className="space-y-2 border-t border-border pt-3">
<div className="flex flex-wrap items-center gap-3">
<Button type="button" size="sm" variant="outline" disabled={testing} onClick={() => void testConnection()}>{testing ? "Testing connection..." : "Test connection"}</Button>
<p className="text-xs text-muted-foreground">Lists tools without running them.</p>
</div>
<div role="status" aria-live="polite" className="space-y-1 text-sm">
{!result && !error && !testing && <p className="text-muted-foreground">Not tested in this visit.</p>}
{error && <p className="text-destructive">{error}</p>}
{result?.status === "error" && <p className="text-destructive">{diagnosticMessages[result.code]}</p>}
{success && <>
<p>Connection test passed. {success.tools.length} {success.tools.length === 1 ? "tool" : "tools"} found.</p>
{success.enabled ? <p className="text-muted-foreground">{automatic} available in workspace chat. Control approval required: {prompts}. Blocked: {success.tools.length - available.length}.</p> : <p className="text-muted-foreground">This server is disabled. Enable it to make its allowed tools available.</p>}
{success.tools.length === 0 && <p className="text-muted-foreground">The server did not advertise any tools.</p>}
{success.tools.length > 0 && <details>
<summary className="cursor-pointer text-sm">Discovered tools</summary>
<ul className="mt-2 space-y-1">{success.tools.map((tool) => <li key={tool.name} className="break-words"><span className="font-mono">{tool.name}</span>: {tool.approval === "deny" ? "Blocked" : tool.approval === "prompt" ? "Control only: approval required" : "Runs automatically"}</li>)}</ul>
</details>}
</>}
{result && <p className="text-xs text-muted-foreground">Last test: {new Date(result.checkedAt).toLocaleString()}. Availability can change.</p>}
</div>
</div>;
}
9 changes: 9 additions & 0 deletions components/settings/mcp-servers/dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -36,6 +37,7 @@ interface McpServerDialogProps {
onFormChange: (updater: (current: FormState) => FormState) => void;
onClose: (open: boolean) => void;
onSave: () => void;
toolNames?: string[];
}

export function McpServerDialog({
Expand All @@ -47,6 +49,7 @@ export function McpServerDialog({
onFormChange,
onClose,
onSave,
toolNames,
}: McpServerDialogProps) {
const dialogTitle = editingServer ? "Edit MCP server" : "Add MCP server";
const dialogDescription = editingServer
Expand Down Expand Up @@ -194,6 +197,12 @@ export function McpServerDialog({
</TabsContent>
</Tabs>

{form.transport === "http" && <PermissionsEditor
extraText={form.extraText}
toolNames={toolNames}
onChange={(extraText) => onFormChange((current) => ({ ...current, extraText }))}
/>}

<div className="space-y-2">
<Label htmlFor="mcp-server-extra">Extra JSON</Label>
<Textarea
Expand Down
10 changes: 9 additions & 1 deletion components/settings/mcp-servers/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { McpServer, KeyValueEntry, FormState } from "./types";
import { EMPTY_FORM } from "./types";
import { readToolPolicy } from "@/lib/mcp-servers/policy";

export function createEntry(partial?: Partial<KeyValueEntry>): KeyValueEntry {
return {
Expand Down Expand Up @@ -85,7 +86,7 @@ export function summarizeServer(server: McpServer) {
return `${server.url ?? "No URL"} - ${headerCount} headers`;
}

function parseExtra(extraText: string) {
export function parseExtra(extraText: string) {
const trimmed = extraText.trim();
if (!trimmed) {
return {};
Expand Down Expand Up @@ -173,6 +174,13 @@ function buildKeyValuePayload(entries: KeyValueEntry[], label: string) {

export function buildPayload(form: FormState) {
const extra = parseExtra(form.extraText);
if (form.transport === "http") {
try {
readToolPolicy(extra);
} catch {
throw new Error("Correct the tool permissions before saving.");
}
}
const env = buildKeyValuePayload(form.envEntries, "Environment");
const headers = buildKeyValuePayload(form.headerEntries, "Header");
const base = {
Expand Down
103 changes: 103 additions & 0 deletions components/settings/mcp-servers/permissions-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { readToolPolicy, toolApproval, updateToolPolicy, normalizeToolNames, type ToolApproval } from "@/lib/mcp-servers/policy";
import { parseExtra } from "./helpers";

export function PermissionsEditor({ extraText, onChange, toolNames = [] }: {
extraText: string;
onChange: (value: string) => void;
toolNames?: string[];
}) {
const [newTool, setNewTool] = useState("");
let extra: Record<string, unknown>;
let policy: ReturnType<typeof readToolPolicy>;
try {
extra = parseExtra(extraText);
policy = readToolPolicy(extra);
} catch {
return <p role="alert" className="text-sm text-destructive">Tool permissions are invalid. Correct the permission fields in Extra JSON before saving.</p>;
}
const write = (value: Record<string, unknown>) => onChange(JSON.stringify(value, null, 2));
const names = [...new Set([...toolNames, ...Object.keys(policy.tools ?? {}), ...(policy.enabled_tools ?? []), ...(policy.disabled_tools ?? [])])].filter(Boolean).sort();
const setMode = (mode: ToolApproval | "inherit", tool?: string) => write(updateToolPolicy(extra, { mode, tool }));
const lines = (value: string) => value ? value.split("\n") : [];

return <section aria-label="Tool permissions" className="space-y-4 border-t border-border pt-4">
<div>
<h3 className="text-sm font-medium">Tool permissions</h3>
<p className="text-sm text-muted-foreground">Choose which tools agents can use. Changes apply on the next turn.</p>
</div>
<div className="space-y-2">
<Label htmlFor="mcp-default-approval">Default approval</Label>
<Select value={policy.default_tools_approval_mode === "approve" ? "auto" : policy.default_tools_approval_mode ?? "auto"} onValueChange={(value) => setMode(value as ToolApproval)}>
<SelectTrigger id="mcp-default-approval"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="auto">Run automatically</SelectItem>
<SelectItem value="prompt">Ask in Control</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">Tools that need approval are available in Control only, not workspace chat. The CLI also receives these permissions.</p>
</div>
<div className="flex items-center gap-3">
<Switch id="mcp-all-tools" checked={policy.enabled_tools === undefined} onCheckedChange={(checked) => {
const next = { ...extra };
if (checked) delete next.enabled_tools;
else next.enabled_tools = [];
write(next);
}} />
<Label htmlFor="mcp-all-tools">Allow all tools unless blocked</Label>
</div>
{policy.enabled_tools !== undefined && <div className="space-y-2">
<Label htmlFor="mcp-allowed-tools">Allowed tools (one name per line)</Label>
<Textarea id="mcp-allowed-tools" value={policy.enabled_tools.join("\n")} onChange={(event) => write({ ...extra, enabled_tools: lines(event.target.value) })} onBlur={(event) => write({ ...extra, enabled_tools: normalizeToolNames(lines(event.target.value)) })} />
<p className="text-sm text-muted-foreground">An empty list allows no tools. New tools stay unavailable until added here.</p>
</div>}
<div className="space-y-2">
<Label htmlFor="mcp-blocked-tools">Blocked tools (one name per line)</Label>
<Textarea id="mcp-blocked-tools" value={(policy.disabled_tools ?? []).join("\n")} onChange={(event) => write({ ...extra, disabled_tools: lines(event.target.value) })} onBlur={(event) => write({ ...extra, disabled_tools: normalizeToolNames(lines(event.target.value)) })} />
<p className="text-sm text-muted-foreground">Blocked tools remain unavailable even if their approval allows them.</p>
</div>
<div className="space-y-3">
<h4 className="text-sm font-medium">Per-tool approval</h4>
{names.map((name) => {
const entry = policy.tools?.[name];
const mode = entry?.approval_mode === "approve" ? "auto" : entry?.approval_mode ?? "inherit";
const effective = toolApproval(policy, name);
return <div key={name} className="grid gap-2 border-b border-border pb-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div className="min-w-0">
<p className="break-all font-mono text-sm">{name}</p>
<p className="text-xs text-muted-foreground">{effective === "deny" ? "Blocked" : effective === "prompt" ? "Control only: approval required" : "Workspace chat and Control"}</p>
{entry?.enabled === false && <div className="mt-2 flex items-center gap-2">
<Switch aria-label={`Enable ${name}`} checked={false} onCheckedChange={() => {
const tools = (extra.tools ?? {}) as Record<string, Record<string, unknown>>;
write({ ...extra, tools: { ...tools, [name]: { ...tools[name], enabled: true } } });
}} />
<span className="text-xs">Disabled in saved settings</span>
</div>}
</div>
<Select disabled={entry?.enabled === false} value={mode} onValueChange={(value) => setMode(value as ToolApproval | "inherit", name)}>
<SelectTrigger aria-label={`Approval for ${name}`}><SelectValue /></SelectTrigger>
Comment thread
charlesrhoward marked this conversation as resolved.
<SelectContent>
<SelectItem value="inherit">Use default</SelectItem>
<SelectItem value="auto">Run automatically</SelectItem>
<SelectItem value="prompt">Ask in Control</SelectItem>
<SelectItem value="deny">Block</SelectItem>
</SelectContent>
</Select>
</div>;
})}
<div className="flex flex-col gap-2 sm:flex-row">
<Input aria-label="Tool name for approval override" placeholder="Exact tool name" value={newTool} onChange={(event) => setNewTool(event.target.value)} />
<Button type="button" variant="outline" disabled={!newTool.trim() || names.includes(newTool.trim())} onClick={() => { setMode("inherit", newTool.trim()); setNewTool(""); }}>Add tool rule</Button>
</div>
<p className="text-xs text-muted-foreground">Test the saved connection to list its tools, or enter an exact tool name.</p>
</div>
</section>;
}
6 changes: 5 additions & 1 deletion components/settings/mcp-servers/server-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { McpServer } from "./types";
import { formatTimestamp, summarizeServer } from "./helpers";
import { ConnectionTest } from "./connection-test";
import type { McpDiagnosticResult } from "@/lib/mcp-servers/diagnostic-result";

interface ServerCardProps {
server: McpServer;
onEdit: (server: McpServer) => void;
onDelete: (server: McpServer) => void;
onTestResult: (result: McpDiagnosticResult) => void;
}

export function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
export function ServerCard({ server, onEdit, onDelete, onTestResult }: ServerCardProps) {
return (
<div className="rounded-lg border border-border bg-background/60 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
Expand Down Expand Up @@ -69,6 +72,7 @@ export function ServerCard({ server, onEdit, onDelete }: ServerCardProps) {
</Button>
</div>
</div>
{server.transport === "http" && <div className="mt-3"><ConnectionTest server={server} onResult={onTestResult} /></div>}
</div>
);
}
24 changes: 17 additions & 7 deletions lib/connections/mcp-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ export async function getMcpTools(

export async function getRemoteMcpTools(
transport: ReturnType<typeof buildMcpTransport>,
options: { validateRequests?: boolean; startupSignal?: AbortSignal } = {}
options: {
validateRequests?: boolean;
startupSignal?: AbortSignal;
cleanupSignal?: () => AbortSignal;
} = {}
): Promise<McpToolsResult> {
await assertSafeOutboundHttpUrlWithDns(transport.url, "mcp_url");
options.startupSignal?.throwIfAborted();
Expand All @@ -36,14 +40,20 @@ export async function getRemoteMcpTools(
fetch: options.validateRequests
? async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
// Teardown must remain possible after discovery is cancelled.
const cleanupSignal =
init?.method === "DELETE" ? options.cleanupSignal?.() : undefined;
await assertSafeOutboundHttpUrlWithDns(url, "mcp_url");
const signals = cleanupSignal
? [cleanupSignal]
: [
...(starting && options.startupSignal
? [options.startupSignal]
: []),
...(init?.signal ? [init.signal] : []),
];
const signal =
starting && options.startupSignal
? AbortSignal.any([
options.startupSignal,
...(init?.signal ? [init.signal] : []),
])
: init?.signal;
signals.length > 0 ? AbortSignal.any(signals) : undefined;
signal?.throwIfAborted();
return fetch(input, { ...init, signal, redirect: "error" });
}
Expand Down
Loading
Loading