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.
This commit is contained in:
Rohit C Prasad
2026-08-20 16:22:41 -07:00
parent 0f68852cbf
commit 2414b4a215
14 changed files with 691 additions and 536 deletions
+29
View File
@@ -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
}
]
}
+9 -1
View File
@@ -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]:
+2
View File
@@ -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 || {},
+58 -47
View File
@@ -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);
});
+18 -17
View File
@@ -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();
});
+4 -2
View File
@@ -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);
+2
View File
@@ -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<string, any>;
@@ -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<IntTab>("connectors");
// Sub-nav count: how many connectors exist. Polled so the badge stays live.
const [connCount, setConnCount] = useState<number | null>(null);
@@ -38,51 +29,25 @@ export function IntegrationsView() {
<div className="px-2 text-[13.5px] font-semibold mb-3 flex items-center gap-2">
<Icon name="plug" size={16} /> Connectors
</div>
{INT_TABS.map((t) => {
const active = tab === t.key;
return (
<button
key={t.key}
className={
"w-full text-left px-2.5 py-2 rounded-lg text-[13px] flex items-center justify-between " +
(active
? "bg-paper text-accent font-medium"
: "text-muted hover:bg-paper hover:text-ink")
}
onClick={() => setTab(t.key)}
>
<span className="flex items-center gap-2 min-w-0">
<Icon name={t.icon} size={15} /> {t.label}
</span>
{t.key === "connectors" && connCount != null && (
<span className={"text-[11px] shrink-0 " + (active ? "text-accent" : "text-faint")}>
{connCount}
</span>
)}
</button>
);
})}
<button className="w-full text-left px-2.5 py-2 rounded-lg text-[13px] flex items-center justify-between bg-paper text-accent font-medium">
<span className="flex items-center gap-2 min-w-0">
<Icon name="plug" size={15} /> Connectors
</span>
{connCount != null && (
<span className="text-[11px] shrink-0 text-accent">{connCount}</span>
)}
</button>
</nav>
<div className="flex-1 min-w-0 overflow-y-auto hairline-scroll">
<div className="max-w-4xl mx-auto px-7 py-6">
{tab === "connectors" ? (
<section>
<PanelHead
title="Connectors"
sub="Apps and tools your coworkers can use. Connected ones come first."
/>
<ConnectorsSection />
</section>
) : (
<section>
<PanelHead
title="MCP servers"
sub="External tool servers (stdio or HTTP), shared across all agents."
/>
<McpTab />
</section>
)}
<section>
<PanelHead
title="Connectors"
sub="Apps and tools your coworkers can use. Connected ones come first."
/>
<ConnectorsSection />
</section>
</div>
</div>
</main>
+2 -411
View File
@@ -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<string, any> }[] = [
{
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<McpServer[]>([]);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-3">
<p className="text-[12.5px] text-muted leading-relaxed">
External tool servers (stdio or HTTP), shared across all agents. Enabled servers' tools are
permission-gated. Changes apply to new sessions {" "}
<button
className="text-accent font-medium hover:underline"
onClick={() => reloadMcp().then(refresh)}
>
reload now
</button>
.
</p>
{servers.length === 0 && !adding ? (
<div className={CARD + " p-4 text-[13px] text-muted"}>
No MCP servers configured.{" "}
<button className="text-accent font-medium" onClick={() => setAdding(true)}>
Add a server
</button>
</div>
) : (
<div className="space-y-2">
{servers.map((s) => (
<McpRow
key={s.name}
server={s}
onToggle={() => toggle(s)}
onRemove={() => remove(s)}
onRefresh={refresh}
/>
))}
</div>
)}
{/* One-click OAuth presets not yet configured. */}
{MCP_PRESETS.filter((p) => !servers.some((s) => s.name === p.name)).map((p) => (
<div key={p.name} className={CARD + " p-3.5 flex items-center gap-3"} data-testid={`mcp-preset-${p.name}`}>
<div className="flex-1 min-w-0">
<div className="text-[14px] font-medium">{p.label}</div>
<div className="text-[11.5px] text-faint">{p.blurb}</div>
</div>
<button
className={BTN_ACCENT}
onClick={async () => {
await addMcpServer(p.name, p.config);
await connectMcp(p.name); // opens the browser sign-in right away
refresh();
}}
>
Connect
</button>
</div>
))}
{adding ? (
<AddForm
onCancel={() => {
setAdding(false);
setError(null);
}}
onError={setError}
onAdded={() => {
setAdding(false);
setError(null);
refresh();
}}
/>
) : servers.length > 0 ? (
<button className={BTN_ACCENT} onClick={() => setAdding(true)}>
+ Add server
</button>
) : null}
{error && <div className="text-[12.5px] text-danger">{error}</div>}
</div>
);
}
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<string | null>(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 (
<div className={CARD + " p-3.5"}>
<div className="flex items-center gap-3">
<Toggle checked={server.enabled} onChange={onToggle} title="Enable this server" />
<div className="flex-1 min-w-0">
<div className="text-[14px] font-medium">{server.name}</div>
<div className="text-[11.5px] text-faint">
{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" : ""}
</div>
</div>
{!isOauth && server.auth_hint && !authorizing && (
<button
className={BTN_ACCENT}
onClick={signInWithOauth}
data-testid={`mcp-authfix-${server.name}`}
>
Sign in
</button>
)}
{isOauth &&
(server.status === "needs_auth" ? (
<button className={BTN_ACCENT} onClick={signIn} data-testid={`mcp-signin-${server.name}`}>
Sign in
</button>
) : authorizing ? (
<span className="text-[12px] text-muted shrink-0">waiting for browser</span>
) : server.status === "connected" ? (
<button
className="text-[12px] text-muted hover:text-ink shrink-0"
onClick={signOut}
data-testid={`mcp-signout-${server.name}`}
>
sign out
</button>
) : null)}
{server.enabled &&
!authorizing &&
!server.auth_hint &&
!(isOauth && server.status !== "connected") && (
<button
className="text-[12px] text-muted hover:text-ink shrink-0"
onClick={runTest}
data-testid={`mcp-test-${server.name}`}
>
test
</button>
)}
<button
className="text-[12px] text-muted hover:text-ink shrink-0"
onClick={loadTools}
disabled={busy}
>
{busy ? "…" : tools ? "hide tools" : "tools"}
</button>
<button className={BTN_DANGER} onClick={onRemove}>
remove
</button>
</div>
{server.last_error && server.status !== "connected" && (
<div className="text-[12.5px] text-danger mt-1.5">{server.last_error}</div>
)}
{toolErr && <div className="text-[12.5px] text-danger mt-1.5">{toolErr}</div>}
{tools && (
<div className="mt-2.5 pt-2.5 border-t border-line flex flex-wrap gap-1.5">
{tools.length === 0 && <div className="text-[12px] text-faint">No tools.</div>}
{tools.map((t) => (
<span
key={t.name}
title={t.description}
className="font-mono text-[11.5px] px-1.5 py-0.5 rounded-md bg-paper border border-line"
>
{t.name}
</span>
))}
</div>
)}
</div>
);
}
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 `{ "<name>": { … } }` object (or a full mcpServers block).');
return;
}
for (const [name, config] of entries) {
await addMcpServer(name, config as Record<string, any>);
}
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 (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<button className={tabBtn(tab === "url")} onClick={() => setTab("url")} data-testid="mcp-add-tab-url">
Remote URL
</button>
<button className={tabBtn(tab === "json")} onClick={() => setTab("json")} data-testid="mcp-add-tab-json">
JSON
</button>
</div>
{tab === "url" ? (
<>
<div className="text-[12.5px] text-muted">
Connect a hosted MCP server. If it needs sign-in, the row will offer it after the
first test.
</div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name (shown in the server list)"
spellCheck={false}
className={INPUT}
data-testid="mcp-add-name"
/>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://mcp.example.com/mcp"
spellCheck={false}
className={INPUT + " font-mono text-[12px]"}
data-testid="mcp-add-url"
/>
</>
) : (
<>
<div className="text-[12.5px] text-muted">Paste server JSON (name config):</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
rows={9}
className="w-full font-mono text-[12px] px-3 py-2.5 rounded-lg border border-line bg-paper text-ink outline-none focus:border-accent resize-y"
/>
</>
)}
<div className="flex items-center gap-3">
<button className={BTN_ACCENT} onClick={tab === "url" ? saveUrl : saveJson}>
{tab === "url" ? "Add & test" : "Add"}
</button>
<button className="text-[12.5px] text-muted hover:text-ink" onClick={onCancel}>
cancel
</button>
</div>
</div>
);
}
// -- Connectors ---------------------------------------------------------------
// The Connectors tab body moved to connectors/ConnectorsSection.tsx (UX-DECISIONS
// §21: connected-first list + per-connector detail subpages). This file keeps the
@@ -1,23 +1,28 @@
import { useState } from "react";
import { type CloudStatus, type Connector, type SlackStatus } from "../../api";
import { type CloudStatus, type Connector, type McpServer, type SlackStatus } from "../../api";
import { ConnectorBadge } from "../../connectors/ConnectorIcon";
import { AddConnectionModal } from "./AddConnectionModal";
import { AddMcpModal, CustomMcpGroup } from "./CustomMcp";
import { CHIP_OK, CHIP_OFF, CHIP_WARN, GRP, GRP_H, FOOT, PILL_QUIET, ROW } from "./ui";
// The Connectors LIST (UX-DECISIONS §21): connected first in their own inset group —
// rows navigate to the connector's detail subpage; problems surface as a chip in the
// list, never one click deep. Available connectors below with a Connect pill.
// Custom MCP servers (UX-034) render as their own group after Connected; the "Add
// custom server" affordance sits at the top of the page (owner ruling: top).
const AVAILABLE_FOLD = 8; // rows shown before "show all"
export function ConnectorsList({
connectors,
mcpServers,
cloud,
slack,
onOpen,
onChanged,
}: {
connectors: Connector[];
mcpServers: McpServer[];
cloud: CloudStatus | null;
slack: SlackStatus | null;
onOpen: (name: string) => void;
@@ -26,17 +31,26 @@ export function ConnectorsList({
const [filter, setFilter] = useState("");
const [showAll, setShowAll] = useState(false);
const [connecting, setConnecting] = useState<string | null>(null);
const [addingMcp, setAddingMcp] = useState(false);
const q = filter.trim().toLowerCase();
const match = (c: Connector) => !q || c.title.toLowerCase().includes(q) || c.name.includes(q);
const connected = connectors.filter((c) => c.connected && match(c));
const available = connectors.filter((c) => !c.connected && c.available && match(c));
const customMcp = mcpServers.filter((s) => !q || s.name.toLowerCase().includes(q));
const shown = showAll || q ? available : available.slice(0, AVAILABLE_FOLD);
const connectingC = connecting ? connectors.find((c) => c.name === connecting) : null;
return (
<div>
<div className="flex items-center justify-end mb-4">
<div className="flex items-center justify-between mb-4">
<button
className={PILL_QUIET}
onClick={() => setAddingMcp(true)}
data-testid="add-custom-server"
>
+ Add custom server
</button>
<input
placeholder="Search"
value={filter}
@@ -71,6 +85,12 @@ export function ConnectorsList({
</>
)}
<CustomMcpGroup
servers={customMcp}
onOpen={(name) => onOpen("mcp:" + name)}
onChanged={onChanged}
/>
<div className={GRP_H}>Available</div>
<div className={GRP}>
{shown.map((c) => (
@@ -120,6 +140,7 @@ export function ConnectorsList({
onChanged={onChanged}
/>
)}
{addingMcp && <AddMcpModal onClose={() => setAddingMcp(false)} onChanged={onChanged} />}
</div>
);
}
@@ -3,11 +3,14 @@ import {
disconnectConnector,
getCloudStatus,
getConnectors,
getMcpServers,
getSlackStatus,
type CloudStatus,
type Connector,
type McpServer,
type SlackStatus,
} from "../../api";
import { McpServerDetail } from "./CustomMcp";
import { ConnectorBadge } from "../../connectors/ConnectorIcon";
import { AllowlistBlock, ConnectorTools, ListeningSessionsBlock, UnauthorizedBlock } from "../ManageTabs";
import { AccountsDetail } from "./AccountsDetail";
@@ -52,11 +55,13 @@ const DETAIL_PAGES: Record<string, (p: DetailProps) => JSX.Element> = {
export function ConnectorsSection() {
const [detail, setDetail] = useState<string | null>(null);
const [connectors, setConnectors] = useState<Connector[]>([]);
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
const [cloud, setCloud] = useState<CloudStatus | null>(null);
const [slack, setSlack] = useState<SlackStatus | null>(null);
const refresh = () => {
getConnectors().then(setConnectors).catch(() => setConnectors([]));
getMcpServers().then(setMcpServers).catch(() => setMcpServers([]));
getCloudStatus().then(setCloud).catch(() => setCloud(null));
getSlackStatus().then(setSlack).catch(() => setSlack(null));
};
@@ -68,6 +73,37 @@ export function ConnectorsSection() {
return () => clearInterval(t);
}, []);
// While an MCP test/sign-in is in flight, poll fast so the chip flips to its
// result (Live / Error / Needs sign-in) without the user touching anything.
const mcpBusy = mcpServers.some((s) => s.status === "authorizing");
useEffect(() => {
if (!mcpBusy) return;
const t = setInterval(refresh, 2000);
return () => clearInterval(t);
}, [mcpBusy]);
// Custom MCP entries route as "mcp:<name>" so they can never collide with a
// connector detail page of the same name.
if (detail?.startsWith("mcp:")) {
const s = mcpServers.find((x) => "mcp:" + x.name === detail);
return (
<div>
<button
className="text-[13px] text-accent mb-3"
data-testid="connectors-breadcrumb"
onClick={() => setDetail(null)}
>
Connectors
</button>
{!s ? (
<div className="text-[13px] text-muted">Loading</div>
) : (
<McpServerDetail server={s} onChanged={refresh} onGone={() => setDetail(null)} />
)}
</div>
);
}
if (detail) {
const c = connectors.find((x) => x.name === detail);
const Page = DETAIL_PAGES[detail];
@@ -104,6 +140,7 @@ export function ConnectorsSection() {
return (
<ConnectorsList
connectors={connectors}
mcpServers={mcpServers}
cloud={cloud}
slack={slack}
onOpen={setDetail}
@@ -0,0 +1,461 @@
import { useEffect, useState } from "react";
import {
addMcpServer,
connectMcp,
deleteMcpServer,
getMcpTools,
patchMcpServer,
signoutMcp,
type McpServer,
} from "../../api";
import { relTime } from "../../providers/ProviderSetup";
import { Icon } from "../Icon";
import { Toggle } from "../Toggle";
import {
CHIP_ERR,
CHIP_OFF,
CHIP_OK,
CHIP_WARN,
GRP,
GRP_H,
PILL_ACCENT,
PILL_QUIET,
ROW,
} from "./ui";
// Custom/BYO MCP servers on the Connectors page (UX-DECISIONS §21 + UX-034: the
// separate MCP tab is retired). They render as a "Custom · MCP" group at the end
// of the Connected section — grouped, not interleaved with first-party rows, so
// the user-supplied trust tier stays visible. Status never claims "Connected"
// for a stdio entry: Live = a connection is open right now; Ready = the one-time
// Test passed (subtitle carries "tested ⟨when⟩", persisted server-side).
// 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.
export const MCP_PRESETS: {
name: string;
label: string;
blurb: string;
config: Record<string, any>;
}[] = [
{
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 mcpChip(s: McpServer) {
const isOauth = s.auth === "oauth";
if (!s.enabled) return <span className={CHIP_OFF}> Off</span>;
if (s.status === "authorizing")
return <span className={CHIP_WARN}> {isOauth ? "Signing in…" : "Testing…"}</span>;
if (s.status === "connected") return <span className={CHIP_OK}> Live</span>;
if (s.auth_hint || s.status === "needs_auth")
return <span className={CHIP_WARN}> Needs sign-in</span>;
if (s.status === "error") return <span className={CHIP_ERR}> Error</span>;
if (s.last_test_at) return <span className={CHIP_OK}> Ready</span>;
return <span className={CHIP_OFF}> Not tested</span>;
}
export function mcpStatusLine(s: McpServer): string {
const bits: string[] = [s.transport];
if (s.status === "connected" && s.tool_count != null) bits.push(`${s.tool_count} tools`);
else if (s.transport === "http" && s.config?.url) {
try {
bits.push(new URL(s.config.url).host);
} catch {
/* leave the host off a malformed url */
}
}
if (s.status !== "connected" && s.last_test_at) {
const rel = relTime(s.last_test_at);
if (rel) bits.push(`tested ${rel}`);
}
return bits.join(" · ");
}
/** Neutral square badge for custom servers (no vendor logo to show). */
function McpGlyph() {
return (
<span className="w-[34px] h-[34px] rounded-lg bg-paper border border-line flex items-center justify-center text-muted shrink-0">
<Icon name="code" size={16} />
</span>
);
}
export function CustomMcpGroup({
servers,
onOpen,
onChanged,
}: {
servers: McpServer[];
onOpen: (name: string) => void;
onChanged: () => void;
}) {
const presets = MCP_PRESETS.filter((p) => !servers.some((s) => s.name === p.name));
if (servers.length === 0 && presets.length === 0) return null;
return (
<>
<div className={GRP_H}>Custom · MCP</div>
<div className={GRP} data-testid="custom-mcp-group">
{servers.map((s) => (
<button
key={s.name}
data-testid={`mcp-row-${s.name}`}
className={ROW + " w-full text-left hover:bg-paper/60"}
onClick={() => onOpen(s.name)}
>
<McpGlyph />
<span className="min-w-0 flex-1">
<span className="font-medium text-[13.5px]">{s.name}</span>
<span className="block text-[12px] text-muted truncate">{mcpStatusLine(s)}</span>
</span>
{mcpChip(s)}
<span className="text-faint text-[15px] shrink-0"></span>
</button>
))}
{presets.map((p) => (
<div key={p.name} className={ROW} data-testid={`mcp-preset-${p.name}`}>
<McpGlyph />
<span className="min-w-0 flex-1">
<span className="font-medium text-[13.5px]">{p.label}</span>
<span className="block text-[12px] text-muted truncate">{p.blurb}</span>
</span>
<span
className={PILL_QUIET + " cursor-pointer"}
role="button"
onClick={async () => {
await addMcpServer(p.name, p.config);
await connectMcp(p.name); // opens the browser sign-in right away
onChanged();
}}
>
Connect
</span>
</div>
))}
</div>
</>
);
}
// -- Add custom server (UX-033 two-tab form, in the page's modal chrome) --------
const EXAMPLE = `{
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
"enabled": true
}
}`;
const INPUT =
"w-full text-[13px] px-3 py-2 rounded-lg border border-line bg-paper text-ink outline-none focus:border-accent";
export function AddMcpModal({
onClose,
onChanged,
}: {
onClose: () => void;
onChanged: () => void;
}) {
const [tab, setTab] = useState<"url" | "json">("url");
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [text, setText] = useState(EXAMPLE);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const saveUrl = async () => {
setError(null);
const n = name.trim();
const u = url.trim();
if (!n) {
setError("Give the server a name.");
return;
}
if (!/^https?:\/\/\S+$/.test(u)) {
setError("Enter the server's full URL (https://…).");
return;
}
await addMcpServer(n, { type: "http", url: u });
// Probe anonymously right away — the row shows Testing…, then Live, an
// error, or Needs sign-in (401 → the OAuth switch on the detail page).
await connectMcp(n);
onChanged();
onClose();
};
const saveJson = async () => {
setError(null);
let parsed: any;
try {
parsed = JSON.parse(text);
} catch (e: any) {
setError("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) {
setError('Paste a `{ "<name>": { … } }` object (or a full mcpServers block).');
return;
}
for (const [n, config] of entries) {
await addMcpServer(n, config as Record<string, any>);
}
onChanged();
onClose();
};
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 (
<div className="fixed inset-0 z-40" data-testid="add-mcp-modal">
<div className="absolute inset-0 bg-black/30" onClick={onClose} />
<div className="absolute left-1/2 top-24 -translate-x-1/2 w-[540px] max-w-[92vw] rounded-xl2 border border-line bg-panel shadow-xl p-5 space-y-3">
<div className="flex items-center justify-between">
<div className="text-[15px] font-semibold">Add custom MCP server</div>
<button className="text-faint hover:text-ink text-[16px] leading-none" onClick={onClose}>
×
</button>
</div>
<div className="flex items-center gap-1.5">
<button className={tabBtn(tab === "url")} onClick={() => setTab("url")} data-testid="mcp-add-tab-url">
Remote URL
</button>
<button className={tabBtn(tab === "json")} onClick={() => setTab("json")} data-testid="mcp-add-tab-json">
JSON
</button>
</div>
{tab === "url" ? (
<>
<div className="text-[12.5px] text-muted">
Connect a hosted MCP server. If it needs sign-in, the row will offer it after the
first test.
</div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name (shown in the connectors list)"
spellCheck={false}
className={INPUT}
data-testid="mcp-add-name"
/>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://mcp.example.com/mcp"
spellCheck={false}
className={INPUT + " font-mono text-[12px]"}
data-testid="mcp-add-url"
/>
</>
) : (
<>
<div className="text-[12.5px] text-muted">Paste server JSON (name config):</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
rows={9}
className="w-full font-mono text-[12px] px-3 py-2.5 rounded-lg border border-line bg-paper text-ink outline-none focus:border-accent resize-y"
/>
</>
)}
<div className="flex items-center gap-3">
<button className={PILL_ACCENT} onClick={tab === "url" ? saveUrl : saveJson}>
{tab === "url" ? "Add & test" : "Add"}
</button>
<button className="text-[12.5px] text-muted hover:text-ink" onClick={onClose}>
cancel
</button>
</div>
{error && <div className="text-[12.5px] text-danger">{error}</div>}
</div>
</div>
);
}
// -- Detail subpage (§21): tools, Test, config, error excerpt, remove -----------
export function McpServerDetail({
server,
onChanged,
onGone,
}: {
server: McpServer;
onChanged: () => void;
onGone: () => void;
}) {
const [tools, setTools] = useState<{ name: string; description: string }[] | null>(null);
const [busy, setBusy] = useState(false);
const [toolErr, setToolErr] = useState<string | null>(null);
const isOauth = server.auth === "oauth";
const authorizing = server.status === "authorizing";
const runTest = async () => {
await connectMcp(server.name);
onChanged();
// The connect runs as a background task; if the first refresh outpaced its
// start, the chip never shows Testing and the page's poll misses the flip.
window.setTimeout(onChanged, 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);
onChanged();
};
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 (
<div className="space-y-4" data-testid={`mcp-detail-${server.name}`}>
<div className="flex items-center gap-3">
<McpGlyph />
<div className="flex-1 min-w-0">
<div className="text-[16px] font-semibold">{server.name}</div>
<div className="text-[12px] text-muted">{mcpStatusLine(server)}</div>
</div>
{mcpChip(server)}
</div>
<div className={GRP}>
<div className={ROW}>
<span className="text-[13px] flex-1">Enabled</span>
<Toggle
checked={server.enabled}
onChange={async () => {
await patchMcpServer(server.name, { enabled: !server.enabled });
onChanged();
}}
title="Enable this server"
/>
</div>
<div className={ROW}>
<span className="text-[13px] flex-1">
Test connection
<span className="block text-[11.5px] text-faint">
Starts the server and lists its tools without opening a session.
</span>
</span>
{server.auth_hint && !isOauth ? (
<span
className={PILL_ACCENT + " cursor-pointer"}
role="button"
onClick={signInWithOauth}
data-testid={`mcp-authfix-${server.name}`}
>
Sign in
</span>
) : isOauth && server.status === "needs_auth" ? (
<span
className={PILL_ACCENT + " cursor-pointer"}
role="button"
onClick={runTest}
data-testid={`mcp-signin-${server.name}`}
>
Sign in
</span>
) : (
<span
className={PILL_QUIET + " cursor-pointer" + (authorizing ? " opacity-50" : "")}
role="button"
onClick={authorizing ? undefined : runTest}
data-testid={`mcp-test-${server.name}`}
>
{authorizing ? "Testing…" : "Test"}
</span>
)}
</div>
{server.last_error && server.status !== "connected" && (
<div className="px-4 py-2.5 text-[12.5px] text-danger break-words">
{server.last_error}
</div>
)}
<div className={ROW}>
<span className="text-[13px] flex-1">Tools</span>
<button className="text-[12.5px] text-muted hover:text-ink" onClick={loadTools} disabled={busy}>
{busy ? "…" : tools ? "hide" : "show"}
</button>
</div>
{toolErr && <div className="px-4 py-2.5 text-[12.5px] text-danger">{toolErr}</div>}
{tools && (
<div className="px-4 py-3 flex flex-wrap gap-1.5">
{tools.length === 0 && <div className="text-[12px] text-faint">No tools.</div>}
{tools.map((t) => (
<span
key={t.name}
title={t.description}
className="font-mono text-[11.5px] px-1.5 py-0.5 rounded-md bg-paper border border-line"
>
{t.name}
</span>
))}
</div>
)}
</div>
<div className={GRP}>
<div className="px-4 py-3">
<div className="text-[12px] font-semibold text-muted mb-1.5">Configuration</div>
<pre className="font-mono text-[11.5px] text-muted whitespace-pre-wrap break-all">
{JSON.stringify(server.config, null, 2)}
</pre>
</div>
</div>
<div className="flex items-center gap-4">
{isOauth && server.status === "connected" && (
<button
className="text-[12.5px] text-muted hover:text-ink"
onClick={async () => {
await signoutMcp(server.name);
onChanged();
}}
data-testid={`mcp-signout-${server.name}`}
>
Sign out
</button>
)}
<button
className="text-[12.5px] text-danger/80 hover:text-danger"
onClick={async () => {
await deleteMcpServer(server.name);
onChanged();
onGone();
}}
data-testid={`mcp-remove-${server.name}`}
>
Remove server
</button>
</div>
</div>
);
}
@@ -35,6 +35,8 @@ export const CHIP_WARN =
"text-[11px] font-medium px-2 py-0.5 rounded-full bg-warnSoft text-warnInk border border-warnInk/20 shrink-0";
export const CHIP_OFF =
"text-[11px] font-medium px-2 py-0.5 rounded-full bg-paper text-muted border border-lineStrong shrink-0";
export const CHIP_ERR =
"text-[11px] font-medium px-2 py-0.5 rounded-full bg-danger/10 text-danger border border-danger/25 shrink-0";
/** Small × affordance (danger on hover). */
export const XBTN = "text-faint hover:text-danger shrink-0 leading-none";
+23
View File
@@ -391,6 +391,29 @@ async def test_connect_mcp_http_401_sets_auth_hint(tmp_path, monkeypatch):
listed = {s["name"]: s for s in manager.list_mcp()}
assert listed["guarded"]["auth_hint"] is True
assert listed["guarded"]["status"] == "error"
assert listed["guarded"]["last_test_at"] is None # failed probe stamps nothing
finally:
srv.shutdown()
srv.server_close()
def test_last_test_at_persists_and_clears_on_delete(tmp_path, monkeypatch):
"""The "Ready · tested ⟨when⟩" claim lives in prefs: it survives a manager
restart and is dropped with the server (a re-add is not pre-trusted)."""
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
_write_json(
tmp_path / "state" / "mcp.json",
{"mcpServers": {"fs": {"command": "echo", "enabled": True}}},
)
manager = SessionManager(data_dir=tmp_path / "data")
manager._prefs.setdefault("mcp_last_test", {})["fs"] = 1_700_000_000
manager._save_prefs()
# A fresh manager (same data dir) still reports the stamp.
manager2 = SessionManager(data_dir=tmp_path / "data")
listed = {s["name"]: s for s in manager2.list_mcp()}
assert listed["fs"]["last_test_at"] == 1_700_000_000
manager2.delete_mcp("fs")
manager3 = SessionManager(data_dir=tmp_path / "data")
assert manager3._prefs.get("mcp_last_test", {}).get("fs") is None