diff --git a/packages/shared/src/agent-platform-types.ts b/packages/shared/src/agent-platform-types.ts index 380505e433..efc8b51d7a 100644 --- a/packages/shared/src/agent-platform-types.ts +++ b/packages/shared/src/agent-platform-types.ts @@ -135,7 +135,6 @@ export interface AgentSpec { tools?: unknown[]; mcps?: unknown[]; skills?: unknown[]; - integrations?: string[]; secrets?: string[]; limits?: { max_turns?: number; diff --git a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx index 9e2a96dd62..683e608447 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx +++ b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx @@ -5,7 +5,12 @@ import { SidebarSimpleIcon, SparkleIcon, } from "@phosphor-icons/react"; +import type { AgentSpec } from "@posthog/shared/agent-platform-types"; import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { + type CustomServerInput, + useMcpConnect, +} from "@posthog/ui/features/mcp-server-manager/useMcpConnect"; import { Button } from "@posthog/ui/primitives/Button"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; import { useEffect, useRef, useState } from "react"; @@ -16,6 +21,7 @@ import { AgentDetailEmptyState } from "../components/AgentDetailLayout"; import { useAgentChat } from "../hooks/useAgentChat"; import { useAgentChatPendingApproval } from "../hooks/useAgentChatPendingApproval"; import { agentIngressBaseUrl } from "../utils/ingress"; +import { AgentBuilderMcpConnectDialog } from "./AgentBuilderMcpConnectDialog"; import { AgentBuilderSecretForm } from "./AgentBuilderSecretForm"; import { AgentBuilderSeedDialog } from "./AgentBuilderSeedDialog"; import { @@ -67,6 +73,27 @@ function buildAgentBuilderContext( }; } +/** Derive a unique, stable `mcps[].id` (tool-name prefix) from a label, avoiding + * collisions with existing entries. Mirrors the config pane's add-from-connection. */ +function uniqueMcpId(label: string, mcps: unknown[]): string { + const base = + (label || "mcp") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32) || "mcp"; + const taken = new Set( + mcps.map((m) => + m && typeof m === "object" + ? (m as Record).id + : undefined, + ), + ); + let id = base; + for (let n = 2; taken.has(id); n++) id = `${base}-${n}`; + return id; +} + /** * The Agent Builder chat — an always-on dock talking to the deployed meta-agent * (backend slug `agent-builder`). Streams through the shared @@ -99,9 +126,15 @@ export function AgentBuilderDock() { const consumeSeed = useAgentBuilderStore((s) => s.consumeSeed); const pendingSecret = useAgentBuilderStore((s) => s.pendingSecret); const setPendingSecret = useAgentBuilderStore((s) => s.setPendingSecret); + const pendingMcpConnect = useAgentBuilderStore((s) => s.pendingMcpConnect); + const setPendingMcpConnect = useAgentBuilderStore( + (s) => s.setPendingMcpConnect, + ); const lastSession = useAgentBuilderStore((s) => s.lastSession); const setLastSession = useAgentBuilderStore((s) => s.setLastSession); + const { connectCustomAsync, refetchInstallations } = useMcpConnect(); const [secretBusy, setSecretBusy] = useState(false); + const [mcpConnectBusy, setMcpConnectBusy] = useState(false); const [placeholder] = useState( () => BUILDER_PLACEHOLDERS[ @@ -213,6 +246,78 @@ export function AgentBuilderDock() { setPendingSecret(null); } + // Resolve a pending connect_mcp: run the native connect (OAuth/api-key handoff + // — tokens never reach the agent), then attach the resulting connection to the + // target agent's draft spec and wake the parked session with the outcome. + async function submitMcpConnect(values: CustomServerInput) { + const pending = pendingMcpConnect; + if (!pending) return; + setMcpConnectBusy(true); + try { + const result = await connectCustomAsync(values); + if (result && "error" in result && result.error) { + throw new Error(result.error); + } + // The new install is keyed by url server-side ((team, user, url)); refetch + // and match to recover its id (the OAuth callback doesn't return it). + const installs = await refetchInstallations(); + const install = installs.find((i) => i.url === values.url); + if (!install) { + throw new Error("connection_not_found_after_connect"); + } + // Attach to the target agent's spec: load → append an mcps[] entry that + // references the connection → PATCH the (draft) revision. + const rev = await client.getAgentRevision( + pending.agentSlug, + pending.revisionId, + ); + if (!rev) { + throw new Error("revision_not_found"); + } + const spec = (rev.spec ?? {}) as AgentSpec; + const mcps = Array.isArray(spec.mcps) ? [...spec.mcps] : []; + const mcpId = uniqueMcpId(values.name || values.url, mcps); + mcps.push({ + id: mcpId, + url: values.url, + connection: install.id, + secrets: [], + }); + await client.updateAgentRevisionSpec( + pending.agentSlug, + pending.revisionId, + { + ...spec, + mcps, + }, + ); + await chat.resolveInteractiveTool(pending.callId, { + result: { + connected: true, + connection_id: install.id, + mcp_id: mcpId, + url: values.url, + }, + }); + setPendingMcpConnect(null); + } catch (err) { + await chat.resolveInteractiveTool(pending.callId, { + error: err instanceof Error ? err.message : "connect_mcp_failed", + }); + setPendingMcpConnect(null); + } finally { + setMcpConnectBusy(false); + } + } + + function cancelMcpConnect() { + if (!pendingMcpConnect) return; + void chat.resolveInteractiveTool(pendingMcpConnect.callId, { + error: "user_cancelled", + }); + setPendingMcpConnect(null); + } + // Edit-with-AI hand-offs: send the seeded prompt once when a new seed lands. // An empty dock starts immediately; if a chat is already in progress, confirm // whether to start fresh or continue (so a deliberate "New agent" / "Edit with @@ -233,6 +338,7 @@ export function AgentBuilderDock() { function seedStartFresh() { if (!seedConfirm) return; setPendingSecret(null); + setPendingMcpConnect(null); chat.newChat(); setLastSession(null); chat.send(seedConfirm); @@ -285,6 +391,7 @@ export function AgentBuilderDock() { size="1" onClick={() => { setPendingSecret(null); + setPendingMcpConnect(null); chat.newChat(); setLastSession(null); }} @@ -355,6 +462,13 @@ export function AgentBuilderDock() { onContinue={seedContinue} onCancel={() => setSeedConfirm(null)} /> + + ); } diff --git a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderMcpConnectDialog.tsx b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderMcpConnectDialog.tsx new file mode 100644 index 0000000000..a778fb30fd --- /dev/null +++ b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderMcpConnectDialog.tsx @@ -0,0 +1,42 @@ +import { AddCustomServerDialog } from "@posthog/ui/features/mcp-server-manager/AddCustomServerDialog"; +import type { CustomServerInput } from "@posthog/ui/features/mcp-server-manager/useMcpConnect"; +import type { PendingMcpConnect } from "./agentBuilderStore"; + +/** + * Modal for the agent builder's `connect_mcp` punch-out. The agent parks its + * turn and supplies a prefilled name/url; the user reviews + completes the + * connect (OAuth / api key) here — the agent never sees the credentials. On + * success the connection is written onto the target agent's spec and the + * session woken. Thin wrapper over {@link AddCustomServerDialog} with + * punch-out-specific copy and the agent's prefilled values. + */ +export function AgentBuilderMcpConnectDialog({ + pending, + busy, + onSubmit, + onCancel, +}: { + pending: PendingMcpConnect | null; + busy: boolean; + onSubmit: (values: CustomServerInput) => void; + onCancel: () => void; +}) { + return ( + { + if (!open) onCancel(); + }} + onSubmit={onSubmit} + initialValues={ + pending ? { name: pending.name, url: pending.url } : undefined + } + title="Connect an MCP server" + description={ + pending?.purpose ?? + "Connect a server for this agent. You complete the sign-in — the agent builder never sees your credentials." + } + /> + ); +} diff --git a/packages/ui/src/features/agent-applications/agent-builder/agentBuilderStore.ts b/packages/ui/src/features/agent-applications/agent-builder/agentBuilderStore.ts index 686cac695f..bf9e2f55dc 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/agentBuilderStore.ts +++ b/packages/ui/src/features/agent-applications/agent-builder/agentBuilderStore.ts @@ -57,6 +57,29 @@ export interface PendingSecret { purpose?: string; } +/** + * An in-flight `connect_mcp` punch-out. The agent parked its turn; the dock + * renders a prefilled connect form, the user completes the auth (OAuth / api + * key — tokens never reach the agent), and on success the new connection is + * written into the target agent's spec and the session woken. + */ +export interface PendingMcpConnect { + /** The parked tool call to resolve via `/send`. */ + callId: string; + /** Agent whose spec gets the `mcps[].connection` entry. */ + agentSlug: string; + /** Draft revision the mcps[] entry is written to (spec edits are revision + * scoped). Sourced from the tool args, falling back to the dock's current + * `agent-config` page context. */ + revisionId: string; + /** Prefilled server name (editable by the user). */ + name?: string; + /** Prefilled MCP server URL (editable by the user). */ + url?: string; + /** One-line reason shown above the form. */ + purpose?: string; +} + interface AgentBuilderStore { /** Dock open/closed (persisted). */ visible: boolean; @@ -68,6 +91,9 @@ interface AgentBuilderStore { seed: AgentBuilderSeed | null; /** In-flight set_secret punch-out the dock renders a form for (ephemeral). */ pendingSecret: PendingSecret | null; + /** In-flight connect_mcp punch-out the dock renders a connect form for + * (ephemeral). */ + pendingMcpConnect: PendingMcpConnect | null; /** * The dock's most recent chat session (persisted) plus the project/org it * belongs to. On reload the dock resumes it from the slug-routed ingress so @@ -92,6 +118,7 @@ interface AgentBuilderStore { /** Mark a seed handled (no-op if a newer seed has since replaced it). */ consumeSeed: (seq: number) => void; setPendingSecret: (pending: PendingSecret | null) => void; + setPendingMcpConnect: (pending: PendingMcpConnect | null) => void; setLastSession: ( session: { id: string; @@ -109,6 +136,7 @@ export const useAgentBuilderStore = create()( page: { kind: "unknown" }, seed: null, pendingSecret: null, + pendingMcpConnect: null, lastSession: null, toggleVisible: () => set((s) => ({ visible: !s.visible })), @@ -123,6 +151,7 @@ export const useAgentBuilderStore = create()( consumeSeed: (seq) => set((s) => (s.seed?.seq === seq ? { seed: null } : s)), setPendingSecret: (pendingSecret) => set({ pendingSecret }), + setPendingMcpConnect: (pendingMcpConnect) => set({ pendingMcpConnect }), setLastSession: (lastSession) => set({ lastSession }), }), { diff --git a/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts b/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts index e25348f589..f296f0be4d 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts +++ b/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts @@ -6,10 +6,12 @@ import { useAgentBuilderStore } from "./agentBuilderStore"; /** * The `kind:'client'` tool ids the agent-builder dock can fulfil — sent to the * runner as `supported_client_tools` at /run so it exposes only these to the - * model. Keep in sync with the handler below plus the built-in toast/get_context. + * model. Keep in sync with the handlers below (plus the built-in + * toast/get_context). `set_secret`/`connect_mcp` are interactive punch-outs. */ export const AGENT_BUILDER_CLIENT_TOOLS = [ "set_secret", + "connect_mcp", "focus_tab", "focus_file", "focus_spec_section", @@ -33,6 +35,9 @@ export function useAgentBuilderClientTools(): ClientToolHandler { const navigate = useNavigate(); const followMode = useAgentBuilderStore((s) => s.followMode); const setPendingSecret = useAgentBuilderStore((s) => s.setPendingSecret); + const setPendingMcpConnect = useAgentBuilderStore( + (s) => s.setPendingMcpConnect, + ); const page = useAgentBuilderStore((s) => s.page); const followRef = useRef(followMode); followRef.current = followMode; @@ -72,6 +77,29 @@ export function useAgentBuilderClientTools(): ClientToolHandler { return { defer: true }; } + // connect_mcp — interactive punch-out. Park the call and render a prefilled + // connect form; the dock runs the native OAuth/api-key connect (auth never + // touches the agent), writes the resulting mcps[].connection onto the + // target agent's spec, and wakes the session. Like set_secret, the target + // revision comes from the args or the current agent-config page. + if (data.tool_id === "connect_mcp") { + const agentSlug = str(args.agent_slug); + if (!agentSlug) return { error: "missing_arg: agent_slug" }; + const p = pageRef.current; + const pageRevision = p.kind === "agent-config" ? p.revision : undefined; + const revisionId = str(args.revision_id) ?? pageRevision; + if (!revisionId) return { error: "missing_arg: revision_id" }; + setPendingMcpConnect({ + callId: data.call_id, + agentSlug, + revisionId, + name: str(args.name), + url: str(args.url), + purpose: str(args.purpose), + }); + return { defer: true }; + } + if (!data.tool_id.startsWith("focus_")) return null; const slug = str(args.slug); if (!followRef.current) { @@ -166,6 +194,6 @@ export function useAgentBuilderClientTools(): ClientToolHandler { return { result: { focused: false, reason: "unknown_focus_target" } }; } }, - [navigate, setPendingSecret], + [navigate, setPendingSecret, setPendingMcpConnect], ); } diff --git a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx index 5f71310f91..7142d55849 100644 --- a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx @@ -10,32 +10,42 @@ import { InfoIcon, KeyIcon, LightningIcon, - LinkIcon, LockKeyIcon, PuzzlePieceIcon, ScrollIcon, SparkleIcon, + TrashIcon, UserIcon, WarningIcon, WebhooksLogoIcon, WrenchIcon, } from "@phosphor-icons/react"; +import type { + McpApprovalState, + McpInstallationTool, +} from "@posthog/api-client/posthog-client"; import type { AgentRevisionState, AgentSpec, BundleFile, } from "@posthog/shared/agent-platform-types"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { AddCustomServerDialog } from "@posthog/ui/features/mcp-server-manager/AddCustomServerDialog"; +import { useMcpConnect } from "@posthog/ui/features/mcp-server-manager/useMcpConnect"; +import { ToolPermissionList } from "@posthog/ui/features/mcp-servers/components/parts/ToolPermissionList"; +import { useMcpInstallationTools } from "@posthog/ui/features/mcp-servers/hooks/useMcpInstallationTools"; import { Badge } from "@posthog/ui/primitives/Badge"; import { Button } from "@posthog/ui/primitives/Button"; import { CodeBlock } from "@posthog/ui/primitives/CodeBlock"; -import { Flex, Text } from "@radix-ui/themes"; -import { type ReactNode, useMemo, useState } from "react"; +import { Flex, Select, Switch, Text } from "@radix-ui/themes"; +import { type ReactNode, useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; import { useAgentApplication } from "../hooks/useAgentApplication"; import { useAgentEnvKeys } from "../hooks/useAgentEnvKeys"; import { useAgentRevision } from "../hooks/useAgentRevision"; import { useAgentRevisionBundle } from "../hooks/useAgentRevisionBundle"; import { useAgentRevisions } from "../hooks/useAgentRevisions"; +import { useApplyAgentSpec } from "../hooks/useApplyAgentSpec"; import { triggerRequiredSecretsFor } from "../utils/triggerSecrets"; import { AgentDetailEmptyState, AgentDetailLayout } from "./AgentDetailLayout"; import { AgentModelConfig } from "./AgentModelConfig"; @@ -155,6 +165,40 @@ function toolRequiresIdentity(t: unknown): string | undefined { function mcpProvider(m: unknown): string | undefined { return str(rec(rec(m).auth).provider); } + +// --- Per-agent MCP tool permissions (agent-level shared connection) --- +// The spec carries allow/approve/deny; the shared ToolPermissionList speaks the +// mcp_store vocabulary (approved/needs_approval/do_not_use). Map at the boundary +// so that component is reused verbatim. +type ToolApprovalLevel = "allow" | "approve" | "deny"; +// New connections start safe-by-default: every tool parks for approval until the +// owner relaxes specific tools. Mirrors the runner's fallback. +const DEFAULT_TOOL_APPROVAL: ToolApprovalLevel = "approve"; +const LEVEL_TO_APPROVAL: Record = { + allow: "approved", + approve: "needs_approval", + deny: "do_not_use", +}; +const APPROVAL_TO_LEVEL: Record = { + approved: "allow", + needs_approval: "approve", + do_not_use: "deny", +}; +function toToolApprovalLevel(v: unknown): ToolApprovalLevel | undefined { + return v === "allow" || v === "approve" || v === "deny" ? v : undefined; +} +/** The per-tool override `level` declared in `mcps[].tools[]`, keyed by name. */ +function toolLevelOverrides(mcpEntry: unknown): Map { + const out = new Map(); + for (const t of arr(rec(mcpEntry).tools)) { + if (typeof t === "object" && t) { + const name = str(rec(t).name); + const level = toToolApprovalLevel(rec(t).level); + if (name && level) out.set(name, level); + } + } + return out; +} interface IdentityConsumers { tools: string[]; mcps: string[]; @@ -200,158 +244,130 @@ function buildTree(spec: AgentSpec, setKeys: string[]): FileTreeNode { ]; const triggers = arr(spec.triggers); - if (triggers.length > 0) { - children.push({ - type: "folder", - name: "triggers", - path: "cfg:triggers", - icon: , - children: triggers.map((t, i) => { - const type = triggerType(t); - const missing = missingSecretsFor(t, setKeys); - return { - type: "file" as const, - name: type, - path: `cfg:trigger/${i}`, - icon: triggerIcon(type), - trailing: - missing.length > 0 ? ( - - ) : isPublic(t) ? ( - public - ) : undefined, - }; - }), - }); - } + children.push({ + type: "folder", + name: "triggers", + path: "cfg:triggers", + icon: , + children: triggers.map((t, i) => { + const type = triggerType(t); + const missing = missingSecretsFor(t, setKeys); + return { + type: "file" as const, + name: type, + path: `cfg:trigger/${i}`, + icon: triggerIcon(type), + trailing: + missing.length > 0 ? ( + + ) : isPublic(t) ? ( + public + ) : undefined, + }; + }), + }); const secretKeys = allSecretKeys(spec, setKeys); - if (secretKeys.length > 0) { - children.push({ - type: "folder", - name: "secrets", - path: "cfg:secrets", + children.push({ + type: "folder", + name: "secrets", + path: "cfg:secrets", + icon: , + children: secretKeys.map((key) => ({ + type: "file" as const, + name: key, + path: `cfg:secret/${key}`, icon: , - children: secretKeys.map((key) => ({ - type: "file" as const, - name: key, - path: `cfg:secret/${key}`, - icon: , - trailing: setKeys.includes(key) ? undefined : ( - not set - ), - })), - }); - } + trailing: setKeys.includes(key) ? undefined : ( + not set + ), + })), + }); const skills = arr(spec.skills); - if (skills.length > 0) { - children.push({ - type: "folder", - name: "skills", - path: "cfg:skills", - icon: , - children: skills.map((s) => { - const r = rec(s); - const id = str(r.id) ?? str(r.path) ?? "skill"; - return { - type: "file" as const, - name: id, - path: `cfg:skill/${id}`, - description: str(r.description), - icon: , - }; - }), - }); - } + children.push({ + type: "folder", + name: "skills", + path: "cfg:skills", + icon: , + children: skills.map((s) => { + const r = rec(s); + const id = str(r.id) ?? str(r.path) ?? "skill"; + return { + type: "file" as const, + name: id, + path: `cfg:skill/${id}`, + description: str(r.description), + icon: , + }; + }), + }); const tools = arr(spec.tools); - if (tools.length > 0) { - children.push({ - type: "folder", - name: "tools", - path: "cfg:tools", - icon: , - children: tools.map((t) => { - const r = rec(t); - const id = toolId(t); - return { - type: "file" as const, - name: shortName(id), - path: `cfg:tool/${id}`, - icon: toolIcon(str(r.kind)), - trailing: - r.requires_approval === true ? ( - - ) : undefined, - }; - }), - }); - } + children.push({ + type: "folder", + name: "tools", + path: "cfg:tools", + icon: , + children: tools.map((t) => { + const r = rec(t); + const id = toolId(t); + return { + type: "file" as const, + name: shortName(id), + path: `cfg:tool/${id}`, + icon: toolIcon(str(r.kind)), + trailing: + r.requires_approval === true ? ( + + ) : undefined, + }; + }), + }); + // Top-level authorable sections always render — even with no entries — so the + // add/connect affordance is reachable on a fresh agent (you add MCP servers, + // tools, skills, triggers, secrets and identities from the empty section). const mcps = arr(spec.mcps); - if (mcps.length > 0) { - children.push({ - type: "folder", - name: "mcps", - path: "cfg:mcps", - icon: , - children: mcps.map((m) => { - const id = str(rec(m).id) ?? "mcp"; - const missing = mcpMissingSecrets(m, setKeys); - return { - type: "file" as const, - name: id, - path: `cfg:mcp/${id}`, - icon: , - trailing: - missing.length > 0 ? ( - - ) : undefined, - }; - }), - }); - } + children.push({ + type: "folder", + name: "mcps", + path: "cfg:mcps", + icon: , + children: mcps.map((m) => { + const id = str(rec(m).id) ?? "mcp"; + const missing = mcpMissingSecrets(m, setKeys); + return { + type: "file" as const, + name: id, + path: `cfg:mcp/${id}`, + icon: , + trailing: + missing.length > 0 ? ( + + ) : undefined, + }; + }), + }); const identities = identityProviders(spec); - if (identities.length > 0) { - children.push({ - type: "folder", - name: "identities", - path: "cfg:identities", - icon: , - children: identities.map((p) => { - const id = providerId(p); - const used = consumerCount(identityConsumers(spec, id)); - return { - type: "file" as const, - name: id, - path: `cfg:identity/${id}`, - icon: , - trailing: - used === 0 ? unused : undefined, - }; - }), - }); - } - - const integrations = arr(spec.integrations).filter( - (s): s is string => typeof s === "string", - ); - if (integrations.length > 0) { - children.push({ - type: "folder", - name: "integrations", - path: "cfg:integrations", - icon: , - children: integrations.map((name) => ({ + children.push({ + type: "folder", + name: "identities", + path: "cfg:identities", + icon: , + children: identities.map((p) => { + const id = providerId(p); + const used = consumerCount(identityConsumers(spec, id)); + return { type: "file" as const, - name, - path: `cfg:integration/${name}`, - icon: , - })), - }); - } + name: id, + path: `cfg:identity/${id}`, + icon: , + trailing: used === 0 ? unused : undefined, + }; + }), + }); children.push({ type: "file", @@ -481,8 +497,6 @@ const SECTION_INFO: Record = { "cfg:mcps": "Remote MCP servers the agent connects to at session start.", "cfg:identities": "Identity providers an asker links against, so the agent can act AS them when a tool or MCP call needs it. Per-asker (binding: principal) by default.", - "cfg:integrations": - "Team-level integrations the agent reuses (configured once at the project level).", "cfg:secrets": "Env keys this agent reads. Values are never shown.", "cfg:limits": "Hard caps on a single run.", }; @@ -555,10 +569,6 @@ function nodeHeader( return { icon: , title: "Identities" }; case "identity": return { icon: , title: id }; - case "integrations": - return { icon: , title: "Integrations" }; - case "integration": - return { icon: , title: id }; case "secrets": return { icon: , title: "Secrets" }; case "secret": @@ -675,10 +685,6 @@ function DetailBody({ ctx={ctx} /> ); - case "integrations": - return ; - case "integration": - return ; case "secrets": return ; case "secret": @@ -1354,28 +1360,118 @@ function SkillBody({ function McpsOverview({ spec, ctx }: { spec: AgentSpec; ctx: Ctx }) { const mcps = arr(spec.mcps); - if (mcps.length === 0) return No MCP servers declared.; + const { installations, connectCustom, connectCustomPending } = + useMcpConnect(); + const applySpec = useApplyAgentSpec(ctx.idOrSlug, ctx.applicationId); + const [showAdd, setShowAdd] = useState(false); + const canEdit = !!ctx.revisionState; + + // Append a new mcps[] entry referencing the chosen connection (id derived + // from its name, url filled from the installation), then select it. + const addFromConnection = (installId: string) => { + const install = (installations ?? []).find((i) => i.id === installId); + if (!install || !ctx.revisionState) return; + const base = + (install.display_name || install.url || "mcp") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 32) || "mcp"; + const taken = new Set(mcps.map((m) => str(rec(m).id))); + let newId = base; + for (let n = 2; taken.has(newId); n++) newId = `${base}-${n}`; + const entry = { + id: newId, + url: install.url ?? "", + connection: install.id, + secrets: [] as string[], + // Safe-by-default: every tool parks for approval until the owner relaxes + // specific ones. Activates the per-agent permission model (vs the legacy + // allowlist) so the runtime + the detail UI agree from the first save. + default_tool_approval: "approve" as const, + }; + applySpec.mutate( + { + revision: { id: ctx.revisionId, state: ctx.revisionState }, + spec: { ...spec, mcps: [...mcps, entry] }, + }, + { + onSuccess: (rev) => { + if (rev.id !== ctx.revisionId) ctx.onSelectRevision?.(rev.id); + ctx.onSelect(`cfg:mcp/${newId}`); + }, + onError: (e) => toast.error(e.message || "Failed to add MCP server"), + }, + ); + }; + return ( - {mcps.map((m) => { - const r = rec(m); - const id = str(r.id) ?? "mcp"; - const missing = mcpMissingSecrets(m, ctx.setKeys); - return ( - } - title={id} - subtitle={str(r.url)} - trailing={ - missing.length > 0 ? ( - - ) : undefined - } - onClick={() => ctx.onSelect(`cfg:mcp/${id}`)} - /> - ); - })} + {mcps.length === 0 ? ( + No MCP servers declared. + ) : ( + mcps.map((m) => { + const r = rec(m); + const id = str(r.id) ?? "mcp"; + const missing = mcpMissingSecrets(m, ctx.setKeys); + return ( + } + title={id} + subtitle={str(r.connection) ? "shared connection" : str(r.url)} + trailing={ + missing.length > 0 ? ( + + ) : undefined + } + onClick={() => ctx.onSelect(`cfg:mcp/${id}`)} + /> + ); + }) + )} + {canEdit ? ( + + {(installations ?? []).length > 0 ? ( + + + + {(installations ?? []).map((i) => ( + + {i.display_name || i.url || i.id} + + ))} + + + ) : ( + No connected MCP servers yet. + )} + + + ) : null} + { + connectCustom(values); + setShowAdd(false); + }} + /> ); } @@ -1390,20 +1486,216 @@ function McpBody({ ctx: Ctx; }) { const r = rec(mcp); + const id = str(r.id) ?? "mcp"; const tools = arr(r.tools); const missing = mcpMissingSecrets(mcp, ctx.setKeys); const provider = mcpProvider(mcp); - const integration = str(rec(r.auth).integration); + const connection = str(r.connection); + + const { + installations, + installationsLoading, + connectCustom, + connectCustomPending, + } = useMcpConnect(); + const applySpec = useApplyAgentSpec(ctx.idOrSlug, ctx.applicationId); + const [showAdd, setShowAdd] = useState(false); + const canEdit = !!ctx.revisionState; + const saving = applySpec.isPending; + + // Live tool catalog for an agent-level shared connection — the connection id + // IS the mcp_store installation id, so we can list its tools and show a + // per-tool permission against each. A principal-level (auth.provider) MCP has + // no installation here, so `connection` is null and this stays empty. + const { tools: catalogTools, isLoading: catalogLoading } = + useMcpInstallationTools(connection ?? null, { autoRefreshIfEmpty: true }); + // Per-agent override level keyed by remote tool name, plus the connection-wide + // default. Effective level per tool = override ?? default. + const overrides = toolLevelOverrides(r); + const defaultLevel = toToolApprovalLevel(r.default_tool_approval); + const effectiveDefault = defaultLevel ?? DEFAULT_TOOL_APPROVAL; + // Project the live catalog into the shared list's vocabulary: each tool's + // displayed state is its override (if any) resolved against the default. The + // panel is permission-agnostic, so the override/default math stays here. + const displayTools: McpInstallationTool[] = catalogTools.map((t) => ({ + ...t, + approval_state: + LEVEL_TO_APPROVAL[overrides.get(t.tool_name) ?? effectiveDefault], + })); + + // Rebuild the full spec with this mcps[] entry transformed, then draft-branch + // (if needed) + PATCH. Lands on (and selects) a new draft off a non-draft. + // Destructure the ctx fields the callback reads so the dep array is stable — + // `ctx` is a fresh object literal on every parent render, which would + // otherwise change identity each time and defeat the useCallback memoization. + const { revisionId, revisionState, onSelectRevision } = ctx; + const apply = useCallback( + (mutate: (entry: Record) => Record) => { + if (!revisionState) return; + const nextMcps = arr(spec.mcps).map((m) => + (str(rec(m).id) ?? "mcp") === id ? mutate(rec(m)) : m, + ); + applySpec.mutate( + { + revision: { id: revisionId, state: revisionState }, + spec: { ...spec, mcps: nextMcps }, + }, + { + onSuccess: (rev) => { + if (rev.id !== revisionId) onSelectRevision?.(rev.id); + }, + onError: (e) => toast.error(e.message || "Failed to save"), + }, + ); + }, + [applySpec, revisionId, revisionState, onSelectRevision, id, spec], + ); + + const setConnection = (value: string) => { + if (value === "none") { + apply((entry) => { + const next = { ...entry }; + delete next.connection; + return next; + }); + return; + } + const install = (installations ?? []).find((i) => i.id === value); + apply((entry) => ({ + ...entry, + connection: value, + url: install?.url ?? entry.url, + })); + }; + + const setToolApproval = (toolName: string, requiresApproval: boolean) => { + apply((entry) => ({ + ...entry, + tools: arr(entry.tools).map((t) => { + const name = typeof t === "string" ? t : (str(rec(t).name) ?? ""); + if (name !== toolName) return t; + const base = typeof t === "object" ? rec(t) : {}; + return { ...base, name, requires_approval: requiresApproval }; + }), + })); + }; + + // Set the connection-wide default permission (allow / approve / deny). Setting + // it activates the per-agent model on this entry (the runner stops treating + // tools[] as a legacy allowlist). + const setDefaultLevel = (level: ToolApprovalLevel) => { + apply((entry) => ({ ...entry, default_tool_approval: level })); + }; + + // Override one tool's permission. Dropping it back to the connection default + // removes the override so the spec stays minimal (no entry ⇒ inherits default). + const setToolLevel = (toolName: string, level: ToolApprovalLevel) => { + apply((entry) => { + const others = arr(entry.tools).filter( + (t) => (typeof t === "string" ? t : str(rec(t).name)) !== toolName, + ); + const tools = + level === effectiveDefault + ? others + : [...others, { name: toolName, level }]; + return { + ...entry, + default_tool_approval: entry.default_tool_approval ?? effectiveDefault, + tools, + }; + }); + }; + + // Drop this whole mcps[] entry from the spec and return to the list. The + // shared connection (the mcp_store installation) is untouched — only the + // agent's reference to it goes away. + const removeMcp = () => { + if (!revisionState) return; + const nextMcps = arr(spec.mcps).filter( + (m) => (str(rec(m).id) ?? "mcp") !== id, + ); + applySpec.mutate( + { + revision: { id: revisionId, state: revisionState }, + spec: { ...spec, mcps: nextMcps }, + }, + { + onSuccess: (rev) => { + if (rev.id !== revisionId) onSelectRevision?.(rev.id); + ctx.onSelect("cfg:mcps"); + }, + onError: (e) => toast.error(e.message || "Failed to remove MCP server"), + }, + ); + }; + + const connectionMissing = + !!connection && !(installations ?? []).some((i) => i.id === connection); + return ( +
+ Connection + + Agent-level: one shared credential an owner connects once (OAuth or + API key) and every asker reuses — askers never sign in. You set it up + here. For per-asker auth instead, leave this unset and wire a + principal identity provider (below) so each asker connects as + themselves. + + + + + + No connection + {(installations ?? []).map((i) => ( + + {i.display_name || i.url || i.id} + + ))} + + + + + {connectionMissing ? ( + + Referenced connection isn't in this project — reconnect it or pick + another. + + ) : null} +
+ + { + connectCustom(values); + setShowAdd(false); + }} + /> + {str(r.url) ? ( ) : null} - {integration ? : null} - {provider ? ( + {!connection && provider ? ( ) : null} - {missing.length > 0 ? ( + {!connection && missing.length > 0 ? ( Missing secret{missing.length > 1 ? "s" : ""}: @@ -1423,36 +1715,85 @@ function McpBody({
) : null} -
- Tools · {tools.length} - {tools.length === 0 ? ( - No tools selected from this server. - ) : ( -
- {tools.map((t) => { - const name = - typeof t === "string" ? t : (str(rec(t).name) ?? "tool"); - const approval = - typeof t === "object" && rec(t).requires_approval === true; - return ( - - - {name} - - {approval ? ( - - ) : null} - - ); - })} + + {connection ? ( +
+ Tool permissions + + The default applies to every tool this server exposes; override + individual tools below. Allow = runs automatically · Approve = asks + the approver each call · Deny = hidden from the agent. + +
+ setDefaultLevel(APPROVAL_TO_LEVEL[v]), + }} + onSetTool={(name, state) => + setToolLevel(name, APPROVAL_TO_LEVEL[state]) + } + emptyTitle="No tools discovered yet." + emptyHint="They appear once the connection is verified." + />
- )} -
+
+ ) : ( +
+ Tools · {tools.length} + {tools.length === 0 ? ( + No tools selected from this server. + ) : ( + + {tools.map((t) => { + const name = + typeof t === "string" ? t : (str(rec(t).name) ?? "tool"); + const requiresApproval = + typeof t === "object" && rec(t).requires_approval === true; + return ( + + + {name} + + + Requires approval + + setToolApproval(name, v === true)} + disabled={!canEdit || saving} + /> + + ); + })} + + )} +
+ )} + + {canEdit ? ( + + + + ) : null} ); } @@ -1604,38 +1945,6 @@ function IdentityBody({ ); } -function IntegrationsOverview({ spec, ctx }: { spec: AgentSpec; ctx: Ctx }) { - const integrations = arr(spec.integrations).filter( - (s): s is string => typeof s === "string", - ); - if (integrations.length === 0) - return No integrations declared.; - return ( - - {integrations.map((name) => ( - } - title={name} - onClick={() => ctx.onSelect(`cfg:integration/${name}`)} - /> - ))} - - ); -} - -function IntegrationBody({ name }: { name: string }) { - return ( - - - - The agent reuses the team's {name} connection. It's configured once at - the project level — there's no per-agent credential here. - - - ); -} - function SecretsOverview({ spec, ctx }: { spec: AgentSpec; ctx: Ctx }) { const keys = allSecretKeys(spec, ctx.setKeys); if (keys.length === 0) return No secrets declared.; diff --git a/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts b/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts index 4bc21013cd..a7f1eac7df 100644 --- a/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts +++ b/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts @@ -51,7 +51,10 @@ export interface UseAgentChatOptions { contextProvider?: () => unknown; /** AgentBuilder UI-driving tools (focus_*, set_secret); null → built-in handling. */ clientTools?: ClientToolHandler; - /** `kind:'client'` tool ids this client can fulfil; sent to the runner at /run. */ + /** + * `kind:'client'` tool ids this client can fulfil; sent to the runner at /run. + * Pass a stable (module-level) array — it keys the session-config memo. + */ supportedClientTools?: readonly string[]; } diff --git a/packages/ui/src/features/mcp-server-manager/AddCustomServerDialog.tsx b/packages/ui/src/features/mcp-server-manager/AddCustomServerDialog.tsx new file mode 100644 index 0000000000..a436287f52 --- /dev/null +++ b/packages/ui/src/features/mcp-server-manager/AddCustomServerDialog.tsx @@ -0,0 +1,52 @@ +import type { McpAuthType } from "@posthog/api-client/posthog-client"; +import { AddCustomServerForm } from "@posthog/ui/features/mcp-server-manager/AddCustomServerForm"; +import type { CustomServerInput } from "@posthog/ui/features/mcp-server-manager/useMcpConnect"; +import { Dialog } from "@radix-ui/themes"; + +/** + * Modal wrapper around {@link AddCustomServerForm}. The form is a full + * multi-field form, so connecting a server pops out a dialog rather than + * expanding inline. Radix unmounts the content on close, so each open starts + * from a fresh form (reset to `initialValues`). Used by the agent-config MCP + * sections and the agent builder's `connect_mcp` punch-out. + */ +export function AddCustomServerDialog({ + open, + pending, + onOpenChange, + onSubmit, + initialValues, + title = "Add MCP server", + description = "Connect a custom MCP server by URL. Tools appear in your agent once the connection is verified.", +}: { + open: boolean; + pending: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (values: CustomServerInput) => void; + initialValues?: { + name?: string; + url?: string; + description?: string; + auth_type?: McpAuthType; + }; + title?: string; + description?: string; +}) { + return ( + + + {title} + + {description} + + onOpenChange(false)} + /> + + + ); +} diff --git a/packages/ui/src/features/mcp-servers/components/parts/AddCustomServerForm.tsx b/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx similarity index 80% rename from packages/ui/src/features/mcp-servers/components/parts/AddCustomServerForm.tsx rename to packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx index 60f43f24a7..0f21ed9e38 100644 --- a/packages/ui/src/features/mcp-servers/components/parts/AddCustomServerForm.tsx +++ b/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx @@ -26,17 +26,34 @@ interface AddCustomServerFormProps { client_id?: string; client_secret?: string; }) => void; + /** Prefill the form (e.g. the agent builder's connect_mcp punch-out supplies a + * suggested name/url). The user can still edit every field before connecting. */ + initialValues?: { + name?: string; + url?: string; + description?: string; + auth_type?: McpAuthType; + }; + /** Hide the in-form Back button + title/description — for when a host chrome + * (e.g. a dialog) already provides them. */ + hideHeader?: boolean; } export function AddCustomServerForm({ pending, onBack, onSubmit, + initialValues, + hideHeader = false, }: AddCustomServerFormProps) { - const [name, setName] = useState(""); - const [url, setUrl] = useState(""); - const [description, setDescription] = useState(""); - const [authType, setAuthType] = useState("oauth"); + const [name, setName] = useState(initialValues?.name ?? ""); + const [url, setUrl] = useState(initialValues?.url ?? ""); + const [description, setDescription] = useState( + initialValues?.description ?? "", + ); + const [authType, setAuthType] = useState( + initialValues?.auth_type ?? "oauth", + ); const [apiKey, setApiKey] = useState(""); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); @@ -76,26 +93,30 @@ export function AddCustomServerForm({ return (
- - - + {!hideHeader && ( + <> + + + - - Add MCP server - - Connect a custom MCP server by URL. Tools appear in your agent once - the connection is verified. - - + + Add MCP server + + Connect a custom MCP server by URL. Tools appear in your agent + once the connection is verified. + + + + )} diff --git a/packages/ui/src/features/mcp-server-manager/useMcpConnect.ts b/packages/ui/src/features/mcp-server-manager/useMcpConnect.ts new file mode 100644 index 0000000000..bd583e0b3b --- /dev/null +++ b/packages/ui/src/features/mcp-server-manager/useMcpConnect.ts @@ -0,0 +1,137 @@ +import type { + McpAuthType, + McpServerInstallation, +} from "@posthog/api-client/posthog-client"; +import { + type IOAuthCallback, + installCustomWithOAuth, + reauthorizeWithOAuth, +} from "@posthog/core/mcp-servers/installFlow"; +import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; +import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useQueryClient } from "@tanstack/react-query"; +import { useSubscription } from "@trpc/tanstack-react-query"; +import { useCallback, useMemo } from "react"; +import { toast } from "sonner"; + +export const mcpKeys = { + servers: ["mcp", "servers"] as const, + installations: ["mcp", "installations"] as const, + tools: (installationId: string) => + ["mcp", "installations", installationId, "tools"] as const, +}; + +type HostTRPCClient = ReturnType; + +/** Host OAuth callback over the desktop's `mcpCallback` tRPC (deep link / dev + * HTTP). The one seam the install flow needs from the host. */ +export function createOAuthCallback( + trpcClient: HostTRPCClient, +): IOAuthCallback { + return { + getCallbackUrl: () => trpcClient.mcpCallback.getCallbackUrl.query(), + openAndWaitForCallback: (args) => + trpcClient.mcpCallback.openAndWaitForCallback.mutate(args), + }; +} + +export interface CustomServerInput { + name: string; + url: string; + description: string; + auth_type: McpAuthType; + api_key?: string; + client_id?: string; + client_secret?: string; +} + +/** + * Shared MCP connect/list primitives: the `mcp_store` install flow behind an + * injectable host OAuth callback, plus the team's installations query. Consumed + * by both the standalone MCP-servers scene and the agent-applications builder. + */ +export function useMcpConnect() { + const trpc = useHostTRPC(); + const trpcClient = useHostTRPCClient(); + const oauth = useMemo(() => createOAuthCallback(trpcClient), [trpcClient]); + const queryClient = useQueryClient(); + + const installationsQuery = useAuthenticatedQuery( + mcpKeys.installations, + (client) => client.getMcpServerInstallations(), + ); + + const invalidateInstallations = useCallback(() => { + queryClient.invalidateQueries({ queryKey: mcpKeys.installations }); + }, [queryClient]); + + const connectCustomMutation = useAuthenticatedMutation( + (client, vars: CustomServerInput) => + installCustomWithOAuth(client, oauth, vars), + { + onSuccess: (data) => { + if (data && "success" in data && data.success) { + toast.success("Server added"); + } else if (data && "error" in data && data.error) { + toast.error(data.error); + } + invalidateInstallations(); + }, + onError: (error: Error) => + toast.error(error.message || "Failed to add server"), + }, + ); + + const reauthorizeMutation = useAuthenticatedMutation( + (client, installationId: string) => + reauthorizeWithOAuth(client, oauth, installationId), + { + onSuccess: (data) => { + if (data && "success" in data && data.success) { + toast.success("Server reconnected"); + } else if (data && "error" in data && data.error) { + toast.error(data.error); + } + invalidateInstallations(); + }, + onError: (error: Error) => + toast.error(error.message || "Failed to reconnect server"), + }, + ); + + useSubscription( + trpc.mcpCallback.onOAuthComplete.subscriptionOptions(undefined, { + onData: (data) => { + if (data.status === "success") { + invalidateInstallations(); + } + }, + }), + ); + + // Awaitable refetch so a caller that just connected can read the freshly + // created installation back (it's keyed `(team, user, url)` server-side). + const refetchInstallations = useCallback(async () => { + const res = await installationsQuery.refetch(); + return (res.data ?? []) as McpServerInstallation[]; + }, [installationsQuery]); + + return { + oauth, + installations: installationsQuery.data as + | McpServerInstallation[] + | undefined, + installationsLoading: installationsQuery.isLoading, + invalidateInstallations, + refetchInstallations, + connectCustom: connectCustomMutation.mutate, + // Awaitable variant — resolves when the OAuth callback completes (or + // immediately for an api-key install). Used by the builder's connect_mcp + // punch-out, which must attach the resulting connection to a spec. + connectCustomAsync: connectCustomMutation.mutateAsync, + connectCustomPending: connectCustomMutation.isPending, + reauthorize: reauthorizeMutation.mutate, + reauthorizePending: reauthorizeMutation.isPending, + }; +} diff --git a/packages/ui/src/features/mcp-servers/components/McpServersView.tsx b/packages/ui/src/features/mcp-servers/components/McpServersView.tsx index 35e4b520c8..d87c6a7131 100644 --- a/packages/ui/src/features/mcp-servers/components/McpServersView.tsx +++ b/packages/ui/src/features/mcp-servers/components/McpServersView.tsx @@ -3,7 +3,7 @@ import type { McpRecommendedServer, McpServerInstallation, } from "@posthog/api-client/posthog-client"; -import { AddCustomServerForm } from "@posthog/ui/features/mcp-servers/components/parts/AddCustomServerForm"; +import { AddCustomServerForm } from "@posthog/ui/features/mcp-server-manager/AddCustomServerForm"; import { MarketplaceView } from "@posthog/ui/features/mcp-servers/components/parts/MarketplaceView"; import { McpInstalledRail } from "@posthog/ui/features/mcp-servers/components/parts/McpInstalledRail"; import { useMcpServers } from "@posthog/ui/features/mcp-servers/hooks/useMcpServers"; diff --git a/packages/ui/src/features/mcp-servers/components/parts/ToolPermissionList.tsx b/packages/ui/src/features/mcp-servers/components/parts/ToolPermissionList.tsx new file mode 100644 index 0000000000..24fee74e1d --- /dev/null +++ b/packages/ui/src/features/mcp-servers/components/parts/ToolPermissionList.tsx @@ -0,0 +1,321 @@ +import { + ArrowClockwise, + Check, + MagnifyingGlass, + Prohibit, + Shield, + X, +} from "@phosphor-icons/react"; +import type { + McpApprovalState, + McpInstallationTool, +} from "@posthog/api-client/posthog-client"; +import { + countActiveTools, + countToolsByApproval, + filterToolsByName, + sortToolsForDisplay, +} from "@posthog/core/mcp-servers/toolDerivation"; +import { ToolPolicyToggle } from "@posthog/ui/features/mcp-servers/components/parts/ToolPolicyToggle"; +import { ToolRow } from "@posthog/ui/features/mcp-servers/components/parts/ToolRow"; +import { + Badge, + Flex, + IconButton, + Separator, + Spinner, + Text, + TextField, + Tooltip, +} from "@radix-ui/themes"; +import { useMemo, useState } from "react"; + +/** + * A top-level approval mode (the segmented Default control). Optional — present + * only when the consumer has a notion of one mode every tool inherits, like the + * agent-specific case where unset tools fall back to a connection-wide default. + */ +export interface ToolPermissionDefaultControl { + value: McpApprovalState; + onChange: (value: McpApprovalState) => void; + /** Inline label beside the toggle. Defaults to "Default". */ + label?: string; +} + +/** Bulk "Set all" affordance — writes every (or every filtered) tool at once. */ +export interface ToolPermissionBulkControl { + /** `tools` is the filtered subset when a search is active, else undefined (all). */ + onSetAll: (state: McpApprovalState, tools?: McpInstallationTool[]) => void; + pending?: boolean; +} + +/** Re-discover the server's tool catalog. */ +export interface ToolPermissionRefreshControl { + onRefresh: () => void; + pending?: boolean; +} + +/** Reveal tools the server has dropped since they were last seen. */ +export interface ToolPermissionRemovedControl { + count: number; + show: boolean; + onToggle: () => void; +} + +export interface ToolPermissionListProps { + /** + * Tools to render. Each `approval_state` is the effective state to *display*; + * how that state is derived (a persisted installation value, or an override + * resolved against a default) is the parent's concern. + */ + tools: McpInstallationTool[]; + /** Per-tool change. The parent decides what persisting it means. */ + onSetTool: (toolName: string, state: McpApprovalState) => void; + isLoading?: boolean; + /** Disable every control (read-only view, or an in-flight save). */ + disabled?: boolean; + /** Section heading. Defaults to "Tools". */ + heading?: string; + /** Top-level approval mode shown in the header. */ + defaultControl?: ToolPermissionDefaultControl; + /** "Set all" icon buttons shown in the header. */ + bulk?: ToolPermissionBulkControl; + /** Refresh-from-server icon button shown in the header. */ + refresh?: ToolPermissionRefreshControl; + /** Removed-tools reveal shown beneath the list. */ + removed?: ToolPermissionRemovedControl; + /** Empty-state copy when no tools are present. */ + emptyTitle?: string; + emptyHint?: string; + /** Show the search field once the list exceeds this length. Defaults to 5. */ + searchThreshold?: number; +} + +/** + * Searchable, expandable tool-permission list with optional default-mode, bulk, + * refresh, and removed-tools controls. Purely presentational: it owns search and + * expand state only — every permission decision is delegated to the parent via + * callbacks, so the same component serves PostHog Code's global MCP-server + * config and an agent's per-server overrides without knowing which it is. + */ +export function ToolPermissionList({ + tools, + onSetTool, + isLoading, + disabled, + heading = "Tools", + defaultControl, + bulk, + refresh, + removed, + emptyTitle = "No tools discovered yet.", + emptyHint = "Try refreshing, or check that the server is online.", + searchThreshold = 5, +}: ToolPermissionListProps) { + const [toolSearch, setToolSearch] = useState(""); + + const counts = useMemo(() => countToolsByApproval(tools), [tools]); + const visibleTools = useMemo(() => sortToolsForDisplay(tools), [tools]); + const filteredTools = useMemo( + () => filterToolsByName(visibleTools, toolSearch), + [visibleTools, toolSearch], + ); + + const bulkDisabled = disabled || bulk?.pending || filteredTools.length === 0; + const bulkTargets = toolSearch ? filteredTools : undefined; + + return ( + + + + {heading} + + {countActiveTools(tools)} + + + {counts.approved ? ( + + {counts.approved} approved + + ) : null} + {counts.needs_approval ? ( + + {counts.needs_approval} need approval + + ) : null} + {counts.do_not_use ? ( + + {counts.do_not_use} blocked + + ) : null} + + + + {defaultControl ? ( + + + {defaultControl.label ?? "Default"}: + + + + ) : null} + {bulk ? ( + + + Set all: + + + bulk.onSetAll("approved", bulkTargets)} + > + + + + + bulk.onSetAll("needs_approval", bulkTargets)} + > + + + + + bulk.onSetAll("do_not_use", bulkTargets)} + > + + + + + ) : null} + {refresh ? ( + <> + {defaultControl || bulk ? ( + + ) : null} + + + {refresh.pending ? ( + + ) : ( + + )} + + + + ) : null} + + + + {isLoading ? ( + + + + ) : visibleTools.length === 0 ? ( + + {refresh?.pending ? ( + + ) : ( + <> + {emptyTitle} + + {emptyHint} + + + )} + + ) : ( + + {visibleTools.length > searchThreshold && ( + setToolSearch(e.target.value)} + placeholder="Search tools..." + size="2" + > + + + + {toolSearch && ( + + setToolSearch("")} + > + + + + )} + + )} + {filteredTools.length === 0 ? ( + + + No tools match “{toolSearch}” + + + ) : ( + filteredTools.map((tool) => ( + + onSetTool(tool.tool_name, approval_state) + } + /> + )) + )} + + )} + + {removed && removed.count > 0 && ( + + + + )} + + ); +} diff --git a/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts b/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts index 50ca0dac80..7bae96091e 100644 --- a/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts +++ b/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts @@ -4,12 +4,15 @@ import type { McpServerInstallation, } from "@posthog/api-client/posthog-client"; import { - type IOAuthCallback, installCustomWithOAuth, installTemplateWithOAuth, reauthorizeWithOAuth, } from "@posthog/core/mcp-servers/installFlow"; import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; +import { + createOAuthCallback, + mcpKeys, +} from "@posthog/ui/features/mcp-server-manager/useMcpConnect"; import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useQueryClient } from "@tanstack/react-query"; @@ -17,22 +20,10 @@ import { useSubscription } from "@trpc/tanstack-react-query"; import { useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; -export const mcpKeys = { - servers: ["mcp", "servers"] as const, - installations: ["mcp", "installations"] as const, - tools: (installationId: string) => - ["mcp", "installations", installationId, "tools"] as const, -}; - -type HostTRPCClient = ReturnType; - -function createOAuthCallback(trpcClient: HostTRPCClient): IOAuthCallback { - return { - getCallbackUrl: () => trpcClient.mcpCallback.getCallbackUrl.query(), - openAndWaitForCallback: (args) => - trpcClient.mcpCallback.openAndWaitForCallback.mutate(args), - }; -} +// `mcpKeys` + `createOAuthCallback` now live in the shared mcp-server-manager +// module (also used by the agent-applications builder). Re-exported here so +// existing importers (e.g. useMcpInstallationTools) keep their path. +export { mcpKeys }; export function useMcpServers() { const trpc = useHostTRPC();