+ Existing environment variable and header values are write-only and are not shown. Saving
+ replaces the full set — re-enter every value you want to keep.
+
+ );
+}
+
+function hasWriteOnlyValues(server: McpServer): boolean {
+ switch (server.transport.type) {
+ case "stdio":
+ return server.transport.env_keys.length > 0;
+ case "http":
+ return server.transport.header_keys.length > 0;
+ case "sandbox":
+ return server.transport.env_keys.length > 0;
+ }
+}
+
+function staleAwareMessage(cause: unknown): string {
+ if (cause instanceof ApiError && cause.status === 409) {
+ return "This MCP server changed since you opened it. Reload the page to get the latest version, then reapply your edits.";
+ }
+ if (cause instanceof ApiError && cause.message) {
+ return cause.message;
+ }
+ return "Couldn't update the MCP server. Please try again.";
+}
diff --git a/apps/fabro-web/app/routes/settings-mcps-new.test.tsx b/apps/fabro-web/app/routes/settings-mcps-new.test.tsx
new file mode 100644
index 000000000..553347939
--- /dev/null
+++ b/apps/fabro-web/app/routes/settings-mcps-new.test.tsx
@@ -0,0 +1,56 @@
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import TestRenderer, { act } from "react-test-renderer";
+import { MemoryRouter } from "react-router";
+
+import { setupReactTestEnv } from "../lib/test-utils";
+
+const { default: SettingsMcpsNew } = await import("./settings-mcps-new");
+
+let teardownReactTestEnv: (() => void) | undefined;
+const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
+
+function renderSettingsMcpsNew(initialEntry = "/settings/mcps/new") {
+ let renderer: TestRenderer.ReactTestRenderer | undefined;
+ act(() => {
+ renderer = TestRenderer.create(
+
+
+ ,
+ );
+ });
+ mountedRenderers.push(renderer!);
+ return renderer!;
+}
+
+function textContent(node: ReturnType): string {
+ if (node == null || typeof node === "boolean") return "";
+ if (typeof node === "string" || typeof node === "number") return String(node);
+ if (Array.isArray(node)) return node.map(textContent).join("");
+ return node.children?.map(textContent).join("") ?? "";
+}
+
+describe("SettingsMcpsNew route", () => {
+ beforeEach(() => {
+ teardownReactTestEnv = setupReactTestEnv();
+ });
+
+ afterEach(() => {
+ act(() => {
+ for (const renderer of mountedRenderers.splice(0)) {
+ renderer.unmount();
+ }
+ });
+ teardownReactTestEnv?.();
+ teardownReactTestEnv = undefined;
+ });
+
+ test("renders the create form for the requested transport", () => {
+ const renderer = renderSettingsMcpsNew("/settings/mcps/new?type=http");
+ const text = textContent(renderer.toJSON());
+
+ expect(text).toContain("New MCP server");
+ expect(renderer.root.findByProps({ "aria-label": "Transport" }).props.value)
+ .toBe("http");
+ expect(renderer.root.findByProps({ "aria-label": "URL" })).toBeDefined();
+ });
+});
diff --git a/apps/fabro-web/app/routes/settings-mcps-new.tsx b/apps/fabro-web/app/routes/settings-mcps-new.tsx
new file mode 100644
index 000000000..949c6cf73
--- /dev/null
+++ b/apps/fabro-web/app/routes/settings-mcps-new.tsx
@@ -0,0 +1,111 @@
+import { useState } from "react";
+import { Link, useNavigate, useSearchParams } from "react-router";
+import { useSWRConfig } from "swr";
+import { ChevronRightIcon } from "@heroicons/react/20/solid";
+
+import {
+ McpServerFormFields,
+ createRequestFromForm,
+ defaultMcpServerFormValues,
+ isMcpServerFormValid,
+ type McpServerFormValues,
+} from "../components/mcp-server-form";
+import {
+ ErrorMessage,
+ PRIMARY_BUTTON_CLASS,
+ SECONDARY_BUTTON_CLASS,
+} from "../components/ui";
+import { useToast } from "../components/toast";
+import { ApiError, apiData, mcpServersApi } from "../lib/api-client";
+import { upsertMcpServerInList } from "../lib/mcp-server-cache";
+import { parseMcpTransportKind } from "../lib/mcp-transport-kinds";
+import { queryKeys } from "../lib/query-keys";
+
+export function meta() {
+ return [{ title: "New MCP server — Fabro" }];
+}
+
+export default function SettingsMcpsNew() {
+ return (
+