Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
AgentSessionLogsParams,
AgentSessionsListParams,
AgentSlackManifest,
AgentSpec,
AgentUsersListResponse,
BundleFile,
DecideApprovalRequest,
Expand Down Expand Up @@ -4688,6 +4689,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<AgentRevision> {
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
Expand Down
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 @@ -67,7 +67,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