From e6fe6eb2c2868975592a63699b15e8562ac2dd7c Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Tue, 10 Nov 2026 02:25:35 +0300 Subject: [PATCH 1/2] feat(studio): Connected agents and MCP client setup, RFC-0044 phase 3b (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Connected agents" on /agents lists the apps a member connected by URL, named as the consent screen named them: a metadata document's host first, a self-registered client as not verified, a look-alike name with a warning, names sanitized. Each row shows what the connection can do in words with its mode, when it was last used and connected, and Disconnect, which calls 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, per-client steps from RFC-0044 §11 (Claude, Claude Code, ChatGPT and Codex, Cursor, VS Code, MCP Inspector), and a banner where a client needs dynamic registration that is off. The key setup stays as its own tab. The API names each grant's client (kind, host, lookalike) and each owner's role, and /api/instance reports features.mcpOauthClients (resource, cimd, dcr). An e2e test drives the MCP TS SDK's auth() against `cloudhal studio --base-url`: discovery, DCR, PKCE, consent, token, tools/call, then Disconnect and a 401. Docs: docs/guides/mcp-oauth.md, the clients page's Connect by URL section, and the MCP server README's Authentication section. The 18 non-English catalogs are machine drafts. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/mcp-oauth-connected-agents.md | 9 + apps/docs/src/lib/site-pages.ts | 1 + apps/studio/src/components/ConnectMcpCard.tsx | 92 +++++ .../studio/src/components/ConnectedAgents.tsx | 264 ++++++++++++++ apps/studio/src/components/McpOauthSetup.tsx | 81 +++++ apps/studio/src/i18n/generated/params.ts | 5 + .../src/i18n/lazy/connectedAgents.ar.ts | 73 ++++ .../src/i18n/lazy/connectedAgents.de.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.en.ts | 71 ++++ .../src/i18n/lazy/connectedAgents.es.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.fa.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.fr.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.he.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.hi.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.id.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.it.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.ja.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.ko.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.nl.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.pl.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.pt-BR.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.ru.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.tr.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.uk.ts | 74 ++++ .../src/i18n/lazy/connectedAgents.zh-Hans.ts | 73 ++++ apps/studio/src/i18n/namespaces.ts | 2 + apps/studio/src/lib/api/agent-grants.ts | 15 +- apps/studio/src/lib/api/team.ts | 7 +- apps/studio/src/lib/oauth-clients.ts | 64 ++++ apps/studio/src/router.tsx | 7 +- apps/studio/src/routes/AgentsPage.tsx | 329 +++++++++--------- apps/studio/test/connected-agents.test.tsx | 142 ++++++++ apps/studio/test/design-rules.test.ts | 13 +- docs/agents/clients.md | 26 +- docs/guides/mcp-oauth.md | 42 +++ packages/api/src/app.ts | 17 +- packages/api/src/auth.ts | 4 + packages/api/src/auth/agent-grants.ts | 114 +++++- packages/api/src/auth/types.ts | 2 + packages/api/src/testing-mcp-oauth-bounds.ts | 24 ++ packages/cli/test/mcp-oauth-e2e.test.ts | 233 +++++++++++++ packages/mcp-server/README.md | 10 + scripts/size-baseline.json | 2 +- 43 files changed, 2718 insertions(+), 188 deletions(-) create mode 100644 .changeset/mcp-oauth-connected-agents.md create mode 100644 apps/studio/src/components/ConnectMcpCard.tsx create mode 100644 apps/studio/src/components/ConnectedAgents.tsx create mode 100644 apps/studio/src/components/McpOauthSetup.tsx create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.ar.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.de.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.en.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.es.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.fa.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.fr.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.he.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.hi.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.id.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.it.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.ja.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.ko.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.nl.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.pl.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.pt-BR.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.ru.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.tr.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.uk.ts create mode 100644 apps/studio/src/i18n/lazy/connectedAgents.zh-Hans.ts create mode 100644 apps/studio/src/lib/oauth-clients.ts create mode 100644 apps/studio/test/connected-agents.test.tsx create mode 100644 docs/guides/mcp-oauth.md create mode 100644 packages/cli/test/mcp-oauth-e2e.test.ts 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..718717a4 --- /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..59366981 --- /dev/null +++ b/apps/studio/src/components/ConnectedAgents.tsx @@ -0,0 +1,264 @@ +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")} + {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 t = useT(); + const c = useT("connectedAgents"); + const me = useMe().data; + const owner = grant.owner; + return ( + + + + + {all && ( + +
+ + + + {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. */} +
+ + +
+
+ + {grant.lastUsedAt ? ( + + ) : ( + + {c("connectedAgents.never")} + + )} + + + } + onClick={onDisconnect} + aria-label={c("connectedAgents.disconnectLabel", { client: grant.clientName })} + /> + } + /> + +
+ ); +} + +/** 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..8cb2117a --- /dev/null +++ b/apps/studio/src/components/McpOauthSetup.tsx @@ -0,0 +1,81 @@ +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 { + OAUTH_CLIENTS, + type OauthClient, + type OauthRegistration, + 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 with this server's registration switches says so (Cursor needs DCR). + */ +export function McpOauthSetup({ + resource, + registration, +}: { + /** The `/mcp` resource tokens are bound to (`features.mcpOauthClients.resource`). */ + resource: string; + registration: OauthRegistration; +}) { + const t = useT(); + const c = useT("connectedAgents"); + const [client, setClient] = useState("claudeAi"); + const snippet = oauthSnippet(client, resource); + const status = oauthClientStatus(client, registration); + const name = c(`connectedAgents.setup.clients.${client}`); + return ( +
+
+ + {c("connectedAgents.setup.url")} + + + + {c("connectedAgents.setup.urlDescription")} + +
+
+