diff --git a/.changeset/mcp-oauth-connected-agents.md b/.changeset/mcp-oauth-connected-agents.md new file mode 100644 index 00000000..e1009502 --- /dev/null +++ b/.changeset/mcp-oauth-connected-agents.md @@ -0,0 +1,9 @@ +--- +"@cloudhal/api": patch +"@cloudhal/cli": patch +"@cloudhal/mcp-server": patch +--- + +Studio shows "Connected agents" and how to connect an MCP client by URL (RFC-0044 Phase 3b). On `/agents`, each member sees the apps they connected through OAuth. A client is named as the consent screen named it: a metadata document's host first, a self-registered client as not verified, a name that looks like a well-known client with a warning. Each row shows what the connection can do in words, its mode and when it was last used, and has Disconnect, which uses the existing revoke. Administrators see every member's connections under **All keys**. "Connect an MCP client" gains a **Connect by URL** tab: the deployment's `/mcp` URL, steps for Claude, Claude Code, ChatGPT and Codex, Cursor, VS Code and the MCP Inspector, and a note where a client needs dynamic client registration that is off. + +`GET /api/agent-grants` now returns `client` (`kind`, a document client's `host`, `lookalike`) with the name sanitized, and with `?all=1` each owner's `role`. `/api/instance` reports `features.mcpOauthClients`: the `/mcp` resource, and whether metadata documents and dynamic registration are on. The new guide is `docs/guides/mcp-oauth.md`, and the MCP server README has an Authentication section. diff --git a/apps/docs/src/lib/site-pages.ts b/apps/docs/src/lib/site-pages.ts index a3d50474..549a84d4 100644 --- a/apps/docs/src/lib/site-pages.ts +++ b/apps/docs/src/lib/site-pages.ts @@ -81,6 +81,7 @@ const docsIndex: SitePage = { - [Agents and MCP](/docs/agents): local and remote servers, modes, approvals, skills. - [Connect an agent](/docs/agents/clients): Claude Code, Codex, Cursor, VS Code, Claude Desktop and more. +- [Remote MCP clients by URL](/docs/guides/mcp-oauth): connect Claude, ChatGPT or Cursor without a key, see and disconnect connected agents, and the operator switches. - [Docs for agents](/docs/agents/docs-for-agents): Markdown pages, \`llms.txt\` and the docs MCP server. - [Harden a Cloudflare deployment](/docs/guides/cloudflare-hardening). diff --git a/apps/studio/src/components/ConnectMcpCard.tsx b/apps/studio/src/components/ConnectMcpCard.tsx new file mode 100644 index 00000000..691263a3 --- /dev/null +++ b/apps/studio/src/components/ConnectMcpCard.tsx @@ -0,0 +1,92 @@ +import { ClipboardText, SectionCard, Tabs, Text } from "@cloudhal/ui"; +import { PlugsConnected } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useT } from "../i18n/index.tsx"; +import { api } from "../lib/api.ts"; +import { McpClientSetup } from "./McpClientSetup.tsx"; +import { McpOauthSetup } from "./McpOauthSetup.tsx"; + +/** + * The "Connect an MCP client" card (DESIGN.md §2a aside): with an agent key (endpoint, header and + * ready-to-paste client config) and, where the server runs OAuth for remote clients (RFC-0044 + * §4), by URL (`McpOauthSetup`). Only the key tab where OAuth is off or not reported. + */ +export function ConnectMcpCard({ endpoint }: { endpoint: string }) { + const t = useT(); + const c = useT("connectedAgents"); + const instance = useQuery({ queryKey: ["instance"], queryFn: api.instance, retry: false }); + const oauth = instance.data?.features.mcpOauth + ? instance.data.features.mcpOauthClients + : undefined; + const [tab, setTab] = useState<"url" | "key">("url"); + const shown = oauth ? tab : "key"; + return ( + } + description={oauth ? c("connectedAgents.setup.description") : t("agents.connect.description")} + > + {oauth && } + {shown === "url" && oauth ? ( + + ) : ( + + )} + + ); +} + +function OauthTabs({ tab, onTab }: { tab: "url" | "key"; onTab: (tab: "url" | "key") => void }) { + const c = useT("connectedAgents"); + return ( + onTab(v === "key" ? "key" : "url")} + tabs={[ + { value: "url", label: c("connectedAgents.setup.tabs.url") }, + { value: "key", label: c("connectedAgents.setup.tabs.key") }, + ]} + /> + ); +} + +function KeySetup({ endpoint }: { endpoint: string }) { + const t = useT(); + return ( + <> +
+
+
+ + {t("agents.connect.endpoint")} + +
+
+ +
+
+
+
+ + {t("agents.connect.header")} + +
+
+ +
+
+
+ + + ); +} diff --git a/apps/studio/src/components/ConnectedAgents.tsx b/apps/studio/src/components/ConnectedAgents.tsx new file mode 100644 index 00000000..9c7c9669 --- /dev/null +++ b/apps/studio/src/components/ConnectedAgents.tsx @@ -0,0 +1,305 @@ +import { + Avatar, + Badge, + Button, + DeleteResource, + SectionCard, + SectionEmpty, + Table, + Text, + Tooltip, +} from "@cloudhal/ui"; +import { LinkBreak, PlugsConnected, Warning } from "@phosphor-icons/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useT } from "../i18n/index.tsx"; +import { initials } from "../lib/account.ts"; +import { SCOPE_GROUPS, type ScopeGroup, scopeGroup, sortScopes } from "../lib/agents.ts"; +import { type AgentGrantSummary, type AgentScope, api } from "../lib/api.ts"; +import { ModeBadge } from "./AgentModes.tsx"; +import { deleteResourceProps } from "./deleteLabels.ts"; +import { ErrorBanner } from "./ErrorBanner.tsx"; +import { RelativeTime } from "./RelativeTime.tsx"; +import { RoleBadge } from "./Roles.tsx"; +import { TableSkeleton } from "./TableSkeleton.tsx"; +import { useToast } from "./Toast.ts"; +import { useMe } from "./useMe.ts"; + +const GROUPS = Object.keys(SCOPE_GROUPS) as ScopeGroup[]; +const TONE: Record = { + read: "secondary", + write: "info", + release: "warning", +}; + +/** + * "Connected agents" on `/agents` (RFC-0044 §4): the apps connected by URL through OAuth, named + * as the consent screen named them (a document client by its host first; a self-registered one + * as not verified, with a look-alike warning), what each can do in words with its mode, when it + * was last used and connected, and Disconnect (`DELETE /api/agent-grants/:id`, the revoke that also + * ends the provider's consent and tokens). `all`: every member's connections (`agents:manage`). + */ +export function ConnectedAgents({ all = false }: { all?: boolean }) { + const t = useT(); + const c = useT("connectedAgents"); + const qc = useQueryClient(); + const toast = useToast(); + const grants = useQuery({ + queryKey: ["agent-grants", all ? "all" : "mine"], + queryFn: all ? api.agentGrants.listAll : api.agentGrants.list, + }); + const [toRevoke, setToRevoke] = useState(); + const revoke = useMutation({ + mutationFn: (grant: AgentGrantSummary) => api.agentGrants.revoke(grant.id), + onSuccess: async (_r, grant) => { + setToRevoke(undefined); + await qc.invalidateQueries({ queryKey: ["agent-grants"] }); + toast.success( + c("connectedAgents.disconnected"), + c("connectedAgents.disconnectedDescription", { client: grant.clientName }), + ); + }, + }); + const instance = useQuery({ queryKey: ["instance"], queryFn: api.instance, retry: false }); + const items = grants.data ?? []; + const columns = all ? 5 : 4; + // Where the server runs no OAuth for remote clients, the section shows only connections left + // from when it did. + if (!instance.data?.features.mcpOauth && items.length === 0) return null; + return ( + } + description={c(all ? "connectedAgents.allDescription" : "connectedAgents.description")} + actions={items.length > 0 ? {items.length} : undefined} + flush + > + {grants.error ? ( +
+ grants.refetch()} /> +
+ ) : !grants.isPending && items.length === 0 ? ( + } + title={c(all ? "connectedAgents.allEmpty" : "connectedAgents.empty")} + description={c("connectedAgents.emptyDescription")} + /> + ) : ( +
+ + + + {c("connectedAgents.columns.agent")} + {/* Phones: member, access and last use stack under the agent (`GrantRow`), so a + row fits next to Disconnect without scrolling sideways (MembersPage). */} + {all && ( + + {c("connectedAgents.columns.owner")} + + )} + + {c("connectedAgents.columns.access")} + + + {c("connectedAgents.columns.lastUsed")} + + + {t("site.rowMenu")} + + + + {grants.isPending ? ( + + ) : ( + + {items.map((g) => ( + setToRevoke(g)} /> + ))} + + )} +
+
+ )} + { + if (!o) { + setToRevoke(undefined); + revoke.reset(); + } + }} + {...deleteResourceProps(t, c("connectedAgents.resource"), { + action: c("connectedAgents.disconnect"), + error: revoke.error, + })} + resourceName={toRevoke?.clientName ?? ""} + isDeleting={revoke.isPending} + onDelete={async () => { + if (toRevoke) await revoke.mutateAsync(toRevoke).catch(() => undefined); + }} + /> +
+ ); +} + +function GrantRow({ + grant, + all, + onDisconnect, +}: { + grant: AgentGrantSummary; + all: boolean; + onDisconnect: () => void; +}) { + const c = useT("connectedAgents"); + return ( + + +
+ + {/* Below `sm` the other columns are hidden and their content stacks here. */} +
+ {all && } + + + + {c("connectedAgents.columns.lastUsed")} + + + +
+
+
+ {all && ( + + + + )} + + + + + + + + } + onClick={onDisconnect} + aria-label={c("connectedAgents.disconnectLabel", { client: grant.clientName })} + /> + } + /> + +
+ ); +} + +/** The member a connection works for (admin view): avatar, name ("You" for oneself), role. */ +function GrantOwner({ grant }: { grant: AgentGrantSummary }) { + const t = useT(); + const me = useMe().data; + const owner = grant.owner; + return ( +
+ + + + {owner?.userId === me?.userId ? t("members.you") : (owner?.name ?? "")} + + {owner?.role && } + +
+ ); +} + +/** What it may do, then how (its mode): one column, so the table fits beside the aside. */ +function GrantAccess({ grant }: { grant: AgentGrantSummary }) { + return ( +
+ + +
+ ); +} + +function GrantLastUsed({ grant }: { grant: AgentGrantSummary }) { + const c = useT("connectedAgents"); + return grant.lastUsedAt ? ( + + ) : ( + + {c("connectedAgents.never")} + + ); +} + +/** As the consent screen named the client: a document's host first, never the name alone. */ +function ClientIdentity({ grant }: { grant: AgentGrantSummary }) { + const c = useT("connectedAgents"); + const { host, kind, lookalike } = grant.client; + return ( +
+ {host && ( + + {c("connectedAgents.identifiedBy", { host })} + + )} + {grant.clientName} + {!host && kind === "self-registered" && ( + + {c("connectedAgents.selfRegistered")} + + )} + {lookalike && ( + }> + {c("connectedAgents.lookalike", { client: lookalike })} + + )} + {/* When it was connected, here rather than in a column of its own: the table fits beside + the aside at 1440 px. */} + + + {c("connectedAgents.columns.connected")} + + + +
+ ); +} + +/** What a connection can do, in words: one pill per scope group, its scopes in the tooltip. */ +function GrantScopes({ scopes }: { scopes: readonly AgentScope[] }) { + const t = useT(); + const sorted = sortScopes(scopes); + return ( +
+ {GROUPS.map((group) => { + const held = sorted.filter((s) => s !== "*" && scopeGroup(s) === group); + if (held.length === 0) return null; + return ( + + {held.map((s) => ( + + {t(`agents.scopes.${s as Exclude}`)} + + ))} + + } + > + {t(`agents.create.groups.${group}`)} + + ); + })} +
+ ); +} diff --git a/apps/studio/src/components/McpOauthSetup.tsx b/apps/studio/src/components/McpOauthSetup.tsx new file mode 100644 index 00000000..5b9e961a --- /dev/null +++ b/apps/studio/src/components/McpOauthSetup.tsx @@ -0,0 +1,96 @@ +import { Banner, ClipboardText, Select, Text } from "@cloudhal/ui"; +import { Warning } from "@phosphor-icons/react"; +import { useState } from "react"; +import { useT } from "../i18n/index.tsx"; +import { + defaultOauthClient, + OAUTH_CLIENTS, + type OauthClient, + type OauthClientStatus, + type OauthServerInfo, + oauthClientStatus, + oauthSnippet, + oauthSnippetLang, +} from "../lib/oauth-clients.ts"; +import { CodeHighlighted } from "./CodeHighlighted.tsx"; + +/** + * "Connect by URL" (RFC-0044 §4, §11): the deployment's `/mcp` URL and, for one client at a time, + * what to paste and where. No key: the client opens Studio to sign in and approve. A client that + * cannot connect says so: Cursor needs DCR, and Claude.ai and ChatGPT cannot reach a local or + * plain-http URL (the picker then starts on Claude Code). + */ +export function McpOauthSetup({ + server, +}: { + /** `features.mcpOauthClients`: the `/mcp` resource tokens are bound to, and the switches. */ + server: OauthServerInfo; +}) { + const t = useT(); + const c = useT("connectedAgents"); + const resource = server.resource; + const [client, setClient] = useState(() => defaultOauthClient(resource)); + const snippet = oauthSnippet(client, resource); + const status = oauthClientStatus(client, server); + return ( +
+
+ + {c("connectedAgents.setup.url")} + + + + {c("connectedAgents.setup.urlDescription")} + +
+
+