+
+ Available to team members
+
+
+ {server.is_team_enabled
+ ? shared
+ ? `Members use ${server.name} through the shared credential — nothing for them to set up.`
+ : `Members can connect their own ${server.name} account.`
+ : `Turned off — members can't see or call ${server.name}.`}
+
+
+
+
+
+ Team rules
+
+ Guardrails evaluated before any scope policy. A matching enabled rule
+ locks the tool for its audience — no scope can loosen it.
+
+
@@ -204,13 +202,11 @@ function AgentCard({
checked={active}
onCheckedChange={(checked) => onToggleStatus(!checked)}
/>
-
- {active
- ? lastCall
- ? `last call ${lastCall}`
- : "No calls yet"
- : "Paused — all access off"}
-
+ {!active && (
+
+ Paused — all access off
+
+ )}
);
From 6c05fb834ec9d1eacad76b6d7efc8dbd0b0e6a0d Mon Sep 17 00:00:00 2001
From: Chris Volzer
Date: Fri, 31 Jul 2026 09:00:25 -0400
Subject: [PATCH 07/32] feat(mcp-gateway): refine server detail experience
---
packages/api-client/src/mcp-gateway.ts | 1 +
.../src/mcp-gateway/gatewayServers.test.ts | 136 +++-
.../core/src/mcp-gateway/gatewayServers.ts | 55 +-
.../mcp-gateway/components/McpGatewayView.tsx | 9 +-
.../components/parts/GatewayAddServer.tsx | 583 +++++++++---------
.../components/parts/GatewayAgentDetail.tsx | 20 +-
.../components/parts/GatewayAuditLog.tsx | 37 +-
.../parts/GatewayDeleteServerDialog.tsx | 59 ++
.../components/parts/GatewayMemberDetail.tsx | 19 +-
.../components/parts/GatewayRail.tsx | 100 +--
.../components/parts/GatewayServerDetail.tsx | 280 ++++++---
.../components/parts/GatewayServersHome.tsx | 123 ++--
.../components/parts/GatewayTeamSettings.tsx | 205 ++----
.../components/parts/GatewayTeamView.tsx | 26 +-
.../components/parts/GatewayToolRow.test.tsx | 47 ++
.../components/parts/GatewayToolRow.tsx | 118 ++--
.../components/parts/GiveAccessDialog.tsx | 2 +-
.../mcp-gateway/components/parts/avatars.tsx | 73 ++-
.../mcp-gateway/hooks/useGatewayServers.ts | 29 +-
.../hooks/useGatewayToolPolicies.ts | 16 +-
20 files changed, 1172 insertions(+), 766 deletions(-)
create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/GatewayDeleteServerDialog.tsx
create mode 100644 packages/ui/src/features/mcp-gateway/components/parts/GatewayToolRow.test.tsx
diff --git a/packages/api-client/src/mcp-gateway.ts b/packages/api-client/src/mcp-gateway.ts
index be6cbde060..a4688162a2 100644
--- a/packages/api-client/src/mcp-gateway.ts
+++ b/packages/api-client/src/mcp-gateway.ts
@@ -121,6 +121,7 @@ export interface McpToolPolicyEntry {
export interface McpResolvedToolPolicy {
tool_name: string;
description: string;
+ input_schema: unknown;
policy_state: McpApprovalState;
/** What the team-level chain yields, ignoring the scope. Null when the team imposes nothing. */
team_state: McpApprovalState | null;
diff --git a/packages/core/src/mcp-gateway/gatewayServers.test.ts b/packages/core/src/mcp-gateway/gatewayServers.test.ts
index c447f8f024..85c5b05f98 100644
--- a/packages/core/src/mcp-gateway/gatewayServers.test.ts
+++ b/packages/core/src/mcp-gateway/gatewayServers.test.ts
@@ -12,6 +12,8 @@ import {
filterGatewayServers,
formatAgo,
formatAuditTime,
+ getGatewayConnectionStatus,
+ getGatewayServerRemovalAction,
isConnectedForYou,
partitionRailServers,
} from "./gatewayServers";
@@ -70,7 +72,7 @@ describe("partitionRailServers", () => {
}),
];
- it("splits connected individual servers from shared ones", () => {
+ it("splits individual installations from shared servers", () => {
const { yourConnections, sharedWithYou } = partitionRailServers(
servers,
"",
@@ -142,6 +144,12 @@ describe("isConnectedForYou", () => {
false,
false,
],
+ [
+ "connection needing reauth does not count",
+ server({ your_connection: connection({ needs_reauth: true }) }),
+ false,
+ false,
+ ],
["member on shared server", server({ auth_mode: "shared" }), false, true],
[
"admin on shared server without own connection",
@@ -155,12 +163,138 @@ describe("isConnectedForYou", () => {
});
});
+describe("getGatewayConnectionStatus", () => {
+ it.each([
+ ["connected", connection(), "connected"],
+ ["pending OAuth", connection({ pending_oauth: true }), "pending_oauth"],
+ [
+ "needs reauthorization",
+ connection({ needs_reauth: true }),
+ "needs_reauth",
+ ],
+ [
+ "reauthorization takes precedence when both flags are set",
+ connection({ pending_oauth: true, needs_reauth: true }),
+ "needs_reauth",
+ ],
+ ] as const)("returns the status for %s", (_label, value, expected) => {
+ expect(getGatewayConnectionStatus(value)).toBe(expected);
+ });
+});
+
+describe("getGatewayServerRemovalAction", () => {
+ const gatewayUser = (id: number) => ({
+ id,
+ uuid: `user-${id}`,
+ email: `user-${id}@example.com`,
+ hedgehog_config: null,
+ });
+
+ it.each([
+ [
+ "deletes a personally added custom server",
+ server({
+ created_by: gatewayUser(1),
+ your_connection: connection(),
+ connections: [
+ {
+ installation_id: "inst-1",
+ user: gatewayUser(1),
+ last_used_at: null,
+ pending_oauth: false,
+ needs_reauth: false,
+ },
+ ],
+ }),
+ false,
+ "delete_for_you",
+ ],
+ [
+ "disconnects from a custom server added by someone else",
+ server({
+ created_by: gatewayUser(2),
+ your_connection: connection(),
+ connections: [
+ {
+ installation_id: "inst-1",
+ user: gatewayUser(1),
+ last_used_at: null,
+ pending_oauth: false,
+ needs_reauth: false,
+ },
+ ],
+ }),
+ false,
+ "disconnect",
+ ],
+ [
+ "disconnects from a catalog server",
+ server({
+ template_id: "template-1",
+ created_by: gatewayUser(1),
+ your_connection: connection(),
+ connections: [
+ {
+ installation_id: "inst-1",
+ user: gatewayUser(1),
+ last_used_at: null,
+ pending_oauth: false,
+ needs_reauth: false,
+ },
+ ],
+ }),
+ false,
+ "disconnect",
+ ],
+ [
+ "disconnects a personal override from a shared custom server",
+ server({
+ auth_mode: "shared",
+ created_by: gatewayUser(1),
+ your_connection: connection(),
+ connections: [
+ {
+ installation_id: "inst-1",
+ user: gatewayUser(1),
+ last_used_at: null,
+ pending_oauth: false,
+ needs_reauth: false,
+ },
+ ],
+ }),
+ false,
+ "disconnect",
+ ],
+ [
+ "deletes a custom server for everyone when requested by an admin",
+ server({}),
+ true,
+ "delete_for_everyone",
+ ],
+ [
+ "does not delete a catalog server for an admin without a connection",
+ server({ template_id: "template-1" }),
+ true,
+ null,
+ ],
+ [
+ "returns no action without a personal connection",
+ server({}),
+ false,
+ null,
+ ],
+ ] as const)("%s", (_label, srv, isAdmin, expected) => {
+ expect(getGatewayServerRemovalAction(srv, isAdmin)).toBe(expected);
+ });
+});
+
describe("countPoliciesByState", () => {
it("counts each state, defaulting to zero", () => {
const policy = (state: McpResolvedToolPolicy["policy_state"]) =>
({
tool_name: "t",
description: "",
+ input_schema: {},
policy_state: state,
team_state: null,
locked: false,
diff --git a/packages/core/src/mcp-gateway/gatewayServers.ts b/packages/core/src/mcp-gateway/gatewayServers.ts
index 311f5c7379..f3cb27f858 100644
--- a/packages/core/src/mcp-gateway/gatewayServers.ts
+++ b/packages/core/src/mcp-gateway/gatewayServers.ts
@@ -2,12 +2,13 @@ import type {
McpApprovalState,
McpAuditDecision,
McpGatewayServer,
+ McpGatewayYourConnection,
McpResolvedToolPolicy,
} from "@posthog/api-client/posthog-client";
import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared";
export interface GatewayRailPartition {
- /** Individual-auth servers the current user has connected. */
+ /** Individual-auth servers with an installation for the current user. */
yourConnections: McpGatewayServer[];
/** Shared-credential servers — pre-authorized for the whole team. */
sharedWithYou: McpGatewayServer[];
@@ -71,12 +72,62 @@ export function isConnectedForYou(
server: McpGatewayServer,
isAdmin: boolean,
): boolean {
- if (server.your_connection && !server.your_connection.pending_oauth) {
+ if (
+ server.your_connection &&
+ getGatewayConnectionStatus(server.your_connection) === "connected"
+ ) {
return true;
}
return server.auth_mode === "shared" && !isAdmin;
}
+export type GatewayConnectionStatus =
+ | "connected"
+ | "pending_oauth"
+ | "needs_reauth";
+
+/** A persisted installation row is not necessarily a usable connection. */
+export function getGatewayConnectionStatus(
+ connection: Pick,
+): GatewayConnectionStatus {
+ if (connection.needs_reauth) return "needs_reauth";
+ if (connection.pending_oauth) return "pending_oauth";
+ return "connected";
+}
+
+export type GatewayServerRemovalAction =
+ | "delete_for_everyone"
+ | "delete_for_you"
+ | "disconnect";
+
+/**
+ * Admins remove custom servers from the team gateway. For members, a custom
+ * server registered by the owner of the current personal installation is
+ * theirs to delete; catalog servers and custom servers registered by somebody
+ * else remain team entries, so removing the caller's installation is
+ * presented as disconnecting instead.
+ */
+export function getGatewayServerRemovalAction(
+ server: McpGatewayServer,
+ isAdmin: boolean,
+): GatewayServerRemovalAction | null {
+ if (isAdmin && server.template_id === null) return "delete_for_everyone";
+
+ const yourConnection = server.your_connection;
+ if (!yourConnection || yourConnection.scope !== "personal") return null;
+
+ const yourConnectionSummary = server.connections.find(
+ (connection) =>
+ connection.installation_id === yourConnection.installation_id,
+ );
+ const personallyAddedCustomServer =
+ server.template_id === null &&
+ server.auth_mode === "individual" &&
+ server.created_by?.id === yourConnectionSummary?.user.id;
+
+ return personallyAddedCustomServer ? "delete_for_you" : "disconnect";
+}
+
export type GatewayPolicyCounts = Record;
export function countPoliciesByState(
diff --git a/packages/ui/src/features/mcp-gateway/components/McpGatewayView.tsx b/packages/ui/src/features/mcp-gateway/components/McpGatewayView.tsx
index 181d8ce275..17a7e16a95 100644
--- a/packages/ui/src/features/mcp-gateway/components/McpGatewayView.tsx
+++ b/packages/ui/src/features/mcp-gateway/components/McpGatewayView.tsx
@@ -14,6 +14,7 @@ import {
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 { DotPatternBackground } from "@posthog/ui/primitives/DotPatternBackground";
import { Box, Flex, ScrollArea } from "@radix-ui/themes";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
@@ -59,10 +60,6 @@ export function McpGatewayView() {
? requestedRoute
: { view: "servers" };
- const activeAgentCount = serviceAccounts.accounts.filter(
- (account) => account.status === "active",
- ).length;
-
const mainContent = (() => {
switch (route.view) {
case "add":
@@ -123,11 +120,11 @@ export function McpGatewayView() {
templatesById={gateway.templatesById}
isAdmin={isAdmin}
canAddServers={canAddServers}
- activeAgentCount={activeAgentCount}
route={route}
onNavigate={setRoute}
/>
-
+
+
{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
index 3ac11247fc..92e988310e 100644
--- a/packages/ui/src/features/mcp-gateway/components/parts/GatewayAddServer.tsx
+++ b/packages/ui/src/features/mcp-gateway/components/parts/GatewayAddServer.tsx
@@ -20,6 +20,7 @@ import { useRegisterGatewayServer } from "@posthog/ui/features/mcp-gateway/hooks
import {
Button,
Flex,
+ Heading,
Select,
Spinner,
Switch,
@@ -27,7 +28,7 @@ import {
TextArea,
TextField,
} from "@radix-ui/themes";
-import { useState } from "react";
+import { type FormEvent, useState } from "react";
interface GatewayAddServerProps {
isAdmin: boolean;
@@ -58,7 +59,8 @@ export function GatewayAddServer({
const canSave = canSubmitGatewayServer(values);
const sharedCredential = effectiveCredentialMode(values) === "shared";
- const submit = () => {
+ const submit = (event: FormEvent) => {
+ event.preventDefault();
if (!canSave || registerPending) return;
const request = buildGatewayInstallRequest(values, { isAdmin });
register(
@@ -74,319 +76,306 @@ export function GatewayAddServer({
};
return (
-
-
- onNavigate({ view: "servers" })}
- >
-
- Back to servers
-
-
-
-
-
- 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.
-
-
+
+ )}
+
- {sharedCredential && (
+ {isAdmin && (
+ <>
+
+
+ Once the server authenticates, you'll be able to configure tool
+ approvals for each agent.
+
+ set("allowPersonal", checked)}
+ title="Enable for the whole team"
+ sub={
+ values.teamEnabled
+ ? "Every member will see this server once it's added."
+ : "Only admins will see it until you enable it in Team settings."
+ }
+ checked={values.teamEnabled}
+ onChange={(checked) => set("teamEnabled", checked)}
/>
- )}
-
-
- Share with agents
-
-
- {accounts.map((account) => {
- const on = values.agentIds.includes(account.id);
- return (
-
-
-
-
- {account.name}
-
-
- {account.handle}
-
-
-
- set(
- "agentIds",
- checked
- ? [...values.agentIds, account.id]
- : values.agentIds.filter(
- (id) => id !== account.id,
- ),
- )
- }
- />
-
- );
- })}
- {accounts.length === 0 && (
-
- No agents yet — create one under Team & agents.
+ {values.authType === "oauth" ? (
+
+ }
+ title="Everyone connects their own account"
+ sub="Members authenticate individually — calls run as each person."
+ onClick={() => set("credentialMode", "individual")}
+ />
+ }
+ title="One shared credential"
+ sub="You connect a service account once; the whole team is pre-authorized."
+ onClick={() => set("credentialMode", "shared")}
+ />
+
+ ) : (
+
+
+
+ API-key servers always use one shared credential — everyone
+ with access calls through the key above.
- )}
-