From e4e8b60aeb9e8aef267a23aa3788d50d3b03767a Mon Sep 17 00:00:00 2001 From: Chris Volzer Date: Tue, 21 Jul 2026 12:41:31 -0400 Subject: [PATCH 01/10] 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 e4d994957b..a4460184c3 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -98,7 +98,29 @@ import { } from "./agent-analytics"; import { buildApiFetcher, 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"; + +export type * from "./mcp-gateway"; export interface ApiClientLogger { warn(...args: unknown[]): void; } @@ -4257,17 +4279,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/`, @@ -4342,12 +4366,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({ @@ -4483,6 +4509,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 8600cec7d0..414f616a49 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -21,5 +21,11 @@ export const GLM_MODEL_FLAG = "posthog-code-glm-model"; 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 + + )} + + +