From 2414b4a2151fc1d9bd685bdee10d1bdec32ced6b Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Thu, 20 Aug 2026 16:22:41 -0700 Subject: [PATCH] Retire MCP tab: custom servers live on the Connectors page Custom - MCP group after Connected; Add custom server modal at top; per-server detail subpage with Test. Status never claims connected for stdio: Live / Ready + persisted tested-at / Not tested / Needs sign-in / Error. --- .claude/launch.json | 29 ++ coworker/server/manager.py | 10 +- surfaces/gui/e2e/fixtures.ts | 2 + surfaces/gui/e2e/mcp-add-test.spec.ts | 105 ++-- surfaces/gui/e2e/mcp-oauth.spec.ts | 35 +- surfaces/gui/e2e/sidebar-account.spec.ts | 6 +- surfaces/gui/src/api.ts | 2 + .../gui/src/components/IntegrationsView.tsx | 77 +-- surfaces/gui/src/components/ManageTabs.tsx | 413 +--------------- .../components/connectors/ConnectorsList.tsx | 25 +- .../connectors/ConnectorsSection.tsx | 37 ++ .../src/components/connectors/CustomMcp.tsx | 461 ++++++++++++++++++ surfaces/gui/src/components/connectors/ui.ts | 2 + tests/test_mcp.py | 23 + 14 files changed, 691 insertions(+), 536 deletions(-) create mode 100644 .claude/launch.json create mode 100644 surfaces/gui/src/components/connectors/CustomMcp.tsx diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000..1d886019 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,29 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "server", + "runtimeExecutable": "/usr/bin/env", + "runtimeArgs": [ + "PYTHONPATH=/Users/rohit/fleet/ro4d/openworker-mcpvis", + "/Users/rohit/fleet/ro4d/openworker/.venv/bin/python", + "-m", + "coworker.server.run", + "--port", + "8765" + ], + "port": 8765 + }, + { + "name": "gui", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "--prefix", + "/Users/rohit/fleet/ro4d/openworker-mcpvis/surfaces/gui", + "run", + "dev" + ], + "port": 1420 + } + ] +} diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 492dc833..97e8a318 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -1064,6 +1064,7 @@ class SessionManager: "auth": "oauth" if is_oauth else None, "status": status, "auth_hint": name in self._mcp_auth_hints, + "last_test_at": self._prefs.get("mcp_last_test", {}).get(name), "last_error": self._mcp_errors.get(name), "tool_count": ( len(self.mcp._conns[name].tools) if connected else None @@ -1092,6 +1093,10 @@ class SessionManager: try: # The ONE place a browser sign-in may start: an explicit connect. conn = await self.mcp.ensure(server, interactive=True) + # The Connectors row says "Ready · tested ⟨when⟩" — the claim must + # survive an app restart, so it lives in prefs, not memory. + self._prefs.setdefault("mcp_last_test", {})[name] = int(time.time()) + self._save_prefs() return {"ok": True, "tools": len(conn.tools)} except Exception as exc: if ( @@ -1173,9 +1178,12 @@ class SessionManager: def delete_mcp(self, name: str) -> dict[str, Any]: ok = delete_global_server(name) if ok: - # A later re-add under the same name starts clean, not pre-failed. + # A later re-add under the same name starts clean, not pre-failed — + # and not pre-trusted (the old entry's test says nothing about the new). self._mcp_errors.pop(name, None) self._mcp_auth_hints.discard(name) + if self._prefs.get("mcp_last_test", {}).pop(name, None) is not None: + self._save_prefs() return {"ok": ok, "name": name} async def mcp_tools(self, name: str) -> dict[str, Any]: diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 51519488..9e7714b5 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -1540,6 +1540,7 @@ export async function mockApi(page: import("@playwright/test").Page) { } else { s2.status = "connected"; s2.tool_count = 6; + s2.last_test_at = 1700000000; // the successful probe stamps the row } } if (s2.status === "authorizing") s2._flip = true; @@ -1556,6 +1557,7 @@ export async function mockApi(page: import("@playwright/test").Page) { auth: b.config?.auth === "oauth" ? "oauth" : null, status: b.config?.auth === "oauth" ? "needs_auth" : "configured", auth_hint: false, + last_test_at: null, last_error: null, tool_count: null, config: b.config || {}, diff --git a/surfaces/gui/e2e/mcp-add-test.spec.ts b/surfaces/gui/e2e/mcp-add-test.spec.ts index 26e99d0e..9f8a8e3e 100644 --- a/surfaces/gui/e2e/mcp-add-test.spec.ts +++ b/surfaces/gui/e2e/mcp-add-test.spec.ts @@ -1,71 +1,82 @@ -// UX-033: the Add MCP server flow (Remote URL + JSON tabs) and the Test button. -// Remote URL adds an http entry and probes it immediately (testing… → connected); -// a guarded server (mock: locked-*) lands on "needs sign-in" with the OAuth switch; -// the JSON paste box remains for stdio/advanced, and every row can be re-tested. +// UX-033/034: custom MCP servers live on the Connectors page. "Add custom server" +// (top of page) opens the two-tab modal (Remote URL / JSON); added entries land in +// the "Custom · MCP" group with honest status chips (Testing… → Live / Error / +// Needs sign-in / Not tested) and a detail subpage with Test. import { expect } from "@playwright/test"; import { test } from "./fixtures"; -async function openMcpTab(page) { +async function openConnectors(page) { await page.goto("/"); await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Connectors", exact: true }).click(); - await page.getByRole("button", { name: "MCP servers", exact: true }).click(); } -test("remote URL tab: add & test flips to connected with tool count", async ({ page }) => { - await openMcpTab(page); - await page.getByRole("button", { name: "Add a server" }).click(); +test("remote URL add: probe flips the row to Live with tool count", async ({ page }) => { + await openConnectors(page); + await page.getByTestId("add-custom-server").click(); // URL tab is the default door; bad URL is caught before anything is added. - await page.getByTestId("mcp-add-name").fill("notes"); - await page.getByTestId("mcp-add-url").fill("mcp.example.com/mcp"); - await page.getByRole("button", { name: "Add & test" }).click(); - await expect(page.getByText("Enter the server's full URL")).toBeVisible(); + const modal = page.getByTestId("add-mcp-modal"); + await modal.getByTestId("mcp-add-name").fill("notes"); + await modal.getByTestId("mcp-add-url").fill("mcp.example.com/mcp"); + await modal.getByRole("button", { name: "Add & test" }).click(); + await expect(modal.getByText("Enter the server's full URL")).toBeVisible(); - await page.getByTestId("mcp-add-url").fill("https://mcp.example.com/mcp"); - await page.getByRole("button", { name: "Add & test" }).click(); + await modal.getByTestId("mcp-add-url").fill("https://mcp.example.com/mcp"); + await modal.getByRole("button", { name: "Add & test" }).click(); - const row = page.locator(".space-y-2 > div").filter({ hasText: "notes" }).first(); - await expect(row).toContainText("testing…"); - await expect(row).toContainText("connected", { timeout: 10_000 }); + const row = page.getByTestId("mcp-row-notes"); + await expect(row).toContainText("Testing…"); + await expect(row).toContainText("Live", { timeout: 10_000 }); await expect(row).toContainText("6 tools"); }); -test("guarded server: 401 → needs sign-in → OAuth switch connects", async ({ page }) => { - await openMcpTab(page); - await page.getByRole("button", { name: "Add a server" }).click(); - await page.getByTestId("mcp-add-name").fill("locked-crm"); - await page.getByTestId("mcp-add-url").fill("https://mcp.locked.example/mcp"); - await page.getByRole("button", { name: "Add & test" }).click(); +test("guarded server: 401 → Needs sign-in chip → OAuth switch on the detail page", async ({ + page, +}) => { + await openConnectors(page); + await page.getByTestId("add-custom-server").click(); + const modal = page.getByTestId("add-mcp-modal"); + await modal.getByTestId("mcp-add-name").fill("locked-crm"); + await modal.getByTestId("mcp-add-url").fill("https://mcp.locked.example/mcp"); + await modal.getByRole("button", { name: "Add & test" }).click(); - // The anonymous probe 401s: the row says needs sign-in and offers the fix. - const row = page.locator(".space-y-2 > div").filter({ hasText: "locked-crm" }).first(); - await expect(row).toContainText("needs sign-in", { timeout: 10_000 }); - await expect(row).toContainText("authentication required"); + // The anonymous probe 401s: the row says needs sign-in; the fix lives one + // click deep on the detail page (with the error excerpt). + const row = page.getByTestId("mcp-row-locked-crm"); + await expect(row).toContainText("Needs sign-in", { timeout: 10_000 }); + await row.click(); - // Sign in switches the entry to oauth and starts the browser flow; the poll - // flips it to connected. - await row.getByTestId("mcp-authfix-locked-crm").click(); - await expect(row).toContainText("signing in…"); - await expect(row).toContainText("connected", { timeout: 10_000 }); - await expect(row).toContainText("oauth"); + const detail = page.getByTestId("mcp-detail-locked-crm"); + await expect(detail).toContainText("authentication required"); + await detail.getByTestId("mcp-authfix-locked-crm").click(); + await expect(detail).toContainText("Signing in…"); + await expect(detail).toContainText("Live", { timeout: 10_000 }); }); -test("JSON tab still adds stdio servers; Test probes an existing row", async ({ page }) => { - await openMcpTab(page); - await page.getByRole("button", { name: "Add a server" }).click(); - await page.getByTestId("mcp-add-tab-json").click(); - await page +test("JSON tab adds stdio as Not tested; detail Test flips it to Live", async ({ page }) => { + await openConnectors(page); + await page.getByTestId("add-custom-server").click(); + const modal = page.getByTestId("add-mcp-modal"); + await modal.getByTestId("mcp-add-tab-json").click(); + await modal .locator("textarea") .fill('{"files": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem"]}}'); - await page.getByRole("button", { name: "Add", exact: true }).click(); + await modal.getByRole("button", { name: "Add", exact: true }).click(); - const row = page.locator(".space-y-2 > div").filter({ hasText: "files" }).first(); - await expect(row).toContainText("stdio · configured"); + // A pasted stdio server is configured, not connected — the chip says so. + const row = page.getByTestId("mcp-row-files"); + await expect(row).toContainText("Not tested"); + await expect(row).toContainText("stdio"); - // Test on the untouched row: testing… then the mock's connected · 6 tools. - await row.getByTestId("mcp-test-files").click(); - await expect(row).toContainText("testing…"); - await expect(row).toContainText("connected", { timeout: 10_000 }); - await expect(row).toContainText("6 tools"); + await row.click(); + const detail = page.getByTestId("mcp-detail-files"); + await detail.getByTestId("mcp-test-files").click(); + await expect(detail).toContainText("Testing…"); + await expect(detail).toContainText("Live", { timeout: 10_000 }); + await expect(detail).toContainText("6 tools"); + + // Remove from the detail page returns to the list without the row. + await detail.getByTestId("mcp-remove-files").click(); + await expect(page.getByTestId("mcp-row-files")).toHaveCount(0); }); diff --git a/surfaces/gui/e2e/mcp-oauth.spec.ts b/surfaces/gui/e2e/mcp-oauth.spec.ts index b6b3e9c3..05e615cc 100644 --- a/surfaces/gui/e2e/mcp-oauth.spec.ts +++ b/surfaces/gui/e2e/mcp-oauth.spec.ts @@ -1,20 +1,20 @@ -// MCP OAuth quick-add (first server: Granola): the MCP tab offers a curated Connect -// card; connecting adds the server, kicks off the browser sign-in ("signing in…"), -// and the tab's poll flips the row to connected. Sign out returns it to needs_auth. +// MCP OAuth quick-add (first server: Granola): the Custom · MCP group on the +// Connectors page offers a curated Connect card; connecting adds the server, kicks +// off the browser sign-in (Signing in…), and the poll flips the row to Live. +// Sign out (detail page) returns it to Needs sign-in. import { expect } from "@playwright/test"; import { test } from "./fixtures"; -async function openMcpTab(page) { +async function openConnectors(page) { await page.goto("/"); await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Connectors", exact: true }).click(); - await page.getByRole("button", { name: "MCP servers", exact: true }).click(); } -test("granola: quick-add card → sign-in flow → connected → sign out", async ({ page }) => { - await openMcpTab(page); +test("granola: quick-add card → sign-in flow → Live → sign out", async ({ page }) => { + await openConnectors(page); - // Curated card renders while granola isn't configured. + // Curated card renders in the Custom · MCP group while granola isn't configured. const preset = page.getByTestId("mcp-preset-granola"); await expect(preset).toContainText("Granola"); await expect(preset).toContainText("Meeting notes"); @@ -22,16 +22,17 @@ test("granola: quick-add card → sign-in flow → connected → sign out", asyn // Connect: adds the server with OAuth pending and starts the browser flow. await preset.getByRole("button", { name: "Connect" }).click(); await expect(page.getByTestId("mcp-preset-granola")).toHaveCount(0); - const row = page.locator(".space-y-2 > div").filter({ hasText: "granola" }).first(); - await expect(row).toContainText("signing in…"); + const row = page.getByTestId("mcp-row-granola"); + await expect(row).toContainText("Signing in…"); - // The 2s status poll flips the mock to connected with its 6 tools. - await expect(row).toContainText("connected", { timeout: 10_000 }); + // The status poll flips the mock to connected with its 6 tools. + await expect(row).toContainText("Live", { timeout: 10_000 }); await expect(row).toContainText("6 tools"); - await expect(row).toContainText("oauth"); - // Sign out forgets tokens; the row needs auth again and offers Sign in. - await row.getByTestId("mcp-signout-granola").click(); - await expect(row).toContainText("needs auth"); - await expect(row.getByTestId("mcp-signin-granola")).toBeVisible(); + // Sign out on the detail page forgets tokens; the chip needs sign-in again. + await row.click(); + const detail = page.getByTestId("mcp-detail-granola"); + await detail.getByTestId("mcp-signout-granola").click(); + await expect(detail).toContainText("Needs sign-in"); + await expect(detail.getByTestId("mcp-signin-granola")).toBeVisible(); }); diff --git a/surfaces/gui/e2e/sidebar-account.spec.ts b/surfaces/gui/e2e/sidebar-account.spec.ts index eefc996d..e2228e39 100644 --- a/surfaces/gui/e2e/sidebar-account.spec.ts +++ b/surfaces/gui/e2e/sidebar-account.spec.ts @@ -45,10 +45,12 @@ test("Activity in the menu is the audit log; Unrouted lives under Inbox ▸ Conf await page.getByTestId("account-menu").getByRole("button", { name: "Activity", exact: true }).click(); await expect(page.getByRole("heading", { name: "Activity" })).toBeVisible(); - // §28: Messaging routing left the Connectors sub-nav entirely (Connectors · MCP only)… + // §28: Messaging routing left the Connectors sub-nav entirely — and the MCP tab + // retired into the Connectors page itself (UX-034), so one sub-nav item remains. await page.getByTestId("account-row").click(); await page.getByTestId("account-menu").getByRole("button", { name: "Connectors", exact: true }).click(); - await expect(page.getByRole("button", { name: "MCP servers" })).toBeVisible(); + await expect(page.getByTestId("add-custom-server")).toBeVisible(); + await expect(page.getByRole("button", { name: "MCP servers" })).toHaveCount(0); await expect(page.getByRole("button", { name: /Messaging routing/ })).toHaveCount(0); // The old fourth sub-nav tab is gone — exactly one page is named Activity now. await expect(page.getByRole("button", { name: "Activity", exact: true })).toHaveCount(0); diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 3638959c..02213f10 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -275,6 +275,8 @@ export interface McpServer { auth?: "oauth" | null; // http server whose anonymous connect hit a 401/403 — offer OAuth sign-in. auth_hint?: boolean; + // Epoch seconds of the last successful explicit Test (persisted server-side). + last_test_at?: number | null; last_error?: string | null; tool_count: number | null; config: Record; diff --git a/surfaces/gui/src/components/IntegrationsView.tsx b/surfaces/gui/src/components/IntegrationsView.tsx index d1674b3f..ab01a129 100644 --- a/surfaces/gui/src/components/IntegrationsView.tsx +++ b/surfaces/gui/src/components/IntegrationsView.tsx @@ -1,25 +1,16 @@ import { useEffect, useState } from "react"; import { getConnectors } from "../api"; -import { McpTab } from "./ManageTabs"; import { ConnectorsSection } from "./connectors/ConnectorsSection"; import { Icon } from "./Icon"; -// The Connectors surface (renamed from "Integrations", §26) keeps the left sub-nav, now just -// Connectors · MCP. The old "Messaging routing" tab (and its ⚠ unrouted badge) moved whole to -// Inbox ▸ Configure (§28): inbox-delivery config belongs with the Inbox, and Unrouted is -// "messages that never reached you". The one remaining Activity is the audit log, reached from -// the account menu. -type IntTab = "connectors" | "mcp"; - -// Fixed sub-nav (UX-DECISIONS §21): connector detail lives as a SUBPAGE under -// Connectors, never as a nav item — the nav must not grow per connector. -const INT_TABS: { key: IntTab; label: string; icon: "plug" | "code" }[] = [ - { key: "connectors", label: "Connectors", icon: "plug" }, - { key: "mcp", label: "MCP servers", icon: "code" }, -]; +// The Connectors surface (renamed from "Integrations", §26). The separate "MCP +// servers" tab is retired (UX-034): custom MCP servers now live on the Connectors +// page itself — a "Custom · MCP" group plus the top "Add custom server" modal — +// so the sub-nav is a single fixed item. The old "Messaging routing" tab (and its +// ⚠ unrouted badge) moved whole to Inbox ▸ Configure (§28); the one remaining +// Activity is the audit log, reached from the account menu. export function IntegrationsView() { - const [tab, setTab] = useState("connectors"); // Sub-nav count: how many connectors exist. Polled so the badge stays live. const [connCount, setConnCount] = useState(null); @@ -38,51 +29,25 @@ export function IntegrationsView() {
Connectors
- {INT_TABS.map((t) => { - const active = tab === t.key; - return ( - - ); - })} +
- {tab === "connectors" ? ( -
- - -
- ) : ( -
- - -
- )} +
+ + +
diff --git a/surfaces/gui/src/components/ManageTabs.tsx b/surfaces/gui/src/components/ManageTabs.tsx index e78e369d..83a18ac8 100644 --- a/surfaces/gui/src/components/ManageTabs.tsx +++ b/surfaces/gui/src/components/ManageTabs.tsx @@ -1,36 +1,26 @@ import { useEffect, useState } from "react"; import { - addMcpServer, allowUser, connectConnector, connectManaged, connectMcpBacked, - connectMcp, - deleteMcpServer, disallowUser, - getMcpServers, - getMcpTools, - signoutMcp, getSettings, getSubscriptions, removeModel, resolveUnauthorized, unsubscribeChannel, - patchMcpServer, - reloadMcp, setDefaultModel, updateConnectorTools, type CloudStatus, type Connector, type Subscription, - type McpServer, type ModelSettings, type ProviderInfo, } from "../api"; import { CloudSignInInline, CloudStatusPending } from "./connectors/CloudSignIn"; import { ModelChecklist } from "./ModelChecklist"; import { ProviderCards, ProviderForm, useProviderSetup } from "../providers/ProviderSetup"; -import { Toggle } from "./Toggle"; // "2h ago"-style label for the providers' Last-used line (null when never used). const relTime = (epoch?: number | null): string | null => { @@ -45,14 +35,12 @@ const relTime = (epoch?: number | null): string | null => { }; // Shared tab bodies for the Settings and Integrations pages (the old top-tab ManageModal was retired -// when Settings/Activity became full-page surfaces): ModelsTab → Settings ▸ Models; ConnectorsTab + -// McpTab → Integrations ▸ Connectors / MCP servers. +// when Settings/Activity became full-page surfaces): ModelsTab → Settings ▸ Models; ConnectorsTab → +// Integrations ▸ Connectors (the MCP tab retired into the Connectors page, UX-034). const SEC_H = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold"; -const CARD = "rounded-xl2 border border-line bg-panel"; const BTN_BORDERED = "text-[12.5px] px-3 py-1.5 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0"; const BTN_ACCENT = "text-[12.5px] px-3 py-1.5 rounded-lg bg-accent text-white shrink-0 disabled:opacity-50"; -const BTN_DANGER = "text-[12.5px] text-danger/80 hover:text-danger shrink-0"; /** Two-letter initials for a chip/avatar (first+last word, else first two chars). */ function initials(name: string): string { @@ -62,14 +50,6 @@ function initials(name: string): string { return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } -const EXAMPLE = `{ - "filesystem": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"], - "enabled": true - } -}`; - // -- Configure Models tab (UX-021: the shared provider gallery + key form) ---- // Settings ▸ Models reuses onboarding §39's ProviderCards/ProviderForm so the two // surfaces can't drift. Settings-only extras: per-card "used Nh ago", a "Remove @@ -232,395 +212,6 @@ function ComposerPickerCard({ ); } -// Curated OAuth quick-adds: remote MCP servers with browser sign-in (OAuth 2.1 + DCR) — -// no keys to paste, tokens stay in the local secret store. First: Granola. -const MCP_PRESETS: { name: string; label: string; blurb: string; config: Record }[] = [ - { - name: "granola", - label: "Granola", - blurb: "Meeting notes & transcripts — sign in with your Granola account.", - config: { type: "http", url: "https://mcp.granola.ai/mcp", auth: "oauth" }, - }, -]; - -export function McpTab() { - const [servers, setServers] = useState([]); - const [adding, setAdding] = useState(false); - const [error, setError] = useState(null); - - const refresh = () => getMcpServers().then(setServers).catch(() => setServers([])); - useEffect(() => { - refresh(); - }, []); - - // While a browser sign-in is in flight, poll so the row flips to connected (or - // surfaces the error) without the user having to touch anything. - const authorizing = servers.some((s) => s.status === "authorizing"); - useEffect(() => { - if (!authorizing) return; - const t = window.setInterval(refresh, 2000); - return () => window.clearInterval(t); - }, [authorizing]); - - const toggle = async (s: McpServer) => { - await patchMcpServer(s.name, { enabled: !s.enabled }); - refresh(); - }; - const remove = async (s: McpServer) => { - await deleteMcpServer(s.name); - refresh(); - }; - - return ( -
-

- External tool servers (stdio or HTTP), shared across all agents. Enabled servers' tools are - permission-gated. Changes apply to new sessions —{" "} - - . -

- - {servers.length === 0 && !adding ? ( -
- No MCP servers configured.{" "} - -
- ) : ( -
- {servers.map((s) => ( - toggle(s)} - onRemove={() => remove(s)} - onRefresh={refresh} - /> - ))} -
- )} - - {/* One-click OAuth presets not yet configured. */} - {MCP_PRESETS.filter((p) => !servers.some((s) => s.name === p.name)).map((p) => ( -
-
-
{p.label}
-
{p.blurb}
-
- -
- ))} - - {adding ? ( - { - setAdding(false); - setError(null); - }} - onError={setError} - onAdded={() => { - setAdding(false); - setError(null); - refresh(); - }} - /> - ) : servers.length > 0 ? ( - - ) : null} - {error &&
{error}
} -
- ); -} - -function McpRow({ - server, - onToggle, - onRemove, - onRefresh, -}: { - server: McpServer; - onToggle: () => void; - onRemove: () => void; - onRefresh: () => void; -}) { - const [tools, setTools] = useState<{ name: string; description: string }[] | null>(null); - const [busy, setBusy] = useState(false); - const [toolErr, setToolErr] = useState(null); - - const isOauth = server.auth === "oauth"; - const authorizing = server.status === "authorizing"; - const signIn = async () => { - await connectMcp(server.name); // browser opens; the tab's poll flips the status - onRefresh(); - }; - const signOut = async () => { - await signoutMcp(server.name); - onRefresh(); - }; - // Test = the same explicit connect the OAuth Sign-in uses, for every server: - // the row flips to "testing…" and the tab's poll lands on connected · N tools - // or the error/stderr excerpt. A connected server just reports live state. - const runTest = async () => { - await connectMcp(server.name); - onRefresh(); - // The connect runs as a background task; if the first refresh outpaced its - // start, the row never shows "authorizing" and the tab's poll never arms. - window.setTimeout(onRefresh, 600); - }; - // Anonymous connect came back 401/403: the fix is sign-in, so switch the entry - // to OAuth (DCR — nothing to register) and start the browser flow right away. - const signInWithOauth = async () => { - await patchMcpServer(server.name, { auth: "oauth" }); - await connectMcp(server.name); - onRefresh(); - }; - - const loadTools = async () => { - if (tools) { - setTools(null); - return; - } - setBusy(true); - setToolErr(null); - const res = await getMcpTools(server.name); - setBusy(false); - if (res.ok) setTools(res.tools); - else setToolErr(res.error || "failed to connect"); - }; - - return ( -
-
- -
-
{server.name}
-
- {server.transport} ·{" "} - {authorizing - ? isOauth - ? "signing in…" - : "testing…" - : server.auth_hint && !isOauth - ? "needs sign-in" - : server.status.replace("_", " ")} - {server.tool_count != null ? ` · ${server.tool_count} tools` : ""} - {server.requires_approval ? " · asks" : ""} - {isOauth ? " · oauth" : ""} -
-
- {!isOauth && server.auth_hint && !authorizing && ( - - )} - {isOauth && - (server.status === "needs_auth" ? ( - - ) : authorizing ? ( - waiting for browser… - ) : server.status === "connected" ? ( - - ) : null)} - {server.enabled && - !authorizing && - !server.auth_hint && - !(isOauth && server.status !== "connected") && ( - - )} - - -
- {server.last_error && server.status !== "connected" && ( -
{server.last_error}
- )} - {toolErr &&
{toolErr}
} - {tools && ( -
- {tools.length === 0 &&
No tools.
} - {tools.map((t) => ( - - {t.name} - - ))} -
- )} -
- ); -} - -const INPUT = - "w-full text-[13px] px-3 py-2 rounded-lg border border-line bg-paper text-ink outline-none focus:border-accent"; - -function AddForm({ - onCancel, - onAdded, - onError, -}: { - onCancel: () => void; - onAdded: () => void; - onError: (e: string | null) => void; -}) { - // Two doors, one flow: Remote URL (name + URL — most hosted servers) and JSON - // (the paste box — stdio and advanced configs). Both end in the row's Test. - const [tab, setTab] = useState<"url" | "json">("url"); - const [name, setName] = useState(""); - const [url, setUrl] = useState(""); - const [text, setText] = useState(EXAMPLE); - - const saveUrl = async () => { - onError(null); - const n = name.trim(); - const u = url.trim(); - if (!n) { - onError("Give the server a name."); - return; - } - if (!/^https?:\/\/\S+$/.test(u)) { - onError("Enter the server's full URL (https://…)."); - return; - } - await addMcpServer(n, { type: "http", url: u }); - // Probe anonymously right away — the row shows testing…, then connected · N - // tools, an error, or "needs sign-in" (401 → the OAuth switch). - await connectMcp(n); - onAdded(); - }; - - const saveJson = async () => { - onError(null); - let parsed: any; - try { - parsed = JSON.parse(text); - } catch (e: any) { - onError("Invalid JSON: " + e.message); - return; - } - // Accept either {mcpServers:{...}}, {name:{...}}, or a single bare config. - const map = parsed.mcpServers || parsed; - const entries = - map && typeof map === "object" && !map.command && !map.url - ? Object.entries(map) - : null; - if (!entries || entries.length === 0) { - onError('Paste a `{ "": { … } }` object (or a full mcpServers block).'); - return; - } - for (const [name, config] of entries) { - await addMcpServer(name, config as Record); - } - onAdded(); - }; - - const tabBtn = (active: boolean) => - "text-[12px] px-2.5 py-1 rounded-md border shrink-0 " + - (active - ? "border-accent text-accent font-medium" - : "border-line text-muted hover:text-ink"); - - return ( -
-
- - -
- {tab === "url" ? ( - <> -
- Connect a hosted MCP server. If it needs sign-in, the row will offer it after the - first test. -
- setName(e.target.value)} - placeholder="Name (shown in the server list)" - spellCheck={false} - className={INPUT} - data-testid="mcp-add-name" - /> - setUrl(e.target.value)} - placeholder="https://mcp.example.com/mcp" - spellCheck={false} - className={INPUT + " font-mono text-[12px]"} - data-testid="mcp-add-url" - /> - - ) : ( - <> -
Paste server JSON (name → config):
-