diff --git a/apps/web/src/pages/tools-page.tsx b/apps/web/src/pages/tools-page.tsx index ede402588..05fc394b7 100644 --- a/apps/web/src/pages/tools-page.tsx +++ b/apps/web/src/pages/tools-page.tsx @@ -2,6 +2,8 @@ // already carries, and a card catalog for the servers it could add. import { useState } from "react"; + +import "../tools/tools-page.css"; import { Badge, Button, @@ -24,7 +26,7 @@ import { QueryView } from "@/lib/api-query"; import { Plugs } from "@/lib/icons"; import { describeApiError } from "@/lib/api-query"; -import { MCP_SERVER_CATALOG, type McpCatalogEntry, type McpServer } from "../mcp-servers"; +import { MCP_SERVER_CATALOG, type McpCatalogEntry } from "../mcp-servers"; import { describeRedeployResult, useAddMcpServer, @@ -37,12 +39,6 @@ import { useBench } from "../bench-context"; import { StageTopBar } from "../shell/stage-top-bar"; import { ConfirmButton } from "../components/confirm-button"; -const AUTH_LABEL: Record = { - none: "No sign-in", - oauth: "Sign in", - token: "Token", -}; - /** The handle a pasted URL's server is stored under; the host's first label * reads better than a random id and is what a person would have typed. */ function handleFromUrl(url: string): string { @@ -51,95 +47,144 @@ function handleFromUrl(url: string): string { return label.toLocaleLowerCase().replace(/[^a-z0-9]+/g, "-"); } -function McpServersTable({ - servers, +function toolHost(url: string): string { + return new URL(url).hostname; +} + +function ToolTile({ + mark, + name, + desc, + official, + children, +}: { + readonly mark: string; + readonly name: string; + readonly desc: string; + readonly official: boolean; + readonly children: React.ReactNode; +}) { + return ( +
+ +

+ {name} + {official ? "Official" : "Custom"} +

+

{desc}

+ {children} +
+ ); +} + +function ConnectedTile({ + server, onRemove, removing, }: { - readonly servers: readonly McpServerRow[]; - readonly onRemove: (server: McpServerRow) => void; + readonly server: McpServerRow; + readonly onRemove: () => void; readonly removing: boolean; }) { + const official = MCP_SERVER_CATALOG.some((entry) => entry.handle === server.handle); + const agents = server.agentNames.length === 0 ? "" : ` · ${server.agentNames.join(", ")}`; return ( - - - - Server - URL - Auth - Tools - Agents - - - - - {servers.map((server) => ( - - {server.name} - {server.url} - {AUTH_LABEL[server.auth]} - {server.tools.length} - - {server.agentNames.length === 0 ? "—" : server.agentNames.join(", ")} - - - { - onRemove(server); - }} - > - Remove - - - - ))} - -
+ +
+ {`${String(server.tools.length)} tools live`} + + + Disconnect + +
+
); } -function DiscoverCard({ +function AvailableTile({ entry, - added, - onAdd, - adding, + onConnect, + busy, }: { readonly entry: McpCatalogEntry; - readonly added: boolean; - readonly onAdd: () => void; - readonly adding: boolean; + readonly onConnect: (token?: string) => void; + readonly busy: boolean; }) { + const [asking, setAsking] = useState(false); + const [token, setToken] = useState(""); + const keyless = entry.auth === "none"; + return ( - -
- {entry.name} - {entry.url} -
-
- - {entry.auth === "none" ? "No sign-in needed" : "Sign-in required"} - - {added ? ( - Added - ) : entry.auth === "none" ? ( - - ) : ( - + +
+ + ) : ( +
+ - )} -
- {entry.auth !== "none" && !added ? ( -

- Until sign-in lands, add this server below with a token you already have. -

- ) : null} -
+ {keyless ? "No key needed" : "Token"} + + )} + ); } @@ -207,7 +252,11 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) { const serversQuery = useMcpServers(tenantId); const add = useAddMcpServer(tenantId); const remove = useRemoveMcpServer(tenantId); + const [filter, setFilter] = useState(""); const crumbs = [{ label: "Tools" }]; + const needle = filter.trim().toLocaleLowerCase(); + const matches = (item: { readonly name: string }) => + needle === "" || item.name.toLocaleLowerCase().includes(needle); function stage(body: React.ReactNode) { return ( @@ -231,9 +280,7 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) { function addServer(input: { url: string; name: string; handle: string; token?: string }) { add.mutate(input, { onSuccess: (result) => { - toast( - `${result.server.name} added to the workspace catalog — ${describeRedeployResult(result)}`, - ); + toast(`${result.server.name} connected — ${describeRedeployResult(result)}`); }, onError: (cause: unknown) => { toast(describeApiError(cause, "adding this server")); @@ -243,61 +290,79 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) { return stage(
-
- - {(servers) => - servers.length === 0 ? ( -

- No MCP servers yet. Add one from Discover below. -

- ) : ( - { - remove.mutate(server, { - onSuccess: (result) => { - toast( - `${server.name} removed from the workspace catalog — ${describeRedeployResult(result)}`, - ); - }, - onError: (cause: unknown) => { - toast(describeApiError(cause, "removing this server")); - }, - }); - }} - /> - ) - } -
-
+ { + setFilter(event.target.value); + }} + /> -
-
- {MCP_SERVER_CATALOG.map((entry) => ( - server.handle === entry.handle) - } - onAdd={() => { - addServer({ - url: entry.url, - name: entry.name, - handle: entry.handle, - }); - }} - /> - ))} - -
-
+ + {(servers) => { + const connected = servers.filter(matches); + const available = MCP_SERVER_CATALOG.filter( + (entry) => !servers.some((server) => server.handle === entry.handle) && matches(entry), + ); + return ( + <> +
+ {connected.length === 0 ? ( +

Nothing connected yet.

+ ) : ( +
+ {connected.map((server) => ( + { + remove.mutate(server, { + onSuccess: (result) => { + toast( + `${server.name} disconnected — ${describeRedeployResult(result)}`, + ); + }, + onError: (cause: unknown) => { + toast(describeApiError(cause, "disconnecting this server")); + }, + }); + }} + /> + ))} +
+ )} +
+ +
+
+ {available.map((entry) => ( + { + addServer({ + url: entry.url, + name: entry.name, + handle: entry.handle, + ...(token === undefined ? {} : { token }), + }); + }} + /> + ))} + +
+
+ + ); + }} +