Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
4ed6e26
feat(agent-applications): manage MCP connections in the agent builder
benjackwhite Jun 25, 2026
a9fd6d1
fix(agent-applications): address MCP-connections review nits
benjackwhite Jun 25, 2026
41eced9
feat(agent-applications): connect_mcp punch-out in the agent builder …
benjackwhite Jun 25, 2026
9b8846d
feat(agent-applications): always show top-level config sections; drop…
benjackwhite Jun 25, 2026
cfae9e1
feat(agent-applications): declare supported_client_tools at /run
benjackwhite Jun 25, 2026
cbbf4cb
feat(agent-applications): render the connect_mcp punch-out as a modal
benjackwhite Jun 25, 2026
9428abd
feat(agent-applications): send supported_client_tools at /run
benjackwhite Jun 25, 2026
58035d7
feat(agent-applications): pop the Connect-new MCP form out as a modal
benjackwhite Jun 25, 2026
18f09bb
Merge remote-tracking branch 'origin/ben/agent-supported-client-tools…
benjackwhite Jun 25, 2026
0811b54
feat(agent-applications): remove an MCP server from an agent
benjackwhite Jun 25, 2026
a8e8ba6
Merge remote-tracking branch 'origin/main' into ben/agent-mcp-connect…
benjackwhite Jun 26, 2026
7708119
feat(agent-applications): drop the legacy integrations field from Age…
benjackwhite Jun 26, 2026
dad3247
Merge remote-tracking branch 'origin/main' into ben/agent-mcp-connect…
benjackwhite Jun 26, 2026
f007779
feat(agent-applications): per-agent MCP tool permissions UI (default …
benjackwhite Jun 26, 2026
c6fa424
feat(agent-applications): shared ToolPermissionList for MCP tool perm…
benjackwhite Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/shared/src/agent-platform-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ export interface AgentSpec {
tools?: unknown[];
mcps?: unknown[];
skills?: unknown[];
integrations?: string[];
secrets?: string[];
limits?: {
max_turns?: number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import {
SidebarSimpleIcon,
SparkleIcon,
} from "@phosphor-icons/react";
import type { AgentSpec } from "@posthog/shared/agent-platform-types";
import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient";
import {
type CustomServerInput,
useMcpConnect,
} from "@posthog/ui/features/mcp-server-manager/useMcpConnect";
import { Button } from "@posthog/ui/primitives/Button";
import { Flex, Text, Tooltip } from "@radix-ui/themes";
import { useEffect, useRef, useState } from "react";
Expand All @@ -16,6 +21,7 @@ import { AgentDetailEmptyState } from "../components/AgentDetailLayout";
import { useAgentChat } from "../hooks/useAgentChat";
import { useAgentChatPendingApproval } from "../hooks/useAgentChatPendingApproval";
import { agentIngressBaseUrl } from "../utils/ingress";
import { AgentBuilderMcpConnectDialog } from "./AgentBuilderMcpConnectDialog";
import { AgentBuilderSecretForm } from "./AgentBuilderSecretForm";
import { AgentBuilderSeedDialog } from "./AgentBuilderSeedDialog";
import {
Expand Down Expand Up @@ -67,6 +73,27 @@ function buildAgentBuilderContext(
};
}

/** Derive a unique, stable `mcps[].id` (tool-name prefix) from a label, avoiding
* collisions with existing entries. Mirrors the config pane's add-from-connection. */
function uniqueMcpId(label: string, mcps: unknown[]): string {
const base =
(label || "mcp")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 32) || "mcp";
const taken = new Set(
mcps.map((m) =>
m && typeof m === "object"
? (m as Record<string, unknown>).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
Expand Down Expand Up @@ -99,9 +126,15 @@ export function AgentBuilderDock() {
const consumeSeed = useAgentBuilderStore((s) => s.consumeSeed);
const pendingSecret = useAgentBuilderStore((s) => s.pendingSecret);
const setPendingSecret = useAgentBuilderStore((s) => s.setPendingSecret);
const pendingMcpConnect = useAgentBuilderStore((s) => s.pendingMcpConnect);
const setPendingMcpConnect = useAgentBuilderStore(
(s) => s.setPendingMcpConnect,
);
const lastSession = useAgentBuilderStore((s) => s.lastSession);
const setLastSession = useAgentBuilderStore((s) => s.setLastSession);
const { connectCustomAsync, refetchInstallations } = useMcpConnect();
const [secretBusy, setSecretBusy] = useState(false);
const [mcpConnectBusy, setMcpConnectBusy] = useState(false);
const [placeholder] = useState(
() =>
BUILDER_PLACEHOLDERS[
Expand Down Expand Up @@ -213,6 +246,78 @@ export function AgentBuilderDock() {
setPendingSecret(null);
}

// Resolve a pending connect_mcp: run the native connect (OAuth/api-key handoff
// — tokens never reach the agent), then attach the resulting connection to the
// target agent's draft spec and wake the parked session with the outcome.
async function submitMcpConnect(values: CustomServerInput) {
const pending = pendingMcpConnect;
if (!pending) return;
setMcpConnectBusy(true);
try {
const result = await connectCustomAsync(values);
if (result && "error" in result && result.error) {
throw new Error(result.error);
}
// The new install is keyed by url server-side ((team, user, url)); refetch
// and match to recover its id (the OAuth callback doesn't return it).
const installs = await refetchInstallations();
const install = installs.find((i) => i.url === values.url);
if (!install) {
throw new Error("connection_not_found_after_connect");
}
// Attach to the target agent's spec: load → append an mcps[] entry that
// references the connection → PATCH the (draft) revision.
const rev = await client.getAgentRevision(
pending.agentSlug,
pending.revisionId,
);
if (!rev) {
throw new Error("revision_not_found");
}
const spec = (rev.spec ?? {}) as AgentSpec;
const mcps = Array.isArray(spec.mcps) ? [...spec.mcps] : [];
const mcpId = uniqueMcpId(values.name || values.url, mcps);
mcps.push({
id: mcpId,
url: values.url,
connection: install.id,
secrets: [],
});
await client.updateAgentRevisionSpec(
pending.agentSlug,
pending.revisionId,
{
...spec,
mcps,
},
);
await chat.resolveInteractiveTool(pending.callId, {
result: {
connected: true,
connection_id: install.id,
mcp_id: mcpId,
url: values.url,
},
});
setPendingMcpConnect(null);
} catch (err) {
await chat.resolveInteractiveTool(pending.callId, {
error: err instanceof Error ? err.message : "connect_mcp_failed",
});
setPendingMcpConnect(null);
} finally {
setMcpConnectBusy(false);
}
}

function cancelMcpConnect() {
if (!pendingMcpConnect) return;
void chat.resolveInteractiveTool(pendingMcpConnect.callId, {
error: "user_cancelled",
});
setPendingMcpConnect(null);
}

// Edit-with-AI hand-offs: send the seeded prompt once when a new seed lands.
// An empty dock starts immediately; if a chat is already in progress, confirm
// whether to start fresh or continue (so a deliberate "New agent" / "Edit with
Expand All @@ -233,6 +338,7 @@ export function AgentBuilderDock() {
function seedStartFresh() {
if (!seedConfirm) return;
setPendingSecret(null);
setPendingMcpConnect(null);
chat.newChat();
setLastSession(null);
chat.send(seedConfirm);
Expand Down Expand Up @@ -285,6 +391,7 @@ export function AgentBuilderDock() {
size="1"
onClick={() => {
setPendingSecret(null);
setPendingMcpConnect(null);
chat.newChat();
setLastSession(null);
}}
Expand Down Expand Up @@ -355,6 +462,13 @@ export function AgentBuilderDock() {
onContinue={seedContinue}
onCancel={() => setSeedConfirm(null)}
/>

<AgentBuilderMcpConnectDialog
pending={pendingMcpConnect}
busy={mcpConnectBusy}
onSubmit={submitMcpConnect}
onCancel={cancelMcpConnect}
/>
</Flex>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { AddCustomServerDialog } from "@posthog/ui/features/mcp-server-manager/AddCustomServerDialog";
import type { CustomServerInput } from "@posthog/ui/features/mcp-server-manager/useMcpConnect";
import type { PendingMcpConnect } from "./agentBuilderStore";

/**
* Modal for the agent builder's `connect_mcp` punch-out. The agent parks its
* turn and supplies a prefilled name/url; the user reviews + completes the
* connect (OAuth / api key) here — the agent never sees the credentials. On
* success the connection is written onto the target agent's spec and the
* session woken. Thin wrapper over {@link AddCustomServerDialog} with
* punch-out-specific copy and the agent's prefilled values.
*/
export function AgentBuilderMcpConnectDialog({
pending,
busy,
onSubmit,
onCancel,
}: {
pending: PendingMcpConnect | null;
busy: boolean;
onSubmit: (values: CustomServerInput) => void;
onCancel: () => void;
}) {
return (
<AddCustomServerDialog
open={!!pending}
pending={busy}
onOpenChange={(open) => {
if (!open) onCancel();
}}
onSubmit={onSubmit}
initialValues={
pending ? { name: pending.name, url: pending.url } : undefined
}
title="Connect an MCP server"
description={
pending?.purpose ??
"Connect a server for this agent. You complete the sign-in — the agent builder never sees your credentials."
}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -109,6 +136,7 @@ export const useAgentBuilderStore = create<AgentBuilderStore>()(
page: { kind: "unknown" },
seed: null,
pendingSecret: null,
pendingMcpConnect: null,
lastSession: null,

toggleVisible: () => set((s) => ({ visible: !s.visible })),
Expand All @@ -123,6 +151,7 @@ export const useAgentBuilderStore = create<AgentBuilderStore>()(
consumeSeed: (seq) =>
set((s) => (s.seed?.seq === seq ? { seed: null } : s)),
setPendingSecret: (pendingSecret) => set({ pendingSecret }),
setPendingMcpConnect: (pendingMcpConnect) => set({ pendingMcpConnect }),
setLastSession: (lastSession) => set({ lastSession }),
}),
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { useAgentBuilderStore } from "./agentBuilderStore";
/**
* The `kind:'client'` tool ids the agent-builder dock can fulfil — sent to the
* runner as `supported_client_tools` at /run so it exposes only these to the
* model. Keep in sync with the handler below plus the built-in toast/get_context.
* model. Keep in sync with the handlers below (plus the built-in
* toast/get_context). `set_secret`/`connect_mcp` are interactive punch-outs.
*/
export const AGENT_BUILDER_CLIENT_TOOLS = [
"set_secret",
"connect_mcp",
"focus_tab",
"focus_file",
"focus_spec_section",
Expand All @@ -33,6 +35,9 @@ export function useAgentBuilderClientTools(): ClientToolHandler {
const navigate = useNavigate();
const followMode = useAgentBuilderStore((s) => s.followMode);
const setPendingSecret = useAgentBuilderStore((s) => s.setPendingSecret);
const setPendingMcpConnect = useAgentBuilderStore(
(s) => s.setPendingMcpConnect,
);
const page = useAgentBuilderStore((s) => s.page);
const followRef = useRef(followMode);
followRef.current = followMode;
Expand Down Expand Up @@ -72,6 +77,29 @@ export function useAgentBuilderClientTools(): ClientToolHandler {
return { defer: true };
}

// connect_mcp — interactive punch-out. Park the call and render a prefilled
// connect form; the dock runs the native OAuth/api-key connect (auth never
// touches the agent), writes the resulting mcps[].connection onto the
// target agent's spec, and wakes the session. Like set_secret, the target
// revision comes from the args or the current agent-config page.
if (data.tool_id === "connect_mcp") {
const agentSlug = str(args.agent_slug);
if (!agentSlug) return { error: "missing_arg: agent_slug" };
const p = pageRef.current;
const pageRevision = p.kind === "agent-config" ? p.revision : undefined;
const revisionId = str(args.revision_id) ?? pageRevision;
if (!revisionId) return { error: "missing_arg: revision_id" };
setPendingMcpConnect({
callId: data.call_id,
agentSlug,
revisionId,
name: str(args.name),
url: str(args.url),
purpose: str(args.purpose),
});
return { defer: true };
}

if (!data.tool_id.startsWith("focus_")) return null;
const slug = str(args.slug);
if (!followRef.current) {
Expand Down Expand Up @@ -166,6 +194,6 @@ export function useAgentBuilderClientTools(): ClientToolHandler {
return { result: { focused: false, reason: "unknown_focus_target" } };
}
},
[navigate, setPendingSecret],
[navigate, setPendingSecret, setPendingMcpConnect],
);
}
Loading
Loading