From be051364623dc521c1a0c524ec9488ae6e6a8891 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:26:36 +0000 Subject: [PATCH 01/32] feat(inbox): make report detail sections collapsible Web Inbox lets users collapse the report Summary, Evidence, Runs, and Reviewers sections. Desktop rendered all of them permanently open, and mobile only collapsed Signals. Desktop: `DetailSection` and `RightColumnSection` take `collapsible` (plus `defaultCollapsed`), turning the header into an `aria-expanded` disclosure button that shares a caret with the other. `rightSlot` stays outside the button so the Evidence count and the Reviewers "Add" popover keep their own click targets. Summary, Evidence, Runs, and Reviewers opt in; the data hooks are untouched, so collapsing only hides rendered content. `pr-review`'s `PrSectionHeader` was a near-copy of that chrome, so `PrCommentsSection` now uses the shared primitive and the duplicate is gone. Mobile: new `ReportSection` disclosure carries the accessible expanded state, and Summary and Suggested reviewers join the existing Signals toggle. Signals keeps its expand analytics. Generated-By: PostHog Code Task-Id: 6694c9d5-a520-4499-9af3-b46a15ab27dd --- apps/mobile/src/app/inbox/[...id].tsx | 64 +++--- .../inbox/components/ReportSection.tsx | 70 +++++++ .../inbox/components/SuggestedReviewers.tsx | 183 +++++++++--------- packages/ui/src/features/inbox/CLAUDE.md | 2 +- .../inbox/components/DetailSection.test.tsx | 80 ++++++++ .../inbox/components/DetailSection.tsx | 68 +++++-- .../inbox/components/InboxDetailFrame.tsx | 7 +- .../inbox/components/ReportTasksSection.tsx | 2 +- .../inbox/components/RightColumnSection.tsx | 52 ++++- .../components/SuggestedReviewersSection.tsx | 1 + .../components/utils/SectionCollapseCaret.tsx | 26 +++ .../features/pr-review/PrCommentsSection.tsx | 55 +++--- .../features/pr-review/PrSectionHeader.tsx | 50 ----- 13 files changed, 432 insertions(+), 228 deletions(-) create mode 100644 apps/mobile/src/features/inbox/components/ReportSection.tsx create mode 100644 packages/ui/src/features/inbox/components/DetailSection.test.tsx create mode 100644 packages/ui/src/features/inbox/components/utils/SectionCollapseCaret.tsx delete mode 100644 packages/ui/src/features/pr-review/PrSectionHeader.tsx diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx index b688222e82..abc2d2690e 100644 --- a/apps/mobile/src/app/inbox/[...id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -16,7 +16,6 @@ import * as Haptics from "expo-haptics"; import { useLocalSearchParams, useRouter } from "expo-router"; import { CaretDown, - CaretRight, ChatCircle, Lightning, Play, @@ -47,6 +46,7 @@ import { } from "@/features/inbox/components/DismissReportSheet"; import { ReportActivity } from "@/features/inbox/components/ReportActivity"; import { ReportFeedbackFooter } from "@/features/inbox/components/ReportFeedbackFooter"; +import { ReportSection } from "@/features/inbox/components/ReportSection"; import { SignalCard } from "@/features/inbox/components/SignalCard"; import { type ReviewerActionExtra, @@ -156,6 +156,7 @@ export default function ReportDetailScreen() { const [dismissOpen, setDismissOpen] = useState(false); const [discussOpen, setDiscussOpen] = useState(false); const [createPrFeedbackOpen, setCreatePrFeedbackOpen] = useState(false); + const [summaryExpanded, setSummaryExpanded] = useState(true); const [signalsExpanded, setSignalsExpanded] = useState(false); const artefactsQuery = useInboxReportArtefacts(reportId ?? null); @@ -519,11 +520,17 @@ export default function ReportDetailScreen() { {/* Summary */} {report.summary && ( - - - + setSummaryExpanded((prev) => !prev)} + > + + + + )} {/* Suggested reviewers */} @@ -538,35 +545,22 @@ export default function ReportDetailScreen() { {/* Signals */} {signals.length > 0 && ( - - - {signalsExpanded ? ( - - ) : ( - - )} - - Signals ({signals.length}) - - - {signalsExpanded && ( - - {signals.map((signal) => ( - - ))} - - )} - + + + {signals.map((signal) => ( + + ))} + + )} {signalsQuery.isLoading && ( Loading signals… diff --git a/apps/mobile/src/features/inbox/components/ReportSection.tsx b/apps/mobile/src/features/inbox/components/ReportSection.tsx new file mode 100644 index 0000000000..9574f86e68 --- /dev/null +++ b/apps/mobile/src/features/inbox/components/ReportSection.tsx @@ -0,0 +1,70 @@ +import { Text } from "@components/text"; +import { CaretDown, CaretRight } from "phosphor-react-native"; +import type { ReactNode } from "react"; +import { Pressable, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; + +interface ReportSectionProps { + title: string; + /** Appended to the title as `(n)` – omit for sections without a count. */ + count?: number; + /** Icon rendered before the title (e.g. ``). */ + icon?: ReactNode; + expanded: boolean; + onToggle: () => void; + /** + * Controls rendered at the end of the header row, outside the disclosure so + * they stay independently tappable. + */ + rightSlot?: ReactNode; + children: ReactNode; +} + +/** + * Collapsible section on the report detail screen (Summary, Reviewers, + * Signals). Mirrors the desktop `DetailSection` disclosure so the same report + * can be scanned the same way on either host. Expansion state is owned by the + * caller – the Signals disclosure fires analytics when it opens. + */ +export function ReportSection({ + title, + count, + icon, + expanded, + onToggle, + rightSlot, + children, +}: ReportSectionProps) { + const themeColors = useThemeColors(); + + return ( + + + + {expanded ? ( + + ) : ( + + )} + {icon} + + {count === undefined ? title : `${title} (${count})`} + + + {rightSlot ? ( + <> + + {rightSlot} + + ) : null} + + {expanded ? children : null} + + ); +} diff --git a/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx b/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx index ced7aac5ea..9ccf7b83fa 100644 --- a/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx +++ b/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx @@ -26,6 +26,7 @@ import { openExternalUrl } from "@/lib/openExternalUrl"; import { useThemeColors } from "@/lib/theme"; import { useUpdateSuggestedReviewers } from "../hooks/useInboxReports"; import { EditReviewersSheet } from "./EditReviewersSheet"; +import { ReportSection } from "./ReportSection"; export type ReviewerActionExtra = Pick< InboxReportActionProperties, @@ -50,6 +51,7 @@ export function SuggestedReviewers({ }: SuggestedReviewersProps) { const themeColors = useThemeColors(); const [editOpen, setEditOpen] = useState(false); + const [expanded, setExpanded] = useState(true); const { mutate: updateReviewers, isPending } = useUpdateSuggestedReviewers(reportId); @@ -108,96 +110,99 @@ export function SuggestedReviewers({ }; return ( - - - - Suggested reviewers - - {isPending && ( - - )} - - setEditOpen(true)} - disabled={isPending} - accessibilityLabel="Add suggested reviewer" - hitSlop={6} - className="flex-row items-center gap-1 rounded-full border border-gray-6 px-2.5 py-1 active:opacity-70 disabled:opacity-50" - > - - Add - - - - {displayReviewers.length === 0 ? ( - - No reviewers assigned. Use “Add” to suggest one. - - ) : ( - - {displayReviewers.map((reviewer) => { - const isMe = - !!reviewer.user?.uuid && - !!meUuid && - reviewer.user.uuid === meUuid; - const displayName = - reviewer.user?.first_name ?? - reviewer.github_name ?? - reviewer.github_login; - return ( - - { - fireAction("click_suggested_reviewer", { - suggested_reviewer_login: reviewer.github_login, - }); - openExternalUrl( - `https://github.com/${reviewer.github_login}`, - ); - }} - hitSlop={4} - className="flex-row items-center gap-2 active:opacity-70" + <> + setExpanded((prev) => !prev)} + rightSlot={ + + {isPending && ( + + )} + setEditOpen(true)} + disabled={isPending} + accessibilityLabel="Add suggested reviewer" + hitSlop={6} + className="flex-row items-center gap-1 rounded-full border border-gray-6 px-2.5 py-1 active:opacity-70 disabled:opacity-50" + > + + Add + + + } + > + {displayReviewers.length === 0 ? ( + + No reviewers assigned. Use “Add” to suggest one. + + ) : ( + + {displayReviewers.map((reviewer) => { + const isMe = + !!reviewer.user?.uuid && + !!meUuid && + reviewer.user.uuid === meUuid; + const displayName = + reviewer.user?.first_name ?? + reviewer.github_name ?? + reviewer.github_login; + return ( + - { + fireAction("click_suggested_reviewer", { + suggested_reviewer_login: reviewer.github_login, + }); + openExternalUrl( + `https://github.com/${reviewer.github_login}`, + ); }} - className="h-6 w-6 rounded-full bg-gray-4" - /> - - {displayName} - - {isMe && ( - - - - )} - - removeReviewer(reviewer)} - disabled={isPending} - accessibilityLabel={`Remove ${displayName}`} - hitSlop={6} - className="h-5 w-5 items-center justify-center rounded-full active:bg-gray-4 disabled:opacity-50" - > - - - - ); - })} - - )} + hitSlop={4} + className="flex-row items-center gap-2 active:opacity-70" + > + + + {displayName} + + {isMe && ( + + + + )} + + removeReviewer(reviewer)} + disabled={isPending} + accessibilityLabel={`Remove ${displayName}`} + hitSlop={6} + className="h-5 w-5 items-center justify-center rounded-full active:bg-gray-4 disabled:opacity-50" + > + + + + ); + })} + + )} + setEditOpen(false)} onToggle={toggleReviewer} /> - + ); } diff --git a/packages/ui/src/features/inbox/CLAUDE.md b/packages/ui/src/features/inbox/CLAUDE.md index b8b8273d57..60fe7c6593 100644 --- a/packages/ui/src/features/inbox/CLAUDE.md +++ b/packages/ui/src/features/inbox/CLAUDE.md @@ -123,7 +123,7 @@ The current UI is single-column, route-based, and card/list oriented. Do not rei Shared primitives exist to keep the surfaces consistent: - `InboxDetailPageHeader` for detail headers. -- `DetailSection` for content sections inside detail screens. +- `DetailSection` for main-column content sections inside detail screens, and `RightColumnSection` for the slimmer supporting sections beside them. Both take `collapsible` (plus `defaultCollapsed`) to turn the header into a disclosure button; `rightSlot` stays outside that button so its own controls remain clickable. Mobile's equivalent is `apps/mobile/src/features/inbox/components/ReportSection.tsx`. - `SignalsList` and the existing detail `SignalCard` for contributing signals. - Badge and metadata helpers in `components/utils/` and `InboxMetaRow`. - `SOURCE_PRODUCT_META` for source-product labels and icons. diff --git a/packages/ui/src/features/inbox/components/DetailSection.test.tsx b/packages/ui/src/features/inbox/components/DetailSection.test.tsx new file mode 100644 index 0000000000..39bb62bcd6 --- /dev/null +++ b/packages/ui/src/features/inbox/components/DetailSection.test.tsx @@ -0,0 +1,80 @@ +import { FileTextIcon } from "@phosphor-icons/react"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { DetailSection } from "./DetailSection"; +import { RightColumnSection } from "./RightColumnSection"; + +// Both column styles share the same collapsible contract, so exercise them +// through one table – a regression in either primitive collapses (or fails to +// collapse) sections on the report detail. +const SECTIONS = [ + { name: "DetailSection", Section: DetailSection }, + { name: "RightColumnSection", Section: RightColumnSection }, +]; + +describe.each(SECTIONS)("$name", ({ Section }) => { + const renderSection = ( + props: Partial[0]> = {}, + ) => { + render( + +
+

