mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-12 23:29:55 +00:00
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:
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1064,6 +1064,7 @@ class SessionManager:
|
|||||||
"auth": "oauth" if is_oauth else None,
|
"auth": "oauth" if is_oauth else None,
|
||||||
"status": status,
|
"status": status,
|
||||||
"auth_hint": name in self._mcp_auth_hints,
|
"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),
|
"last_error": self._mcp_errors.get(name),
|
||||||
"tool_count": (
|
"tool_count": (
|
||||||
len(self.mcp._conns[name].tools) if connected else None
|
len(self.mcp._conns[name].tools) if connected else None
|
||||||
@@ -1092,6 +1093,10 @@ class SessionManager:
|
|||||||
try:
|
try:
|
||||||
# The ONE place a browser sign-in may start: an explicit connect.
|
# The ONE place a browser sign-in may start: an explicit connect.
|
||||||
conn = await self.mcp.ensure(server, interactive=True)
|
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)}
|
return {"ok": True, "tools": len(conn.tools)}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if (
|
if (
|
||||||
@@ -1173,9 +1178,12 @@ class SessionManager:
|
|||||||
def delete_mcp(self, name: str) -> dict[str, Any]:
|
def delete_mcp(self, name: str) -> dict[str, Any]:
|
||||||
ok = delete_global_server(name)
|
ok = delete_global_server(name)
|
||||||
if ok:
|
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_errors.pop(name, None)
|
||||||
self._mcp_auth_hints.discard(name)
|
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}
|
return {"ok": ok, "name": name}
|
||||||
|
|
||||||
async def mcp_tools(self, name: str) -> dict[str, Any]:
|
async def mcp_tools(self, name: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -1540,6 +1540,7 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
|||||||
} else {
|
} else {
|
||||||
s2.status = "connected";
|
s2.status = "connected";
|
||||||
s2.tool_count = 6;
|
s2.tool_count = 6;
|
||||||
|
s2.last_test_at = 1700000000; // the successful probe stamps the row
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (s2.status === "authorizing") s2._flip = true;
|
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,
|
auth: b.config?.auth === "oauth" ? "oauth" : null,
|
||||||
status: b.config?.auth === "oauth" ? "needs_auth" : "configured",
|
status: b.config?.auth === "oauth" ? "needs_auth" : "configured",
|
||||||
auth_hint: false,
|
auth_hint: false,
|
||||||
|
last_test_at: null,
|
||||||
last_error: null,
|
last_error: null,
|
||||||
tool_count: null,
|
tool_count: null,
|
||||||
config: b.config || {},
|
config: b.config || {},
|
||||||
|
|||||||
@@ -1,71 +1,82 @@
|
|||||||
// UX-033: the Add MCP server flow (Remote URL + JSON tabs) and the Test button.
|
// UX-033/034: custom MCP servers live on the Connectors page. "Add custom server"
|
||||||
// Remote URL adds an http entry and probes it immediately (testing… → connected);
|
// (top of page) opens the two-tab modal (Remote URL / JSON); added entries land in
|
||||||
// a guarded server (mock: locked-*) lands on "needs sign-in" with the OAuth switch;
|
// the "Custom · MCP" group with honest status chips (Testing… → Live / Error /
|
||||||
// the JSON paste box remains for stdio/advanced, and every row can be re-tested.
|
// Needs sign-in / Not tested) and a detail subpage with Test.
|
||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
import { test } from "./fixtures";
|
import { test } from "./fixtures";
|
||||||
|
|
||||||
async function openMcpTab(page) {
|
async function openConnectors(page) {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByTestId("account-row").click();
|
await page.getByTestId("account-row").click();
|
||||||
await page.getByRole("button", { name: "Connectors", exact: true }).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 }) => {
|
test("remote URL add: probe flips the row to Live with tool count", async ({ page }) => {
|
||||||
await openMcpTab(page);
|
await openConnectors(page);
|
||||||
await page.getByRole("button", { name: "Add a server" }).click();
|
await page.getByTestId("add-custom-server").click();
|
||||||
|
|
||||||
// URL tab is the default door; bad URL is caught before anything is added.
|
// URL tab is the default door; bad URL is caught before anything is added.
|
||||||
await page.getByTestId("mcp-add-name").fill("notes");
|
const modal = page.getByTestId("add-mcp-modal");
|
||||||
await page.getByTestId("mcp-add-url").fill("mcp.example.com/mcp");
|
await modal.getByTestId("mcp-add-name").fill("notes");
|
||||||
await page.getByRole("button", { name: "Add & test" }).click();
|
await modal.getByTestId("mcp-add-url").fill("mcp.example.com/mcp");
|
||||||
await expect(page.getByText("Enter the server's full URL")).toBeVisible();
|
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 modal.getByTestId("mcp-add-url").fill("https://mcp.example.com/mcp");
|
||||||
await page.getByRole("button", { name: "Add & test" }).click();
|
await modal.getByRole("button", { name: "Add & test" }).click();
|
||||||
|
|
||||||
const row = page.locator(".space-y-2 > div").filter({ hasText: "notes" }).first();
|
const row = page.getByTestId("mcp-row-notes");
|
||||||
await expect(row).toContainText("testing…");
|
await expect(row).toContainText("Testing…");
|
||||||
await expect(row).toContainText("connected", { timeout: 10_000 });
|
await expect(row).toContainText("Live", { timeout: 10_000 });
|
||||||
await expect(row).toContainText("6 tools");
|
await expect(row).toContainText("6 tools");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("guarded server: 401 → needs sign-in → OAuth switch connects", async ({ page }) => {
|
test("guarded server: 401 → Needs sign-in chip → OAuth switch on the detail page", async ({
|
||||||
await openMcpTab(page);
|
page,
|
||||||
await page.getByRole("button", { name: "Add a server" }).click();
|
}) => {
|
||||||
await page.getByTestId("mcp-add-name").fill("locked-crm");
|
await openConnectors(page);
|
||||||
await page.getByTestId("mcp-add-url").fill("https://mcp.locked.example/mcp");
|
await page.getByTestId("add-custom-server").click();
|
||||||
await page.getByRole("button", { name: "Add & test" }).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.
|
// The anonymous probe 401s: the row says needs sign-in; the fix lives one
|
||||||
const row = page.locator(".space-y-2 > div").filter({ hasText: "locked-crm" }).first();
|
// click deep on the detail page (with the error excerpt).
|
||||||
await expect(row).toContainText("needs sign-in", { timeout: 10_000 });
|
const row = page.getByTestId("mcp-row-locked-crm");
|
||||||
await expect(row).toContainText("authentication required");
|
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
|
const detail = page.getByTestId("mcp-detail-locked-crm");
|
||||||
// flips it to connected.
|
await expect(detail).toContainText("authentication required");
|
||||||
await row.getByTestId("mcp-authfix-locked-crm").click();
|
await detail.getByTestId("mcp-authfix-locked-crm").click();
|
||||||
await expect(row).toContainText("signing in…");
|
await expect(detail).toContainText("Signing in…");
|
||||||
await expect(row).toContainText("connected", { timeout: 10_000 });
|
await expect(detail).toContainText("Live", { timeout: 10_000 });
|
||||||
await expect(row).toContainText("oauth");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("JSON tab still adds stdio servers; Test probes an existing row", async ({ page }) => {
|
test("JSON tab adds stdio as Not tested; detail Test flips it to Live", async ({ page }) => {
|
||||||
await openMcpTab(page);
|
await openConnectors(page);
|
||||||
await page.getByRole("button", { name: "Add a server" }).click();
|
await page.getByTestId("add-custom-server").click();
|
||||||
await page.getByTestId("mcp-add-tab-json").click();
|
const modal = page.getByTestId("add-mcp-modal");
|
||||||
await page
|
await modal.getByTestId("mcp-add-tab-json").click();
|
||||||
|
await modal
|
||||||
.locator("textarea")
|
.locator("textarea")
|
||||||
.fill('{"files": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem"]}}');
|
.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();
|
// A pasted stdio server is configured, not connected — the chip says so.
|
||||||
await expect(row).toContainText("stdio · configured");
|
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.click();
|
||||||
await row.getByTestId("mcp-test-files").click();
|
const detail = page.getByTestId("mcp-detail-files");
|
||||||
await expect(row).toContainText("testing…");
|
await detail.getByTestId("mcp-test-files").click();
|
||||||
await expect(row).toContainText("connected", { timeout: 10_000 });
|
await expect(detail).toContainText("Testing…");
|
||||||
await expect(row).toContainText("6 tools");
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
// MCP OAuth quick-add (first server: Granola): the MCP tab offers a curated Connect
|
// MCP OAuth quick-add (first server: Granola): the Custom · MCP group on the
|
||||||
// card; connecting adds the server, kicks off the browser sign-in ("signing in…"),
|
// Connectors page offers a curated Connect card; connecting adds the server, kicks
|
||||||
// and the tab's poll flips the row to connected. Sign out returns it to needs_auth.
|
// 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 { expect } from "@playwright/test";
|
||||||
import { test } from "./fixtures";
|
import { test } from "./fixtures";
|
||||||
|
|
||||||
async function openMcpTab(page) {
|
async function openConnectors(page) {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByTestId("account-row").click();
|
await page.getByTestId("account-row").click();
|
||||||
await page.getByRole("button", { name: "Connectors", exact: true }).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 }) => {
|
test("granola: quick-add card → sign-in flow → Live → sign out", async ({ page }) => {
|
||||||
await openMcpTab(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");
|
const preset = page.getByTestId("mcp-preset-granola");
|
||||||
await expect(preset).toContainText("Granola");
|
await expect(preset).toContainText("Granola");
|
||||||
await expect(preset).toContainText("Meeting notes");
|
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.
|
// Connect: adds the server with OAuth pending and starts the browser flow.
|
||||||
await preset.getByRole("button", { name: "Connect" }).click();
|
await preset.getByRole("button", { name: "Connect" }).click();
|
||||||
await expect(page.getByTestId("mcp-preset-granola")).toHaveCount(0);
|
await expect(page.getByTestId("mcp-preset-granola")).toHaveCount(0);
|
||||||
const row = page.locator(".space-y-2 > div").filter({ hasText: "granola" }).first();
|
const row = page.getByTestId("mcp-row-granola");
|
||||||
await expect(row).toContainText("signing in…");
|
await expect(row).toContainText("Signing in…");
|
||||||
|
|
||||||
// The 2s status poll flips the mock to connected with its 6 tools.
|
// The status poll flips the mock to connected with its 6 tools.
|
||||||
await expect(row).toContainText("connected", { timeout: 10_000 });
|
await expect(row).toContainText("Live", { timeout: 10_000 });
|
||||||
await expect(row).toContainText("6 tools");
|
await expect(row).toContainText("6 tools");
|
||||||
await expect(row).toContainText("oauth");
|
|
||||||
|
|
||||||
// Sign out forgets tokens; the row needs auth again and offers Sign in.
|
// Sign out on the detail page forgets tokens; the chip needs sign-in again.
|
||||||
await row.getByTestId("mcp-signout-granola").click();
|
await row.click();
|
||||||
await expect(row).toContainText("needs auth");
|
const detail = page.getByTestId("mcp-detail-granola");
|
||||||
await expect(row.getByTestId("mcp-signin-granola")).toBeVisible();
|
await detail.getByTestId("mcp-signout-granola").click();
|
||||||
|
await expect(detail).toContainText("Needs sign-in");
|
||||||
|
await expect(detail.getByTestId("mcp-signin-granola")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 page.getByTestId("account-menu").getByRole("button", { name: "Activity", exact: true }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Activity" })).toBeVisible();
|
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-row").click();
|
||||||
await page.getByTestId("account-menu").getByRole("button", { name: "Connectors", exact: true }).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);
|
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.
|
// 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);
|
await expect(page.getByRole("button", { name: "Activity", exact: true })).toHaveCount(0);
|
||||||
|
|||||||
@@ -275,6 +275,8 @@ export interface McpServer {
|
|||||||
auth?: "oauth" | null;
|
auth?: "oauth" | null;
|
||||||
// http server whose anonymous connect hit a 401/403 — offer OAuth sign-in.
|
// http server whose anonymous connect hit a 401/403 — offer OAuth sign-in.
|
||||||
auth_hint?: boolean;
|
auth_hint?: boolean;
|
||||||
|
// Epoch seconds of the last successful explicit Test (persisted server-side).
|
||||||
|
last_test_at?: number | null;
|
||||||
last_error?: string | null;
|
last_error?: string | null;
|
||||||
tool_count: number | null;
|
tool_count: number | null;
|
||||||
config: Record<string, any>;
|
config: Record<string, any>;
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { getConnectors } from "../api";
|
import { getConnectors } from "../api";
|
||||||
import { McpTab } from "./ManageTabs";
|
|
||||||
import { ConnectorsSection } from "./connectors/ConnectorsSection";
|
import { ConnectorsSection } from "./connectors/ConnectorsSection";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
|
|
||||||
// The Connectors surface (renamed from "Integrations", §26) keeps the left sub-nav, now just
|
// The Connectors surface (renamed from "Integrations", §26). The separate "MCP
|
||||||
// Connectors · MCP. The old "Messaging routing" tab (and its ⚠ unrouted badge) moved whole to
|
// servers" tab is retired (UX-034): custom MCP servers now live on the Connectors
|
||||||
// Inbox ▸ Configure (§28): inbox-delivery config belongs with the Inbox, and Unrouted is
|
// page itself — a "Custom · MCP" group plus the top "Add custom server" modal —
|
||||||
// "messages that never reached you". The one remaining Activity is the audit log, reached from
|
// so the sub-nav is a single fixed item. The old "Messaging routing" tab (and its
|
||||||
// the account menu.
|
// ⚠ unrouted badge) moved whole to Inbox ▸ Configure (§28); the one remaining
|
||||||
type IntTab = "connectors" | "mcp";
|
// Activity is the audit log, reached from the account menu.
|
||||||
|
|
||||||
// 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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function IntegrationsView() {
|
export function IntegrationsView() {
|
||||||
const [tab, setTab] = useState<IntTab>("connectors");
|
|
||||||
// Sub-nav count: how many connectors exist. Polled so the badge stays live.
|
// Sub-nav count: how many connectors exist. Polled so the badge stays live.
|
||||||
const [connCount, setConnCount] = useState<number | null>(null);
|
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">
|
<div className="px-2 text-[13.5px] font-semibold mb-3 flex items-center gap-2">
|
||||||
<Icon name="plug" size={16} /> Connectors
|
<Icon name="plug" size={16} /> Connectors
|
||||||
</div>
|
</div>
|
||||||
{INT_TABS.map((t) => {
|
<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">
|
||||||
const active = tab === t.key;
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
return (
|
<Icon name="plug" size={15} /> Connectors
|
||||||
<button
|
</span>
|
||||||
key={t.key}
|
{connCount != null && (
|
||||||
className={
|
<span className="text-[11px] shrink-0 text-accent">{connCount}</span>
|
||||||
"w-full text-left px-2.5 py-2 rounded-lg text-[13px] flex items-center justify-between " +
|
)}
|
||||||
(active
|
</button>
|
||||||
? "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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 overflow-y-auto hairline-scroll">
|
<div className="flex-1 min-w-0 overflow-y-auto hairline-scroll">
|
||||||
<div className="max-w-4xl mx-auto px-7 py-6">
|
<div className="max-w-4xl mx-auto px-7 py-6">
|
||||||
{tab === "connectors" ? (
|
<section>
|
||||||
<section>
|
<PanelHead
|
||||||
<PanelHead
|
title="Connectors"
|
||||||
title="Connectors"
|
sub="Apps and tools your coworkers can use. Connected ones come first."
|
||||||
sub="Apps and tools your coworkers can use. Connected ones come first."
|
/>
|
||||||
/>
|
<ConnectorsSection />
|
||||||
<ConnectorsSection />
|
</section>
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<section>
|
|
||||||
<PanelHead
|
|
||||||
title="MCP servers"
|
|
||||||
sub="External tool servers (stdio or HTTP), shared across all agents."
|
|
||||||
/>
|
|
||||||
<McpTab />
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,36 +1,26 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
addMcpServer,
|
|
||||||
allowUser,
|
allowUser,
|
||||||
connectConnector,
|
connectConnector,
|
||||||
connectManaged,
|
connectManaged,
|
||||||
connectMcpBacked,
|
connectMcpBacked,
|
||||||
connectMcp,
|
|
||||||
deleteMcpServer,
|
|
||||||
disallowUser,
|
disallowUser,
|
||||||
getMcpServers,
|
|
||||||
getMcpTools,
|
|
||||||
signoutMcp,
|
|
||||||
getSettings,
|
getSettings,
|
||||||
getSubscriptions,
|
getSubscriptions,
|
||||||
removeModel,
|
removeModel,
|
||||||
resolveUnauthorized,
|
resolveUnauthorized,
|
||||||
unsubscribeChannel,
|
unsubscribeChannel,
|
||||||
patchMcpServer,
|
|
||||||
reloadMcp,
|
|
||||||
setDefaultModel,
|
setDefaultModel,
|
||||||
updateConnectorTools,
|
updateConnectorTools,
|
||||||
type CloudStatus,
|
type CloudStatus,
|
||||||
type Connector,
|
type Connector,
|
||||||
type Subscription,
|
type Subscription,
|
||||||
type McpServer,
|
|
||||||
type ModelSettings,
|
type ModelSettings,
|
||||||
type ProviderInfo,
|
type ProviderInfo,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { CloudSignInInline, CloudStatusPending } from "./connectors/CloudSignIn";
|
import { CloudSignInInline, CloudStatusPending } from "./connectors/CloudSignIn";
|
||||||
import { ModelChecklist } from "./ModelChecklist";
|
import { ModelChecklist } from "./ModelChecklist";
|
||||||
import { ProviderCards, ProviderForm, useProviderSetup } from "../providers/ProviderSetup";
|
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).
|
// "2h ago"-style label for the providers' Last-used line (null when never used).
|
||||||
const relTime = (epoch?: number | null): string | null => {
|
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
|
// 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 +
|
// when Settings/Activity became full-page surfaces): ModelsTab → Settings ▸ Models; ConnectorsTab →
|
||||||
// McpTab → Integrations ▸ Connectors / MCP servers.
|
// 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 SEC_H = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold";
|
||||||
const CARD = "rounded-xl2 border border-line bg-panel";
|
|
||||||
const BTN_BORDERED =
|
const BTN_BORDERED =
|
||||||
"text-[12.5px] px-3 py-1.5 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
|
"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_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). */
|
/** Two-letter initials for a chip/avatar (first+last word, else first two chars). */
|
||||||
function initials(name: string): string {
|
function initials(name: string): string {
|
||||||
@@ -62,14 +50,6 @@ function initials(name: string): string {
|
|||||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
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) ----
|
// -- Configure Models tab (UX-021: the shared provider gallery + key form) ----
|
||||||
// Settings ▸ Models reuses onboarding §39's ProviderCards/ProviderForm so the two
|
// 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
|
// 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 ---------------------------------------------------------------
|
// -- Connectors ---------------------------------------------------------------
|
||||||
// The Connectors tab body moved to connectors/ConnectorsSection.tsx (UX-DECISIONS
|
// The Connectors tab body moved to connectors/ConnectorsSection.tsx (UX-DECISIONS
|
||||||
// §21: connected-first list + per-connector detail subpages). This file keeps the
|
// §21: connected-first list + per-connector detail subpages). This file keeps the
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
import { useState } from "react";
|
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 { ConnectorBadge } from "../../connectors/ConnectorIcon";
|
||||||
import { AddConnectionModal } from "./AddConnectionModal";
|
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";
|
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 —
|
// 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
|
// 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.
|
// 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"
|
const AVAILABLE_FOLD = 8; // rows shown before "show all"
|
||||||
|
|
||||||
export function ConnectorsList({
|
export function ConnectorsList({
|
||||||
connectors,
|
connectors,
|
||||||
|
mcpServers,
|
||||||
cloud,
|
cloud,
|
||||||
slack,
|
slack,
|
||||||
onOpen,
|
onOpen,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
connectors: Connector[];
|
connectors: Connector[];
|
||||||
|
mcpServers: McpServer[];
|
||||||
cloud: CloudStatus | null;
|
cloud: CloudStatus | null;
|
||||||
slack: SlackStatus | null;
|
slack: SlackStatus | null;
|
||||||
onOpen: (name: string) => void;
|
onOpen: (name: string) => void;
|
||||||
@@ -26,17 +31,26 @@ export function ConnectorsList({
|
|||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
const [connecting, setConnecting] = useState<string | null>(null);
|
const [connecting, setConnecting] = useState<string | null>(null);
|
||||||
|
const [addingMcp, setAddingMcp] = useState(false);
|
||||||
|
|
||||||
const q = filter.trim().toLowerCase();
|
const q = filter.trim().toLowerCase();
|
||||||
const match = (c: Connector) => !q || c.title.toLowerCase().includes(q) || c.name.includes(q);
|
const match = (c: Connector) => !q || c.title.toLowerCase().includes(q) || c.name.includes(q);
|
||||||
const connected = connectors.filter((c) => c.connected && match(c));
|
const connected = connectors.filter((c) => c.connected && match(c));
|
||||||
const available = connectors.filter((c) => !c.connected && c.available && 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 shown = showAll || q ? available : available.slice(0, AVAILABLE_FOLD);
|
||||||
const connectingC = connecting ? connectors.find((c) => c.name === connecting) : null;
|
const connectingC = connecting ? connectors.find((c) => c.name === connecting) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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
|
<input
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
value={filter}
|
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_H}>Available</div>
|
||||||
<div className={GRP}>
|
<div className={GRP}>
|
||||||
{shown.map((c) => (
|
{shown.map((c) => (
|
||||||
@@ -120,6 +140,7 @@ export function ConnectorsList({
|
|||||||
onChanged={onChanged}
|
onChanged={onChanged}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{addingMcp && <AddMcpModal onClose={() => setAddingMcp(false)} onChanged={onChanged} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import {
|
|||||||
disconnectConnector,
|
disconnectConnector,
|
||||||
getCloudStatus,
|
getCloudStatus,
|
||||||
getConnectors,
|
getConnectors,
|
||||||
|
getMcpServers,
|
||||||
getSlackStatus,
|
getSlackStatus,
|
||||||
type CloudStatus,
|
type CloudStatus,
|
||||||
type Connector,
|
type Connector,
|
||||||
|
type McpServer,
|
||||||
type SlackStatus,
|
type SlackStatus,
|
||||||
} from "../../api";
|
} from "../../api";
|
||||||
|
import { McpServerDetail } from "./CustomMcp";
|
||||||
import { ConnectorBadge } from "../../connectors/ConnectorIcon";
|
import { ConnectorBadge } from "../../connectors/ConnectorIcon";
|
||||||
import { AllowlistBlock, ConnectorTools, ListeningSessionsBlock, UnauthorizedBlock } from "../ManageTabs";
|
import { AllowlistBlock, ConnectorTools, ListeningSessionsBlock, UnauthorizedBlock } from "../ManageTabs";
|
||||||
import { AccountsDetail } from "./AccountsDetail";
|
import { AccountsDetail } from "./AccountsDetail";
|
||||||
@@ -52,11 +55,13 @@ const DETAIL_PAGES: Record<string, (p: DetailProps) => JSX.Element> = {
|
|||||||
export function ConnectorsSection() {
|
export function ConnectorsSection() {
|
||||||
const [detail, setDetail] = useState<string | null>(null);
|
const [detail, setDetail] = useState<string | null>(null);
|
||||||
const [connectors, setConnectors] = useState<Connector[]>([]);
|
const [connectors, setConnectors] = useState<Connector[]>([]);
|
||||||
|
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
|
||||||
const [cloud, setCloud] = useState<CloudStatus | null>(null);
|
const [cloud, setCloud] = useState<CloudStatus | null>(null);
|
||||||
const [slack, setSlack] = useState<SlackStatus | null>(null);
|
const [slack, setSlack] = useState<SlackStatus | null>(null);
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
getConnectors().then(setConnectors).catch(() => setConnectors([]));
|
getConnectors().then(setConnectors).catch(() => setConnectors([]));
|
||||||
|
getMcpServers().then(setMcpServers).catch(() => setMcpServers([]));
|
||||||
getCloudStatus().then(setCloud).catch(() => setCloud(null));
|
getCloudStatus().then(setCloud).catch(() => setCloud(null));
|
||||||
getSlackStatus().then(setSlack).catch(() => setSlack(null));
|
getSlackStatus().then(setSlack).catch(() => setSlack(null));
|
||||||
};
|
};
|
||||||
@@ -68,6 +73,37 @@ export function ConnectorsSection() {
|
|||||||
return () => clearInterval(t);
|
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) {
|
if (detail) {
|
||||||
const c = connectors.find((x) => x.name === detail);
|
const c = connectors.find((x) => x.name === detail);
|
||||||
const Page = DETAIL_PAGES[detail];
|
const Page = DETAIL_PAGES[detail];
|
||||||
@@ -104,6 +140,7 @@ export function ConnectorsSection() {
|
|||||||
return (
|
return (
|
||||||
<ConnectorsList
|
<ConnectorsList
|
||||||
connectors={connectors}
|
connectors={connectors}
|
||||||
|
mcpServers={mcpServers}
|
||||||
cloud={cloud}
|
cloud={cloud}
|
||||||
slack={slack}
|
slack={slack}
|
||||||
onOpen={setDetail}
|
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";
|
"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 =
|
export const CHIP_OFF =
|
||||||
"text-[11px] font-medium px-2 py-0.5 rounded-full bg-paper text-muted border border-lineStrong shrink-0";
|
"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). */
|
/** Small × affordance (danger on hover). */
|
||||||
export const XBTN = "text-faint hover:text-danger shrink-0 leading-none";
|
export const XBTN = "text-faint hover:text-danger shrink-0 leading-none";
|
||||||
|
|||||||
@@ -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()}
|
listed = {s["name"]: s for s in manager.list_mcp()}
|
||||||
assert listed["guarded"]["auth_hint"] is True
|
assert listed["guarded"]["auth_hint"] is True
|
||||||
assert listed["guarded"]["status"] == "error"
|
assert listed["guarded"]["status"] == "error"
|
||||||
|
assert listed["guarded"]["last_test_at"] is None # failed probe stamps nothing
|
||||||
finally:
|
finally:
|
||||||
srv.shutdown()
|
srv.shutdown()
|
||||||
srv.server_close()
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user