From 4ed6e2690f11326aaaf3e0abd67eee13acb2bb14 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 25 Jun 2026 12:47:42 +0200 Subject: [PATCH 01/12] feat(agent-applications): manage MCP connections in the agent builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets an owner attach a shared MCP connection to an agent and edit its per-tool permissions, reusing PostHog Code's native mcp_store connect flow. Pairs with the runtime in posthog#66007 (spec.mcps[].connection). - mcp-server-manager: new shared module — useMcpConnect (installations query + connect/reauthorize over the host OAuth callback) + the moved AddCustomServerForm. mcp-servers' useMcpServers now consumes the shared mcpKeys/createOAuthCallback. - agent-applications: editable McpsOverview (add an MCP from a connection, or connect a new server inline) + McpBody (connection picker, per-tool requires_approval toggles), persisted via useApplyAgentSpec. - foundation (built on main): api-client updateAgentRevisionSpec + useApplyAgentSpec (+ test) — draft-branch-on-save then PATCH. Compatible with the in-flight agent-model-policy-ui branch so they merge cleanly. - drive-by: cast spec.entrypoint in ModelBody to fix a pre-existing @posthog/ui typecheck error (AgentSpec index signature) unrelated to this change. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/api-client/src/posthog-client.ts | 20 ++ .../components/AgentConfigurationPane.tsx | 294 ++++++++++++++++-- .../hooks/useApplyAgentSpec.test.ts | 123 ++++++++ .../hooks/useApplyAgentSpec.ts | 71 +++++ .../AddCustomServerForm.tsx | 0 .../mcp-server-manager/useMcpConnect.ts | 125 ++++++++ .../mcp-servers/components/McpServersView.tsx | 2 +- .../mcp-servers/hooks/useMcpServers.ts | 25 +- 8 files changed, 611 insertions(+), 49 deletions(-) create mode 100644 packages/ui/src/features/agent-applications/hooks/useApplyAgentSpec.test.ts create mode 100644 packages/ui/src/features/agent-applications/hooks/useApplyAgentSpec.ts rename packages/ui/src/features/{mcp-servers/components/parts => mcp-server-manager}/AddCustomServerForm.tsx (100%) create mode 100644 packages/ui/src/features/mcp-server-manager/useMcpConnect.ts diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index eed207fd55..6fdffd90a7 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -32,6 +32,7 @@ import type { AgentSessionLogsParams, AgentSessionsListParams, AgentSlackManifest, + AgentSpec, AgentUsersListResponse, BundleFile, DecideApprovalRequest, @@ -4679,6 +4680,25 @@ export class PostHogAPIClient { return (await response.json()) as AgentRevision; } + /** PATCH a draft revision's spec. Only valid on a draft — clone via + * `createAgentDraftRevisionFrom` first for ready/live/archived. */ + async updateAgentRevisionSpec( + idOrSlug: string, + revisionId: string, + spec: AgentSpec, + ): Promise { + const teamId = await this.getTeamId(); + const path = `${this.agentApplicationsPath(teamId)}${encodeURIComponent(idOrSlug)}/revisions/${encodeURIComponent(revisionId)}/`; + const url = new URL(`${this.api.baseUrl}${path}`); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path, + overrides: { body: JSON.stringify({ spec }) }, + }); + return (await response.json()) as AgentRevision; + } + /** * A revision's bundle, flattened to per-file rows. The server returns a typed * `{ bundle: { agent_md, skills[], tools[] } }`; we expand it to the canonical diff --git a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx index 7c4206d6d2..ad682cd78e 100644 --- a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx @@ -21,20 +21,25 @@ import { WrenchIcon, } from "@phosphor-icons/react"; import type { + AgentRevisionState, AgentSpec, BundleFile, } from "@posthog/shared/agent-platform-types"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { AddCustomServerForm } from "@posthog/ui/features/mcp-server-manager/AddCustomServerForm"; +import { useMcpConnect } from "@posthog/ui/features/mcp-server-manager/useMcpConnect"; 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 { AgentRevisionBar } from "./AgentRevisionBar"; @@ -62,9 +67,14 @@ const USAGE_HOST = "https://"; interface Ctx { idOrSlug: string; revisionId: string; + /** App UUID + revision state — needed to apply a spec edit (draft-branch then + * PATCH). Absent while the revision is still loading → editing is disabled. */ + applicationId?: string; + revisionState?: AgentRevisionState; ingressBaseUrl?: string; setKeys: string[]; onSelect: (node: string) => void; + onSelectRevision?: (revisionId: string) => void; onOpenSession?: (sessionId: string) => void; } @@ -398,9 +408,12 @@ export function AgentConfigurationPane({ ? { idOrSlug, revisionId, + applicationId: application?.id, + revisionState: revision?.state, ingressBaseUrl: application?.ingress_base_url ?? undefined, setKeys, onSelect: onSelectNode, + onSelectRevision, onOpenSession, } : null; @@ -699,7 +712,9 @@ function ModelBody({ spec }: { spec: AgentSpec }) { {spec.entrypoint ? ( - + // `entrypoint` resolves to `{}` via AgentSpec's `[key: string]: unknown` + // index signature (pre-existing); guarded truthy above, so cast. + ) : null} ); @@ -1342,28 +1357,115 @@ 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[], + }; + 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} + {showAdd ? ( + setShowAdd(false)} + onSubmit={(values) => { + connectCustom(values); + setShowAdd(false); + }} + /> + ) : null} ); } @@ -1378,20 +1480,143 @@ 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; + + // 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. + const apply = useCallback( + (mutate: (entry: Record) => Record) => { + if (!ctx.revisionState) return; + const nextMcps = arr(spec.mcps).map((m) => + (str(rec(m).id) ?? "mcp") === id ? mutate(rec(m)) : m, + ); + applySpec.mutate( + { + revision: { id: ctx.revisionId, state: ctx.revisionState }, + spec: { ...spec, mcps: nextMcps }, + }, + { + onSuccess: (rev) => { + if (rev.id !== ctx.revisionId) ctx.onSelectRevision?.(rev.id); + }, + onError: (e) => toast.error(e.message || "Failed to save"), + }, + ); + }, + [applySpec, ctx, 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 }; + }), + })); + }; + + const connectionMissing = + !!connection && !(installations ?? []).some((i) => i.id === connection); + return ( +
+ Connection + + One shared MCP connection an owner connected once — used by every + asker. Supersedes per-asker auth and bring-your-own-token. + + + + + + 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} +
+ + {showAdd ? ( + setShowAdd(false)} + onSubmit={(values) => { + connectCustom(values); + setShowAdd(false); + }} + /> + ) : null} + {str(r.url) ? ( ) : null} {integration ? : null} - {provider ? ( + {!connection && provider ? ( ) : null} - {missing.length > 0 ? ( + {!connection && missing.length > 0 ? ( Missing secret{missing.length > 1 ? "s" : ""}: @@ -1411,16 +1636,17 @@ 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 = + const requiresApproval = typeof t === "object" && rec(t).requires_approval === true; return ( {name} - {approval ? ( - - ) : null} + + Requires approval + + setToolApproval(name, v === true)} + disabled={!canEdit || saving} + /> ); })} -
+ )}
diff --git a/packages/ui/src/features/agent-applications/hooks/useApplyAgentSpec.test.ts b/packages/ui/src/features/agent-applications/hooks/useApplyAgentSpec.test.ts new file mode 100644 index 0000000000..01891f529f --- /dev/null +++ b/packages/ui/src/features/agent-applications/hooks/useApplyAgentSpec.test.ts @@ -0,0 +1,123 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Capture the mutationFn the hook hands to react-query so we can exercise the +// create-draft-vs-patch branching directly, without a live QueryClient. +let mutationFn: (vars: { + revision: { id: string; state: string }; + spec: unknown; +}) => Promise; + +vi.mock("@tanstack/react-query", () => ({ + useMutation: (opts: { mutationFn: typeof mutationFn }) => { + mutationFn = opts.mutationFn; + return { mutate: vi.fn() }; + }, + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +const client = { + createAgentDraftRevisionFrom: vi.fn(), + updateAgentRevisionSpec: vi.fn(), + transitionAgentRevision: vi.fn(), +}; + +vi.mock("@posthog/ui/features/auth/authClient", () => ({ + useAuthenticatedClient: () => client, +})); +vi.mock("../../auth/store", () => ({ + useAuthStateValue: () => 1, +})); + +import { useApplyAgentSpec } from "./useApplyAgentSpec"; + +describe("useApplyAgentSpec", () => { + beforeEach(() => { + client.createAgentDraftRevisionFrom.mockReset(); + client.updateAgentRevisionSpec.mockReset(); + client.transitionAgentRevision.mockReset(); + }); + + it("PATCHes a draft in place — no new draft branched", async () => { + client.updateAgentRevisionSpec.mockResolvedValue({ + id: "d1", + state: "draft", + }); + renderHook(() => useApplyAgentSpec("agent-slug", "app-1")); + const spec = { mcps: [{ id: "incident", connection: "c1" }] }; + + await mutationFn({ revision: { id: "d1", state: "draft" }, spec }); + + expect(client.createAgentDraftRevisionFrom).not.toHaveBeenCalled(); + expect(client.updateAgentRevisionSpec).toHaveBeenCalledWith( + "agent-slug", + "d1", + spec, + ); + }); + + it("clones to a fresh draft then PATCHes it when the source isn't a draft", async () => { + client.createAgentDraftRevisionFrom.mockResolvedValue({ + id: "new-draft", + state: "draft", + }); + client.updateAgentRevisionSpec.mockResolvedValue({ + id: "new-draft", + state: "draft", + }); + renderHook(() => useApplyAgentSpec("agent-slug", "app-1")); + const spec = { mcps: [{ id: "incident", connection: "c1" }] }; + + await mutationFn({ revision: { id: "live-1", state: "live" }, spec }); + + expect(client.createAgentDraftRevisionFrom).toHaveBeenCalledWith( + "app-1", + "live-1", + ); + expect(client.updateAgentRevisionSpec).toHaveBeenCalledWith( + "agent-slug", + "new-draft", + spec, + ); + }); + + it("throws when a clone is needed but the application id is missing", async () => { + renderHook(() => useApplyAgentSpec("agent-slug", undefined)); + + await expect( + mutationFn({ revision: { id: "live-1", state: "live" }, spec: {} }), + ).rejects.toThrow(/Application/); + expect(client.createAgentDraftRevisionFrom).not.toHaveBeenCalled(); + }); + + it("archives the orphaned draft (and rethrows) when the PATCH fails after a clone", async () => { + client.createAgentDraftRevisionFrom.mockResolvedValue({ + id: "new-draft", + state: "draft", + }); + const patchErr = new Error("spec.mcps: invalid"); + client.updateAgentRevisionSpec.mockRejectedValue(patchErr); + client.transitionAgentRevision.mockResolvedValue({ id: "new-draft" }); + renderHook(() => useApplyAgentSpec("agent-slug", "app-1")); + + await expect( + mutationFn({ revision: { id: "live-1", state: "live" }, spec: {} }), + ).rejects.toThrow(patchErr); + expect(client.transitionAgentRevision).toHaveBeenCalledWith( + "agent-slug", + "new-draft", + "archive", + ); + }); + + it("does NOT archive when an in-place draft PATCH fails (nothing was cloned)", async () => { + client.updateAgentRevisionSpec.mockRejectedValue(new Error("boom")); + renderHook(() => useApplyAgentSpec("agent-slug", "app-1")); + + await expect( + mutationFn({ revision: { id: "d1", state: "draft" }, spec: {} }), + ).rejects.toThrow(/boom/); + expect(client.createAgentDraftRevisionFrom).not.toHaveBeenCalled(); + expect(client.transitionAgentRevision).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/features/agent-applications/hooks/useApplyAgentSpec.ts b/packages/ui/src/features/agent-applications/hooks/useApplyAgentSpec.ts new file mode 100644 index 0000000000..53e83cbcb7 --- /dev/null +++ b/packages/ui/src/features/agent-applications/hooks/useApplyAgentSpec.ts @@ -0,0 +1,71 @@ +import type { + AgentRevision, + AgentRevisionState, + AgentSpec, +} from "@posthog/shared/agent-platform-types"; +import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAuthStateValue } from "../../auth/store"; +import { agentApplicationsKeys } from "./agentApplicationsKeys"; + +/** + * Apply a spec change ("create draft and apply changes"): if the target + * revision is already a draft, PATCH its spec in place; otherwise clone it to a + * fresh draft first and PATCH that. Freeze/promote stay separate (the revision + * bar's lifecycle buttons) — this only lands the edit on an editable draft. + * + * Returns the revision the change landed on so the caller can select it (a new + * draft whenever the source wasn't a draft). + */ +export function useApplyAgentSpec( + idOrSlug: string, + applicationId: string | undefined, +) { + const client = useAuthenticatedClient(); + const queryClient = useQueryClient(); + const projectId = useAuthStateValue((state) => state.currentProjectId); + + return useMutation< + AgentRevision, + Error, + { revision: { id: string; state: AgentRevisionState }; spec: AgentSpec } + >({ + mutationFn: async ({ revision, spec }) => { + let targetId = revision.id; + const clonedDraft = revision.state !== "draft"; + if (clonedDraft) { + if (!applicationId) { + throw new Error("Application not loaded yet"); + } + const draft = await client.createAgentDraftRevisionFrom( + applicationId, + revision.id, + ); + targetId = draft.id; + } + try { + return await client.updateAgentRevisionSpec(idOrSlug, targetId, spec); + } catch (err) { + // Cloned-then-failed leaves an orphan draft (a copy with no edit + // landed). Archive it best-effort so repeated failed applies don't pile + // up empty drafts; never mask the original error. A pre-existing draft + // passed in by the caller is left untouched. + if (clonedDraft) { + await client + .transitionAgentRevision(idOrSlug, targetId, "archive") + .catch(() => undefined); + } + throw err; + } + }, + onSuccess: () => { + for (const key of [ + agentApplicationsKeys.detail(projectId, idOrSlug), + agentApplicationsKeys.revisions(projectId, idOrSlug), + ["agent-applications", "revision", projectId, idOrSlug], + ]) { + void queryClient.invalidateQueries({ queryKey: key }); + } + }, + }); +} 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 100% rename from packages/ui/src/features/mcp-servers/components/parts/AddCustomServerForm.tsx rename to packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx 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..d5a7e1c6d4 --- /dev/null +++ b/packages/ui/src/features/mcp-server-manager/useMcpConnect.ts @@ -0,0 +1,125 @@ +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(); + } + }, + }), + ); + + return { + oauth, + installations: installationsQuery.data as + | McpServerInstallation[] + | undefined, + installationsLoading: installationsQuery.isLoading, + invalidateInstallations, + connectCustom: connectCustomMutation.mutate, + 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/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(); From a9fd6d175b0c5cb130567105bc0805777497acbb Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 25 Jun 2026 14:16:43 +0200 Subject: [PATCH 02/12] fix(agent-applications): address MCP-connections review nits - useApplyAgentSpec: invalidate the revision detail via a new agentApplicationsKeys.revisionPrefix() instead of a hardcoded key array, so the centralised key registry stays the single source of truth. - McpBody: destructure the ctx fields the apply() callback reads (revisionId, revisionState, onSelectRevision) into its dep array; ctx is a fresh object literal each render, so depending on it defeated the useCallback memoization. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/AgentConfigurationPane.tsx | 12 ++++++++---- .../hooks/agentApplicationsKeys.ts | 7 +++++++ .../agent-applications/hooks/useApplyAgentSpec.ts | 2 +- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx index ad682cd78e..415f084290 100644 --- a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx @@ -1500,26 +1500,30 @@ function McpBody({ // 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 (!ctx.revisionState) return; + if (!revisionState) return; const nextMcps = arr(spec.mcps).map((m) => (str(rec(m).id) ?? "mcp") === id ? mutate(rec(m)) : m, ); applySpec.mutate( { - revision: { id: ctx.revisionId, state: ctx.revisionState }, + revision: { id: revisionId, state: revisionState }, spec: { ...spec, mcps: nextMcps }, }, { onSuccess: (rev) => { - if (rev.id !== ctx.revisionId) ctx.onSelectRevision?.(rev.id); + if (rev.id !== revisionId) onSelectRevision?.(rev.id); }, onError: (e) => toast.error(e.message || "Failed to save"), }, ); }, - [applySpec, ctx, id, spec], + [applySpec, revisionId, revisionState, onSelectRevision, id, spec], ); const setConnection = (value: string) => { diff --git a/packages/ui/src/features/agent-applications/hooks/agentApplicationsKeys.ts b/packages/ui/src/features/agent-applications/hooks/agentApplicationsKeys.ts index 3ad880a70a..bd3a1fec5c 100644 --- a/packages/ui/src/features/agent-applications/hooks/agentApplicationsKeys.ts +++ b/packages/ui/src/features/agent-applications/hooks/agentApplicationsKeys.ts @@ -54,6 +54,13 @@ export const agentApplicationsKeys = { ] as const, revisions: (projectId: number | null, idOrSlug: string) => ["agent-applications", "revisions", projectId, idOrSlug] as const, + /** + * Shared prefix for every per-revision detail query of an agent (each + * `revision(...)` key sits under it), so invalidating this prefix refetches + * the open revision's spec after an edit without needing its id. + */ + revisionPrefix: (projectId: number | null, idOrSlug: string) => + ["agent-applications", "revision", projectId, idOrSlug] as const, revision: (projectId: number | null, idOrSlug: string, revisionId: string) => [ "agent-applications", diff --git a/packages/ui/src/features/agent-applications/hooks/useApplyAgentSpec.ts b/packages/ui/src/features/agent-applications/hooks/useApplyAgentSpec.ts index 53e83cbcb7..e75c29861d 100644 --- a/packages/ui/src/features/agent-applications/hooks/useApplyAgentSpec.ts +++ b/packages/ui/src/features/agent-applications/hooks/useApplyAgentSpec.ts @@ -62,7 +62,7 @@ export function useApplyAgentSpec( for (const key of [ agentApplicationsKeys.detail(projectId, idOrSlug), agentApplicationsKeys.revisions(projectId, idOrSlug), - ["agent-applications", "revision", projectId, idOrSlug], + agentApplicationsKeys.revisionPrefix(projectId, idOrSlug), ]) { void queryClient.invalidateQueries({ queryKey: key }); } From 41eced970b13d1023a974fb332539e07aa357717 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 25 Jun 2026 14:38:20 +0200 Subject: [PATCH 03/12] feat(agent-applications): connect_mcp punch-out in the agent builder dock The Edit-with-AI meta-agent's connect_mcp client tool parks the turn and renders a PREFILLED AddCustomServerForm in the dock; the user completes the OAuth/api-key connect themselves (auth never reaches the agent). On success the new mcp_store connection is attached to the target agent's draft spec (mcps[].connection) and the session is woken with { connected, connection_id, mcp_id }. Mirrors the set_secret punch-out. - agentBuilderStore: pendingMcpConnect state + setter - useAgentBuilderClientTools: connect_mcp handler (parks + defers) - AgentBuilderDock: connect form rendering + connect -> attach-to-spec -> resolve - AddCustomServerForm: initialValues prop for prefill - useMcpConnect: connectCustomAsync + refetchInstallations Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agent-builder/AgentBuilderDock.tsx | 128 ++++++++++++++++++ .../agent-builder/agentBuilderStore.ts | 29 ++++ .../useAgentBuilderClientTools.ts | 28 +++- .../AddCustomServerForm.tsx | 21 ++- .../mcp-server-manager/useMcpConnect.ts | 12 ++ 5 files changed, 213 insertions(+), 5 deletions(-) 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 8cda60b527..76f6788771 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,13 @@ 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 { AddCustomServerForm } from "@posthog/ui/features/mcp-server-manager/AddCustomServerForm"; +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"; @@ -64,6 +70,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 @@ -96,9 +123,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[ @@ -209,6 +242,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 @@ -229,6 +334,7 @@ export function AgentBuilderDock() { function seedStartFresh() { if (!seedConfirm) return; setPendingSecret(null); + setPendingMcpConnect(null); chat.newChat(); setLastSession(null); chat.send(seedConfirm); @@ -281,6 +387,7 @@ export function AgentBuilderDock() { size="1" onClick={() => { setPendingSecret(null); + setPendingMcpConnect(null); chat.newChat(); setLastSession(null); }} @@ -334,6 +441,27 @@ export function AgentBuilderDock() { onSubmit={submitSecret} onCancel={cancelSecret} /> + ) : pendingMcpConnect ? ( + + {pendingMcpConnect.purpose ? ( + + {pendingMcpConnect.purpose} + + ) : null} + + ) : null } composerDisabledReason={ 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 aa90a64ddd..24d79f7761 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts +++ b/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts @@ -17,6 +17,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; @@ -56,6 +59,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) { @@ -150,6 +176,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/mcp-server-manager/AddCustomServerForm.tsx b/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx index 60f43f24a7..9d4ea272c9 100644 --- a/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx +++ b/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx @@ -26,17 +26,30 @@ 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; + }; } export function AddCustomServerForm({ pending, onBack, onSubmit, + initialValues, }: 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(""); diff --git a/packages/ui/src/features/mcp-server-manager/useMcpConnect.ts b/packages/ui/src/features/mcp-server-manager/useMcpConnect.ts index d5a7e1c6d4..bd583e0b3b 100644 --- a/packages/ui/src/features/mcp-server-manager/useMcpConnect.ts +++ b/packages/ui/src/features/mcp-server-manager/useMcpConnect.ts @@ -110,6 +110,13 @@ export function useMcpConnect() { }), ); + // 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 @@ -117,7 +124,12 @@ export function useMcpConnect() { | 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, From 9b8846ddcef54b9ea330736df51426b3f72011fa Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 25 Jun 2026 14:59:07 +0200 Subject: [PATCH 04/12] feat(agent-applications): always show top-level config sections; drop legacy integrations - buildTree renders the authorable folder sections (triggers, secrets, skills, tools, mcps, identities) even when empty, so the add/connect affordance is reachable on a fresh agent (you couldn't add an MCP to an agent that had none). - Remove the deprecated integrations UI from the config pane (tree section, overview/body, routing, description, the legacy mcps[].auth.integration row, now-unused LinkIcon). Removing integrations from the agent-platform spec + runtime is a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/AgentConfigurationPane.tsx | 297 +++++++----------- 1 file changed, 112 insertions(+), 185 deletions(-) diff --git a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx index 415f084290..934814b374 100644 --- a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx @@ -10,7 +10,6 @@ import { InfoIcon, KeyIcon, LightningIcon, - LinkIcon, LockKeyIcon, PuzzlePieceIcon, ScrollIcon, @@ -202,158 +201,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", @@ -483,8 +454,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.", }; @@ -557,10 +526,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": @@ -677,10 +642,6 @@ function DetailBody({ ctx={ctx} /> ); - case "integrations": - return ; - case "integration": - return ; case "secrets": return ; case "secret": @@ -1484,7 +1445,6 @@ function McpBody({ 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 { @@ -1616,7 +1576,6 @@ function McpBody({ {str(r.url) ? ( ) : null} - {integration ? : null} {!connection && provider ? ( ) : null} @@ -1828,38 +1787,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.; From cfae9e181271d9a01bbbbc21b5016ffafadb18fd Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 25 Jun 2026 19:15:08 +0200 Subject: [PATCH 05/12] feat(agent-applications): declare supported_client_tools at /run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-builder dock now sends the kind:'client' tool ids it can execute (AGENT_BUILDER_CLIENT_TOOLS — set_secret, connect_mcp, focus_*, toast, get_context) in the /run body so the runner knows it can punch out the interactive connect_mcp form here instead of relaying a URL. Threaded AGENT_BUILDER_CLIENT_TOOLS → useAgentChat (supportedClientTools option) → AgentChatSession → agentChatService → runAgentSession (api-client) → POST /run body. Replaces relying on the never-sent x-posthog-client header. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/api-client/src/posthog-client.ts | 10 +++++++++- .../core/src/agent-chat/agentChatService.ts | 1 + packages/core/src/agent-chat/identifiers.ts | 6 ++++++ .../agent-builder/AgentBuilderDock.tsx | 6 +++++- .../useAgentBuilderClientTools.ts | 19 +++++++++++++++++++ .../agent-applications/hooks/useAgentChat.ts | 19 ++++++++++++++++++- 6 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 6fdffd90a7..e1a88c612e 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -4984,14 +4984,22 @@ export class PostHogAPIClient { ingressBaseUrl: string, message: string, previewToken?: string | null, + supportedClientTools?: readonly string[], ): Promise<{ session_id: string; resumed?: boolean }> { const url = new URL(`${ingressBaseUrl.replace(/\/$/, "")}/run`); + // `supported_client_tools`: the kind:'client' tool ids this client can + // execute this session, so the runner knows which interactive client tools + // (e.g. connect_mcp) it can punch out a form for vs. must relay a URL for. + const body: Record = { message }; + if (supportedClientTools && supportedClientTools.length > 0) { + body.supported_client_tools = supportedClientTools; + } const response = await this.api.fetcher.fetch({ method: "post", url, path: url.pathname, parameters: previewTokenHeader(previewToken), - overrides: { body: JSON.stringify({ message }) }, + overrides: { body: JSON.stringify(body) }, }); return (await response.json()) as { session_id: string; resumed?: boolean }; } diff --git a/packages/core/src/agent-chat/agentChatService.ts b/packages/core/src/agent-chat/agentChatService.ts index 8541084c17..c3a4f018bd 100644 --- a/packages/core/src/agent-chat/agentChatService.ts +++ b/packages/core/src/agent-chat/agentChatService.ts @@ -431,6 +431,7 @@ export class AgentChatService { session.ingressBaseUrl, session.buildWireText(text), token, + session.supportedClientTools, ), ); agentChatStore.getState().setSessionId(session.chatId, session_id); diff --git a/packages/core/src/agent-chat/identifiers.ts b/packages/core/src/agent-chat/identifiers.ts index b12805f0b5..2271b6cf9a 100644 --- a/packages/core/src/agent-chat/identifiers.ts +++ b/packages/core/src/agent-chat/identifiers.ts @@ -49,6 +49,12 @@ export interface AgentChatSession { ingressBaseUrl: string; /** Non-null targets a specific draft revision (preview token attached per call). */ revisionId: string | null; + /** + * The `kind:'client'` tool ids this client can execute this session, sent to + * the runner at /run so it knows which interactive client tools it can punch + * out a form for (e.g. `connect_mcp`) vs. must relay a URL for. Omit ⇒ none. + */ + supportedClientTools?: readonly string[]; createMapper(): AgentChatMapper; /** Resolve a client-tool call; `defer`/null ⇒ the service won't post a result. */ resolveClientTool( 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 76f6788771..8103fbf43d 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx +++ b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx @@ -31,7 +31,10 @@ import { useAgentBuilderStore, } from "./agentBuilderStore"; import { suggestionsForPage } from "./agentBuilderSuggestions"; -import { useAgentBuilderClientTools } from "./useAgentBuilderClientTools"; +import { + AGENT_BUILDER_CLIENT_TOOLS, + useAgentBuilderClientTools, +} from "./useAgentBuilderClientTools"; const CHAT_ID = AGENT_BUILDER_CHAT_ID; @@ -144,6 +147,7 @@ export function AgentBuilderDock() { chatId: CHAT_ID, agentSlug: AGENT_BUILDER_SLUG, ingressBaseUrl, + supportedClientTools: AGENT_BUILDER_CLIENT_TOOLS, contextProvider: () => buildAgentBuilderContext(page, followMode, { id: currentProjectId, 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 24d79f7761..3b2b07bbe5 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts +++ b/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts @@ -3,6 +3,25 @@ import { useCallback, useRef } from "react"; import type { ClientToolHandler } from "../hooks/useAgentChat"; 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 knows it can punch out the + * interactive client tools (connect_mcp, set_secret) to this client rather than + * relay a URL. Keep in sync with the handlers below (plus toast/get_context, + * which the core chat resolver fulfils built-in). + */ +export const AGENT_BUILDER_CLIENT_TOOLS = [ + "set_secret", + "connect_mcp", + "focus_tab", + "focus_file", + "focus_spec_section", + "focus_revision", + "focus_session", + "toast", + "get_context", +] as const; + /** * The agent builder's UI-driving client tools. The agent calls these to steer the * user's screen (`focus_*`, which navigate code's agent routes and report back diff --git a/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts b/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts index 26d9537e01..aed5a53595 100644 --- a/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts +++ b/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts @@ -50,6 +50,13 @@ export interface UseAgentChatOptions { contextProvider?: () => unknown; /** AgentBuilder UI-driving tools (focus_*, set_secret); null → built-in handling. */ clientTools?: ClientToolHandler; + /** + * The `kind:'client'` tool ids this client can execute this session, forwarded + * to the runner at /run so it knows which interactive client tools it can + * punch out a form for (e.g. `connect_mcp`). Pass a stable (module-level) + * array. AgentBuilder only. + */ + supportedClientTools?: readonly string[]; } /** @@ -68,6 +75,7 @@ export function useAgentChat({ recordHistory = false, contextProvider, clientTools, + supportedClientTools, }: UseAgentChatOptions) { const client = useAuthenticatedClient(); const service = useService(AGENT_CHAT_SERVICE); @@ -88,6 +96,7 @@ export function useAgentChat({ agentSlug, ingressBaseUrl: ingressBaseUrl ?? "", revisionId, + supportedClientTools, createMapper: createAgentChatMapper, resolveClientTool: (data) => resolveClientTool( @@ -113,7 +122,15 @@ export function useAgentChat({ }) : undefined, }), - [chatId, agentSlug, ingressBaseUrl, revisionId, recordHistory, recordChat], + [ + chatId, + agentSlug, + ingressBaseUrl, + revisionId, + supportedClientTools, + recordHistory, + recordChat, + ], ); const send = useCallback( From cbbf4cbd75ccbefac6f5fff961eba8f74931f9f7 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 25 Jun 2026 19:46:38 +0200 Subject: [PATCH 06/12] feat(agent-applications): render the connect_mcp punch-out as a modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline punch-out crammed the full AddCustomServerForm into the dock's narrow above-composer strip — ugly. Move connect_mcp to a proper Dialog (AgentBuilderMcpConnectDialog); the set_secret one-line punch-out stays inline. Adds a hideHeader prop to AddCustomServerForm so the dialog owns the title/close chrome (no in-form Back button / duplicate heading). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../agent-builder/AgentBuilderDock.tsx | 30 +++-------- .../AgentBuilderMcpConnectDialog.tsx | 50 +++++++++++++++++++ .../AddCustomServerForm.tsx | 46 ++++++++++------- 3 files changed, 85 insertions(+), 41 deletions(-) create mode 100644 packages/ui/src/features/agent-applications/agent-builder/AgentBuilderMcpConnectDialog.tsx 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 8103fbf43d..3366e82a47 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx +++ b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx @@ -7,7 +7,6 @@ import { } from "@phosphor-icons/react"; import type { AgentSpec } from "@posthog/shared/agent-platform-types"; import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; -import { AddCustomServerForm } from "@posthog/ui/features/mcp-server-manager/AddCustomServerForm"; import { type CustomServerInput, useMcpConnect, @@ -22,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 { @@ -445,27 +445,6 @@ export function AgentBuilderDock() { onSubmit={submitSecret} onCancel={cancelSecret} /> - ) : pendingMcpConnect ? ( - - {pendingMcpConnect.purpose ? ( - - {pendingMcpConnect.purpose} - - ) : null} - - ) : null } composerDisabledReason={ @@ -483,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..575a64ca31 --- /dev/null +++ b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderMcpConnectDialog.tsx @@ -0,0 +1,50 @@ +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"; +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. A modal (vs. the inline secret punch-out) because the connect + * form is a full form, not a one-line input. + */ +export function AgentBuilderMcpConnectDialog({ + pending, + busy, + onSubmit, + onCancel, +}: { + pending: PendingMcpConnect | null; + busy: boolean; + onSubmit: (values: CustomServerInput) => void; + onCancel: () => void; +}) { + return ( + { + if (!open) onCancel(); + }} + > + + Connect an MCP server + + {pending?.purpose ?? + "Connect a server for this agent. You complete the sign-in — the agent builder never sees your credentials."} + + {pending ? ( + + ) : null} + + + ); +} diff --git a/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx b/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx index 9d4ea272c9..0f21ed9e38 100644 --- a/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx +++ b/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx @@ -34,6 +34,9 @@ interface AddCustomServerFormProps { 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({ @@ -41,6 +44,7 @@ export function AddCustomServerForm({ onBack, onSubmit, initialValues, + hideHeader = false, }: AddCustomServerFormProps) { const [name, setName] = useState(initialValues?.name ?? ""); const [url, setUrl] = useState(initialValues?.url ?? ""); @@ -89,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. + + + + )} From 9428abdef8ef0469c9dd7ede0a5eb8900c097737 Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 25 Jun 2026 20:10:01 +0200 Subject: [PATCH 07/12] feat(agent-applications): send supported_client_tools at /run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Producer counterpart to the agent-platform server change that gates client-tool exposure on a per-run capability list. The agent-builder dock now declares the `kind:'client'` tool ids it can fulfil and sends them in the /run body as `supported_client_tools`, so the runner exposes only those to the model (spec ∩ supported). - `runAgentSession` (api-client): optional `supportedClientTools`, added to the /run body when non-empty. - `AgentChatSession` (core): carries `supportedClientTools`; `agentChatService` forwards it to `runAgentSession`. - `useAgentChat` (ui): new `supportedClientTools` option threaded into the session config. - `AGENT_BUILDER_CLIENT_TOOLS` declares the dock's fulfillable ids (set_secret, focus_*, toast, get_context) — verified to exactly match the agent-builder spec's `kind:'client'` tools — and is passed from the dock. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/api-client/src/posthog-client.ts | 9 ++++++++- packages/core/src/agent-chat/agentChatService.ts | 1 + packages/core/src/agent-chat/identifiers.ts | 2 ++ .../agent-builder/AgentBuilderDock.tsx | 6 +++++- .../agent-builder/useAgentBuilderClientTools.ts | 16 ++++++++++++++++ .../agent-applications/hooks/useAgentChat.ts | 14 +++++++++++++- 6 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index eed207fd55..ef4db40c13 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -4964,14 +4964,21 @@ export class PostHogAPIClient { ingressBaseUrl: string, message: string, previewToken?: string | null, + supportedClientTools?: readonly string[], ): Promise<{ session_id: string; resumed?: boolean }> { const url = new URL(`${ingressBaseUrl.replace(/\/$/, "")}/run`); + // `supported_client_tools`: the kind:'client' tool ids this client can + // execute this session, so the runner exposes only those to the model. + const body: Record = { message }; + if (supportedClientTools && supportedClientTools.length > 0) { + body.supported_client_tools = supportedClientTools; + } const response = await this.api.fetcher.fetch({ method: "post", url, path: url.pathname, parameters: previewTokenHeader(previewToken), - overrides: { body: JSON.stringify({ message }) }, + overrides: { body: JSON.stringify(body) }, }); return (await response.json()) as { session_id: string; resumed?: boolean }; } diff --git a/packages/core/src/agent-chat/agentChatService.ts b/packages/core/src/agent-chat/agentChatService.ts index 8541084c17..c3a4f018bd 100644 --- a/packages/core/src/agent-chat/agentChatService.ts +++ b/packages/core/src/agent-chat/agentChatService.ts @@ -431,6 +431,7 @@ export class AgentChatService { session.ingressBaseUrl, session.buildWireText(text), token, + session.supportedClientTools, ), ); agentChatStore.getState().setSessionId(session.chatId, session_id); diff --git a/packages/core/src/agent-chat/identifiers.ts b/packages/core/src/agent-chat/identifiers.ts index b12805f0b5..9c37d9e7ad 100644 --- a/packages/core/src/agent-chat/identifiers.ts +++ b/packages/core/src/agent-chat/identifiers.ts @@ -49,6 +49,8 @@ export interface AgentChatSession { ingressBaseUrl: string; /** Non-null targets a specific draft revision (preview token attached per call). */ revisionId: string | null; + /** `kind:'client'` tool ids this client can fulfil; sent to the runner at /run. */ + supportedClientTools?: readonly string[]; createMapper(): AgentChatMapper; /** Resolve a client-tool call; `defer`/null ⇒ the service won't post a result. */ resolveClientTool( 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 8cda60b527..9e2a96dd62 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx +++ b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderDock.tsx @@ -25,7 +25,10 @@ import { useAgentBuilderStore, } from "./agentBuilderStore"; import { suggestionsForPage } from "./agentBuilderSuggestions"; -import { useAgentBuilderClientTools } from "./useAgentBuilderClientTools"; +import { + AGENT_BUILDER_CLIENT_TOOLS, + useAgentBuilderClientTools, +} from "./useAgentBuilderClientTools"; const CHAT_ID = AGENT_BUILDER_CHAT_ID; @@ -118,6 +121,7 @@ export function AgentBuilderDock() { orgId: currentOrgId, }), clientTools, + supportedClientTools: AGENT_BUILDER_CLIENT_TOOLS, }); const pendingApproval = useAgentChatPendingApproval(CHAT_ID); 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 aa90a64ddd..e25348f589 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts +++ b/packages/ui/src/features/agent-applications/agent-builder/useAgentBuilderClientTools.ts @@ -3,6 +3,22 @@ import { useCallback, useRef } from "react"; import type { ClientToolHandler } from "../hooks/useAgentChat"; 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. + */ +export const AGENT_BUILDER_CLIENT_TOOLS = [ + "set_secret", + "focus_tab", + "focus_file", + "focus_spec_section", + "focus_revision", + "focus_session", + "toast", + "get_context", +] as const; + /** * The agent builder's UI-driving client tools. The agent calls these to steer the * user's screen (`focus_*`, which navigate code's agent routes and report back diff --git a/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts b/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts index 26d9537e01..659a8aeadf 100644 --- a/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts +++ b/packages/ui/src/features/agent-applications/hooks/useAgentChat.ts @@ -50,6 +50,8 @@ 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. */ + supportedClientTools?: readonly string[]; } /** @@ -68,6 +70,7 @@ export function useAgentChat({ recordHistory = false, contextProvider, clientTools, + supportedClientTools, }: UseAgentChatOptions) { const client = useAuthenticatedClient(); const service = useService(AGENT_CHAT_SERVICE); @@ -88,6 +91,7 @@ export function useAgentChat({ agentSlug, ingressBaseUrl: ingressBaseUrl ?? "", revisionId, + supportedClientTools, createMapper: createAgentChatMapper, resolveClientTool: (data) => resolveClientTool( @@ -113,7 +117,15 @@ export function useAgentChat({ }) : undefined, }), - [chatId, agentSlug, ingressBaseUrl, revisionId, recordHistory, recordChat], + [ + chatId, + agentSlug, + ingressBaseUrl, + revisionId, + supportedClientTools, + recordHistory, + recordChat, + ], ); const send = useCallback( From 58035d72f508f99aea2441e165703b635a8ef77c Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 25 Jun 2026 20:32:05 +0200 Subject: [PATCH 08/12] feat(agent-applications): pop the Connect-new MCP form out as a modal The agent-config MCP sections expanded the custom-server form inline under a Connect-new/Cancel toggle. Pop it out in a dialog instead (matching the agent builder's connect_mcp punch-out). Extract AddCustomServerDialog as the shared modal chrome and refactor AgentBuilderMcpConnectDialog to delegate to it, so both the manual UI and the punch-out share one wrapper. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AgentBuilderMcpConnectDialog.tsx | 38 ++++++-------- .../components/AgentConfigurationPane.tsx | 48 ++++++++--------- .../AddCustomServerDialog.tsx | 52 +++++++++++++++++++ 3 files changed, 90 insertions(+), 48 deletions(-) create mode 100644 packages/ui/src/features/mcp-server-manager/AddCustomServerDialog.tsx diff --git a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderMcpConnectDialog.tsx b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderMcpConnectDialog.tsx index 575a64ca31..a778fb30fd 100644 --- a/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderMcpConnectDialog.tsx +++ b/packages/ui/src/features/agent-applications/agent-builder/AgentBuilderMcpConnectDialog.tsx @@ -1,6 +1,5 @@ -import { AddCustomServerForm } from "@posthog/ui/features/mcp-server-manager/AddCustomServerForm"; +import { AddCustomServerDialog } from "@posthog/ui/features/mcp-server-manager/AddCustomServerDialog"; import type { CustomServerInput } from "@posthog/ui/features/mcp-server-manager/useMcpConnect"; -import { Dialog } from "@radix-ui/themes"; import type { PendingMcpConnect } from "./agentBuilderStore"; /** @@ -8,8 +7,8 @@ import type { PendingMcpConnect } from "./agentBuilderStore"; * 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. A modal (vs. the inline secret punch-out) because the connect - * form is a full form, not a one-line input. + * session woken. Thin wrapper over {@link AddCustomServerDialog} with + * punch-out-specific copy and the agent's prefilled values. */ export function AgentBuilderMcpConnectDialog({ pending, @@ -23,28 +22,21 @@ export function AgentBuilderMcpConnectDialog({ onCancel: () => void; }) { return ( - { if (!open) onCancel(); }} - > - - Connect an MCP server - - {pending?.purpose ?? - "Connect a server for this agent. You complete the sign-in — the agent builder never sees your credentials."} - - {pending ? ( - - ) : null} - - + 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/components/AgentConfigurationPane.tsx b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx index 934814b374..feb6751576 100644 --- a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx @@ -25,7 +25,7 @@ import type { BundleFile, } from "@posthog/shared/agent-platform-types"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; -import { AddCustomServerForm } from "@posthog/ui/features/mcp-server-manager/AddCustomServerForm"; +import { AddCustomServerDialog } from "@posthog/ui/features/mcp-server-manager/AddCustomServerDialog"; import { useMcpConnect } from "@posthog/ui/features/mcp-server-manager/useMcpConnect"; import { Badge } from "@posthog/ui/primitives/Badge"; import { Button } from "@posthog/ui/primitives/Button"; @@ -1410,23 +1410,22 @@ function McpsOverview({ spec, ctx }: { spec: AgentSpec; ctx: Ctx }) { ) : null} - {showAdd ? ( - setShowAdd(false)} - onSubmit={(values) => { - connectCustom(values); - setShowAdd(false); - }} - /> - ) : null} + { + connectCustom(values); + setShowAdd(false); + }} + /> ); } @@ -1548,10 +1547,10 @@ function McpBody({ {connectionMissing ? ( @@ -1562,16 +1561,15 @@ function McpBody({ ) : null} - {showAdd ? ( - setShowAdd(false)} - onSubmit={(values) => { - connectCustom(values); - setShowAdd(false); - }} - /> - ) : null} + { + connectCustom(values); + setShowAdd(false); + }} + /> {str(r.url) ? ( 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)} + /> + + + ); +} From 0811b54fa81f06eb6cd029ff7f2b7100fe37cd7c Mon Sep 17 00:00:00 2001 From: Ben White Date: Thu, 25 Jun 2026 21:16:16 +0200 Subject: [PATCH 09/12] feat(agent-applications): remove an MCP server from an agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP config could add (Connect new / Add from a connection) and edit the connection, but had no way to drop an mcps[] entry — only clear its connection. Add a Remove server action to the MCP detail that filters the entry out of the spec and returns to the list. The shared mcp_store installation is untouched; only the agent's reference goes away. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/AgentConfigurationPane.tsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx index feb6751576..2056ba22fb 100644 --- a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx @@ -14,6 +14,7 @@ import { PuzzlePieceIcon, ScrollIcon, SparkleIcon, + TrashIcon, UserIcon, WarningIcon, WebhooksLogoIcon, @@ -1514,6 +1515,29 @@ function McpBody({ })); }; + // 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); @@ -1634,6 +1658,21 @@ function McpBody({ )} + + {canEdit ? ( + + + + ) : null} ); } From 7708119e61b01d86c8ee540267a3d607443763e3 Mon Sep 17 00:00:00 2001 From: Ben White Date: Fri, 26 Jun 2026 10:55:50 +0200 Subject: [PATCH 10/12] feat(agent-applications): drop the legacy integrations field from AgentSpec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI removal of the integrations section (IntegrationsOverview/IntegrationBody, cfg:integrations node, SECTION_INFO entry) left `AgentSpec.integrations?: string[]` behind — a soft regression where a shipped spec's integrations would be silently un-editable. Nothing in the app reads the field (the agent-platform schema already dropped it), so remove it for a total removal. Per review: this is a deliberate "nobody uses integrations" call, not collateral from the MCP refactor. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/shared/src/agent-platform-types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/shared/src/agent-platform-types.ts b/packages/shared/src/agent-platform-types.ts index 52965dca12..71af61dcde 100644 --- a/packages/shared/src/agent-platform-types.ts +++ b/packages/shared/src/agent-platform-types.ts @@ -67,7 +67,6 @@ export interface AgentSpec { tools?: unknown[]; mcps?: unknown[]; skills?: unknown[]; - integrations?: string[]; secrets?: string[]; limits?: { max_turns?: number; From f0077797de3103197e5455851ad12d6b9acd2efd Mon Sep 17 00:00:00 2001 From: Ben White Date: Fri, 26 Jun 2026 12:32:15 +0200 Subject: [PATCH 11/12] feat(agent-applications): per-agent MCP tool permissions UI (default + per-tool overrides) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connection MCP detail now shows the server's live tool catalog (useMcpInstallationTools) with a connection-wide default permission and per-tool overrides, reusing the standalone manager's ToolRow / ToolPolicyToggle. Levels map allow/approve/deny <-> approved/needs_approval/do_not_use at the boundary; edits persist to spec.mcps[].default_tool_approval + tools[].level via the existing draft-branch-then-PATCH apply(). Add-from-connection now stamps a safe default_tool_approval: 'approve' so the runtime and UI agree from first save. Also clarifies the agent-level (shared connection — owner connects once, every asker reuses) vs principal-level (per-asker identity provider) distinction in the connection description. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/AgentConfigurationPane.tsx | 204 ++++++++++++++---- 1 file changed, 167 insertions(+), 37 deletions(-) diff --git a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx index a00d87d175..964da3f279 100644 --- a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx @@ -20,6 +20,10 @@ import { WebhooksLogoIcon, WrenchIcon, } from "@phosphor-icons/react"; +import type { + McpApprovalState, + McpInstallationTool, +} from "@posthog/api-client/posthog-client"; import type { AgentRevisionState, AgentSpec, @@ -28,6 +32,9 @@ import type { 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 { ToolPolicyToggle } from "@posthog/ui/features/mcp-servers/components/parts/ToolPolicyToggle"; +import { ToolRow } from "@posthog/ui/features/mcp-servers/components/parts/ToolRow"; +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"; @@ -159,6 +166,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 reused ToolRow/ToolPolicyToggle speak +// the mcp_store vocabulary (approved/needs_approval/do_not_use). Map at the +// boundary so those components are 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[]; @@ -1345,6 +1386,10 @@ function McpsOverview({ spec, ctx }: { spec: AgentSpec; ctx: Ctx }) { 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( { @@ -1459,6 +1504,18 @@ function McpBody({ 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; + // 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 — @@ -1516,6 +1573,32 @@ function McpBody({ })); }; + // 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. @@ -1547,8 +1630,11 @@ function McpBody({
Connection - One shared MCP connection an owner connected once — used by every - asker. Supersedes per-asker auth and bring-your-own-token. + 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. ) : 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 requiresApproval = - typeof t === "object" && rec(t).requires_approval === true; - return ( - - - {name} - - - Requires approval - - setToolApproval(name, v === true)} - disabled={!canEdit || saving} - /> - - ); - })} + {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. + + + Default + setDefaultLevel(APPROVAL_TO_LEVEL[v])} + disabled={!canEdit || saving} + /> - )} -
+
+ {catalogLoading ? ( + Loading the server's tools… + ) : catalogTools.length === 0 ? ( + + No tools discovered yet — they appear once the connection is + verified. + + ) : ( + + {catalogTools.map((t: McpInstallationTool) => { + const level = overrides.get(t.tool_name) ?? effectiveDefault; + return ( + + setToolLevel(t.tool_name, APPROVAL_TO_LEVEL[state]) + } + /> + ); + })} + + )} +
+
+ ) : ( +
+ 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 ? ( From c6fa42403be1bb2594e76abf187504a641e01fff Mon Sep 17 00:00:00 2001 From: Ben White Date: Fri, 26 Jun 2026 14:22:15 +0200 Subject: [PATCH 12/12] feat(agent-applications): shared ToolPermissionList for MCP tool permissions Extract the searchable/expandable tool list (counts, per-tool policy toggle, expandable descriptions) into a presentational ToolPermissionList beside the mcp-servers parts, and wire McpBody to it. The parent owns config + persistence (spec default_tool_approval + per-tool level overrides); the component is callback-driven so it can also back the global MCP-servers manager unchanged. ServerDetailView is left as-is. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/AgentConfigurationPane.tsx | 60 ++-- .../components/parts/ToolPermissionList.tsx | 321 ++++++++++++++++++ 2 files changed, 346 insertions(+), 35 deletions(-) create mode 100644 packages/ui/src/features/mcp-servers/components/parts/ToolPermissionList.tsx diff --git a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx index 964da3f279..7142d55849 100644 --- a/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx +++ b/packages/ui/src/features/agent-applications/components/AgentConfigurationPane.tsx @@ -32,8 +32,7 @@ import type { 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 { ToolPolicyToggle } from "@posthog/ui/features/mcp-servers/components/parts/ToolPolicyToggle"; -import { ToolRow } from "@posthog/ui/features/mcp-servers/components/parts/ToolRow"; +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"; @@ -168,9 +167,9 @@ function mcpProvider(m: unknown): string | undefined { } // --- Per-agent MCP tool permissions (agent-level shared connection) --- -// The spec carries allow/approve/deny; the reused ToolRow/ToolPolicyToggle speak -// the mcp_store vocabulary (approved/needs_approval/do_not_use). Map at the -// boundary so those components are reused verbatim. +// 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. @@ -1515,6 +1514,14 @@ function McpBody({ 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. @@ -1717,38 +1724,21 @@ function McpBody({ individual tools below. Allow = runs automatically · Approve = asks the approver each call · Deny = hidden from the agent. - - Default - setDefaultLevel(APPROVAL_TO_LEVEL[v])} +
+ 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." /> - -
- {catalogLoading ? ( - Loading the server's tools… - ) : catalogTools.length === 0 ? ( - - No tools discovered yet — they appear once the connection is - verified. - - ) : ( - - {catalogTools.map((t: McpInstallationTool) => { - const level = overrides.get(t.tool_name) ?? effectiveDefault; - return ( - - setToolLevel(t.tool_name, APPROVAL_TO_LEVEL[state]) - } - /> - ); - })} - - )}
) : ( 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 && ( + + + + )} + + ); +}