+ Runs on your computer through the CLI. Not available in web chat.
+
Command
@@ -161,6 +164,9 @@ export function McpServerDialog({
+
+ Available in web chat and the CLI. Web chat requires a public server URL.
+
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..0eb9165a 100644
--- a/lib/connections/mcp-tools.ts
+++ b/lib/connections/mcp-tools.ts
@@ -13,12 +13,25 @@ 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
+): Promise {
+ await assertSafeOutboundHttpUrlWithDns(transport.url, "mcp_url");
const client = await createMCPClient({
- transport: buildMcpTransport(conn, credential),
+ transport: {
+ ...transport,
+ // Validate every outbound request, including a legacy SSE message URL.
+ // Do not let redirects forward stored headers to an unchecked target.
+ fetch: async (input, init) => {
+ const url = input instanceof Request ? input.url : String(input);
+ await assertSafeOutboundHttpUrlWithDns(url, "mcp_url");
+ return fetch(input, { ...init, redirect: "error" });
+ },
+ },
});
try {
diff --git a/lib/mcp-servers/chat.test.ts b/lib/mcp-servers/chat.test.ts
new file mode 100644
index 00000000..41fce676
--- /dev/null
+++ b/lib/mcp-servers/chat.test.ts
@@ -0,0 +1,272 @@
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import {
+ buildDynamicConnectionTools,
+ cleanupMcpClients,
+} from "../agents/tools/connections";
+import type { McpServerRow } from "./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;
+
+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;
+ 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" &&
+ 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.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.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..a81985c2
--- /dev/null
+++ b/lib/mcp-servers/chat.ts
@@ -0,0 +1,124 @@
+import { createHash } from "node:crypto";
+import type { Tool } from "ai";
+import { z } from "zod";
+import { supabaseAdmin } from "@/lib/supabase/admin";
+import { getRemoteMcpTools } 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";
+ return perTool?.approval_mode ?? policy.default_tools_approval_mode;
+}
+
+/** 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();
+ try {
+ const servers = await listSavedHttpMcpServers(userId);
+ const results = await Promise.allSettled(
+ servers.map(async (server) => {
+ const policy = toolPolicySchema.parse(server.extra ?? {});
+ if (!server.url) throw new Error("Missing MCP URL");
+ // Local HTTP servers can still be synced to the CLI, but the hosted
+ // runtime must not read their secrets or dial private addresses.
+ await assertSafeOutboundHttpUrlWithDns(server.url, "mcp_url");
+ const refs = normalizeStringRecord(server.header_refs, "header_refs");
+ const headers = normalizeStringRecord(
+ server.header_plain,
+ "header_plain"
+ );
+ const secrets = await resolveVaultSecrets(Object.values(refs));
+ 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;
+ }
+ return {
+ server,
+ policy,
+ loaded: await getRemoteMcpTools({
+ type: "http",
+ url: server.url,
+ headers,
+ }),
+ };
+ })
+ );
+ for (const result of results) {
+ 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"
+ );
+ 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] = Object.assign(tool, {
+ description: `${server.name}: ${tool.description ?? name}`,
+ });
+ if (approval === "prompt") askToolNames.add(toolName);
+ }
+ }
+ } catch {
+ console.warn("[mcp-servers] Saved HTTP servers could not load for chat");
+ }
+ 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/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]> = [];
From 4ae261c0afb56c7a7376e3365afa70e2957e7540 Mon Sep 17 00:00:00 2001
From: Charles Howard <96023061+charlesrhoward@users.noreply.github.com>
Date: Tue, 22 Sep 2026 13:40:40 -0400
Subject: [PATCH 2/4] test: preserve Integration tools when MCP catalog fails
---
lib/mcp-servers/chat.test.ts | 38 ++++++++++++++++++++++++++++++++++++
1 file changed, 38 insertions(+)
diff --git a/lib/mcp-servers/chat.test.ts b/lib/mcp-servers/chat.test.ts
index 41fce676..46ee2706 100644
--- a/lib/mcp-servers/chat.test.ts
+++ b/lib/mcp-servers/chat.test.ts
@@ -4,6 +4,7 @@ import {
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",
@@ -195,6 +196,43 @@ it("isolates catalog errors", async () => {
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");
+ await cleanupMcpClients(built.mcpCleanups);
+});
+
it.each([
{ enabled_tools: [] },
{ disabled_tools: ["find_docs"] },
From c2e5f50e73df49daf90eab63b9d1c87be71a3ec6 Mon Sep 17 00:00:00 2001
From: Charles Howard <96023061+charlesrhoward@users.noreply.github.com>
Date: Tue, 22 Sep 2026 13:56:36 -0400
Subject: [PATCH 3/4] fix: bound saved MCP startup and preserve Integration
transport behavior
---
app/api/control/chat/_lib/connection-tools.ts | 4 +-
lib/connections/mcp-tools.ts | 37 ++++--
lib/mcp-servers/chat.test.ts | 53 +++++++++
lib/mcp-servers/chat.ts | 105 ++++++++++++------
tests/e2e/deployment-skew.spec.ts | 1 +
5 files changed, 157 insertions(+), 43 deletions(-)
diff --git a/app/api/control/chat/_lib/connection-tools.ts b/app/api/control/chat/_lib/connection-tools.ts
index 4b66c45e..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;
diff --git a/lib/connections/mcp-tools.ts b/lib/connections/mcp-tools.ts
index 0eb9165a..ea957cfa 100644
--- a/lib/connections/mcp-tools.ts
+++ b/lib/connections/mcp-tools.ts
@@ -9,6 +9,9 @@ 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
@@ -17,25 +20,40 @@ export async function getMcpTools(
}
export async function getRemoteMcpTools(
- transport: ReturnType
+ transport: ReturnType,
+ options: { validateRequests?: boolean; startupSignal?: AbortSignal } = {}
): Promise {
await assertSafeOutboundHttpUrlWithDns(transport.url, "mcp_url");
+ options.startupSignal?.throwIfAborted();
+ let starting = true;
const client = await createMCPClient({
+ initializationOptions: { signal: options.startupSignal },
transport: {
...transport,
- // Validate every outbound request, including a legacy SSE message URL.
- // Do not let redirects forward stored headers to an unchecked target.
- fetch: async (input, init) => {
- const url = input instanceof Request ? input.url : String(input);
- await assertSafeOutboundHttpUrlWithDns(url, "mcp_url");
- return fetch(input, { ...init, redirect: "error" });
- },
+ // 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,
@@ -48,6 +66,9 @@ export async function getRemoteMcpTools(
} 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
index 46ee2706..714a5935 100644
--- a/lib/mcp-servers/chat.test.ts
+++ b/lib/mcp-servers/chat.test.ts
@@ -30,6 +30,8 @@ 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");
@@ -40,6 +42,7 @@ beforeEach(() => {
secrets = [{ id: "secret-1", decrypted_secret: "Bearer test-secret" }];
failCatalog = false;
failDiscovery = false;
+ stallDiscovery = false;
vi.stubGlobal(
"fetch",
async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -66,6 +69,20 @@ beforeEach(() => {
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 &&
@@ -100,6 +117,7 @@ beforeEach(() => {
});
afterEach(() => {
+ vi.useRealTimers();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
@@ -230,6 +248,41 @@ it("keeps existing Integration tools available when the saved catalog fails", as
.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);
+ 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);
});
diff --git a/lib/mcp-servers/chat.ts b/lib/mcp-servers/chat.ts
index a81985c2..748767cf 100644
--- a/lib/mcp-servers/chat.ts
+++ b/lib/mcp-servers/chat.ts
@@ -2,7 +2,10 @@ import { createHash } from "node:crypto";
import type { Tool } from "ai";
import { z } from "zod";
import { supabaseAdmin } from "@/lib/supabase/admin";
-import { getRemoteMcpTools } from "@/lib/connections/mcp-tools";
+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";
@@ -53,6 +56,44 @@ function toolApproval(policy: z.infer, name: string) {
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;
+ }
+ return {
+ server,
+ policy,
+ loaded: await getRemoteMcpTools(
+ { type: "http", url: server.url, headers },
+ { validateRequests: true, startupSignal: signal }
+ ),
+ };
+}
+
/** The catalog is personal. Filter before reading any Vault-backed headers. */
export async function loadSavedMcpServerTools(
userId: string,
@@ -61,42 +102,32 @@ export async function loadSavedMcpServerTools(
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")),
+ CONNECTION_TOOL_STARTUP_TIMEOUT_MS
+ );
try {
- const servers = await listSavedHttpMcpServers(userId);
+ const servers = await duringStartup(
+ listSavedHttpMcpServers(userId),
+ startup.signal
+ );
const results = await Promise.allSettled(
- servers.map(async (server) => {
- const policy = toolPolicySchema.parse(server.extra ?? {});
- if (!server.url) throw new Error("Missing MCP URL");
- // Local HTTP servers can still be synced to the CLI, but the hosted
- // runtime must not read their secrets or dial private addresses.
- await assertSafeOutboundHttpUrlWithDns(server.url, "mcp_url");
- const refs = normalizeStringRecord(server.header_refs, "header_refs");
- const headers = normalizeStringRecord(
- server.header_plain,
- "header_plain"
- );
- const secrets = await resolveVaultSecrets(Object.values(refs));
- 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;
- }
- return {
- server,
- policy,
- loaded: await getRemoteMcpTools({
- type: "http",
- url: server.url,
- headers,
- }),
- };
- })
+ servers.map((server) =>
+ duringStartup(loadSavedServer(server, startup.signal), startup.signal)
+ )
);
- for (const result of results) {
+ 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"
+ "[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;
}
@@ -111,14 +142,20 @@ export async function loadSavedMcpServerTools(
.digest("hex")
.slice(0, 12);
const toolName = `saved_${sanitize(server.name).slice(0, 18)}_${sanitize(name).slice(0, 24)}_${hash}`;
- dynamicTools[toolName] = Object.assign(tool, {
+ 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");
+ 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/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(() => {
From 675da0e1002d40721f08af48b040f0326ee47d15 Mon Sep 17 00:00:00 2001
From: Charles Howard <96023061+charlesrhoward@users.noreply.github.com>
Date: Tue, 22 Sep 2026 14:08:14 -0400
Subject: [PATCH 4/4] fix: isolate saved MCP startup deadlines
---
lib/mcp-servers/chat.test.ts | 40 ++++++++++++++++++++++++++++++++++++
lib/mcp-servers/chat.ts | 29 ++++++++++++++++++--------
2 files changed, 60 insertions(+), 9 deletions(-)
diff --git a/lib/mcp-servers/chat.test.ts b/lib/mcp-servers/chat.test.ts
index 714a5935..c01c2534 100644
--- a/lib/mcp-servers/chat.test.ts
+++ b/lib/mcp-servers/chat.test.ts
@@ -276,6 +276,11 @@ it("bounds saved-server startup, keeps healthy tools, and does not abort later t
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!(
@@ -286,6 +291,41 @@ it("bounds saved-server startup, keeps healthy tools, and does not abort later t
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"] },
diff --git a/lib/mcp-servers/chat.ts b/lib/mcp-servers/chat.ts
index 748767cf..001be36a 100644
--- a/lib/mcp-servers/chat.ts
+++ b/lib/mcp-servers/chat.ts
@@ -53,6 +53,7 @@ function toolApproval(policy: z.infer, name: string) {
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;
}
@@ -84,14 +85,23 @@ async function loadSavedServer(server: ChatServerRow, signal: AbortSignal) {
if (value === undefined) throw new Error("Missing MCP secret");
headers[name] = value;
}
- return {
- server,
- policy,
- loaded: await getRemoteMcpTools(
- { type: "http", url: server.url, headers },
- { validateRequests: true, startupSignal: signal }
- ),
- };
+ // 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. */
@@ -105,7 +115,8 @@ export async function loadSavedMcpServerTools(
const startup = new AbortController();
const timer = setTimeout(
() => startup.abort(new Error("MCP startup timed out")),
- CONNECTION_TOOL_STARTUP_TIMEOUT_MS
+ // Leave time to return healthy results before Control's outer deadline.
+ CONNECTION_TOOL_STARTUP_TIMEOUT_MS - 2000
);
try {
const servers = await duringStartup(