body

+
+
, + ); + }; + + it("renders the body with no toggle when not collapsible", () => { + renderSection(); + + expect(screen.getByText("body")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("hides and restores the body from the header, reporting expanded state", async () => { + const user = userEvent.setup(); + renderSection({ collapsible: true }); + + const toggle = screen.getByRole("button", { name: /summary/i }); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText("body")).not.toBeInTheDocument(); + + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("body")).toBeInTheDocument(); + }); + + it("starts collapsed with defaultCollapsed", () => { + renderSection({ collapsible: true, defaultCollapsed: true }); + + expect(screen.getByRole("button", { name: /summary/i })).toHaveAttribute( + "aria-expanded", + "false", + ); + expect(screen.queryByText("body")).not.toBeInTheDocument(); + }); + + it("leaves rightSlot controls outside the toggle so they stay clickable", async () => { + const user = userEvent.setup(); + const onAdd = vi.fn(); + renderSection({ + collapsible: true, + rightSlot: ( + + ), + }); + + await user.click(screen.getByRole("button", { name: "Add" })); + + expect(onAdd).toHaveBeenCalledOnce(); + expect(screen.getByText("body")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/inbox/components/DetailSection.tsx b/packages/ui/src/features/inbox/components/DetailSection.tsx index c72d787816..357b43e84a 100644 --- a/packages/ui/src/features/inbox/components/DetailSection.tsx +++ b/packages/ui/src/features/inbox/components/DetailSection.tsx @@ -1,38 +1,78 @@ import type { IconProps } from "@phosphor-icons/react"; +import { SectionCollapseCaret } from "@posthog/ui/features/inbox/components/utils/SectionCollapseCaret"; import { Flex, Text } from "@radix-ui/themes"; import type { ComponentType, ReactNode } from "react"; +import { useState } from "react"; interface DetailSectionProps { Icon: ComponentType; title: string; + /** + * Controls rendered at the end of the header row. Kept outside the collapse + * toggle so its own click targets stay independently usable. + */ rightSlot?: ReactNode; + /** When set, the icon + title row toggles the body open and closed. */ + collapsible?: boolean; + /** Start collapsed. Only honoured together with `collapsible`. */ + defaultCollapsed?: boolean; children: ReactNode; } +/** + * Main-column content section: icon + title, a spanning divider, then the + * body. Pass `collapsible` to turn the header into a disclosure button – the + * divider and `rightSlot` keep their geometry either way, so a collapsible + * section sits on the same baseline as a static one. + */ export function DetailSection({ Icon, title, rightSlot, + collapsible = false, + defaultCollapsed = false, children, }: DetailSectionProps) { + const [collapsed, setCollapsed] = useState(defaultCollapsed); + const open = !collapsible || !collapsed; + + const header = ( + <> + + + + {title} + + +
+ {collapsible && } + + ); + return ( - - - - - {title} - - -
+ + {collapsible ? ( + + ) : ( + + {header} + + )} {rightSlot &&
{rightSlot}
}
-
{children}
+ {open &&
{children}
} ); } diff --git a/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx b/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx index e122dcd0ae..e845a2ab22 100644 --- a/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx +++ b/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx @@ -178,7 +178,11 @@ export function InboxDetailFrame({
- + {evidenceCount} signal diff --git a/packages/ui/src/features/inbox/components/ReportTasksSection.tsx b/packages/ui/src/features/inbox/components/ReportTasksSection.tsx index 905d6bb3a4..5098de0338 100644 --- a/packages/ui/src/features/inbox/components/ReportTasksSection.tsx +++ b/packages/ui/src/features/inbox/components/ReportTasksSection.tsx @@ -21,7 +21,7 @@ export function ReportTasksSection({ report }: ReportTasksSectionProps) { if (!reportTasks || reportTasks.length === 0) return null; return ( - + {reportTasks.map(({ task, purposeLabel }) => ( ; title: string; + /** + * Controls rendered at the end of the caption row. Kept outside the collapse + * toggle so its own click targets stay independently usable. + */ rightSlot?: ReactNode; + /** When set, the caption toggles the body open and closed. */ + collapsible?: boolean; + /** Start collapsed. Only honoured together with `collapsible`. */ + defaultCollapsed?: boolean; children: ReactNode; } @@ -13,31 +23,55 @@ interface RightColumnSectionProps { * Slim caption header used by every section in the detail-view right column. * Smaller and lighter than `DetailSection` (no spanning divider) so the * side column reads as supporting detail rather than competing with the - * main Summary on the left. + * main Summary on the left. Pass `collapsible` to turn the caption into a + * disclosure button. */ export function RightColumnSection({ Icon, title, rightSlot, + collapsible = false, + defaultCollapsed = false, children, }: RightColumnSectionProps) { + const [collapsed, setCollapsed] = useState(defaultCollapsed); + const open = !collapsible || !collapsed; + + const caption = ( + <> + + + {title} + + {collapsible && } + + ); + return ( - - - - {title} - - + {collapsible ? ( + + ) : ( + + {caption} + + )} {rightSlot &&
{rightSlot}
}
-
{children}
+ {open &&
{children}
}
); } diff --git a/packages/ui/src/features/inbox/components/SuggestedReviewersSection.tsx b/packages/ui/src/features/inbox/components/SuggestedReviewersSection.tsx index 75e460435e..415898eddd 100644 --- a/packages/ui/src/features/inbox/components/SuggestedReviewersSection.tsx +++ b/packages/ui/src/features/inbox/components/SuggestedReviewersSection.tsx @@ -168,6 +168,7 @@ function SuggestedReviewersBody({ {isPending && } diff --git a/packages/ui/src/features/inbox/components/utils/SectionCollapseCaret.tsx b/packages/ui/src/features/inbox/components/utils/SectionCollapseCaret.tsx new file mode 100644 index 0000000000..6fe939e139 --- /dev/null +++ b/packages/ui/src/features/inbox/components/utils/SectionCollapseCaret.tsx @@ -0,0 +1,26 @@ +import { CaretDownIcon } from "@phosphor-icons/react"; +import { cn } from "@posthog/quill"; + +/** + * Disclosure caret for collapsible detail sections – points down when the + * section is open, right when it is collapsed. Shared by `DetailSection` and + * `RightColumnSection` so both column styles read the same way. + */ +export function SectionCollapseCaret({ + open, + size = 12, +}: { + open: boolean; + size?: number; +}) { + return ( + + ); +} diff --git a/packages/ui/src/features/pr-review/PrCommentsSection.tsx b/packages/ui/src/features/pr-review/PrCommentsSection.tsx index cf2d574367..a6202311d4 100644 --- a/packages/ui/src/features/pr-review/PrCommentsSection.tsx +++ b/packages/ui/src/features/pr-review/PrCommentsSection.tsx @@ -1,11 +1,11 @@ import { ArrowSquareOutIcon, ChatCircleIcon } from "@phosphor-icons/react"; import { Spinner } from "@posthog/quill"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { DetailSection } from "@posthog/ui/features/inbox/components/DetailSection"; import { NestedButton } from "@posthog/ui/primitives/NestedButton"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { openExternalUrl } from "../../shell/openExternal"; -import { PrSectionHeader } from "./PrSectionHeader"; import { usePrComments } from "./usePrComments"; import { usePrReviewThreads } from "./usePrReviewThreads"; @@ -32,7 +32,6 @@ interface PrCommentsSectionProps { export function PrCommentsSection({ prUrl }: PrCommentsSectionProps) { const commentsQuery = usePrComments(prUrl); const threadsQuery = usePrReviewThreads(prUrl); - const [collapsed, setCollapsed] = useState(true); const items = useMemo((): CommentItem[] => { // Conversation items mix issue comments and review summaries, whose ids @@ -70,18 +69,21 @@ export function PrCommentsSection({ prUrl }: PrCommentsSectionProps) { if (commentsQuery.isLoading || threadsQuery.isLoading) { return ( - {}} - summary={ + collapsible + defaultCollapsed + rightSlot={ Loading… } - /> + > + {/* Header-only while loading – the section lands collapsed anyway. */} + {null} +
); } @@ -110,26 +112,23 @@ export function PrCommentsSection({ prUrl }: PrCommentsSectionProps) { } return ( -
- setCollapsed(!collapsed)} - summary={ - - {items.length} comment{items.length === 1 ? "" : "s"} - - } - /> - {!collapsed && ( -
- {items.map((item) => ( - - ))} -
- )} -
+ + {items.length} comment{items.length === 1 ? "" : "s"} + + } + > +
+ {items.map((item) => ( + + ))} +
+
); } diff --git a/packages/ui/src/features/pr-review/PrSectionHeader.tsx b/packages/ui/src/features/pr-review/PrSectionHeader.tsx deleted file mode 100644 index 6a3227a421..0000000000 --- a/packages/ui/src/features/pr-review/PrSectionHeader.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { IconProps } from "@phosphor-icons/react"; -import { CaretDownIcon } from "@phosphor-icons/react"; -import { Text } from "@radix-ui/themes"; -import type { ComponentType, ReactNode } from "react"; - -/** - * Clickable section header matching the DetailSection chrome, for the - * collapsible PR sections (checks, comments). The right slot stays visible - * while collapsed so it can carry a summary. - */ -export function PrSectionHeader({ - Icon, - title, - collapsed, - onToggle, - summary, -}: { - Icon: ComponentType; - title: string; - collapsed: boolean; - onToggle: () => void; - summary?: ReactNode; -}) { - return ( - - ); -} From 692654eb05a811a8b699bcd563c176d5cf0ad1b7 Mon Sep 17 00:00:00 2001 From: Chris Volzer Date: Fri, 31 Jul 2026 09:00:14 -0400 Subject: [PATCH 02/32] feat(mcp-gateway): implement the team MCP gateway UI behind the mcp-gateway flag Replaces the per-user MCP marketplace with the team gateway surface from the design handoff when the mcp-gateway flag is on: servers home with connection status, server detail with per-scope tool policies and the admin access section, team & agents roster, agent service-account detail (identity, token rotation, shared servers, call history), member detail with per-server revocation, team settings (custom-server gate, approval baselines, server access, team rules), the audit log, and the gateway add-server form with sharing options. The legacy marketplace remains the fallback while the flag is off. Adds hand-written /api/projects/{id}/mcp_gateway/* client methods and types to @posthog/api-client (the endpoints are not in the generated OpenAPI client yet), and portable helpers with tests in @posthog/core/mcp-gateway. Generated-By: PostHog Code Task-Id: 7ecfb6f3-39d8-4443-96e7-36e9d1ebd144 --- packages/api-client/src/mcp-gateway.ts | 234 +++++ packages/api-client/src/posthog-client.ts | 374 +++++++- .../src/mcp-gateway/gatewayAddServer.test.ts | 109 +++ .../core/src/mcp-gateway/gatewayAddServer.ts | 96 ++ .../src/mcp-gateway/gatewayInstallFlow.ts | 40 + .../src/mcp-gateway/gatewayServers.test.ts | 224 +++++ .../core/src/mcp-gateway/gatewayServers.ts | 146 +++ packages/shared/src/flags.ts | 6 + .../mcp-gateway/components/McpGatewayView.tsx | 139 +++ .../components/parts/GatewayAddServer.tsx | 502 ++++++++++ .../components/parts/GatewayAgentDetail.tsx | 371 +++++++ .../components/parts/GatewayAuditLog.tsx | 283 ++++++ .../components/parts/GatewayMemberDetail.tsx | 187 ++++ .../components/parts/GatewayRail.tsx | 328 +++++++ .../components/parts/GatewayServerDetail.tsx | 907 ++++++++++++++++++ .../components/parts/GatewayServersHome.tsx | 303 ++++++ .../components/parts/GatewayTeamSettings.tsx | 323 +++++++ .../components/parts/GatewayTeamView.tsx | 283 ++++++ .../components/parts/GatewayToolRow.tsx | 146 +++ .../components/parts/GiveAccessDialog.tsx | 225 +++++ .../components/parts/NewTokenDialog.tsx | 75 ++ .../mcp-gateway/components/parts/avatars.tsx | 118 +++ .../src/features/mcp-gateway/gatewayRoute.ts | 30 + .../features/mcp-gateway/hooks/gatewayKeys.ts | 58 ++ .../mcp-gateway/hooks/useGatewayAudit.ts | 55 ++ .../mcp-gateway/hooks/useGatewayConfig.ts | 60 ++ .../mcp-gateway/hooks/useGatewayMembers.ts | 51 + .../mcp-gateway/hooks/useGatewayRules.ts | 38 + .../mcp-gateway/hooks/useGatewayServers.ts | 203 ++++ .../hooks/useGatewayToolPolicies.ts | 103 ++ .../hooks/useRegisterGatewayServer.ts | 42 + .../mcp-gateway/hooks/useServiceAccounts.ts | 142 +++ .../mcp-servers/components/McpServersView.tsx | 11 + 33 files changed, 6195 insertions(+), 17 deletions(-) create mode 100644 packages/api-client/src/mcp-gateway.ts create mode 100644 packages/core/src/mcp-gateway/gatewayAddServer.test.ts create mode 100644 packages/core/src/mcp-gateway/gatewayAddServer.ts create mode 100644 packages/core/src/mcp-gateway/gatewayInstallFlow.ts create mode 100644 packages/core/src/mcp-gateway/gatewayServers.test.ts create mode 100644 packages/core/src/mcp-gateway/gatewayServers.ts create mode 100644 packages/ui/src/features/mcp-gateway/components/McpGatewayView.tsx create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/GatewayAddServer.tsx create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/GatewayAgentDetail.tsx create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/GatewayAuditLog.tsx create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/GatewayMemberDetail.tsx create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/GatewayRail.tsx create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/GatewayServerDetail.tsx create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/GatewayServersHome.tsx create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/GatewayTeamSettings.tsx create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/GatewayTeamView.tsx create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/GatewayToolRow.tsx create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/GiveAccessDialog.tsx create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/NewTokenDialog.tsx create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/avatars.tsx create mode 100644 packages/ui/src/features/mcp-gateway/gatewayRoute.ts create mode 100644 packages/ui/src/features/mcp-gateway/hooks/gatewayKeys.ts create mode 100644 packages/ui/src/features/mcp-gateway/hooks/useGatewayAudit.ts create mode 100644 packages/ui/src/features/mcp-gateway/hooks/useGatewayConfig.ts create mode 100644 packages/ui/src/features/mcp-gateway/hooks/useGatewayMembers.ts create mode 100644 packages/ui/src/features/mcp-gateway/hooks/useGatewayRules.ts create mode 100644 packages/ui/src/features/mcp-gateway/hooks/useGatewayServers.ts create mode 100644 packages/ui/src/features/mcp-gateway/hooks/useGatewayToolPolicies.ts create mode 100644 packages/ui/src/features/mcp-gateway/hooks/useRegisterGatewayServer.ts create mode 100644 packages/ui/src/features/mcp-gateway/hooks/useServiceAccounts.ts diff --git a/packages/api-client/src/mcp-gateway.ts b/packages/api-client/src/mcp-gateway.ts new file mode 100644 index 0000000000..be6cbde060 --- /dev/null +++ b/packages/api-client/src/mcp-gateway.ts @@ -0,0 +1,234 @@ +// Types for the team MCP gateway API (`/api/projects/{id}/mcp_gateway/*`). +// Hand-written mirrors of the Django serializers in +// products/mcp_store/backend/presentation/gateway_views.py — these endpoints +// ship behind the `mcp-gateway` flag and are not in the generated OpenAPI +// client yet. +import type { Schemas } from "./generated"; +import type { McpApprovalState, McpCategory } from "./types"; + +export type McpGatewayUser = Schemas.UserBasic; + +export type McpGatewayAuthMode = "individual" | "shared"; +export type McpGatewayScopeType = "team" | "member" | "agent"; +export type McpPolicyPreset = "allow" | "user" | "ask" | "block"; +export type McpServiceAccountStatus = "active" | "paused"; +export type McpAuditDecision = "auto" | "approved" | "pending" | "blocked"; +export type McpAuditQuickFilter = "all" | "agents" | "approvals" | "blocked"; +export type McpOrgRuleAudience = "everyone" | "members" | "agents"; +export type McpOrgRuleEffect = "needs_approval" | "do_not_use"; +export type McpPolicyDecidedBy = + | "rule" + | "scope" + | "team" + | "preset" + | "legacy" + | "default"; + +/** One member's personal connection to a gateway server. */ +export interface McpGatewayConnection { + installation_id: string; + user: McpGatewayUser; + last_used_at: string | null; + pending_oauth: boolean; + needs_reauth: boolean; +} + +/** The requesting user's own connection to a gateway server. */ +export interface McpGatewayYourConnection { + installation_id: string; + scope: "personal" | "shared"; + /** Per-connection switch — false when self-disabled. */ + is_enabled: boolean; + pending_oauth: boolean; + needs_reauth: boolean; + last_used_at: string | null; +} + +/** The admin-managed shared credential of a shared-auth server. */ +export interface McpGatewaySharedCredential { + installation_id: string; + managed_by: McpGatewayUser | null; + is_enabled: boolean; + pending_oauth: boolean; + needs_reauth: boolean; + last_used_at: string | null; +} + +/** One agent's access to a gateway server. */ +export interface McpGatewayAgentAccess { + service_account_id: string; + name: string; + /** Agent identity handle, e.g. svc-support. */ + handle: string; + status: McpServiceAccountStatus; + last_active_at: string | null; + granted_by: McpGatewayUser | null; +} + +/** A server registered in the team's gateway, with connection summary. */ +export interface McpGatewayServer { + id: string; + name: string; + url: string; + description: string; + category: McpCategory; + auth_mode: McpGatewayAuthMode; + is_team_enabled: boolean; + allow_personal_connections: boolean; + icon_key: string; + docs_url: string; + template_id: string | null; + tool_count: number; + /** Members with a personal connection to this server. */ + connections: McpGatewayConnection[]; + your_connection: McpGatewayYourConnection | null; + /** Set when auth_mode is "shared", else null. */ + shared_credential: McpGatewaySharedCredential | null; + agents: McpGatewayAgentAccess[]; + /** Ids of members whose access an admin has turned off. */ + revoked_user_ids: number[]; + is_revoked_for_you: boolean; + created_by: McpGatewayUser | null; + created_at: string; + updated_at: string; +} + +export interface McpGatewayServerUpdate { + name?: string; + description?: string; + category?: McpCategory; + /** Master switch — off means members and agents can neither see nor call the server. */ + is_team_enabled?: boolean; + /** Shared-credential servers: whether members may also connect their own account. */ + allow_personal_connections?: boolean; +} + +/** Which policy scope a tools query or policy upsert targets. */ +export interface McpGatewayPolicyScope { + scope_type?: McpGatewayScopeType; + /** Member scope target. Defaults to the requesting user. */ + scope_user_id?: number; + /** Agent scope target. Required when scope_type is "agent". */ + scope_service_account_id?: string; +} + +export interface McpToolPolicyEntry { + tool_name: string; + policy_state: McpApprovalState; +} + +/** One tool with its effective policy for the requested scope. */ +export interface McpResolvedToolPolicy { + tool_name: string; + description: string; + policy_state: McpApprovalState; + /** What the team-level chain yields, ignoring the scope. Null when the team imposes nothing. */ + team_state: McpApprovalState | null; + /** True when the requester can't change this row (rule match, or admin-imposed for a member). */ + locked: boolean; + decided_by: McpPolicyDecidedBy; + /** Matching org rule name, when decided_by is "rule". */ + rule_name: string; + rule_description: string; +} + +export interface McpServiceAccount { + id: string; + name: string; + description: string; + /** Stable identity handle the agent authenticates as, e.g. svc-docs-agent. */ + handle: string; + status: McpServiceAccountStatus; + /** Masked bearer token; the full token is only shown once. */ + token_mask: string; + server_ids: string[]; + last_active_at: string | null; + created_at: string; + updated_at: string; +} + +export interface McpServiceAccountWithToken extends McpServiceAccount { + /** The full bearer token. Returned exactly once — on creation or rotation. */ + token: string; +} + +export interface McpOrgRule { + id: string; + name: string; + description: string; + applies_to: McpOrgRuleAudience; + effect: McpOrgRuleEffect; + /** fnmatch pattern against tool names. Blank matches destructive tools heuristically. */ + tool_pattern: string; + enabled: boolean; + created_at: string; + updated_at: string; +} + +export interface McpAuditActorServiceAccount { + id: string; + name: string; + handle: string; +} + +export interface McpAuditEvent { + id: string; + created_at: string; + server_name: string; + tool_name: string; + decision: McpAuditDecision; + actor_user: McpGatewayUser | null; + actor_service_account: McpAuditActorServiceAccount | null; + /** Denormalized actor label (email or handle) that survives deletion. */ + actor_label: string; +} + +export interface McpAuditCounts { + all: number; + agents: number; + approvals: number; + blocked: number; +} + +export interface McpAuditPage { + count: number; + results: McpAuditEvent[]; +} + +export interface TeamMcpGatewayConfig { + allow_custom_servers: boolean; + /** Empty string until an admin applies a preset from Team settings. */ + member_default_preset: McpPolicyPreset | ""; + agent_default_preset: McpPolicyPreset | ""; + /** Whether the requesting user can administer the gateway. */ + is_admin: boolean; +} + +export interface TeamMcpGatewayConfigUpdate { + allow_custom_servers?: boolean; + member_default_preset?: McpPolicyPreset; + agent_default_preset?: McpPolicyPreset; +} + +/** One team member's gateway posture (admin overview). */ +export interface McpGatewayMemberSummary { + user: McpGatewayUser; + is_org_admin: boolean; + /** Gateway servers the member has a personal connection to. */ + connected_server_ids: string[]; + /** Gateway servers an admin turned off for this member. */ + revoked_server_ids: string[]; +} + +/** Sharing options accepted by install_custom / install_template (admin-only + * except `scope`), used when registering a server with the gateway. */ +export interface McpGatewayInstallSharingOptions { + /** "personal" is per-user; "shared" is team-wide (the shared credential). */ + scope?: "personal" | "shared"; + /** Whether the server starts enabled for the whole team. */ + team_enabled?: boolean; + /** Shared-credential servers: whether members may also connect personal accounts. */ + allow_personal?: boolean; + /** Service accounts to share the server with at install time. */ + agent_ids?: string[]; +} diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 05fb47285a..d39c88df8c 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -120,11 +120,33 @@ import { requestErrorStatus, } from "./fetcher"; import { createApiClient, type Schemas } from "./generated"; +import type { + McpAuditCounts, + McpAuditEvent, + McpAuditPage, + McpAuditQuickFilter, + McpGatewayInstallSharingOptions, + McpGatewayMemberSummary, + McpGatewayPolicyScope, + McpGatewayServer, + McpGatewayServerUpdate, + McpOrgRule, + McpPolicyPreset, + McpResolvedToolPolicy, + McpServiceAccount, + McpServiceAccountStatus, + McpServiceAccountWithToken, + McpToolPolicyEntry, + TeamMcpGatewayConfig, + TeamMcpGatewayConfigUpdate, +} from "./mcp-gateway"; import type { SpendAnalysisResponse } from "./spend-analysis"; import { normalizeTaskResponse, normalizeTaskRunResponse, } from "./task-normalization"; + +export type * from "./mcp-gateway"; export interface ApiClientLogger { warn(...args: unknown[]): void; } @@ -4604,17 +4626,19 @@ export class PostHogAPIClient { return data.results ?? []; } - async installCustomMcpServer(options: { - name: string; - url: string; - auth_type: McpAuthType; - api_key?: string; - description?: string; - client_id?: string; - client_secret?: string; - install_source?: "posthog" | "posthog-code"; - posthog_code_callback_url?: string; - }): Promise { + async installCustomMcpServer( + options: { + name: string; + url: string; + auth_type: McpAuthType; + api_key?: string; + description?: string; + client_id?: string; + client_secret?: string; + install_source?: "posthog" | "posthog-code"; + posthog_code_callback_url?: string; + } & McpGatewayInstallSharingOptions, + ): Promise { const teamId = await this.getTeamId(); const apiUrl = new URL( `${this.api.baseUrl}/api/environments/${teamId}/mcp_server_installations/install_custom/`, @@ -4689,12 +4713,14 @@ export class PostHogAPIClient { } } - async installMcpTemplate(options: { - template_id: string; - api_key?: string; - install_source?: "posthog" | "posthog-code"; - posthog_code_callback_url?: string; - }): Promise { + async installMcpTemplate( + options: { + template_id: string; + api_key?: string; + install_source?: "posthog" | "posthog-code"; + posthog_code_callback_url?: string; + } & McpGatewayInstallSharingOptions, + ): Promise { const teamId = await this.getTeamId(); const path = `/api/environments/${teamId}/mcp_server_installations/install_template/`; const response = await this.api.fetcher.fetch({ @@ -4830,6 +4856,320 @@ export class PostHogAPIClient { return data.results ?? []; } + // ---- MCP gateway (team control plane, behind the `mcp-gateway` flag) ---- + + /** + * JSON request against the team-scoped MCP gateway API. `path` is relative + * to `/api/projects/{teamId}/` and must keep its trailing slash. + */ + private async mcpGatewayFetch(args: { + method: "get" | "post" | "patch" | "delete"; + path: string; + search?: Record; + body?: unknown; + errorLabel: string; + }): Promise { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/${args.path}`; + const url = new URL(`${this.api.baseUrl}${path}`); + for (const [key, value] of Object.entries(args.search ?? {})) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + const response = await this.api.fetcher.fetch({ + method: args.method, + url, + path, + ...(args.body !== undefined + ? { overrides: { body: JSON.stringify(args.body) } } + : {}), + }); + if (!response.ok && response.status !== 204) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + (errorData as { detail?: string }).detail ?? + `${args.errorLabel}: ${response.statusText}`, + ); + } + if (response.status === 204) return undefined as T; + return (await response.json().catch(() => undefined)) as T; + } + + async getMcpGatewayConfig(): Promise { + return this.mcpGatewayFetch({ + method: "get", + path: "mcp_gateway/config/", + errorLabel: "Failed to fetch gateway settings", + }); + } + + async updateMcpGatewaySettings( + update: TeamMcpGatewayConfigUpdate, + ): Promise { + return this.mcpGatewayFetch({ + method: "post", + path: "mcp_gateway/config/update_settings/", + body: update, + errorLabel: "Failed to update gateway settings", + }); + } + + async applyMcpGatewayPreset(options: { + audience: "members" | "agents"; + preset: McpPolicyPreset; + }): Promise { + return this.mcpGatewayFetch({ + method: "post", + path: "mcp_gateway/config/apply_preset/", + body: options, + errorLabel: "Failed to apply policy baseline", + }); + } + + async getMcpGatewayServers(): Promise { + const data = await this.mcpGatewayFetch<{ results?: McpGatewayServer[] }>({ + method: "get", + path: "mcp_gateway/servers/", + search: { limit: 500 }, + errorLabel: "Failed to fetch gateway servers", + }); + return data.results ?? []; + } + + async getMcpGatewayServer(serverId: string): Promise { + return this.mcpGatewayFetch({ + method: "get", + path: `mcp_gateway/servers/${serverId}/`, + errorLabel: "Failed to fetch gateway server", + }); + } + + async updateMcpGatewayServer( + serverId: string, + updates: McpGatewayServerUpdate, + ): Promise { + return this.mcpGatewayFetch({ + method: "patch", + path: `mcp_gateway/servers/${serverId}/`, + body: updates, + errorLabel: "Failed to update gateway server", + }); + } + + async deleteMcpGatewayServer(serverId: string): Promise { + await this.mcpGatewayFetch({ + method: "delete", + path: `mcp_gateway/servers/${serverId}/`, + errorLabel: "Failed to remove gateway server", + }); + } + + /** Tool catalog with the effective policy resolved for one scope. */ + async getMcpGatewayToolPolicies( + serverId: string, + scope: McpGatewayPolicyScope = {}, + ): Promise { + const data = await this.mcpGatewayFetch<{ + results?: McpResolvedToolPolicy[]; + }>({ + method: "get", + path: `mcp_gateway/servers/${serverId}/tools/`, + search: { + scope_type: scope.scope_type, + scope_user_id: scope.scope_user_id, + scope_service_account_id: scope.scope_service_account_id, + }, + errorLabel: "Failed to fetch tool policies", + }); + return data.results ?? []; + } + + /** Upsert per-tool states for a scope; returns the re-resolved catalog. */ + async upsertMcpGatewayToolPolicies( + serverId: string, + options: McpGatewayPolicyScope & { policies: McpToolPolicyEntry[] }, + ): Promise { + const data = await this.mcpGatewayFetch<{ + results?: McpResolvedToolPolicy[]; + }>({ + method: "post", + path: `mcp_gateway/servers/${serverId}/policies/`, + body: options, + errorLabel: "Failed to update tool policies", + }); + return data.results ?? []; + } + + async getMcpServiceAccounts(): Promise { + const data = await this.mcpGatewayFetch<{ results?: McpServiceAccount[] }>({ + method: "get", + path: "mcp_gateway/service_accounts/", + search: { limit: 500 }, + errorLabel: "Failed to fetch service accounts", + }); + return data.results ?? []; + } + + async getMcpServiceAccount(accountId: string): Promise { + return this.mcpGatewayFetch({ + method: "get", + path: `mcp_gateway/service_accounts/${accountId}/`, + errorLabel: "Failed to fetch service account", + }); + } + + /** Returns the full bearer token exactly once. */ + async createMcpServiceAccount(options: { + name: string; + description?: string; + }): Promise { + return this.mcpGatewayFetch({ + method: "post", + path: "mcp_gateway/service_accounts/", + body: options, + errorLabel: "Failed to create service account", + }); + } + + async updateMcpServiceAccount( + accountId: string, + updates: { + name?: string; + description?: string; + status?: McpServiceAccountStatus; + }, + ): Promise { + return this.mcpGatewayFetch({ + method: "patch", + path: `mcp_gateway/service_accounts/${accountId}/`, + body: updates, + errorLabel: "Failed to update service account", + }); + } + + async deleteMcpServiceAccount(accountId: string): Promise { + await this.mcpGatewayFetch({ + method: "delete", + path: `mcp_gateway/service_accounts/${accountId}/`, + errorLabel: "Failed to delete service account", + }); + } + + /** Grant or revoke one agent's access to one gateway server. */ + async setMcpServiceAccountAccess( + accountId: string, + options: { + gateway_server_id: string; + enabled: boolean; + /** Agent-scope tool policies to set alongside the grant. */ + policies?: McpToolPolicyEntry[]; + }, + ): Promise { + return this.mcpGatewayFetch({ + method: "post", + path: `mcp_gateway/service_accounts/${accountId}/access/`, + body: options, + errorLabel: "Failed to update agent access", + }); + } + + /** Mints a new token; the previous one stops working immediately. */ + async rotateMcpServiceAccountToken( + accountId: string, + ): Promise { + return this.mcpGatewayFetch({ + method: "post", + path: `mcp_gateway/service_accounts/${accountId}/rotate_token/`, + errorLabel: "Failed to rotate token", + }); + } + + async getMcpGatewayMembers(): Promise { + const data = await this.mcpGatewayFetch({ + method: "get", + path: "mcp_gateway/members/", + errorLabel: "Failed to fetch gateway members", + }); + return data ?? []; + } + + /** Turn one gateway server off (or back on) for one member. */ + async setMcpGatewayMemberAccess( + userId: number, + options: { gateway_server_id: string; enabled: boolean }, + ): Promise { + await this.mcpGatewayFetch({ + method: "post", + path: `mcp_gateway/members/${userId}/set_access/`, + body: options, + errorLabel: "Failed to update member access", + }); + } + + async getMcpGatewayRules(): Promise { + const data = await this.mcpGatewayFetch<{ results?: McpOrgRule[] }>({ + method: "get", + path: "mcp_gateway/rules/", + search: { limit: 500 }, + errorLabel: "Failed to fetch team rules", + }); + return data.results ?? []; + } + + async updateMcpGatewayRule( + ruleId: string, + updates: Partial< + Pick< + McpOrgRule, + | "name" + | "description" + | "applies_to" + | "effect" + | "tool_pattern" + | "enabled" + > + >, + ): Promise { + return this.mcpGatewayFetch({ + method: "patch", + path: `mcp_gateway/rules/${ruleId}/`, + body: updates, + errorLabel: "Failed to update team rule", + }); + } + + async getMcpGatewayAuditEvents( + options: { + quickFilter?: McpAuditQuickFilter; + actorServiceAccountId?: string; + limit?: number; + offset?: number; + } = {}, + ): Promise { + const data = await this.mcpGatewayFetch<{ + count?: number; + results?: McpAuditEvent[]; + }>({ + method: "get", + path: "mcp_gateway/audit/", + search: { + quick_filter: options.quickFilter, + actor_service_account_id: options.actorServiceAccountId, + limit: options.limit, + offset: options.offset, + }, + errorLabel: "Failed to fetch audit log", + }); + return { count: data.count ?? 0, results: data.results ?? [] }; + } + + async getMcpGatewayAuditCounts(): Promise { + return this.mcpGatewayFetch({ + method: "get", + path: "mcp_gateway/audit/counts/", + errorLabel: "Failed to fetch audit counts", + }); + } + private parseFetcherError(error: unknown): { status: number; body: Record; diff --git a/packages/core/src/mcp-gateway/gatewayAddServer.test.ts b/packages/core/src/mcp-gateway/gatewayAddServer.test.ts new file mode 100644 index 0000000000..f984ddf989 --- /dev/null +++ b/packages/core/src/mcp-gateway/gatewayAddServer.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { + buildGatewayInstallRequest, + canSubmitGatewayServer, + effectiveCredentialMode, + GATEWAY_ADD_SERVER_DEFAULTS, + type GatewayAddServerValues, +} from "./gatewayAddServer"; + +function values( + overrides: Partial = {}, +): GatewayAddServerValues { + return { + ...GATEWAY_ADD_SERVER_DEFAULTS, + name: "Internal Wiki", + url: "https://mcp.example.com/sse", + ...overrides, + }; +} + +describe("canSubmitGatewayServer", () => { + it.each([ + ["valid name and url", values(), true], + ["missing name", values({ name: " " }), false], + ["invalid url", values({ url: "not-a-url" }), false], + ])("%s", (_label, input, expected) => { + expect(canSubmitGatewayServer(input)).toBe(expected); + }); +}); + +describe("effectiveCredentialMode", () => { + it("forces shared for api-key servers", () => { + expect( + effectiveCredentialMode( + values({ authType: "api_key", credentialMode: "individual" }), + ), + ).toBe("shared"); + }); + + it("respects the chosen mode for oauth servers", () => { + expect( + effectiveCredentialMode( + values({ authType: "oauth", credentialMode: "shared" }), + ), + ).toBe("shared"); + expect(effectiveCredentialMode(values({ authType: "oauth" }))).toBe( + "individual", + ); + }); +}); + +describe("buildGatewayInstallRequest", () => { + it("builds an individual oauth install with admin sharing options", () => { + const request = buildGatewayInstallRequest( + values({ description: " Wiki tools ", agentIds: ["svc-1"] }), + { isAdmin: true }, + ); + expect(request).toEqual({ + name: "Internal Wiki", + url: "https://mcp.example.com/sse", + description: "Wiki tools", + auth_type: "oauth", + scope: "personal", + team_enabled: true, + agent_ids: ["svc-1"], + }); + }); + + it("marks shared-credential installs and carries allow_personal", () => { + const request = buildGatewayInstallRequest( + values({ credentialMode: "shared", allowPersonal: false }), + { isAdmin: true }, + ); + expect(request.scope).toBe("shared"); + expect(request.allow_personal).toBe(false); + }); + + it("api-key installs share the key and include it", () => { + const request = buildGatewayInstallRequest( + values({ authType: "api_key", apiKey: "sk-123" }), + { isAdmin: true }, + ); + expect(request.auth_type).toBe("api_key"); + expect(request.api_key).toBe("sk-123"); + expect(request.scope).toBe("shared"); + }); + + it("includes oauth client credentials only when provided", () => { + const bare = buildGatewayInstallRequest(values(), { isAdmin: true }); + expect(bare.client_id).toBeUndefined(); + const withCreds = buildGatewayInstallRequest( + values({ clientId: " id ", clientSecret: "secret" }), + { isAdmin: true }, + ); + expect(withCreds.client_id).toBe("id"); + expect(withCreds.client_secret).toBe("secret"); + }); + + it("omits every sharing option for non-admin members", () => { + const request = buildGatewayInstallRequest( + values({ credentialMode: "shared", agentIds: ["svc-1"] }), + { isAdmin: false }, + ); + expect(request.scope).toBeUndefined(); + expect(request.team_enabled).toBeUndefined(); + expect(request.allow_personal).toBeUndefined(); + expect(request.agent_ids).toBeUndefined(); + }); +}); diff --git a/packages/core/src/mcp-gateway/gatewayAddServer.ts b/packages/core/src/mcp-gateway/gatewayAddServer.ts new file mode 100644 index 0000000000..58c4840154 --- /dev/null +++ b/packages/core/src/mcp-gateway/gatewayAddServer.ts @@ -0,0 +1,96 @@ +import type { + McpAuthType, + McpGatewayInstallSharingOptions, +} from "@posthog/api-client/posthog-client"; +import { isValidMcpUrl } from "../mcp-servers/customServerForm"; + +export type GatewayCredentialMode = "individual" | "shared"; + +export interface GatewayAddServerValues { + name: string; + url: string; + description: string; + authType: McpAuthType; + apiKey: string; + clientId: string; + clientSecret: string; + /** Admin-only sharing options; ignored for non-admin members. */ + teamEnabled: boolean; + credentialMode: GatewayCredentialMode; + allowPersonal: boolean; + agentIds: string[]; +} + +export const GATEWAY_ADD_SERVER_DEFAULTS: GatewayAddServerValues = { + name: "", + url: "", + description: "", + authType: "oauth", + apiKey: "", + clientId: "", + clientSecret: "", + teamEnabled: true, + credentialMode: "individual", + allowPersonal: true, + agentIds: [], +}; + +export function canSubmitGatewayServer( + values: Pick, +): boolean { + return values.name.trim() !== "" && isValidMcpUrl(values.url); +} + +/** API-key servers always run through one shared key held by the gateway. */ +export function effectiveCredentialMode( + values: Pick, +): GatewayCredentialMode { + return values.authType === "api_key" ? "shared" : values.credentialMode; +} + +export interface GatewayInstallRequest extends McpGatewayInstallSharingOptions { + name: string; + url: string; + description: string; + auth_type: McpAuthType; + api_key?: string; + client_id?: string; + client_secret?: string; +} + +/** + * install_custom payload for registering a server with the gateway. Sharing + * options are attached only for admins — the backend rejects non-default + * values from members. + */ +export function buildGatewayInstallRequest( + values: GatewayAddServerValues, + options: { isAdmin: boolean }, +): GatewayInstallRequest { + const credentialMode = effectiveCredentialMode(values); + return { + name: values.name.trim(), + url: values.url.trim(), + description: values.description.trim(), + auth_type: values.authType, + ...(values.authType === "api_key" && values.apiKey + ? { api_key: values.apiKey } + : {}), + ...(values.authType === "oauth" && values.clientId.trim() + ? { client_id: values.clientId.trim() } + : {}), + ...(values.authType === "oauth" && values.clientSecret.trim() + ? { client_secret: values.clientSecret.trim() } + : {}), + ...(options.isAdmin + ? { + scope: credentialMode === "shared" ? "shared" : "personal", + team_enabled: values.teamEnabled, + ...(credentialMode === "shared" + ? { allow_personal: values.allowPersonal } + : {}), + ...(values.agentIds.length ? { agent_ids: values.agentIds } : {}), + } + : {}), + }; +} diff --git a/packages/core/src/mcp-gateway/gatewayInstallFlow.ts b/packages/core/src/mcp-gateway/gatewayInstallFlow.ts new file mode 100644 index 0000000000..5c1a22d89b --- /dev/null +++ b/packages/core/src/mcp-gateway/gatewayInstallFlow.ts @@ -0,0 +1,40 @@ +import type { McpServerInstallation } from "@posthog/api-client/types"; +import type { + IOAuthCallback, + OAuthCallbackResult, +} from "../mcp-servers/installFlow"; +import type { GatewayInstallRequest } from "./gatewayAddServer"; + +interface OAuthRedirect { + redirect_url: string; +} + +interface GatewayInstallClient { + installCustomMcpServer( + options: GatewayInstallRequest & { + install_source?: "posthog" | "posthog-code"; + posthog_code_callback_url?: string; + }, + ): Promise; +} + +/** + * Register a custom server with the gateway. OAuth servers round-trip through + * the host browser callback; API-key servers complete immediately. + */ +export async function registerGatewayServerWithOAuth( + client: GatewayInstallClient, + oauth: IOAuthCallback, + request: GatewayInstallRequest, +): Promise { + const { callbackUrl } = await oauth.getCallbackUrl(); + const data = await client.installCustomMcpServer({ + ...request, + install_source: "posthog-code", + posthog_code_callback_url: callbackUrl, + }); + if ("redirect_url" in data && data.redirect_url) { + return oauth.openAndWaitForCallback({ redirectUrl: data.redirect_url }); + } + return { success: true }; +} diff --git a/packages/core/src/mcp-gateway/gatewayServers.test.ts b/packages/core/src/mcp-gateway/gatewayServers.test.ts new file mode 100644 index 0000000000..c447f8f024 --- /dev/null +++ b/packages/core/src/mcp-gateway/gatewayServers.test.ts @@ -0,0 +1,224 @@ +import type { + McpGatewayServer, + McpGatewayYourConnection, + McpResolvedToolPolicy, +} from "@posthog/api-client/posthog-client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + agentHandlePreview, + countGatewayServersByCategory, + countPoliciesByState, + defaultAgentGrantPolicy, + filterGatewayServers, + formatAgo, + formatAuditTime, + isConnectedForYou, + partitionRailServers, +} from "./gatewayServers"; + +function connection( + overrides: Partial = {}, +): McpGatewayYourConnection { + return { + installation_id: "inst-1", + scope: "personal", + is_enabled: true, + pending_oauth: false, + needs_reauth: false, + last_used_at: null, + ...overrides, + }; +} + +function server(overrides: Partial): McpGatewayServer { + return { + id: "srv-1", + name: "Test", + url: "https://mcp.example.com", + description: "", + category: "dev", + auth_mode: "individual", + is_team_enabled: true, + allow_personal_connections: true, + icon_key: "", + docs_url: "", + template_id: null, + tool_count: 0, + connections: [], + your_connection: null, + shared_credential: null, + agents: [], + revoked_user_ids: [], + is_revoked_for_you: false, + created_by: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +describe("partitionRailServers", () => { + const servers = [ + server({ id: "a", name: "Alpha", your_connection: connection() }), + server({ id: "b", name: "Beta" }), + server({ id: "c", name: "Gamma", auth_mode: "shared" }), + server({ + id: "d", + name: "Delta", + auth_mode: "shared", + your_connection: connection(), + }), + ]; + + it("splits connected individual servers from shared ones", () => { + const { yourConnections, sharedWithYou } = partitionRailServers( + servers, + "", + ); + expect(yourConnections.map((s) => s.id)).toEqual(["a"]); + expect(sharedWithYou.map((s) => s.id)).toEqual(["c", "d"]); + }); + + it("filters both sections by name", () => { + const { yourConnections, sharedWithYou } = partitionRailServers( + servers, + "gam", + ); + expect(yourConnections).toEqual([]); + expect(sharedWithYou.map((s) => s.id)).toEqual(["c"]); + }); +}); + +describe("filterGatewayServers", () => { + const servers = [ + server({ id: "a", name: "Linear", description: "Ticket tracker" }), + server({ + id: "b", + name: "GitHub", + description: "Code hosting", + category: "data", + }), + server({ id: "c", name: "Notion", url: "https://mcp.notion.so" }), + ]; + + it("matches name, description and url case-insensitively", () => { + expect(filterGatewayServers(servers, "TICKET", null)[0]?.id).toBe("a"); + expect(filterGatewayServers(servers, "notion.so", null)[0]?.id).toBe("c"); + }); + + it("applies the category chip", () => { + expect(filterGatewayServers(servers, "", "data").map((s) => s.id)).toEqual([ + "b", + ]); + }); + + it("combines search and category", () => { + expect(filterGatewayServers(servers, "linear", "data")).toEqual([]); + }); +}); + +describe("countGatewayServersByCategory", () => { + it("tallies per category", () => { + const counts = countGatewayServersByCategory([ + server({ id: "a", category: "dev" }), + server({ id: "b", category: "dev" }), + server({ id: "c", category: "data" }), + ]); + expect(counts).toEqual({ dev: 2, data: 1 }); + }); +}); + +describe("isConnectedForYou", () => { + it.each([ + [ + "personal connection", + server({ your_connection: connection() }), + false, + true, + ], + [ + "pending oauth does not count", + server({ your_connection: connection({ pending_oauth: true }) }), + false, + false, + ], + ["member on shared server", server({ auth_mode: "shared" }), false, true], + [ + "admin on shared server without own connection", + server({ auth_mode: "shared" }), + true, + false, + ], + ["not connected", server({}), false, false], + ] as const)("%s", (_label, srv, isAdmin, expected) => { + expect(isConnectedForYou(srv, isAdmin)).toBe(expected); + }); +}); + +describe("countPoliciesByState", () => { + it("counts each state, defaulting to zero", () => { + const policy = (state: McpResolvedToolPolicy["policy_state"]) => + ({ + tool_name: "t", + description: "", + policy_state: state, + team_state: null, + locked: false, + decided_by: "default", + rule_name: "", + rule_description: "", + }) satisfies McpResolvedToolPolicy; + expect( + countPoliciesByState([ + policy("approved"), + policy("approved"), + policy("do_not_use"), + ]), + ).toEqual({ approved: 2, needs_approval: 0, do_not_use: 1 }); + }); +}); + +describe("time formatting", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-21T12:00:00")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("formatAgo renders short relative times", () => { + expect(formatAgo("2026-07-21T10:00:00")).toBe("2h ago"); + expect(formatAgo("2026-07-21T11:59:50")).toBe("just now"); + expect(formatAgo(null)).toBeNull(); + }); + + it("formatAuditTime buckets by local day", () => { + expect(formatAuditTime("2026-07-21T09:58:00")).toBe("Today 09:58"); + expect(formatAuditTime("2026-07-20T17:22:00")).toBe("Yesterday 17:22"); + expect(formatAuditTime("2026-07-15T09:12:00")).toMatch(/^Jul 15 09:12$/); + }); +}); + +describe("defaultAgentGrantPolicy", () => { + it.each([ + ["delete-row", "do_not_use"], + ["run-migration", "do_not_use"], + ["send", "do_not_use"], + ["list-tables", "approved"], + ["search", "approved"], + ] as const)("%s → %s", (tool, expected) => { + expect(defaultAgentGrantPolicy(tool)).toBe(expected); + }); +}); + +describe("agentHandlePreview", () => { + it.each([ + ["Docs Agent", "svc-docs-agent"], + [" Support!! Bot ", "svc-support-bot"], + ["---", null], + ["", null], + ])("%s → %s", (name, expected) => { + expect(agentHandlePreview(name)).toBe(expected); + }); +}); diff --git a/packages/core/src/mcp-gateway/gatewayServers.ts b/packages/core/src/mcp-gateway/gatewayServers.ts new file mode 100644 index 0000000000..311f5c7379 --- /dev/null +++ b/packages/core/src/mcp-gateway/gatewayServers.ts @@ -0,0 +1,146 @@ +import type { + McpApprovalState, + McpAuditDecision, + McpGatewayServer, + McpResolvedToolPolicy, +} from "@posthog/api-client/posthog-client"; +import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared"; + +export interface GatewayRailPartition { + /** Individual-auth servers the current user has connected. */ + yourConnections: McpGatewayServer[]; + /** Shared-credential servers — pre-authorized for the whole team. */ + sharedWithYou: McpGatewayServer[]; +} + +/** Split servers into the two rail sections, filtered by the rail search. */ +export function partitionRailServers( + servers: McpGatewayServer[], + search: string, +): GatewayRailPartition { + const query = search.trim().toLowerCase(); + const matches = (server: McpGatewayServer) => + !query || server.name.toLowerCase().includes(query); + return { + yourConnections: servers.filter( + (server) => + server.auth_mode === "individual" && + server.your_connection !== null && + matches(server), + ), + sharedWithYou: servers.filter( + (server) => server.auth_mode === "shared" && matches(server), + ), + }; +} + +/** Home-screen filter: search over name/description/url plus category chip. */ +export function filterGatewayServers( + servers: McpGatewayServer[], + search: string, + category: string | null, +): McpGatewayServer[] { + const query = search.trim().toLowerCase(); + return servers.filter((server) => { + if (category && server.category !== category) return false; + if (!query) return true; + return ( + server.name.toLowerCase().includes(query) || + server.description.toLowerCase().includes(query) || + server.url.toLowerCase().includes(query) + ); + }); +} + +export function countGatewayServersByCategory( + servers: McpGatewayServer[], +): Record { + const counts: Record = {}; + for (const server of servers) { + counts[server.category] = (counts[server.category] ?? 0) + 1; + } + return counts; +} + +/** + * Whether the current user can call this server without connecting first: + * they hold a working personal connection, or (for non-admin members) the + * shared credential pre-authorizes them. + */ +export function isConnectedForYou( + server: McpGatewayServer, + isAdmin: boolean, +): boolean { + if (server.your_connection && !server.your_connection.pending_oauth) { + return true; + } + return server.auth_mode === "shared" && !isAdmin; +} + +export type GatewayPolicyCounts = Record; + +export function countPoliciesByState( + policies: McpResolvedToolPolicy[], +): GatewayPolicyCounts { + const counts: GatewayPolicyCounts = { + approved: 0, + needs_approval: 0, + do_not_use: 0, + }; + for (const policy of policies) { + counts[policy.policy_state] += 1; + } + return counts; +} + +/** "2h ago" / "just now" for last-used and last-active timestamps. */ +export function formatAgo(timestamp: string | null): string | null { + if (!timestamp) return null; + const short = formatRelativeTimeShort(timestamp); + return short === "now" ? "just now" : `${short} ago`; +} + +/** Audit-table timestamp: "Today 09:58", "Yesterday 17:22", "Jul 15 09:12". */ +export function formatAuditTime(timestamp: string, now?: Date): string { + const date = new Date(timestamp); + const dayDiff = getLocalDayDiff(date, now); + const time = date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + if (dayDiff <= 0) return `Today ${time}`; + if (dayDiff === 1) return `Yesterday ${time}`; + const day = date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + return `${day} ${time}`; +} + +export const AUDIT_DECISION_LABELS: Record = { + auto: "Auto-approved", + approved: "Approved", + pending: "Awaiting approval", + blocked: "Blocked", +}; + +// Mirrors the backend's destructive-tool heuristic; only used to seed the +// per-tool defaults when sharing a server with an agent. +const DESTRUCTIVE_TOOL_RE = + /delete|update|post|write|create|run-migration|close|drop|send/; + +/** Default policy offered when granting an agent access to a tool. */ +export function defaultAgentGrantPolicy(toolName: string): McpApprovalState { + return DESTRUCTIVE_TOOL_RE.test(toolName) ? "do_not_use" : "approved"; +} + +/** Identity handle a new agent will authenticate as, e.g. "svc-docs-agent". */ +export function agentHandlePreview(name: string): string | null { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + return slug ? `svc-${slug}` : null; +} diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index 3a4ee8118b..fe5834a13d 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -31,5 +31,11 @@ export const FAST_MODE_FLAG = "posthog-desktop-fast-mode"; export const SPOKEN_NARRATION_FLAG = "posthog-code-spoken-narration"; // Gates importing and relaying local MCP servers into cloud task runs. export const LOCAL_MCP_IMPORT_FLAG = "posthog-code-local-mcp-import"; +/** + * Team MCP gateway (shared credentials, per-scope tool policies, agent + * service accounts, audit log) replacing the per-user MCP marketplace. + * Owned by the backend rollout in posthog/posthog — same flag key there. + */ +export const MCP_GATEWAY_FLAG = "mcp-gateway"; /** Per-task estimated cost readout in the context usage indicator. */ export const TASK_COST_FLAG = "posthog-code-task-cost"; diff --git a/packages/ui/src/features/mcp-gateway/components/McpGatewayView.tsx b/packages/ui/src/features/mcp-gateway/components/McpGatewayView.tsx new file mode 100644 index 0000000000..715af1fe37 --- /dev/null +++ b/packages/ui/src/features/mcp-gateway/components/McpGatewayView.tsx @@ -0,0 +1,139 @@ +import { GatewayAddServer } from "@posthog/ui/features/mcp-gateway/components/parts/GatewayAddServer"; +import { GatewayAgentDetail } from "@posthog/ui/features/mcp-gateway/components/parts/GatewayAgentDetail"; +import { GatewayAuditLog } from "@posthog/ui/features/mcp-gateway/components/parts/GatewayAuditLog"; +import { GatewayMemberDetail } from "@posthog/ui/features/mcp-gateway/components/parts/GatewayMemberDetail"; +import { GatewayRail } from "@posthog/ui/features/mcp-gateway/components/parts/GatewayRail"; +import { GatewayServerDetail } from "@posthog/ui/features/mcp-gateway/components/parts/GatewayServerDetail"; +import { GatewayServersHome } from "@posthog/ui/features/mcp-gateway/components/parts/GatewayServersHome"; +import { GatewayTeamSettings } from "@posthog/ui/features/mcp-gateway/components/parts/GatewayTeamSettings"; +import { GatewayTeamView } from "@posthog/ui/features/mcp-gateway/components/parts/GatewayTeamView"; +import { + type GatewayRoute, + isRouteAllowed, +} from "@posthog/ui/features/mcp-gateway/gatewayRoute"; +import { useGatewayConfig } from "@posthog/ui/features/mcp-gateway/hooks/useGatewayConfig"; +import { useGatewayServers } from "@posthog/ui/features/mcp-gateway/hooks/useGatewayServers"; +import { useServiceAccounts } from "@posthog/ui/features/mcp-gateway/hooks/useServiceAccounts"; +import { Box, Flex, ScrollArea } from "@radix-ui/themes"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; + +/** + * Team MCP gateway: one control plane for the servers a team runs, who can + * reach them (members and agent service accounts), per-tool policies per + * scope, and the audit log. Renders behind the `mcp-gateway` flag in place of + * the per-user marketplace. + */ +export function McpGatewayView() { + const queryClient = useQueryClient(); + const [route, setRoute] = useState({ view: "servers" }); + + const { isAdmin, allowCustomServers, configLoading } = useGatewayConfig(); + const canAddServers = isAdmin || allowCustomServers; + const gateway = useGatewayServers(); + const serviceAccounts = useServiceAccounts(); + + // Refresh gateway state when the window regains focus — connections and + // policies can change from the web app or another teammate meanwhile. + useEffect(() => { + const refresh = () => { + queryClient.invalidateQueries({ queryKey: ["mcp"] }); + }; + const onVisibility = () => { + if (document.visibilityState === "visible") refresh(); + }; + window.addEventListener("focus", refresh); + document.addEventListener("visibilitychange", onVisibility); + return () => { + window.removeEventListener("focus", refresh); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [queryClient]); + + // Role guard: if the config resolves to a narrower role than the current + // route needs, fall back to the servers home. + useEffect(() => { + if (configLoading) return; + if (!isRouteAllowed(route, { isAdmin, canAddServers })) { + setRoute({ view: "servers" }); + } + }, [route, isAdmin, canAddServers, configLoading]); + + const activeAgentCount = serviceAccounts.accounts.filter( + (account) => account.status === "active", + ).length; + + const mainContent = (() => { + switch (route.view) { + case "add": + return ( + + ); + case "server": + return ( + + ); + case "team": + return ; + case "agent": + return ( + + ); + case "member": + return ( + + ); + case "settings": + return ; + case "audit": + return ; + default: + return ( + + ); + } + })(); + + return ( + + + + + + {mainContent} + + + + + ); +} diff --git a/packages/ui/src/features/mcp-gateway/components/parts/GatewayAddServer.tsx b/packages/ui/src/features/mcp-gateway/components/parts/GatewayAddServer.tsx new file mode 100644 index 0000000000..e6a406bc95 --- /dev/null +++ b/packages/ui/src/features/mcp-gateway/components/parts/GatewayAddServer.tsx @@ -0,0 +1,502 @@ +import { + ArrowLeft, + CaretRight, + Check, + Key, + Users, +} from "@phosphor-icons/react"; +import type { McpServiceAccount } from "@posthog/api-client/posthog-client"; +import { + buildGatewayInstallRequest, + canSubmitGatewayServer, + effectiveCredentialMode, + GATEWAY_ADD_SERVER_DEFAULTS, + type GatewayAddServerValues, +} from "@posthog/core/mcp-gateway/gatewayAddServer"; +import { isValidMcpUrl } from "@posthog/core/mcp-servers/customServerForm"; +import { RobotAvatar } from "@posthog/ui/features/mcp-gateway/components/parts/avatars"; +import type { GatewayRoute } from "@posthog/ui/features/mcp-gateway/gatewayRoute"; +import { useGatewayServers } from "@posthog/ui/features/mcp-gateway/hooks/useGatewayServers"; +import { useRegisterGatewayServer } from "@posthog/ui/features/mcp-gateway/hooks/useRegisterGatewayServer"; +import { + Button, + Flex, + Select, + Spinner, + Switch, + Text, + TextArea, + TextField, +} from "@radix-ui/themes"; +import { useEffect, useState } from "react"; + +function normalizeUrl(url: string): string { + return url.trim().replace(/\/+$/, ""); +} + +interface GatewayAddServerProps { + isAdmin: boolean; + accounts: McpServiceAccount[]; + onNavigate: (route: GatewayRoute) => void; +} + +/** Register a custom MCP server with the gateway. */ +export function GatewayAddServer({ + isAdmin, + accounts, + onNavigate, +}: GatewayAddServerProps) { + const [values, setValues] = useState( + GATEWAY_ADD_SERVER_DEFAULTS, + ); + const [showKey, setShowKey] = useState(false); + const [optionalOpen, setOptionalOpen] = useState(false); + // URL of the just-registered server; once the refreshed registry contains + // it, jump to its detail page. + const [pendingUrl, setPendingUrl] = useState(null); + + const { servers } = useGatewayServers(); + const { register, registerPending } = useRegisterGatewayServer(); + + useEffect(() => { + if (!pendingUrl) return; + const created = servers.find( + (server) => normalizeUrl(server.url) === pendingUrl, + ); + if (created) { + setPendingUrl(null); + onNavigate({ view: "server", serverId: created.id }); + } + }, [pendingUrl, servers, onNavigate]); + + const set = ( + key: K, + value: GatewayAddServerValues[K], + ) => setValues((previous) => ({ ...previous, [key]: value })); + + const urlInvalid = values.url.trim() !== "" && !isValidMcpUrl(values.url); + const canSave = canSubmitGatewayServer(values); + const sharedCredential = effectiveCredentialMode(values) === "shared"; + + const submit = () => { + if (!canSave || registerPending) return; + const request = buildGatewayInstallRequest(values, { isAdmin }); + register( + { request }, + { + onSuccess: (result) => { + if (result && "error" in result && result.error) return; + setPendingUrl(normalizeUrl(request.url)); + }, + }, + ); + }; + + return ( + + + + + + + + Add a custom server + + + Register an MCP server with the gateway. Every call routes through the + gateway, so tool policies, approvals and the audit log apply from the + first request. + + + + + + + set("name", e.target.value)} + placeholder="e.g. Internal Wiki" + autoFocus + /> + + + set("url", e.target.value)} + placeholder="https://mcp.example.com/sse" + spellCheck={false} + className="font-mono" + /> + {urlInvalid && ( + + Enter a full URL, like https://mcp.example.com + + )} + + +