diff --git a/docs/LOCAL-DEVELOPMENT.md b/docs/LOCAL-DEVELOPMENT.md index 41c375f2d0..0991eeaf66 100644 --- a/docs/LOCAL-DEVELOPMENT.md +++ b/docs/LOCAL-DEVELOPMENT.md @@ -127,10 +127,59 @@ region you pick at login. ## Troubleshooting +### Feature flags never enabled (flag-gated UI missing) + +If flag-gated surfaces (e.g. the MCP gateway behind `mcp-gateway`) never show up +even though the flag is enabled in your PostHog project, check +`VITE_POSTHOG_API_HOST` in `.env`: it must include the scheme +(`http://localhost:8010`, not `localhost:8010`). posthog-js concatenates the +host into request URLs verbatim, so a scheme-less value produces URLs like +`localhost:8010/flags/…` that the browser rejects as an invalid protocol — +every flag fetch fails silently and `isFeatureEnabled` returns `undefined` for +everything (flags never loaded). Prefer `node scripts/use-local-posthog.mjs` +over hand-editing; it writes the correct form. + +To confirm what the running app sees, run in the renderer console (or via CDP): + +```js +posthog.config.api_host; // must start with http:// or https:// +posthog.isFeatureEnabled("mcp-gateway"); // undefined ⇒ flags never loaded +``` + +`.env` changes need a dev-server restart (`pnpm dev`) to take effect. + ### "Invalid client_id" error during OAuth The OAuth application in your local PostHog must have the client ID `DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ`. Verify at http://localhost:8010/admin/posthog/oauthapplication/. +### "OAuth error: invalid_scope" + +PostHog Code requests the wildcard scope `*` (see `OAUTH_SCOPES` in +`packages/shared/src/oauth.ts`). PostHog's OAuth server only grants `*` at +`/authorize` when the OAuth application's **scope ceiling is empty** — this is +the grandfathering path for the PostHog Code client. If the application has any +explicit `scopes` or `optional_scopes` configured, the wildcard is rejected with +`invalid_scope`. + +Fix: clear the scope ceiling on your local OAuth application so it matches the +production app. Either edit it at +http://localhost:8010/admin/posthog/oauthapplication/ (empty the **Scopes** and +**Optional scopes** fields), or run in your PostHog repo: + +```bash +python manage.py shell -c " +from posthog.models.oauth import OAuthApplication +app = OAuthApplication.objects.get(client_id='DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ') +app.scopes = [] +app.optional_scopes = [] +app.save() +print('cleared scope ceiling for', app.client_id) +" +``` + +Then retry login. (Do not add `*` to the ceiling — an explicit ceiling never +grants the wildcard, even if `*` is listed.) + ### "Redirect URI mismatch" Make sure the OAuth application's redirect URIs include `http://localhost:8237/callback` and `http://localhost:8239/callback`. Check for trailing slashes. diff --git a/packages/api-client/src/mcp-gateway.ts b/packages/api-client/src/mcp-gateway.ts new file mode 100644 index 0000000000..fa6037b27d --- /dev/null +++ b/packages/api-client/src/mcp-gateway.ts @@ -0,0 +1,208 @@ +// 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, McpAuthType, McpCategory } from "./types"; + +export type McpGatewayUser = Schemas.UserBasic; + +export type McpGatewayScopeType = "team" | "member" | "agent"; +export type McpServiceAccountStatus = "active" | "paused"; +export type McpAuditDecision = "auto" | "approved" | "pending" | "blocked"; +export type McpAuditQuickFilter = "all" | "agents" | "approvals" | "blocked"; +export type McpPolicyDecidedBy = + | "rule" + | "scope" + | "team" + | "preset" + | "legacy" + | "default"; + +/** One member's 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; + /** Per-connection switch — false when self-disabled. */ + 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. posthog-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; + is_team_enabled: boolean; + icon_key: string; + docs_url: string; + template_id: string | null; + /** + * Fixed authentication type for catalog templates. Null for custom + * servers, where each member chooses when connecting. + */ + template_auth_type: McpAuthType | null; + tool_count: number; + /** Members with a connection to this server. Admin-only; empty for members. */ + connections: McpGatewayConnection[]; + your_connection: McpGatewayYourConnection | 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; +} + +/** 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; + 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; + /** True when a rule or Blocked team ceiling leaves no editable state. */ + 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. posthog-support. */ + 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. */ + token: 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; + /** Whether members may share MCP connections with agents and manage agent tool policies. */ + allow_member_agent_access: boolean; + /** + * Whether catalog servers the team never touched (no gateway row) are + * enabled. Covers templates published after the admin last curated. + */ + default_servers_enabled: boolean; + /** Whether the requesting user can administer the gateway. */ + is_admin: boolean; +} + +export interface TeamMcpGatewayConfigUpdate { + allow_custom_servers?: boolean; + allow_member_agent_access?: boolean; + default_servers_enabled?: boolean; +} + +/** 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[]; +} + +/** + * Gateway options accepted by install_custom / install_template. Credentials + * are always personal to the installer; agents reach them through grants. + */ +export interface McpGatewayInstallSharingOptions { + /** Whether the server starts enabled for the whole team. */ + team_enabled?: boolean; + /** Service accounts to grant the server to at install time, when team settings allow it. */ + agent_ids?: string[]; +} diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 05fb47285a..5007ffc495 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -120,11 +120,31 @@ import { requestErrorStatus, } from "./fetcher"; import { createApiClient, type Schemas } from "./generated"; +import type { + McpAuditCounts, + McpAuditEvent, + McpAuditPage, + McpAuditQuickFilter, + McpGatewayInstallSharingOptions, + McpGatewayMemberSummary, + McpGatewayPolicyScope, + McpGatewayServer, + McpGatewayServerUpdate, + 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 +4624,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 +4711,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 +4854,303 @@ 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", + }); + } + + /** + * Admin: set the team posture for untouched catalog servers and bulk-apply + * the same state to every existing gateway row. + */ + async setAllMcpGatewayServersEnabled( + enabled: boolean, + ): Promise { + return this.mcpGatewayFetch({ + method: "post", + path: "mcp_gateway/config/set_all_servers_enabled/", + body: { enabled }, + errorLabel: "Failed to update servers", + }); + } + + 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", + }); + } + + /** + * Admin: enable or disable a catalog template the team never touched, + * materializing a gateway row for it (or updating the existing one). + */ + async setMcpGatewayTemplateEnabled(options: { + templateId: string; + enabled: boolean; + }): Promise { + return this.mcpGatewayFetch({ + method: "post", + path: "mcp_gateway/servers/set_template_enabled/", + body: { template_id: options.templateId, enabled: options.enabled }, + errorLabel: "Failed to update catalog server", + }); + } + + /** + * Disconnect every member and delete the row. The registry is sparse, so a + * deleted catalog server simply follows the team default again. + */ + 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", + }); + } + + async getMcpGatewayMembers(): Promise { + const data = await this.mcpGatewayFetch<{ + results?: McpGatewayMemberSummary[]; + }>({ + method: "get", + path: "mcp_gateway/members/", + search: { limit: 500 }, + errorLabel: "Failed to fetch gateway members", + }); + return data.results ?? []; + } + + /** 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 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/api-client/src/types.ts b/packages/api-client/src/types.ts index 0c6146081c..e63cfd4a1a 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -6,8 +6,15 @@ export type McpApprovalState = Schemas.MCPServerInstallationToolApprovalStateEnum; export type McpAuthType = Schemas.MCPAuthTypeEnum; export type McpRecommendedServer = Schemas.MCPServerTemplate; -export type McpServerInstallation = Schemas.MCPServerInstallation; -export type McpInstallationTool = Schemas.MCPServerInstallationTool; +export type McpServerInstallation = Schemas.MCPServerInstallation & { + scope?: "personal" | "shared"; +}; +export type McpInstallationTool = Schemas.MCPServerInstallationTool & { + /** Team-admin ceiling returned by gateway-aware backends. */ + team_state?: McpApprovalState | null; + locked?: boolean; + decided_by?: "rule" | "scope" | "team" | "preset" | "legacy" | "default"; +}; export type McpOAuthRedirectResponse = Schemas.OAuthRedirectResponse; export type McpInstallSource = "posthog" | "posthog-code" | "posthog-mobile"; export type McpInstallResponse = 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..b59815ff0b --- /dev/null +++ b/packages/core/src/mcp-gateway/gatewayAddServer.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { + buildGatewayInstallRequest, + canSubmitGatewayServer, + 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("buildGatewayInstallRequest", () => { + it("builds an oauth install with admin team options", () => { + const request = buildGatewayInstallRequest( + values({ description: " Wiki tools ", agentIds: ["svc-1"] }), + { isAdmin: true, canManageAgentAccess: true }, + ); + expect(request).toEqual({ + name: "Internal Wiki", + url: "https://mcp.example.com/sse", + description: "Wiki tools", + auth_type: "oauth", + team_enabled: true, + agent_ids: ["svc-1"], + }); + }); + + it("includes the key on api-key installs", () => { + const request = buildGatewayInstallRequest( + values({ authType: "api_key", apiKey: "sk-123" }), + { isAdmin: true, canManageAgentAccess: true }, + ); + expect(request.auth_type).toBe("api_key"); + expect(request.api_key).toBe("sk-123"); + }); + + it("includes oauth client credentials only when provided", () => { + const bare = buildGatewayInstallRequest(values(), { + isAdmin: true, + canManageAgentAccess: true, + }); + expect(bare.client_id).toBeUndefined(); + const withCreds = buildGatewayInstallRequest( + values({ clientId: " id ", clientSecret: "secret" }), + { isAdmin: true, canManageAgentAccess: true }, + ); + expect(withCreds.client_id).toBe("id"); + expect(withCreds.client_secret).toBe("secret"); + }); + + it("lets permitted members share with agents without team enablement", () => { + const request = buildGatewayInstallRequest( + values({ agentIds: ["svc-1"] }), + { isAdmin: false, canManageAgentAccess: true }, + ); + expect(request.team_enabled).toBeUndefined(); + expect(request.agent_ids).toEqual(["svc-1"]); + }); + + it("omits agent grants when team settings make them admin-only", () => { + const request = buildGatewayInstallRequest( + values({ agentIds: ["svc-1"] }), + { isAdmin: false, canManageAgentAccess: false }, + ); + 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..23870376d7 --- /dev/null +++ b/packages/core/src/mcp-gateway/gatewayAddServer.ts @@ -0,0 +1,77 @@ +import type { + McpAuthType, + McpGatewayInstallSharingOptions, +} from "@posthog/api-client/posthog-client"; +import { isValidMcpUrl } from "../mcp-servers/customServerForm"; + +export interface GatewayAddServerValues { + name: string; + url: string; + description: string; + authType: McpAuthType; + apiKey: string; + clientId: string; + clientSecret: string; + /** Team sharing options are admin-only; agentIds follows the team setting. */ + teamEnabled: boolean; + agentIds: string[]; +} + +export const GATEWAY_ADD_SERVER_DEFAULTS: GatewayAddServerValues = { + name: "", + url: "", + description: "", + authType: "oauth", + apiKey: "", + clientId: "", + clientSecret: "", + teamEnabled: true, + agentIds: [], +}; + +export function canSubmitGatewayServer( + values: Pick, +): boolean { + return values.name.trim() !== "" && isValidMcpUrl(values.url); +} + +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. The + * credential is always personal to the installer. Team-wide options are + * attached only for admins; agent grants are attached whenever the team + * allows this member to manage agent access. + */ +export function buildGatewayInstallRequest( + values: GatewayAddServerValues, + options: { isAdmin: boolean; canManageAgentAccess: boolean }, +): GatewayInstallRequest { + 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 ? { team_enabled: values.teamEnabled } : {}), + ...(options.canManageAgentAccess && values.agentIds.length + ? { agent_ids: values.agentIds } + : {}), + }; +} diff --git a/packages/core/src/mcp-gateway/gatewayConnect.test.ts b/packages/core/src/mcp-gateway/gatewayConnect.test.ts new file mode 100644 index 0000000000..67a27f7e1f --- /dev/null +++ b/packages/core/src/mcp-gateway/gatewayConnect.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + InstallFlowClient, + IOAuthCallback, +} from "../mcp-servers/installFlow"; +import { + canSubmitGatewayConnect, + connectGatewayServer, + GATEWAY_CONNECT_DEFAULTS, + type GatewayConnectCredentials, + gatewayConnectAuthType, + gatewayConnectNeedsCredentials, + templateConnectNeedsCredentials, +} from "./gatewayConnect"; + +function credentials( + overrides: Partial = {}, +): GatewayConnectCredentials { + return { ...GATEWAY_CONNECT_DEFAULTS, ...overrides }; +} + +function fakes() { + const client: InstallFlowClient = { + installMcpTemplate: vi.fn().mockResolvedValue({ id: "inst-1" }), + installCustomMcpServer: vi.fn().mockResolvedValue({ id: "inst-2" }), + authorizeMcpInstallation: vi + .fn() + .mockResolvedValue({ redirect_url: "https://auth" }), + }; + const oauth: IOAuthCallback = { + getCallbackUrl: vi + .fn() + .mockResolvedValue({ callbackUrl: "posthog://callback" }), + openAndWaitForCallback: vi.fn().mockResolvedValue({ success: true }), + }; + return { client, oauth }; +} + +describe("gatewayConnectAuthType", () => { + it.each([ + [ + "oauth template", + { template_id: "t1", template_auth_type: "oauth" }, + "oauth", + ], + [ + "api-key template", + { template_id: "t1", template_auth_type: "api_key" }, + "api_key", + ], + [ + "template with no reported type", + { template_id: "t1", template_auth_type: null }, + "oauth", + ], + [ + "custom server — member chooses", + { template_id: null, template_auth_type: null }, + null, + ], + ] as const)("%s", (_label, server, expected) => { + expect(gatewayConnectAuthType(server)).toBe(expected); + }); +}); + +describe("gatewayConnectNeedsCredentials", () => { + it.each([ + [ + "oauth template connects directly", + { template_id: "t1", template_auth_type: "oauth" }, + false, + ], + [ + "api-key template asks for the key", + { template_id: "t1", template_auth_type: "api_key" }, + true, + ], + [ + "custom server asks the member to choose", + { template_id: null, template_auth_type: null }, + true, + ], + ] as const)("%s", (_label, server, expected) => { + expect(gatewayConnectNeedsCredentials(server)).toBe(expected); + }); +}); + +describe("templateConnectNeedsCredentials", () => { + it.each([ + ["oauth template", { auth_type: "oauth" }, false], + ["api-key template", { auth_type: "api_key" }, true], + ["auth type unreported", {}, false], + ] as const)("%s", (_label, template, expected) => { + expect( + templateConnectNeedsCredentials( + template as { auth_type?: "oauth" | "api_key" }, + ), + ).toBe(expected); + }); +}); + +describe("canSubmitGatewayConnect", () => { + it.each([ + ["oauth needs no key", credentials(), true], + [ + "api_key with a key", + credentials({ authType: "api_key", apiKey: "sk-1" }), + true, + ], + [ + "api_key with a blank key", + credentials({ authType: "api_key", apiKey: " " }), + false, + ], + ])("%s", (_label, input, expected) => { + expect(canSubmitGatewayConnect(input)).toBe(expected); + }); +}); + +describe("connectGatewayServer", () => { + const template = { + template_id: "t1", + name: "Linear", + url: "https://mcp.linear.app", + description: "", + }; + const custom = { + template_id: null, + name: "Internal Wiki", + url: "https://mcp.example.com/sse", + description: "Wiki tools", + }; + + it("installs a template with the member's API key", async () => { + const { client, oauth } = fakes(); + await connectGatewayServer( + client, + oauth, + template, + credentials({ authType: "api_key", apiKey: "sk-1" }), + ); + expect(client.installMcpTemplate).toHaveBeenCalledWith({ + template_id: "t1", + api_key: "sk-1", + install_source: "posthog-code", + posthog_code_callback_url: "posthog://callback", + }); + expect(client.installCustomMcpServer).not.toHaveBeenCalled(); + }); + + it("installs an oauth template without a key by default", async () => { + const { client, oauth } = fakes(); + await connectGatewayServer(client, oauth, template); + expect(client.installMcpTemplate).toHaveBeenCalledWith({ + template_id: "t1", + api_key: undefined, + install_source: "posthog-code", + posthog_code_callback_url: "posthog://callback", + }); + }); + + it("connects a custom server with the chosen api_key mechanism", async () => { + const { client, oauth } = fakes(); + const result = await connectGatewayServer( + client, + oauth, + custom, + credentials({ + authType: "api_key", + apiKey: "sk-2", + // A stale value from flipping the auth select must not leak through. + clientId: "leftover", + clientSecret: "leftover", + }), + ); + expect(client.installCustomMcpServer).toHaveBeenCalledWith({ + name: "Internal Wiki", + url: "https://mcp.example.com/sse", + description: "Wiki tools", + auth_type: "api_key", + api_key: "sk-2", + client_id: undefined, + client_secret: undefined, + install_source: "posthog-code", + posthog_code_callback_url: "posthog://callback", + }); + // API-key installs return no redirect, so no browser round-trip. + expect(oauth.openAndWaitForCallback).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + it("connects a custom server over oauth with optional client credentials", async () => { + const { client, oauth } = fakes(); + vi.mocked(client.installCustomMcpServer).mockResolvedValue({ + redirect_url: "https://auth.example.com", + }); + await connectGatewayServer( + client, + oauth, + custom, + credentials({ clientId: " id ", clientSecret: "secret" }), + ); + expect(client.installCustomMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + auth_type: "oauth", + api_key: undefined, + client_id: "id", + client_secret: "secret", + }), + ); + expect(oauth.openAndWaitForCallback).toHaveBeenCalledWith({ + redirectUrl: "https://auth.example.com", + }); + }); +}); diff --git a/packages/core/src/mcp-gateway/gatewayConnect.ts b/packages/core/src/mcp-gateway/gatewayConnect.ts new file mode 100644 index 0000000000..798b4d87fa --- /dev/null +++ b/packages/core/src/mcp-gateway/gatewayConnect.ts @@ -0,0 +1,113 @@ +import type { + McpAuthType, + McpGatewayServer, +} from "@posthog/api-client/posthog-client"; +import { + type InstallFlowClient, + type IOAuthCallback, + installCustomWithOAuth, + installTemplateWithOAuth, + type OAuthCallbackResult, +} from "../mcp-servers/installFlow"; + +/** Personal credentials a member supplies when connecting to a gateway server. */ +export interface GatewayConnectCredentials { + authType: McpAuthType; + apiKey: string; + /** Optional OAuth client for providers without dynamic client registration. */ + clientId: string; + clientSecret: string; +} + +export const GATEWAY_CONNECT_DEFAULTS: GatewayConnectCredentials = { + authType: "oauth", + apiKey: "", + clientId: "", + clientSecret: "", +}; + +type GatewayConnectServer = Pick< + McpGatewayServer, + "template_id" | "template_auth_type" +>; + +/** + * The auth mechanism a connection must use: catalog templates fix it, custom + * servers leave it null — every credential is personal, so each member picks + * their own mechanism when they connect. + */ +export function gatewayConnectAuthType( + server: GatewayConnectServer, +): McpAuthType | null { + return server.template_id ? (server.template_auth_type ?? "oauth") : null; +} + +/** + * OAuth connects need no input up front — the browser round-trip collects the + * grant. Everything else (API-key templates, custom servers where the member + * chooses) must collect credentials before installing. + */ +export function gatewayConnectNeedsCredentials( + server: GatewayConnectServer, +): boolean { + return gatewayConnectAuthType(server) !== "oauth"; +} + +/** Same decision for a catalog template with no gateway row yet. */ +export function templateConnectNeedsCredentials(template: { + auth_type?: McpAuthType; +}): boolean { + return (template.auth_type ?? "oauth") !== "oauth"; +} + +export function canSubmitGatewayConnect( + credentials: Pick, +): boolean { + return credentials.authType !== "api_key" || credentials.apiKey.trim() !== ""; +} + +export interface GatewayConnectTarget { + template_id: string | null; + name: string; + url: string; + description: string; +} + +/** + * Connect the caller's own credential to a gateway server, or to a catalog + * template with no row yet — the backend materializes the row. Honors the + * chosen auth mechanism instead of assuming OAuth: API-key connects complete + * inline, OAuth connects round-trip the host browser callback. + */ +export async function connectGatewayServer( + client: InstallFlowClient, + oauth: IOAuthCallback, + target: GatewayConnectTarget, + credentials: GatewayConnectCredentials = GATEWAY_CONNECT_DEFAULTS, +): Promise { + const apiKey = + credentials.authType === "api_key" && credentials.apiKey + ? credentials.apiKey + : undefined; + if (target.template_id) { + return installTemplateWithOAuth(client, oauth, { + template_id: target.template_id, + api_key: apiKey, + }); + } + return installCustomWithOAuth(client, oauth, { + name: target.name, + url: target.url, + description: target.description, + auth_type: credentials.authType, + api_key: apiKey, + client_id: + credentials.authType === "oauth" && credentials.clientId.trim() + ? credentials.clientId.trim() + : undefined, + client_secret: + credentials.authType === "oauth" && credentials.clientSecret.trim() + ? credentials.clientSecret.trim() + : undefined, + }); +} 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..e4be219d71 --- /dev/null +++ b/packages/core/src/mcp-gateway/gatewayServers.test.ts @@ -0,0 +1,491 @@ +import type { + McpGatewayServer, + McpGatewayYourConnection, + McpResolvedToolPolicy, +} from "@posthog/api-client/posthog-client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + countGatewayServersByCategory, + countPoliciesByState, + defaultAgentGrantPolicy, + filterCatalogTemplates, + filterGatewayServers, + formatAgo, + formatAuditTime, + getGatewayConnectionStatus, + getGatewayRailStatus, + getGatewayServerRemovalAction, + isAgentPolicyState, + isConnectedForYou, + isPolicyStateAllowedByCeiling, + normalizeGatewayServerUrl, + railConnectedServers, + recommendedCatalogTemplates, + resolvePolicyStateForScope, +} from "./gatewayServers"; + +describe("agent tool policies", () => { + it.each([ + ["approved", true], + ["needs_approval", false], + ["do_not_use", true], + ] as const)("allows %s for agents: %s", (state, expected) => { + expect(isAgentPolicyState(state)).toBe(expected); + }); + + it("treats approval-gated tools as blocked for agents only", () => { + expect(resolvePolicyStateForScope("needs_approval", "agent")).toBe( + "do_not_use", + ); + expect(resolvePolicyStateForScope("needs_approval", "member")).toBe( + "needs_approval", + ); + expect(resolvePolicyStateForScope("needs_approval", "team")).toBe( + "needs_approval", + ); + }); +}); + +describe("isPolicyStateAllowedByCeiling", () => { + it.each([ + ["approved", "needs_approval", false], + ["needs_approval", "needs_approval", true], + ["do_not_use", "needs_approval", true], + ["approved", "do_not_use", false], + ["needs_approval", "do_not_use", false], + ["do_not_use", "do_not_use", true], + ["approved", "approved", true], + ["approved", null, true], + ] as const)("%s under a %s ceiling is %s", (state, ceiling, expected) => { + expect(isPolicyStateAllowedByCeiling(state, ceiling)).toBe(expected); + }); +}); + +function connection( + overrides: Partial = {}, +): McpGatewayYourConnection { + return { + installation_id: "inst-1", + 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", + is_team_enabled: true, + icon_key: "", + docs_url: "", + template_id: null, + template_auth_type: null, + tool_count: 0, + connections: [], + your_connection: 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("railConnectedServers", () => { + const servers = [ + server({ id: "a", name: "Alpha", your_connection: connection() }), + server({ id: "b", name: "Beta" }), + server({ id: "c", name: "Gamma", your_connection: connection() }), + ]; + + it("lists only the servers the caller has connected", () => { + expect(railConnectedServers(servers, "").map((s) => s.id)).toEqual([ + "a", + "c", + ]); + }); + + it("filters by name", () => { + expect(railConnectedServers(servers, "gam").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("filterCatalogTemplates", () => { + const templates = [ + { id: "t1", name: "Linear", description: "Tickets", url: "https://a" }, + { id: "t2", name: "GitHub", url: "https://b", category: "data" }, + ]; + + it("matches name/description/url and tolerates missing fields", () => { + expect(filterCatalogTemplates(templates, "tickets", null)).toEqual([ + templates[0], + ]); + expect(filterCatalogTemplates(templates, "https://b", null)).toEqual([ + templates[1], + ]); + }); + + it("applies the category chip", () => { + expect(filterCatalogTemplates(templates, "", "data")).toEqual([ + templates[1], + ]); + }); +}); + +describe("normalizeGatewayServerUrl", () => { + it.each([ + ["https://mcp.linear.app/sse/", "https://mcp.linear.app/sse"], + ["https://mcp.linear.app/sse", "https://mcp.linear.app/sse"], + [" https://mcp.linear.app// ", "https://mcp.linear.app"], + ])("normalizes %s", (input, expected) => { + expect(normalizeGatewayServerUrl(input)).toBe(expected); + }); +}); + +describe("recommendedCatalogTemplates", () => { + const templates = [ + { id: "t1", url: "https://mcp.linear.app/sse" }, + { id: "t2", url: "https://mcp.notion.so/mcp" }, + { id: "t3", url: "https://mcp.stripe.com" }, + ]; + + it("excludes templates already materialized by template id", () => { + const servers = [server({ template_id: "t1", url: "https://elsewhere" })]; + expect(recommendedCatalogTemplates(servers, templates)).toEqual([ + templates[1], + templates[2], + ]); + }); + + it("excludes templates matched by trailing-slash-insensitive url", () => { + const servers = [ + server({ template_id: null, url: "https://mcp.notion.so/mcp/" }), + ]; + expect(recommendedCatalogTemplates(servers, templates)).toEqual([ + templates[0], + templates[2], + ]); + }); + + it("returns every template when the registry is empty", () => { + expect(recommendedCatalogTemplates([], templates)).toEqual(templates); + }); +}); + +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([ + ["own connection", server({ your_connection: connection() }), true], + [ + "pending oauth does not count", + server({ your_connection: connection({ pending_oauth: true }) }), + false, + ], + [ + "connection needing reauth does not count", + server({ your_connection: connection({ needs_reauth: true }) }), + false, + ], + ["not connected", server({}), false], + ] as const)("%s", (_label, srv, expected) => { + expect(isConnectedForYou(srv)).toBe(expected); + }); +}); + +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("getGatewayRailStatus", () => { + it.each([ + ["no connection", server({}), null], + [ + "a usable connection", + server({ your_connection: connection() }), + "connected", + ], + [ + "a connection pending OAuth", + server({ your_connection: connection({ pending_oauth: true }) }), + "pending_oauth", + ], + [ + "a connection needing reauthorization", + server({ your_connection: connection({ needs_reauth: true }) }), + "needs_reauth", + ], + [ + "a self-disabled connection", + server({ your_connection: connection({ is_enabled: false }) }), + "self_disabled", + ], + [ + "revoked access", + server({ is_revoked_for_you: true, your_connection: connection() }), + "revoked", + ], + [ + "a team-disabled server", + server({ is_team_enabled: false, your_connection: connection() }), + "team_off", + ], + [ + "self-disabled outranking the auth states", + server({ + your_connection: connection({ is_enabled: false, needs_reauth: true }), + }), + "self_disabled", + ], + [ + "revocation outranking self-disable", + server({ + is_revoked_for_you: true, + your_connection: connection({ is_enabled: false }), + }), + "revoked", + ], + [ + "the team master switch outranking everything", + server({ + is_team_enabled: false, + is_revoked_for_you: true, + your_connection: connection({ is_enabled: false, needs_reauth: true }), + }), + "team_off", + ], + ] as const)("returns the status for %s", (_label, srv, expected) => { + expect(getGatewayRailStatus(srv)).toBe(expected); + }); +}); + +describe("getGatewayServerRemovalAction", () => { + const gatewayUser = (id: number) => ({ + id, + uuid: `user-${id}`, + email: `user-${id}@example.com`, + hedgehog_config: null, + }); + + // Members never receive `connections` (it is admin-only), so every + // non-admin case keeps the default empty roster — the real API shape. + it.each([ + [ + "deletes a personally added custom server", + server({ + created_by: gatewayUser(1), + your_connection: connection(), + }), + false, + 1, + "delete_for_you", + ], + [ + "disconnects from a custom server added by someone else", + server({ + created_by: gatewayUser(2), + your_connection: connection(), + }), + false, + 1, + "disconnect", + ], + [ + "disconnects from a custom server with no recorded creator", + server({ + created_by: null, + your_connection: connection(), + }), + false, + 1, + "disconnect", + ], + [ + "disconnects when the current user is unknown", + server({ + created_by: gatewayUser(1), + your_connection: connection(), + }), + false, + null, + "disconnect", + ], + [ + "disconnects from a catalog server", + server({ + template_id: "template-1", + created_by: gatewayUser(1), + your_connection: connection(), + }), + false, + 1, + "disconnect", + ], + [ + "deletes a custom server for everyone when requested by an admin", + server({}), + true, + 1, + "delete_for_everyone", + ], + [ + "does not delete a catalog server for an admin without a connection", + server({ template_id: "template-1" }), + true, + 1, + null, + ], + [ + "returns no action without a personal connection", + server({}), + false, + 1, + null, + ], + ] as const)("%s", (_label, srv, isAdmin, currentUserId, expected) => { + expect(getGatewayServerRemovalAction(srv, isAdmin, currentUserId)).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, + 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 }); + }); + + it("counts approval-gated agent tools as blocked", () => { + expect( + countPoliciesByState( + [ + { + tool_name: "send_message", + description: "", + input_schema: {}, + policy_state: "needs_approval", + team_state: null, + locked: false, + decided_by: "scope", + rule_name: "", + rule_description: "", + }, + ], + "agent", + ), + ).toEqual({ approved: 0, 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); + }); +}); diff --git a/packages/core/src/mcp-gateway/gatewayServers.ts b/packages/core/src/mcp-gateway/gatewayServers.ts new file mode 100644 index 0000000000..cb70f85949 --- /dev/null +++ b/packages/core/src/mcp-gateway/gatewayServers.ts @@ -0,0 +1,292 @@ +import type { + McpApprovalState, + McpAuditDecision, + McpGatewayScopeType, + McpGatewayServer, + McpGatewayYourConnection, + McpResolvedToolPolicy, +} from "@posthog/api-client/posthog-client"; +import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared"; + +/** The rail lists the servers the caller has connected, under the rail search. */ +export function railConnectedServers( + servers: McpGatewayServer[], + search: string, +): McpGatewayServer[] { + const query = search.trim().toLowerCase(); + return servers.filter( + (server) => + server.your_connection !== null && + (!query || server.name.toLowerCase().includes(query)), + ); +} + +interface GatewayServerLike { + name: string; + description?: string; + url: string; + category?: string; +} + +function matchesSearchAndCategory( + entry: GatewayServerLike, + query: string, + category: string | null, +): boolean { + if (category && entry.category !== category) return false; + if (!query) return true; + return ( + entry.name.toLowerCase().includes(query) || + (entry.description ?? "").toLowerCase().includes(query) || + entry.url.toLowerCase().includes(query) + ); +} + +/** 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) => + matchesSearchAndCategory(server, query, category), + ); +} + +/** Same search/category filter, for catalog templates on the home screen. */ +export function filterCatalogTemplates( + templates: T[], + search: string, + category: string | null, +): T[] { + const query = search.trim().toLowerCase(); + return templates.filter((template) => + matchesSearchAndCategory(template, query, category), + ); +} + +/** Trailing-slash-insensitive URL identity for row/template matching. */ +export function normalizeGatewayServerUrl(url: string): string { + return url.trim().replace(/\/+$/, ""); +} + +/** + * The registry is sparse: a catalog template has a gateway row only once + * someone connected to it or an admin toggled it. "Recommended" templates are + * the active catalog entries with no row — matched neither by template id nor + * by URL (trailing-slash-insensitive) — shown as connect-only cards. + */ +export function recommendedCatalogTemplates< + T extends { id: string; url: string }, +>( + servers: Pick[], + templates: T[], +): T[] { + const rowTemplateIds = new Set(); + const rowUrls = new Set(); + for (const server of servers) { + if (server.template_id) rowTemplateIds.add(server.template_id); + rowUrls.add(normalizeGatewayServerUrl(server.url)); + } + return templates.filter( + (template) => + !rowTemplateIds.has(template.id) && + !rowUrls.has(normalizeGatewayServerUrl(template.url)), + ); +} + +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 — + * every credential is the caller's own, so this is their connection's state. + */ +export function isConnectedForYou(server: McpGatewayServer): boolean { + return ( + !!server.your_connection && + getGatewayConnectionStatus(server.your_connection) === "connected" + ); +} + +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 GatewayRailStatus = + | GatewayConnectionStatus + | "team_off" + | "revoked" + | "self_disabled"; + +/** + * Rail-row status: folds the switches the raw connection status can't see — + * the admin master switch, per-user revocation, and the caller's own enable + * toggle — so a connection that can't be used never reads as connected. The + * auth states only matter once all three switches are on. Null when the + * caller has no connection. + */ +export function getGatewayRailStatus( + server: Pick< + McpGatewayServer, + "is_team_enabled" | "is_revoked_for_you" | "your_connection" + >, +): GatewayRailStatus | null { + const connection = server.your_connection; + if (!connection) return null; + if (!server.is_team_enabled) return "team_off"; + if (server.is_revoked_for_you) return "revoked"; + if (!connection.is_enabled) return "self_disabled"; + return getGatewayConnectionStatus(connection); +} + +export type GatewayServerRemovalAction = + | "delete_for_everyone" + | "delete_for_you" + | "disconnect"; + +/** + * Admins remove custom servers from the team gateway. For members, a custom + * server they registered themselves 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. + * + * The caller's identity must come in from the session user — + * `server.connections` is admin-only (empty for members), so it cannot + * identify a member caller. + */ +export function getGatewayServerRemovalAction( + server: McpGatewayServer, + isAdmin: boolean, + currentUserId: number | null, +): GatewayServerRemovalAction | null { + if (isAdmin && server.template_id === null) return "delete_for_everyone"; + + if (!server.your_connection) return null; + + const personallyAddedCustomServer = + server.template_id === null && + server.created_by !== null && + server.created_by.id === currentUserId; + + return personallyAddedCustomServer ? "delete_for_you" : "disconnect"; +} + +export type GatewayPolicyCounts = Record; + +export const AGENT_POLICY_STATES = [ + "approved", + "do_not_use", +] as const satisfies readonly McpApprovalState[]; + +export type AgentPolicyState = (typeof AGENT_POLICY_STATES)[number]; + +export function isAgentPolicyState( + state: McpApprovalState, +): state is AgentPolicyState { + return state !== "needs_approval"; +} + +/** + * Agents have no approval responder. A policy that would wait for approval is + * therefore unavailable to the agent, just like an explicit block. + */ +export function resolvePolicyStateForScope( + state: McpApprovalState, + scopeType: McpGatewayScopeType, +): McpApprovalState { + return scopeType === "agent" && state === "needs_approval" + ? "do_not_use" + : state; +} + +const POLICY_STRICTNESS: Record = { + approved: 0, + needs_approval: 1, + do_not_use: 2, +}; + +/** A scope may match the team ceiling or choose a more restrictive state. */ +export function isPolicyStateAllowedByCeiling( + state: McpApprovalState, + ceiling: McpApprovalState | null | undefined, +): boolean { + return ceiling === null || ceiling === undefined + ? true + : POLICY_STRICTNESS[state] >= POLICY_STRICTNESS[ceiling]; +} + +export function countPoliciesByState( + policies: McpResolvedToolPolicy[], + scopeType: McpGatewayScopeType = "member", +): GatewayPolicyCounts { + const counts: GatewayPolicyCounts = { + approved: 0, + needs_approval: 0, + do_not_use: 0, + }; + for (const policy of policies) { + counts[resolvePolicyStateForScope(policy.policy_state, scopeType)] += 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): AgentPolicyState { + return DESTRUCTIVE_TOOL_RE.test(toolName) ? "do_not_use" : "approved"; +} diff --git a/packages/core/src/mcp-gateway/gatewayToolDiscovery.test.ts b/packages/core/src/mcp-gateway/gatewayToolDiscovery.test.ts new file mode 100644 index 0000000000..d90e8004c2 --- /dev/null +++ b/packages/core/src/mcp-gateway/gatewayToolDiscovery.test.ts @@ -0,0 +1,179 @@ +import type { + McpGatewayServer, + McpGatewayYourConnection, +} from "@posthog/api-client/posthog-client"; +import { describe, expect, it, vi } from "vitest"; +import { + discoverGatewayTools, + findGatewayServer, + shouldDiscoverGatewayTools, + usableInstallationId, +} from "./gatewayToolDiscovery"; + +function connection( + overrides: Partial = {}, +): McpGatewayYourConnection { + return { + installation_id: "inst-1", + is_enabled: true, + pending_oauth: false, + needs_reauth: false, + last_used_at: null, + ...overrides, + }; +} + +function server(overrides: Partial = {}): McpGatewayServer { + return { + id: "srv-1", + name: "Linear", + url: "https://mcp.linear.app/sse", + description: "", + category: "dev", + is_team_enabled: true, + icon_key: "", + docs_url: "", + template_id: null, + template_auth_type: null, + tool_count: 0, + connections: [], + your_connection: 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, + }; +} + +function client(servers: McpGatewayServer[]) { + return { + getMcpGatewayServers: vi.fn().mockResolvedValue(servers), + refreshMcpInstallationTools: vi.fn().mockResolvedValue([]), + }; +} + +describe("findGatewayServer", () => { + const servers = [ + server({ id: "srv-1", template_id: "linear", url: "https://a.example" }), + server({ + id: "srv-2", + template_id: null, + url: "https://b.example/", + your_connection: connection({ installation_id: "inst-2" }), + }), + ]; + + it.each([ + ["by id", { serverId: "srv-2" }, "srv-2"], + ["by installation", { installationId: "inst-2" }, "srv-2"], + ["by template", { templateId: "linear" }, "srv-1"], + ["by url", { url: "https://b.example" }, "srv-2"], + [ + "by url ignoring a trailing slash", + { url: "https://a.example/" }, + "srv-1", + ], + [ + "falls back to url when the template misses", + { templateId: "unknown", url: "https://b.example" }, + "srv-2", + ], + ])("matches %s", (_label, match, expected) => { + expect(findGatewayServer(servers, match)?.id).toBe(expected); + }); + + it.each([ + ["nothing matches", { serverId: "missing" }], + ["no match criteria", {}], + ])("returns null when %s", (_label, match) => { + expect(findGatewayServer(servers, match)).toBeNull(); + }); +}); + +describe("usableInstallationId", () => { + it.each([ + ["a connected credential", connection(), "inst-1"], + ["a self-disabled connection", connection({ is_enabled: false }), "inst-1"], + ["a pending oauth connection", connection({ pending_oauth: true }), null], + ["a stale connection", connection({ needs_reauth: true }), null], + ["no connection", null, null], + ])("resolves %s", (_label, your_connection, expected) => { + expect(usableInstallationId(server({ your_connection }))).toBe(expected); + }); + + it("returns null without a server", () => { + expect(usableInstallationId(null)).toBeNull(); + }); +}); + +describe("shouldDiscoverGatewayTools", () => { + it.each([ + ["an empty catalog and a live connection", 0, connection(), true], + ["an already-populated catalog", 12, connection(), false], + ["no usable connection", 0, connection({ needs_reauth: true }), false], + ])("is %s -> %s", (_label, tool_count, your_connection, expected) => { + expect( + shouldDiscoverGatewayTools(server({ tool_count, your_connection })), + ).toBe(expected); + }); +}); + +describe("discoverGatewayTools", () => { + it("lists tools through the caller's fresh connection", async () => { + const api = client([server({ your_connection: connection() })]); + + const result = await discoverGatewayTools(api, { serverId: "srv-1" }); + + expect(api.refreshMcpInstallationTools).toHaveBeenCalledWith("inst-1"); + expect(result).toEqual({ + serverId: "srv-1", + installationId: "inst-1", + discovered: true, + }); + }); + + it("re-reads the registry so a just-created row is visible", async () => { + const api = client([ + server({ template_id: "linear", your_connection: connection() }), + ]); + + await discoverGatewayTools(api, { templateId: "linear" }); + + expect(api.getMcpGatewayServers).toHaveBeenCalledTimes(1); + }); + + it("uses a caller-supplied registry snapshot instead of re-reading", async () => { + const api = client([]); + const servers = [server({ your_connection: connection() })]; + + const result = await discoverGatewayTools( + api, + { serverId: "srv-1" }, + { servers }, + ); + + expect(api.getMcpGatewayServers).not.toHaveBeenCalled(); + expect(result.discovered).toBe(true); + }); + + it.each([ + ["no-server", [], { serverId: "missing" }], + ["no-connection", [server()], { serverId: "srv-1" }], + [ + "already-populated", + [server({ tool_count: 9, your_connection: connection() })], + { serverId: "srv-1" }, + ], + ])("skips with %s", async (skipped, servers, match) => { + const api = client(servers); + + const result = await discoverGatewayTools(api, match); + + expect(result.discovered).toBe(false); + expect(result.skipped).toBe(skipped); + expect(api.refreshMcpInstallationTools).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/mcp-gateway/gatewayToolDiscovery.ts b/packages/core/src/mcp-gateway/gatewayToolDiscovery.ts new file mode 100644 index 0000000000..9f591dce67 --- /dev/null +++ b/packages/core/src/mcp-gateway/gatewayToolDiscovery.ts @@ -0,0 +1,138 @@ +import type { McpGatewayServer } from "@posthog/api-client/posthog-client"; +import { normalizeGatewayServerUrl } from "./gatewayServers"; + +/** + * A gateway server's tool catalog only exists once something lists it from the + * upstream server through a caller's credential. Connecting stores the + * credential but discovers nothing, so without this the registry keeps a row + * with zero tools until an admin hits the manual refresh button. + */ +export interface GatewayToolDiscoveryClient { + getMcpGatewayServers(): Promise; + refreshMcpInstallationTools(installationId: string): Promise; +} + +/** How to find the just-connected server in the re-read registry. */ +export interface GatewayServerMatch { + serverId?: string | null; + /** The caller's own installation, for flows that only know the credential. */ + installationId?: string | null; + templateId?: string | null; + url?: string | null; +} + +export type GatewayToolDiscoverySkip = + | "no-server" + | "no-connection" + | "already-populated"; + +export interface GatewayToolDiscoveryResult { + serverId: string | null; + installationId: string | null; + discovered: boolean; + skipped?: GatewayToolDiscoverySkip; +} + +/** + * Match a registry row by id, then installation, then template, then URL. The + * add-server flow only knows the URL it submitted; connect-from-catalog only + * knows the template id; reconnect only knows the installation. + */ +export function findGatewayServer( + servers: McpGatewayServer[], + match: GatewayServerMatch, +): McpGatewayServer | null { + if (match.serverId) { + return servers.find((server) => server.id === match.serverId) ?? null; + } + if (match.installationId) { + const byInstallation = servers.find( + (server) => + server.your_connection?.installation_id === match.installationId, + ); + if (byInstallation) return byInstallation; + } + if (match.templateId) { + const byTemplate = servers.find( + (server) => server.template_id === match.templateId, + ); + if (byTemplate) return byTemplate; + } + if (match.url) { + const target = normalizeGatewayServerUrl(match.url); + return ( + servers.find( + (server) => normalizeGatewayServerUrl(server.url) === target, + ) ?? null + ); + } + return null; +} + +/** + * The caller's own installation, when it can actually reach the server. + * A self-disabled connection still holds a usable credential; one that is + * mid-OAuth or needs reauth does not. + */ +export function usableInstallationId( + server: McpGatewayServer | null, +): string | null { + const connection = server?.your_connection; + if (!connection) return null; + if (connection.pending_oauth || connection.needs_reauth) return null; + return connection.installation_id; +} + +/** + * Discover only when the team has no catalog yet. A populated `tool_count` + * means someone already listed the tools, and re-listing on every connect + * would hit the upstream server for nothing. + */ +export function shouldDiscoverGatewayTools( + server: McpGatewayServer | null, +): boolean { + if (!server) return false; + if (server.tool_count > 0) return false; + return usableInstallationId(server) !== null; +} + +/** + * Re-read the registry after a connect and, if the server still has no tools, + * list them through the caller's fresh credential. Callers invalidate the + * returned `serverId`'s tool queries when `discovered` is true. + */ +export async function discoverGatewayTools( + client: GatewayToolDiscoveryClient, + match: GatewayServerMatch, + options: { servers?: McpGatewayServer[] } = {}, +): Promise { + const servers = options.servers ?? (await client.getMcpGatewayServers()); + const server = findGatewayServer(servers, match); + if (!server) { + return { + serverId: null, + installationId: null, + discovered: false, + skipped: "no-server", + }; + } + const installationId = usableInstallationId(server); + if (!installationId) { + return { + serverId: server.id, + installationId: null, + discovered: false, + skipped: "no-connection", + }; + } + if (server.tool_count > 0) { + return { + serverId: server.id, + installationId, + discovered: false, + skipped: "already-populated", + }; + } + await client.refreshMcpInstallationTools(installationId); + return { serverId: server.id, installationId, discovered: true }; +} 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..5403d1b4ca --- /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 { 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"; + +/** + * 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 [requestedRoute, setRoute] = useState({ + view: "servers", + }); + + const { isAdmin, allowCustomServers, canManageAgentAccess, 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, applied at render: if the config resolves to a narrower role + // than the stored route needs, show the servers home instead. + const route: GatewayRoute = + configLoading || isRouteAllowed(requestedRoute, { isAdmin, canAddServers }) + ? requestedRoute + : { view: "servers" }; + + 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.test.tsx b/packages/ui/src/features/mcp-gateway/components/parts/GatewayAddServer.test.tsx new file mode 100644 index 0000000000..4249382e9d --- /dev/null +++ b/packages/ui/src/features/mcp-gateway/components/parts/GatewayAddServer.test.tsx @@ -0,0 +1,52 @@ +import type { McpServiceAccount } from "@posthog/api-client/posthog-client"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock( + "@posthog/ui/features/mcp-gateway/hooks/useRegisterGatewayServer", + () => ({ + useRegisterGatewayServer: () => ({ + register: vi.fn(), + registerPending: false, + }), + }), +); + +import { GatewayAddServer } from "./GatewayAddServer"; + +const account = { + id: "agent-1", + name: "Support agent", + description: "", + handle: "support-agent", + status: "active", + token_mask: "", + server_ids: [], + last_active_at: null, + created_at: "2026-07-23T12:00:00Z", + updated_at: "2026-07-23T12:00:00Z", +} as McpServiceAccount; + +describe("GatewayAddServer", () => { + it("keeps team and agent sharing without offering shared credentials", () => { + render( + + + , + ); + + expect(screen.getByText("Enable for the whole team")).toBeInTheDocument(); + expect(screen.getByText("Share with agents")).toBeInTheDocument(); + expect(screen.getByText(account.name)).toBeInTheDocument(); + expect(screen.queryByText("One shared credential")).not.toBeInTheDocument(); + expect( + screen.queryByText("Allow personal connections"), + ).not.toBeInTheDocument(); + }); +}); 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..fd3303195a --- /dev/null +++ b/packages/ui/src/features/mcp-gateway/components/parts/GatewayAddServer.tsx @@ -0,0 +1,395 @@ +import { ArrowLeft, CaretRight, Check } from "@phosphor-icons/react"; +import type { McpServiceAccount } from "@posthog/api-client/posthog-client"; +import { + buildGatewayInstallRequest, + canSubmitGatewayServer, + 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 { useRegisterGatewayServer } from "@posthog/ui/features/mcp-gateway/hooks/useRegisterGatewayServer"; +import { + Button, + Flex, + Heading, + Select, + Spinner, + Switch, + Text, + TextArea, + TextField, +} from "@radix-ui/themes"; +import { type FormEvent, useState } from "react"; + +interface GatewayAddServerProps { + isAdmin: boolean; + canManageAgentAccess: boolean; + accounts: McpServiceAccount[]; + onNavigate: (route: GatewayRoute) => void; +} + +/** Register a custom MCP server with the gateway. */ +export function GatewayAddServer({ + isAdmin, + canManageAgentAccess, + accounts, + onNavigate, +}: GatewayAddServerProps) { + const [values, setValues] = useState( + GATEWAY_ADD_SERVER_DEFAULTS, + ); + const [showKey, setShowKey] = useState(false); + const [optionalOpen, setOptionalOpen] = useState(false); + + const { register, registerPending } = useRegisterGatewayServer(); + + 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 submit = (event: FormEvent) => { + event.preventDefault(); + if (!canSave || registerPending) return; + const request = buildGatewayInstallRequest(values, { + isAdmin, + canManageAgentAccess, + }); + register( + { request }, + { + onSuccess: (result) => { + if (result.created) { + onNavigate({ view: "server", serverId: result.created.id }); + } + }, + }, + ); + }; + + 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 + + )} + + +