diff --git a/app/api/control/chat/_lib/connection-tools.ts b/app/api/control/chat/_lib/connection-tools.ts index 4b799312..0243d8ac 100644 --- a/app/api/control/chat/_lib/connection-tools.ts +++ b/app/api/control/chat/_lib/connection-tools.ts @@ -20,13 +20,15 @@ import { } from "@/lib/team-capabilities"; import type { Connection } from "@/lib/types"; import type { Tool } from "ai"; +import { CONNECTION_TOOL_STARTUP_TIMEOUT_MS } from "@/lib/connections/mcp-tools"; /** * How long a Control turn waits for connection tools. Remote MCP servers are * someone else's uptime: a slow one must cost the turn its connection tools, * never the turn itself. Same posture as the memory block: fail open. */ -export const CONTROL_CONNECTION_TOOLS_TIMEOUT_MS = 8000; +export const CONTROL_CONNECTION_TOOLS_TIMEOUT_MS = + CONNECTION_TOOL_STARTUP_TIMEOUT_MS; export type ControlConnectionTools = { tools: Record; @@ -99,7 +101,6 @@ export async function loadControlConnectionTools( } const connections = await deps.loadConnections(input.userId, repoId); - if (connections.length === 0) return NO_CONNECTION_TOOLS; const building = deps.buildTools(connections, { userId: input.userId, diff --git a/components/settings/mcp-servers-page-client.tsx b/components/settings/mcp-servers-page-client.tsx index c150cc58..4a5287dc 100644 --- a/components/settings/mcp-servers-page-client.tsx +++ b/components/settings/mcp-servers-page-client.tsx @@ -131,8 +131,8 @@ export function McpServersPageClient() {
Server Definitions
- Manage MCP servers that sync with the Mogplex CLI. Saved secrets - stay hidden in the browser. + Enabled public HTTP servers provide tools in web chat and the CLI. + Local servers run through the CLI. Saved secrets stay hidden in the browser.
diff --git a/components/settings/mcp-servers/dialogs.tsx b/components/settings/mcp-servers/dialogs.tsx index 5bc6eb16..f8924430 100644 --- a/components/settings/mcp-servers/dialogs.tsx +++ b/components/settings/mcp-servers/dialogs.tsx @@ -51,7 +51,7 @@ export function McpServerDialog({ const dialogTitle = editingServer ? "Edit MCP server" : "Add MCP server"; const dialogDescription = editingServer ? "Update the synced server definition. Saved secrets stay masked until you overwrite or clear them." - : "Create a synced MCP server definition for CLI and agent sessions."; + : "Add a server for web chat and the CLI."; return ( @@ -86,6 +86,7 @@ export function McpServerDialog({
onFormChange((current) => ({ ...current, enabled: checked })) @@ -94,8 +95,7 @@ export function McpServerDialog({
Enabled
- Disabled servers stay stored but do not sync into the CLI - cache. + Disabled servers stay saved but cannot provide tools.
@@ -112,11 +112,14 @@ export function McpServerDialog({ className="space-y-4" > - stdio - http + Streamable HTTP + Local (CLI only) +

+ Runs on your computer through the CLI. Not available in web chat. +

@@ -161,6 +164,9 @@ export function McpServerDialog({ +

+ Available in web chat and the CLI. Web chat requires a public server URL. +

diff --git a/components/settings/mcp-servers/server-card.tsx b/components/settings/mcp-servers/server-card.tsx index e2b50e6a..07bafe4e 100644 --- a/components/settings/mcp-servers/server-card.tsx +++ b/components/settings/mcp-servers/server-card.tsx @@ -26,6 +26,7 @@ export function ServerCard({ server, onEdit, onDelete }: ServerCardProps) { {server.enabled ? "Enabled" : "Disabled"} + {server.transport === "stdio" && CLI only}

{summarizeServer(server)} diff --git a/components/settings/mcp-servers/types.ts b/components/settings/mcp-servers/types.ts index 6d4eaa35..bc400dd1 100644 --- a/components/settings/mcp-servers/types.ts +++ b/components/settings/mcp-servers/types.ts @@ -44,7 +44,7 @@ export type FormState = { export const EMPTY_FORM: FormState = { name: "", enabled: true, - transport: "stdio", + transport: "http", command: "", argsText: "", url: "", diff --git a/lib/agents/tools/connections.ts b/lib/agents/tools/connections.ts index d95482a7..cd5bf7b3 100644 --- a/lib/agents/tools/connections.ts +++ b/lib/agents/tools/connections.ts @@ -12,6 +12,7 @@ import { logConnectionEvent } from "@/lib/connections/logging"; import { hasCapability, type Capability } from "@/lib/team-capabilities"; import type { Connection } from "@/lib/types"; import { sanitize } from "./shared"; +import { loadSavedMcpServerTools } from "@/lib/mcp-servers/chat"; export const DYNAMIC_CONNECTION_CAPABILITY: Capability = "connections.create"; @@ -141,25 +142,37 @@ export async function buildDynamicConnectionTools( return false; }); + const savedLoading = ctx.userId + ? loadSavedMcpServerTools(ctx.userId, ctx.canAskApproval) + : null; const results = await Promise.allSettled( runnable.map((conn) => loadConnectionTools(conn, ctx, getValidAccessToken, deps.getCredentials) ) ); - return { - ...registerLoadedTools( - results.map((result, index) => - result.status === "fulfilled" && result.value - ? { - loaded: result.value, - asks: runnable[index].approval_mode === "ask", - } - : null - ) - ), - withheldConnections, - }; + const registered = registerLoadedTools( + results.map((result, index) => + result.status === "fulfilled" && result.value + ? { + loaded: result.value, + asks: runnable[index].approval_mode === "ask", + } + : null + ) + ); + if (savedLoading) { + const saved = await savedLoading; + registered.mcpCleanups.push(...saved.mcpCleanups); + for (const [name, tool] of Object.entries(saved.dynamicTools)) { + // Preserve the permissions and identity of existing integration tools. + if (name in registered.dynamicTools) continue; + registered.dynamicTools[name] = tool; + registered.mcpToolNames.add(name); + if (saved.askToolNames.has(name)) registered.askToolNames.add(name); + } + } + return { ...registered, withheldConnections }; } /** Name each loaded tool and sort the names into the sets callers act on. */ diff --git a/lib/agents/tools/index.ts b/lib/agents/tools/index.ts index 2b5160e2..0c1a31b5 100644 --- a/lib/agents/tools/index.ts +++ b/lib/agents/tools/index.ts @@ -447,14 +447,6 @@ export async function buildTools(opts: { } const connections = await loadScopedConnections(opts.userId, opts.repoId); - if (connections.length === 0) { - recordDeniedTools(opts.teamId ?? null, opts.userId, deniedTools); - return { - tools: withIdempotency(staticTools), - connections: [], - cleanup: emptyCleanup, - }; - } const { dynamicTools, mcpCleanups, mcpToolNames, restToolNames } = await buildDynamicConnectionTools(connections, { diff --git a/lib/connections/mcp-tools.ts b/lib/connections/mcp-tools.ts index ce36b774..ea957cfa 100644 --- a/lib/connections/mcp-tools.ts +++ b/lib/connections/mcp-tools.ts @@ -9,20 +9,51 @@ export type McpToolsResult = { cleanup: () => Promise; }; +// The existing Control startup budget also bounds saved-server discovery. +export const CONNECTION_TOOL_STARTUP_TIMEOUT_MS = 8000; + export async function getMcpTools( conn: Connection, credential?: string ): Promise { - if (conn.mcp_url) { - await assertSafeOutboundHttpUrlWithDns(conn.mcp_url, "mcp_url"); - } + return getRemoteMcpTools(buildMcpTransport(conn, credential)); +} + +export async function getRemoteMcpTools( + transport: ReturnType, + options: { validateRequests?: boolean; startupSignal?: AbortSignal } = {} +): Promise { + await assertSafeOutboundHttpUrlWithDns(transport.url, "mcp_url"); + options.startupSignal?.throwIfAborted(); + let starting = true; const client = await createMCPClient({ - transport: buildMcpTransport(conn, credential), + initializationOptions: { signal: options.startupSignal }, + transport: { + ...transport, + // Saved servers use guarded requests. Keep existing Integrations' + // transport behavior unchanged. + fetch: options.validateRequests + ? async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + await assertSafeOutboundHttpUrlWithDns(url, "mcp_url"); + const signal = + starting && options.startupSignal + ? AbortSignal.any([ + options.startupSignal, + ...(init?.signal ? [init.signal] : []), + ]) + : init?.signal; + signal?.throwIfAborted(); + return fetch(input, { ...init, signal, redirect: "error" }); + } + : undefined, + }, }); try { const tools = await client.tools(); + options.startupSignal?.throwIfAborted(); let closed = false; return { tools, @@ -35,6 +66,9 @@ export async function getMcpTools( } catch (error) { await client.close().catch(() => undefined); throw error; + } finally { + // A startup deadline must never abort a later tool call. + starting = false; } } diff --git a/lib/mcp-servers/chat.test.ts b/lib/mcp-servers/chat.test.ts new file mode 100644 index 00000000..c01c2534 --- /dev/null +++ b/lib/mcp-servers/chat.test.ts @@ -0,0 +1,403 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { + buildDynamicConnectionTools, + cleanupMcpClients, +} from "../agents/tools/connections"; +import type { McpServerRow } from "./types"; +import type { Connection } from "@/lib/types"; + +const server = (overrides: Partial = {}): McpServerRow => ({ + id: "11111111-1111-4111-8111-111111111111", + user_id: "user-1", + name: "Documentation", + enabled: true, + transport: "http", + command: null, + args: [], + env_refs: {}, + env_plain: {}, + url: "https://8.8.8.8/mcp", + header_refs: { Authorization: "secret-1" }, + header_plain: { "X-Project": "docs" }, + extra: {}, + created_at: "2026-09-22T00:00:00Z", + updated_at: "2026-09-22T00:00:00Z", + ...overrides, +}); + +let rows: McpServerRow[]; +let requests: Request[]; +let secrets: Array<{ id: string; decrypted_secret: string }>; +let failCatalog = false; +let failDiscovery = false; +let stallDiscovery = false; +let onDiscovery: () => void = () => undefined; + +beforeEach(() => { + vi.stubEnv("MOGPLEX_DATA_BACKEND", "supabase"); + vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "https://db.example.com"); + vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", "test-service-key"); + rows = [server()]; + requests = []; + secrets = [{ id: "secret-1", decrypted_secret: "Bearer test-secret" }]; + failCatalog = false; + failDiscovery = false; + stallDiscovery = false; + vi.stubGlobal( + "fetch", + async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + requests.push(request); + const url = new URL(request.url); + if (url.pathname.endsWith("/user_mcp_servers")) { + if (failCatalog) + return Response.json({ message: "unavailable" }, { status: 503 }); + return Response.json( + rows.filter((row) => + ["user_id", "enabled", "transport"].every((key) => { + const value = url.searchParams.get(key); + return !value || value === `eq.${row[key as keyof McpServerRow]}`; + }) + ) + ); + } + if (url.pathname.endsWith("/decrypted_secrets")) + return Response.json(secrets); + if (url.hostname === "db.example.com") return Response.json([]); + if (request.method === "DELETE") + return new Response(null, { status: 204 }); + const body = await request.json(); + if (body.method === "notifications/initialized") + return new Response(null, { status: 202 }); + if ( + body.method === "tools/list" && + stallDiscovery && + url.pathname === "/slow" + ) { + return new Promise((_resolve, reject) => { + request.signal.addEventListener( + "abort", + () => reject(request.signal.reason), + { once: true } + ); + onDiscovery(); + }); + } + if ( + body.method === "tools/list" && + failDiscovery && + url.pathname === "/broken" + ) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const result = + body.method === "initialize" + ? { + protocolVersion: "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: "fixture", version: "1" }, + } + : body.method === "tools/list" + ? { + tools: [ + { + name: "find_docs", + description: "Find docs", + inputSchema: { type: "object", properties: {} }, + }, + ], + } + : { content: [{ type: "text", text: "Documentation found" }] }; + return Response.json( + { jsonrpc: "2.0", id: body.id, result }, + { headers: { "mcp-session-id": "fixture-session" } } + ); + } + ); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +it("loads a saved HTTP server without an Integration, calls its tool with saved headers, and closes it", async () => { + const built = await buildDynamicConnectionTools([], { userId: "user-1" }); + const names = Object.keys(built.dynamicTools); + expect(names).toHaveLength(1); + expect(built.mcpToolNames.has(names[0])).toBe(true); + const result = await built.dynamicTools[names[0]].execute!( + {}, + { toolCallId: "call-1", messages: [], context: undefined } + ); + expect(result).toMatchObject({ content: [{ text: "Documentation found" }] }); + const calls = requests.filter((request) => + request.url.startsWith("https://8.8.8.8") + ); + expect(calls.length).toBeGreaterThan(0); + for (const request of calls) { + expect(request.headers.get("Authorization")).toBe("Bearer test-secret"); + expect(request.headers.get("X-Project")).toBe("docs"); + expect(request.redirect).toBe("error"); + } + await cleanupMcpClients(built.mcpCleanups); + await cleanupMcpClients(built.mcpCleanups); + expect( + requests.filter((request) => request.method === "DELETE") + ).toHaveLength(1); +}); + +it("excludes other users, disabled entries, stdio, and local URLs before reading their secrets or making MCP requests", async () => { + rows = [ + server({ user_id: "user-2" }), + server({ enabled: false }), + server({ transport: "stdio", command: "npx", url: null }), + server({ url: "http://127.0.0.1/mcp" }), + ]; + const built = await buildDynamicConnectionTools([], { userId: "user-1" }); + expect(built.dynamicTools).toEqual({}); + expect(requests).toHaveLength(1); +}); + +it("does not read the catalog without a user", async () => { + const built = await buildDynamicConnectionTools([], {}); + expect(built.dynamicTools).toEqual({}); + expect(requests).toHaveLength(0); +}); + +it("makes saved tools available through the workspace chat entry point with no Integrations", async () => { + const { buildTools } = await import("../agents/tools/index"); + const built = await buildTools({ + userId: "user-1", + capabilities: new Set(["connections.create"]), + }); + expect( + Object.keys(built.tools).some((name) => + name.startsWith("saved_documentation_") + ) + ).toBe(true); + expect(built.connections).toEqual([]); + await built.cleanup(); +}); + +it("does not load saved tools when the workspace team capability is denied", async () => { + const { buildTools } = await import("../agents/tools/index"); + const built = await buildTools({ + userId: "user-1", + teamId: "team-1", + capabilities: new Set(), + }); + expect( + Object.keys(built.tools).some((name) => name.startsWith("saved_")) + ).toBe(false); + expect( + requests.some((request) => request.url.includes("user_mcp_servers")) + ).toBe(false); + await built.cleanup(); +}); + +it("does not attempt anonymous access when a saved secret is missing", async () => { + secrets = []; + const built = await buildDynamicConnectionTools([], { userId: "user-1" }); + expect(built.dynamicTools).toEqual({}); + expect( + requests.every( + (request) => new URL(request.url).hostname === "db.example.com" + ) + ).toBe(true); +}); + +it("isolates catalog errors", async () => { + failCatalog = true; + const built = await buildDynamicConnectionTools([], { userId: "user-1" }); + expect(built.dynamicTools).toEqual({}); +}); + +it("keeps existing Integration tools available when the saved catalog fails", async () => { + failCatalog = true; + const connection = { + id: "integration-1", + name: "Existing docs", + type: "mcp_server", + mcp_transport: "http", + mcp_url: "https://8.8.8.8/mcp", + auth_type: "bearer", + approval_mode: "auto", + } as Connection; + const built = await buildDynamicConnectionTools( + [connection], + { userId: "user-1" }, + { + getCredentials: async () => "integration-secret", + } + ); + expect(Object.keys(built.dynamicTools)).toEqual(["existing_docs_find_docs"]); + expect(built.mcpToolNames.has("existing_docs_find_docs")).toBe(true); + const result = await built.dynamicTools.existing_docs_find_docs.execute!( + {}, + { + toolCallId: "existing-call", + messages: [], + context: undefined, + } + ); + expect(result).toMatchObject({ content: [{ text: "Documentation found" }] }); + expect( + requests + .find((request) => request.url.startsWith("https://8.8.8.8")) + ?.headers.get("Authorization") + ).toBe("Bearer integration-secret"); + expect( + requests.find((request) => request.url.startsWith("https://8.8.8.8")) + ?.redirect + ).toBe("error"); + await cleanupMcpClients(built.mcpCleanups); +}); + +it("bounds saved-server startup, keeps healthy tools, and does not abort later tool calls", async () => { + vi.useFakeTimers(); + stallDiscovery = true; + const started = new Promise((resolve) => { + onDiscovery = resolve; + }); + rows.push( + server({ + id: "22222222-2222-4222-8222-222222222222", + name: "Slow", + url: "https://8.8.8.8/slow", + }) + ); + const loading = buildDynamicConnectionTools([], { userId: "user-1" }); + await started; + await vi.advanceTimersByTimeAsync(8001); + const built = await loading; + expect(Object.keys(built.dynamicTools)).toHaveLength(1); + expect( + requests.find((request) => request.url.endsWith("/slow"))?.signal.aborted + ).toBe(true); + expect( + requests + .filter((request) => request.url.endsWith("/mcp")) + .every((request) => !request.signal.aborted) + ).toBe(true); + const tool = Object.values(built.dynamicTools)[0]; + expect( + await tool.execute!( + {}, + { toolCallId: "after-deadline", messages: [], context: undefined } + ) + ).toMatchObject({ content: [{ text: "Documentation found" }] }); + await cleanupMcpClients(built.mcpCleanups); +}); + +it("returns healthy saved tools before Control's outer startup deadline", async () => { + const { loadControlConnectionTools } = + await import("@/app/api/control/chat/_lib/connection-tools"); + vi.useFakeTimers(); + stallDiscovery = true; + const started = new Promise((resolve) => { + onDiscovery = resolve; + }); + rows.push( + server({ + id: "22222222-2222-4222-8222-222222222222", + url: "https://8.8.8.8/slow", + }) + ); + let settled = false; + const loading = loadControlConnectionTools( + { userId: "user-1", teamId: null, enabled: true }, + { + resolveCapabilities: async () => new Set(["*"]), + loadConnections: async () => [], + buildTools: buildDynamicConnectionTools, + timeoutMs: 8000, + } + ).then((value) => { + settled = true; + return value; + }); + await started; + await vi.advanceTimersByTimeAsync(6001); + expect(settled).toBe(true); + const loaded = await loading; + expect(Object.keys(loaded.tools)).toHaveLength(1); + await loaded.cleanup(); +}); + +it.each([ + { enabled_tools: [] }, + { disabled_tools: ["find_docs"] }, + { tools: { find_docs: { enabled: false } } }, + { tools: { find_docs: { approval_mode: "deny" } } }, + { default_tools_approval_mode: "prompt" }, + { disabled_tools: "invalid" }, +])( + "honors saved tool restrictions without broadening access: %j", + async (extra) => { + rows = [server({ extra })]; + const built = await buildDynamicConnectionTools([], { userId: "user-1" }); + expect(built.dynamicTools).toEqual({}); + await cleanupMcpClients(built.mcpCleanups); + } +); + +it("marks explicitly prompted tools for Control approval and preserves per-tool overrides", async () => { + rows = [server({ extra: { default_tools_approval_mode: "prompt" } })]; + const built = await buildDynamicConnectionTools([], { + userId: "user-1", + canAskApproval: true, + }); + expect([...built.askToolNames]).toEqual(Object.keys(built.dynamicTools)); + expect(built.askToolNames.size).toBe(1); + await cleanupMcpClients(built.mcpCleanups); + rows = [ + server({ + extra: { + default_tools_approval_mode: "prompt", + tools: { find_docs: { approval_mode: "approve" } }, + }, + }), + ]; + const automatic = await buildDynamicConnectionTools([], { userId: "user-1" }); + expect(Object.keys(automatic.dynamicTools)).toHaveLength(1); + expect(automatic.askToolNames.size).toBe(0); + await cleanupMcpClients(automatic.mcpCleanups); +}); + +it("isolates a failed server and closes its session while loading the other server", async () => { + failDiscovery = true; + rows.push( + server({ + id: "22222222-2222-4222-8222-222222222222", + name: "Broken", + url: "https://8.8.8.8/broken", + }) + ); + const built = await buildDynamicConnectionTools([], { userId: "user-1" }); + expect(Object.keys(built.dynamicTools)).toHaveLength(1); + expect( + requests.some( + (request) => + request.method === "DELETE" && request.url.endsWith("/broken") + ) + ).toBe(true); + await cleanupMcpClients(built.mcpCleanups); +}); + +it("uses distinct valid tool names for servers with colliding display names and reloads enabled state each turn", async () => { + rows = [ + server({ name: "a-b" }), + server({ name: "a b", id: "22222222-2222-4222-8222-222222222222" }), + ]; + const built = await buildDynamicConnectionTools([], { userId: "user-1" }); + expect(Object.keys(built.dynamicTools)).toHaveLength(2); + for (const name of Object.keys(built.dynamicTools)) + expect(name).toMatch(/^[\w-]{1,64}$/); + await cleanupMcpClients(built.mcpCleanups); + rows = rows.map((row) => ({ ...row, enabled: false })); + expect( + (await buildDynamicConnectionTools([], { userId: "user-1" })).dynamicTools + ).toEqual({}); +}); diff --git a/lib/mcp-servers/chat.ts b/lib/mcp-servers/chat.ts new file mode 100644 index 00000000..001be36a --- /dev/null +++ b/lib/mcp-servers/chat.ts @@ -0,0 +1,172 @@ +import { createHash } from "node:crypto"; +import type { Tool } from "ai"; +import { z } from "zod"; +import { supabaseAdmin } from "@/lib/supabase/admin"; +import { + getRemoteMcpTools, + CONNECTION_TOOL_STARTUP_TIMEOUT_MS, +} from "@/lib/connections/mcp-tools"; +import { assertSafeOutboundHttpUrlWithDns } from "@/lib/security/outbound-url"; +import { sanitize } from "@/lib/agents/tools/shared"; +import { normalizeStringRecord } from "./validation"; +import { resolveVaultSecrets } from "./secrets"; +import type { McpServerRow } from "./types"; + +type ChatServerRow = Pick< + McpServerRow, + "id" | "name" | "url" | "header_refs" | "header_plain" | "extra" +>; + +const toolPolicySchema = z.object({ + enabled_tools: z.array(z.string()).optional(), + disabled_tools: z.array(z.string()).optional(), + default_tools_approval_mode: z.enum(["auto", "approve", "prompt"]).optional(), + tools: z + .record( + z.string(), + z.object({ + enabled: z.boolean().optional(), + approval_mode: z.enum(["auto", "approve", "prompt", "deny"]).optional(), + }) + ) + .optional(), +}); + +export async function listSavedHttpMcpServers( + userId: string, + db = supabaseAdmin +) { + const { data, error } = await db + .from("user_mcp_servers") + .select("id, name, url, header_refs, header_plain, extra") + .eq("user_id", userId) + .eq("enabled", true) + .eq("transport", "http") + .order("created_at"); + if (error) throw error; + return (data ?? []) as ChatServerRow[]; +} + +function toolApproval(policy: z.infer, name: string) { + if (policy.enabled_tools && !policy.enabled_tools.includes(name)) + return "deny"; + if (policy.disabled_tools?.includes(name)) return "deny"; + const perTool = policy.tools?.[name]; + if (perTool?.enabled === false) return "deny"; + // CLI approval.ts defines "approve" as pre-approved, equivalent to "auto". + return perTool?.approval_mode ?? policy.default_tools_approval_mode; +} + +function duringStartup(work: Promise, signal: AbortSignal): Promise { + let onAbort: () => void; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + }); + return Promise.race([work, aborted]).finally(() => { + signal.removeEventListener("abort", onAbort); + }); +} + +async function loadSavedServer(server: ChatServerRow, signal: AbortSignal) { + const policy = toolPolicySchema.parse(server.extra ?? {}); + if (!server.url) throw new Error("Missing MCP URL"); + // Local HTTP servers still sync to the CLI. The hosted runtime must not + // read their secrets or dial private addresses. + await assertSafeOutboundHttpUrlWithDns(server.url, "mcp_url"); + signal.throwIfAborted(); + const refs = normalizeStringRecord(server.header_refs, "header_refs"); + const headers = normalizeStringRecord(server.header_plain, "header_plain"); + const secrets = await resolveVaultSecrets(Object.values(refs)); + signal.throwIfAborted(); + for (const [name, id] of Object.entries(refs)) { + const value = secrets.get(id); + if (value === undefined) throw new Error("Missing MCP secret"); + headers[name] = value; + } + // Detach a healthy session from the shared deadline: its startup requests + // can include streams that stay open for the lifetime of the client. + const startup = new AbortController(); + const abort = () => startup.abort(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + try { + return { + server, + policy, + loaded: await getRemoteMcpTools( + { type: "http", url: server.url, headers }, + { validateRequests: true, startupSignal: startup.signal } + ), + }; + } finally { + signal.removeEventListener("abort", abort); + } +} + +/** The catalog is personal. Filter before reading any Vault-backed headers. */ +export async function loadSavedMcpServerTools( + userId: string, + canAskApproval = false +) { + const dynamicTools: Record = {}; + const mcpCleanups: Array<() => Promise> = []; + const askToolNames = new Set(); + const startup = new AbortController(); + const timer = setTimeout( + () => startup.abort(new Error("MCP startup timed out")), + // Leave time to return healthy results before Control's outer deadline. + CONNECTION_TOOL_STARTUP_TIMEOUT_MS - 2000 + ); + try { + const servers = await duringStartup( + listSavedHttpMcpServers(userId), + startup.signal + ); + const results = await Promise.allSettled( + servers.map((server) => + duringStartup(loadSavedServer(server, startup.signal), startup.signal) + ) + ); + for (const [index, result] of results.entries()) { + if (result.status === "rejected") { + // Upstream errors may include secret-bearing URLs or header values. + console.warn( + "[mcp-servers] A saved HTTP server could not load for chat", + { + userId, + serverId: servers[index].id, + timedOut: + startup.signal.aborted && result.reason === startup.signal.reason, + } + ); + continue; + } + const { server, loaded, policy } = result.value; + mcpCleanups.push(loaded.cleanup); + for (const [name, tool] of Object.entries(loaded.tools)) { + const approval = toolApproval(policy, name); + if (approval === "deny" || (approval === "prompt" && !canAskApproval)) + continue; + const hash = createHash("sha256") + .update(`${server.id}:${name}`) + .digest("hex") + .slice(0, 12); + const toolName = `saved_${sanitize(server.name).slice(0, 18)}_${sanitize(name).slice(0, 24)}_${hash}`; + dynamicTools[toolName] = { + ...tool, + description: `${server.name}: ${tool.description ?? name}`, + } as Tool; + if (approval === "prompt") askToolNames.add(toolName); + } + } + } catch { + console.warn("[mcp-servers] Saved HTTP servers could not load for chat", { + userId, + timedOut: startup.signal.aborted, + }); + } finally { + clearTimeout(timer); + } + return { dynamicTools, mcpCleanups, askToolNames }; +} diff --git a/tests/db/mcp-servers-chat.test.ts b/tests/db/mcp-servers-chat.test.ts new file mode 100644 index 00000000..d1d4ce0c --- /dev/null +++ b/tests/db/mcp-servers-chat.test.ts @@ -0,0 +1,57 @@ +import { readFile } from "node:fs/promises"; +import { PGlite } from "@electric-sql/pglite"; +import { expect, it } from "vitest"; +import { createPostgrestShim } from "@/lib/db/postgrest-shim"; +import { listSavedHttpMcpServers } from "@/lib/mcp-servers/chat"; + +it("reads only the caller's enabled HTTP catalog entries and sees disabling and deletion on the next turn", async () => { + const pg = await PGlite.create(); + try { + const baseline = await readFile( + new URL("../../neon/baseline.sql", import.meta.url), + "utf8" + ); + const ddl = baseline.match( + /CREATE TABLE public\.user_mcp_servers \([\s\S]*?\n\);/ + )?.[0]; + expect(ddl).toBeDefined(); + await pg.exec(ddl!); + const owner = "00000000-0000-4000-8000-000000000001"; + const other = "00000000-0000-4000-8000-000000000002"; + await pg.query( + `insert into user_mcp_servers(user_id,name,transport,url,command,enabled) values + ($1,'active','http','https://example.com/mcp',null,true), + ($1,'disabled','http','https://example.com/mcp',null,false), + ($1,'local','stdio',null,'npx',true), + ($2,'other','http','https://example.com/mcp',null,true)`, + [owner, other] + ); + const db = createPostgrestShim({ + query: async (sql, values) => ({ + rows: (await pg.query(sql, values)).rows as Record[], + }), + }) as unknown as Parameters[1]; + expect( + (await listSavedHttpMcpServers(owner, db)).map((row) => row.name) + ).toEqual(["active"]); + await pg.query( + "update user_mcp_servers set enabled=false where user_id=$1 and name='active'", + [owner] + ); + expect(await listSavedHttpMcpServers(owner, db)).toEqual([]); + await pg.query( + "update user_mcp_servers set enabled=true where user_id=$1 and name='active'", + [owner] + ); + await pg.query( + "delete from user_mcp_servers where user_id=$1 and name='active'", + [owner] + ); + expect(await listSavedHttpMcpServers(owner, db)).toEqual([]); + expect( + (await listSavedHttpMcpServers(other, db)).map((row) => row.name) + ).toEqual(["other"]); + } finally { + await pg.close(); + } +}); diff --git a/tests/e2e/deployment-skew.spec.ts b/tests/e2e/deployment-skew.spec.ts index dd5a9830..bf55c4c8 100644 --- a/tests/e2e/deployment-skew.spec.ts +++ b/tests/e2e/deployment-skew.spec.ts @@ -53,6 +53,7 @@ test("an old page keeps its API release and unsaved form after schema drift", as }); await page.goto(scopedPath("settings/mcp")); await page.getByRole("button", { name: "Add server" }).click(); + await page.getByRole("tab", { name: "Local (CLI only)" }).click(); await page.getByLabel("Name").fill("My unsaved server"); await page.getByLabel("Command").fill("npx"); const documentToken = await page.evaluate(() => { diff --git a/tests/e2e/mcp-servers-settings.spec.ts b/tests/e2e/mcp-servers-settings.spec.ts index 2381ed6c..0aa8ff1a 100644 --- a/tests/e2e/mcp-servers-settings.spec.ts +++ b/tests/e2e/mcp-servers-settings.spec.ts @@ -185,6 +185,20 @@ test("Connections MCP tab supports add, edit, secret overwrite, and delete witho await page.goto(scopedPath("connections?tab=mcp")); await page.getByRole("button", { name: "Add server" }).click(); + await expect( + page.getByRole("tab", { name: "Streamable HTTP" }) + ).toHaveAttribute("aria-selected", "true"); + await expect( + page.getByText( + "Available in web chat and the CLI. Web chat requires a public server URL." + ) + ).toBeVisible(); + await page.getByRole("tab", { name: "Local (CLI only)" }).click(); + await expect( + page.getByText( + "Runs on your computer through the CLI. Not available in web chat." + ) + ).toBeVisible(); await page.getByLabel("Name").fill("supabase"); await page.getByLabel("Command").fill("npx"); await page @@ -196,6 +210,7 @@ test("Connections MCP tab supports add, edit, secret overwrite, and delete witho await page.getByRole("button", { name: "Create server" }).click(); await expect(page.getByText("supabase")).toBeVisible(); + await expect(page.getByText("CLI only", { exact: true })).toBeVisible(); expect(postBodies).toHaveLength(1); await page.getByRole("button", { name: "Edit" }).click(); @@ -224,4 +239,27 @@ test("Connections MCP tab supports add, edit, secret overwrite, and delete witho .getByRole("button", { name: "Delete" }) .click(); await expect(page.getByText("No synced MCP servers yet.")).toBeVisible(); + + await page.getByRole("button", { name: "Add server" }).click(); + await page.getByLabel("Name").fill("remote-docs"); + await page + .getByLabel("URL", { exact: true }) + .fill("https://docs.example.com/mcp"); + await page.getByRole("button", { name: "Add secret header" }).click(); + await page.getByPlaceholder("Header name").fill("Authorization"); + await page.getByPlaceholder("Secret value").fill("Bearer saved-http-secret"); + await page.getByRole("button", { name: "Create server" }).click(); + await expect(page.getByText("remote-docs", { exact: true })).toBeVisible(); + expect(postBodies[1]).toMatchObject({ + transport: "http", + enabled: true, + url: "https://docs.example.com/mcp", + headerSecrets: { Authorization: "Bearer saved-http-secret" }, + }); + await page.getByRole("button", { name: "Edit" }).click(); + await expect(page.getByPlaceholder("Overwrite saved secret")).toHaveValue(""); + await page.getByRole("switch", { name: "Server enabled" }).click(); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page.getByText("Disabled", { exact: true })).toBeVisible(); + expect(patchBodies[2]).toMatchObject({ enabled: false, headerSecrets: {} }); }); diff --git a/tests/unit/control-connection-tools.test.ts b/tests/unit/control-connection-tools.test.ts index 8d642b35..024649c3 100644 --- a/tests/unit/control-connection-tools.test.ts +++ b/tests/unit/control-connection-tools.test.ts @@ -54,6 +54,15 @@ const solo = { enabled: true, }; +test("should load saved MCP tools even when there are no Integration connections", async () => { + const loaded = await loadControlConnectionTools( + solo, + makeDeps({ loadConnections: async () => [] }) + ); + assert.deepEqual(loaded.connections, []); + assert.deepEqual(Object.keys(loaded.tools), ["trigger_dev_list_runs"]); +}); + test("should load the operator's connection tools for a Control turn in solo scope", async () => { const requested: Array<[string, string | undefined]> = [];