From b1afe3aab2d6bd5a0d42358bd634a9479b6256a7 Mon Sep 17 00:00:00 2001 From: Chris Volzer Date: Thu, 9 Jul 2026 09:54:17 -0400 Subject: [PATCH 1/5] feat(mcp-store): support personal and shared MCP installation scope Port of PostHog/posthog#69658 to the Code client. The backend now supports a scope on MCP Store installations: personal (per-user, the existing default) or shared (team-wide, visible to all project members and autonomous agents). - api-client: add scope to MCPServerInstallation and the install_custom/install_template request contracts, mirroring the regenerated OpenAPI types; export McpInstallationScope. - core: thread scope through CustomServerFormValues, buildCustomServerRequest, and the OAuth install flows. - ui: Visibility (Personal/Shared) selector in the add-server form, with the shared option gated to admins (creation is admin-only on the backend since a shared install exposes the installer's credential); Shared badge in the installed rail and server detail header. Shared installations returned by the list endpoint flow into agent sessions automatically via the existing workspace-server wiring. --- packages/api-client/src/generated.ts | 5 +++ packages/api-client/src/posthog-client.ts | 4 ++ packages/api-client/src/types.ts | 1 + .../src/mcp-servers/customServerForm.test.ts | 5 +++ .../core/src/mcp-servers/customServerForm.ts | 8 +++- .../core/src/mcp-servers/installFlow.test.ts | 4 ++ packages/core/src/mcp-servers/installFlow.ts | 10 ++++- packages/core/src/mcp-servers/status.test.ts | 1 + .../AddCustomServerForm.tsx | 37 ++++++++++++++++++- .../mcp-server-manager/useMcpConnect.ts | 2 + .../components/parts/McpInstalledRail.tsx | 1 + .../components/parts/ServerDetailView.tsx | 7 ++++ .../mcp-servers/hooks/useMcpServers.ts | 18 +++++++-- 13 files changed, 97 insertions(+), 6 deletions(-) diff --git a/packages/api-client/src/generated.ts b/packages/api-client/src/generated.ts index 94b8b7470f..60d87fe1f9 100644 --- a/packages/api-client/src/generated.ts +++ b/packages/api-client/src/generated.ts @@ -9350,6 +9350,7 @@ export namespace Schemas { export type InsightsToolCall = { query: string; insight_type: InsightTypeEnum }; export type InstallCustomAuthTypeEnum = "api_key" | "oauth"; export type InstallSourceEnum = "posthog" | "posthog-code"; + export type MCPInstallationScopeEnum = "personal" | "shared"; export type InstallCustom = { name: string; url: string; @@ -9360,12 +9361,14 @@ export namespace Schemas { client_secret?: string | undefined; install_source?: (InstallSourceEnum & unknown) | undefined; posthog_code_callback_url?: string | undefined; + scope?: (MCPInstallationScopeEnum & unknown) | undefined; }; export type InstallTemplate = { template_id: string; api_key?: string | undefined; install_source?: (InstallSourceEnum & unknown) | undefined; posthog_code_callback_url?: string | undefined; + scope?: (MCPInstallationScopeEnum & unknown) | undefined; }; export type IntegrationKindEnum = | "azure-blob" @@ -9707,6 +9710,7 @@ export namespace Schemas { updated_at: string | null; }; export type MCPAuthTypeEnum = "api_key" | "oauth"; + export type MCPServerInstallationScopeEnum = "personal" | "shared"; export type MCPServerInstallation = { id: string; template_id: string | null; @@ -9717,6 +9721,7 @@ export namespace Schemas { description?: string | undefined; auth_type?: MCPAuthTypeEnum | undefined; is_enabled?: boolean | undefined; + scope: MCPServerInstallationScopeEnum; needs_reauth: boolean; pending_oauth: boolean; proxy_url: string; diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index c22eee052f..4c0dc9787a 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -169,6 +169,7 @@ import type { McpApprovalState, McpAuthType, McpCategory, + McpInstallationScope, McpInstallationTool, McpRecommendedServer, McpServerInstallation, @@ -177,6 +178,7 @@ export type { McpApprovalState, McpAuthType, McpCategory, + McpInstallationScope, McpInstallationTool, McpRecommendedServer, McpServerInstallation, @@ -3959,6 +3961,7 @@ export class PostHogAPIClient { client_secret?: string; install_source?: "posthog" | "posthog-code"; posthog_code_callback_url?: string; + scope?: "personal" | "shared"; }): Promise { const teamId = await this.getTeamId(); const apiUrl = new URL( @@ -4039,6 +4042,7 @@ export class PostHogAPIClient { api_key?: string; install_source?: "posthog" | "posthog-code"; posthog_code_callback_url?: string; + scope?: "personal" | "shared"; }): Promise { const teamId = await this.getTeamId(); const path = `/api/environments/${teamId}/mcp_server_installations/install_template/`; diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index a17f035ad1..21a8090274 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -5,6 +5,7 @@ export type McpCategory = Schemas.CategoryEnum; export type McpApprovalState = Schemas.MCPServerInstallationToolApprovalStateEnum; export type McpAuthType = Schemas.MCPAuthTypeEnum; +export type McpInstallationScope = Schemas.MCPServerInstallationScopeEnum; export type McpRecommendedServer = Schemas.MCPServerTemplate; export type McpServerInstallation = Schemas.MCPServerInstallation; export type McpInstallationTool = Schemas.MCPServerInstallationTool; diff --git a/packages/core/src/mcp-servers/customServerForm.test.ts b/packages/core/src/mcp-servers/customServerForm.test.ts index 9b48da11a5..4719041ec1 100644 --- a/packages/core/src/mcp-servers/customServerForm.test.ts +++ b/packages/core/src/mcp-servers/customServerForm.test.ts @@ -17,6 +17,7 @@ function values( apiKey: "", clientId: "", clientSecret: "", + scope: "personal", ...overrides, }; } @@ -75,6 +76,10 @@ describe("buildCustomServerRequest", () => { ).toBeUndefined(); }); + it.each(["personal", "shared"] as const)("forwards the %s scope", (scope) => { + expect(buildCustomServerRequest(values({ scope })).scope).toBe(scope); + }); + it("includes client_id/client_secret only for oauth when non-empty", () => { const req = buildCustomServerRequest( values({ authType: "oauth", clientId: " cid ", clientSecret: " sec " }), diff --git a/packages/core/src/mcp-servers/customServerForm.ts b/packages/core/src/mcp-servers/customServerForm.ts index 6d9452939d..e827618629 100644 --- a/packages/core/src/mcp-servers/customServerForm.ts +++ b/packages/core/src/mcp-servers/customServerForm.ts @@ -1,4 +1,7 @@ -import type { McpAuthType } from "@posthog/api-client/types"; +import type { + McpAuthType, + McpInstallationScope, +} from "@posthog/api-client/types"; export interface CustomServerFormValues { name: string; @@ -8,6 +11,7 @@ export interface CustomServerFormValues { apiKey: string; clientId: string; clientSecret: string; + scope: McpInstallationScope; } export interface CustomServerRequest { @@ -18,6 +22,7 @@ export interface CustomServerRequest { api_key?: string; client_id?: string; client_secret?: string; + scope: McpInstallationScope; } export function isValidMcpUrl(url: string): boolean { @@ -38,6 +43,7 @@ export function buildCustomServerRequest( url: values.url.trim(), description: values.description.trim(), auth_type: values.authType, + scope: values.scope, ...(values.authType === "api_key" && values.apiKey ? { api_key: values.apiKey } : {}), diff --git a/packages/core/src/mcp-servers/installFlow.test.ts b/packages/core/src/mcp-servers/installFlow.test.ts index 0450b3349f..1f661691c3 100644 --- a/packages/core/src/mcp-servers/installFlow.test.ts +++ b/packages/core/src/mcp-servers/installFlow.test.ts @@ -33,11 +33,13 @@ describe("installTemplateWithOAuth", () => { const result = await installTemplateWithOAuth(client, oauth, { template_id: "tpl-1", api_key: "k", + scope: "shared", }); expect(client.installMcpTemplate).toHaveBeenCalledWith({ template_id: "tpl-1", api_key: "k", + scope: "shared", install_source: "posthog-code", posthog_code_callback_url: "cb://here", }); @@ -82,6 +84,7 @@ describe("installCustomWithOAuth", () => { url: "https://x", description: "d", auth_type: "oauth", + scope: "shared", }); expect(client.installCustomMcpServer).toHaveBeenCalledWith({ @@ -89,6 +92,7 @@ describe("installCustomWithOAuth", () => { url: "https://x", description: "d", auth_type: "oauth", + scope: "shared", install_source: "posthog-code", posthog_code_callback_url: "cb://here", }); diff --git a/packages/core/src/mcp-servers/installFlow.ts b/packages/core/src/mcp-servers/installFlow.ts index 5dbd11d5c8..f303acb1e0 100644 --- a/packages/core/src/mcp-servers/installFlow.ts +++ b/packages/core/src/mcp-servers/installFlow.ts @@ -1,5 +1,6 @@ import type { McpAuthType, + McpInstallationScope, McpServerInstallation, } from "@posthog/api-client/types"; @@ -15,6 +16,7 @@ export interface InstallFlowClient { api_key?: string; install_source?: "posthog" | "posthog-code"; posthog_code_callback_url?: string; + scope?: McpInstallationScope; }): Promise; installCustomMcpServer(options: { name: string; @@ -26,6 +28,7 @@ export interface InstallFlowClient { client_secret?: string; install_source?: "posthog" | "posthog-code"; posthog_code_callback_url?: string; + scope?: McpInstallationScope; }): Promise; authorizeMcpInstallation(options: { installation_id: string; @@ -55,7 +58,11 @@ function hasRedirect(data: InstallResult): data is OAuthRedirect { export async function installTemplateWithOAuth( client: InstallFlowClient, oauth: IOAuthCallback, - vars: { template_id: string; api_key?: string }, + vars: { + template_id: string; + api_key?: string; + scope?: McpInstallationScope; + }, ): Promise { const { callbackUrl } = await oauth.getCallbackUrl(); const data = await client.installMcpTemplate({ @@ -80,6 +87,7 @@ export async function installCustomWithOAuth( api_key?: string; client_id?: string; client_secret?: string; + scope?: McpInstallationScope; }, ): Promise { const { callbackUrl } = await oauth.getCallbackUrl(); diff --git a/packages/core/src/mcp-servers/status.test.ts b/packages/core/src/mcp-servers/status.test.ts index 8b6d02c9ca..ec4c0848e8 100644 --- a/packages/core/src/mcp-servers/status.test.ts +++ b/packages/core/src/mcp-servers/status.test.ts @@ -16,6 +16,7 @@ function makeInstallation( updated_at: "2026-01-01T00:00:00Z", needs_reauth: false, pending_oauth: false, + scope: "personal", ...overrides, }; } diff --git a/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx b/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx index 0f21ed9e38..3d6abb325e 100644 --- a/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx +++ b/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx @@ -1,9 +1,13 @@ import { ArrowLeft, CaretDown, CaretRight, Plus } from "@phosphor-icons/react"; -import type { McpAuthType } from "@posthog/api-client/posthog-client"; +import type { + McpAuthType, + McpInstallationScope, +} from "@posthog/api-client/posthog-client"; import { buildCustomServerRequest, canSubmitCustomServer, } from "@posthog/core/mcp-servers/customServerForm"; +import { useIsOrgAdmin } from "@posthog/ui/features/auth/useOrgRole"; import { Button, Flex, @@ -25,6 +29,7 @@ interface AddCustomServerFormProps { api_key?: string; client_id?: string; client_secret?: string; + scope: McpInstallationScope; }) => void; /** Prefill the form (e.g. the agent builder's connect_mcp punch-out supplies a * suggested name/url). The user can still edit every field before connecting. */ @@ -57,8 +62,15 @@ export function AddCustomServerForm({ const [apiKey, setApiKey] = useState(""); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); + const [scope, setScope] = useState("personal"); const [showAdvanced, setShowAdvanced] = useState(false); + // Shared servers expose the installer's credential to every project member + // and all autonomous agents, so creating one is admin-only (enforced again + // on the backend). Non-admins still see shared servers in the installed list. + const { isAdmin } = useIsOrgAdmin(); + const canAddShared = isAdmin === true; + const canSubmit = canSubmitCustomServer({ name, url }) && !pending; const handleSubmit = useCallback( @@ -74,6 +86,7 @@ export function AddCustomServerForm({ apiKey, clientId, clientSecret, + scope, }), ); }, @@ -86,6 +99,7 @@ export function AddCustomServerForm({ apiKey, clientId, clientSecret, + scope, onSubmit, ], ); @@ -154,6 +168,27 @@ export function AddCustomServerForm({ /> + + Visibility + + {canAddShared + ? "Shared servers are available to all project members and autonomous agents." + : "Only project admins can add shared servers."} + + setScope(val as McpInstallationScope)} + > + + + Personal (only you) + + Shared (everyone in project) + + + + + Auth type {installation.tool_count ?? 0} tools + {installation.scope === "shared" ? " · Shared" : ""} {name} + {installation?.scope === "shared" && ( + + + Shared + + + )} {installation && ( {statusLabel} diff --git a/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts b/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts index 201ce716ba..9b0ca12c73 100644 --- a/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts +++ b/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts @@ -1,5 +1,6 @@ import type { McpAuthType, + McpInstallationScope, McpRecommendedServer, McpServerInstallation, } from "@posthog/api-client/posthog-client"; @@ -101,8 +102,14 @@ export function useMcpServers() { ); const installTemplateMutation = useAuthenticatedMutation( - (client, vars: { template_id: string; api_key?: string }) => - installTemplateWithOAuth(client, oauth, vars), + ( + client, + vars: { + template_id: string; + api_key?: string; + scope?: McpInstallationScope; + }, + ) => installTemplateWithOAuth(client, oauth, vars), { onSuccess: (data) => { if (data && "success" in data && data.success) { @@ -121,11 +128,15 @@ export function useMcpServers() { ); const installTemplate = useCallback( - (template: McpRecommendedServer, opts?: { api_key?: string }) => { + ( + template: McpRecommendedServer, + opts?: { api_key?: string; scope?: McpInstallationScope }, + ) => { setInstallingId(template.id); installTemplateMutation.mutate({ template_id: template.id, api_key: opts?.api_key, + scope: opts?.scope, }); }, [installTemplateMutation], @@ -142,6 +153,7 @@ export function useMcpServers() { api_key?: string; client_id?: string; client_secret?: string; + scope?: McpInstallationScope; }, ) => installCustomWithOAuth(client, oauth, vars), { From 53f28e316c9329e9c6b47bbfe08b8a01ba8ca9d0 Mon Sep 17 00:00:00 2001 From: Chris Volzer Date: Thu, 9 Jul 2026 11:02:30 -0400 Subject: [PATCH 2/5] =?UTF-8?q?feat(mcp-store):=20phase=201=20shared=20MCP?= =?UTF-8?q?=20UX=20=E2=80=94=20escalation,=20gating,=20personal-wins=20ded?= =?UTF-8?q?upe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the personal/shared installation scope: - Share with project / Unshare actions in the server detail view. Sharing is owner+admin only and sits behind a consent dialog spelling out that project members and autonomous agents will act as the sharer on the connected service. Backed by new shareMcpInstallation / unshareMcpInstallation api-client methods (POST {id}/share|unshare). - Owner/admin gating driven by the new is_owner serializer field: tool policy toggles, bulk actions, refresh, and the enable switch are owner-only on shared servers; Remove is owner-or-admin. Unknown is_owner (older backends) keeps controls usable — the backend is the enforcement point. - "Connect personally" on a teammate's shared server so members can use their own identity instead of the shared credential. - Personal wins over shared: agent session wiring dedupes installations by URL, dropping a shared install when the user has their own personal connection to the same server. Pairs with the posthog backend phase-1 branch (share/unshare endpoints, admin delete override, is_owner, sandbox dedupe, basic audit logging). --- packages/api-client/src/generated.ts | 1 + packages/api-client/src/posthog-client.ts | 37 +++ .../mcp-servers/components/McpServersView.tsx | 41 ++- .../components/parts/ServerDetailView.tsx | 276 +++++++++++++----- .../mcp-servers/components/parts/ToolRow.tsx | 5 +- .../mcp-servers/hooks/useMcpServers.ts | 32 ++ .../src/services/agent/auth-adapter.test.ts | 66 +++++ .../src/services/agent/auth-adapter.ts | 19 +- 8 files changed, 392 insertions(+), 85 deletions(-) diff --git a/packages/api-client/src/generated.ts b/packages/api-client/src/generated.ts index 60d87fe1f9..e6d8562f81 100644 --- a/packages/api-client/src/generated.ts +++ b/packages/api-client/src/generated.ts @@ -9722,6 +9722,7 @@ export namespace Schemas { auth_type?: MCPAuthTypeEnum | undefined; is_enabled?: boolean | undefined; scope: MCPServerInstallationScopeEnum; + is_owner?: boolean | undefined; needs_reauth: boolean; pending_oauth: boolean; proxy_url: string; diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 4c0dc9787a..fb4fcf0bed 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -4066,6 +4066,43 @@ export class PostHogAPIClient { | Schemas.OAuthRedirectResponse; } + /** Escalate a personal installation to team-wide shared (owner + admin only). */ + async shareMcpInstallation( + installationId: string, + ): Promise { + return this.postMcpInstallationScopeAction(installationId, "share"); + } + + /** Revert a shared installation to personal (owner or admin). */ + async unshareMcpInstallation( + installationId: string, + ): Promise { + return this.postMcpInstallationScopeAction(installationId, "unshare"); + } + + private async postMcpInstallationScopeAction( + installationId: string, + action: "share" | "unshare", + ): Promise { + const teamId = await this.getTeamId(); + const path = `/api/environments/${teamId}/mcp_server_installations/${installationId}/${action}/`; + const response = await this.api.fetcher.fetch({ + method: "post", + url: new URL(`${this.api.baseUrl}${path}`), + path, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + (errorData as { detail?: string }).detail ?? + `Failed to ${action} MCP server: ${response.statusText}`, + ); + } + + return (await response.json()) as McpServerInstallation; + } + async authorizeMcpInstallation(options: { installation_id: string; install_source?: "posthog" | "posthog-code"; diff --git a/packages/ui/src/features/mcp-servers/components/McpServersView.tsx b/packages/ui/src/features/mcp-servers/components/McpServersView.tsx index d87c6a7131..f6540f0f1e 100644 --- a/packages/ui/src/features/mcp-servers/components/McpServersView.tsx +++ b/packages/ui/src/features/mcp-servers/components/McpServersView.tsx @@ -74,6 +74,10 @@ export function McpServersView() { installCustomPending, reauthorize, reauthorizePending, + share, + sharePending, + unshare, + unsharePending, } = useMcpServers(); useEffect(() => { @@ -208,6 +212,19 @@ export function McpServersView() { view.kind === "detail-installation" ? selectedInstallation : null; const template = selectedTemplate; + // Whether the user already holds their own (personal) installation of + // the server shown — matched by URL or template, since a teammate's + // shared install and the user's personal one are distinct rows. + const hasPersonalInstall = + !!install && + installationList.some( + (i) => + i.id !== install.id && + i.scope !== "shared" && + ((!!install.url && i.url === install.url) || + (!!install.template_id && i.template_id === install.template_id)), + ); + if (!install && !template) { return ( @@ -227,14 +244,26 @@ export function McpServersView() { installation={install} template={template} isEnabled={install?.is_enabled !== false} - isInstalling={!!template && installingId === template.id && !install} + isInstalling={!!template && installingId === template.id} isReauthorizing={reauthorizePending} + isSharing={sharePending} + isUnsharing={unsharePending} + hasPersonalInstall={hasPersonalInstall} onBack={() => setView({ kind: "marketplace" })} onConnect={() => { - if (template) { + if (!template) return; + if (install) { + // "Connect personally" from a teammate's shared install. The + // template-id watcher would immediately match the existing + // shared row, so identify the new personal row by id snapshot + // instead (same mechanism as custom installs). + setPendingCustomKnownIds( + new Set(installationList.map((i) => i.id)), + ); + } else { setPendingTemplateId(template.id); - installTemplate(template); } + installTemplate(template); }} onReauthorize={() => { if (install) reauthorize(install.id); @@ -245,6 +274,12 @@ export function McpServersView() { onUninstall={() => { if (install) setUninstallTarget(install); }} + onShare={() => { + if (install) share(install.id); + }} + onUnshare={() => { + if (install) unshare(install.id); + }} /> ); } diff --git a/packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx b/packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx index a39f0ff86d..859da893ed 100644 --- a/packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx +++ b/packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx @@ -8,6 +8,7 @@ import { Prohibit, Shield, Trash, + UsersThree, X, } from "@phosphor-icons/react"; import type { @@ -23,6 +24,7 @@ import { filterToolsByName, sortToolsForDisplay, } from "@posthog/core/mcp-servers/toolDerivation"; +import { useIsOrgAdmin } from "@posthog/ui/features/auth/useOrgRole"; import { ServerIcon } from "@posthog/ui/features/mcp-servers/components/parts/icons"; import { STATUS_COLORS, @@ -31,6 +33,7 @@ import { import { ToolRow } from "@posthog/ui/features/mcp-servers/components/parts/ToolRow"; import { useMcpInstallationTools } from "@posthog/ui/features/mcp-servers/hooks/useMcpInstallationTools"; import { + AlertDialog, Badge, Button, Flex, @@ -50,11 +53,18 @@ interface ServerDetailViewProps { isEnabled: boolean; isInstalling: boolean; isReauthorizing: boolean; + isSharing: boolean; + isUnsharing: boolean; + /** Whether the current user already has their own personal installation for + * this server — hides "Connect personally" on a teammate's shared server. */ + hasPersonalInstall: boolean; onBack: () => void; onConnect: () => void; onReauthorize: () => void; onToggleEnabled: (enabled: boolean) => void; onUninstall: () => void; + onShare: () => void; + onUnshare: () => void; } export function ServerDetailView({ @@ -63,14 +73,34 @@ export function ServerDetailView({ isEnabled, isInstalling, isReauthorizing, + isSharing, + isUnsharing, + hasPersonalInstall, onBack, onConnect, onReauthorize, onToggleEnabled, onUninstall, + onShare, + onUnshare, }: ServerDetailViewProps) { const [showRemoved, setShowRemoved] = useState(false); const [toolSearch, setToolSearch] = useState(""); + const [shareConfirmOpen, setShareConfirmOpen] = useState(false); + + // Shared-scope gating. `is_owner` is absent on older backends; treat unknown + // as owner so controls stay usable — the backend is the enforcement point. + const { isAdmin } = useIsOrgAdmin(); + const isShared = installation?.scope === "shared"; + const isOwner = !!installation && installation.is_owner !== false; + const canShare = !!installation && !isShared && isOwner && isAdmin === true; + const canUnshare = + !!installation && isShared && (isOwner || isAdmin === true); + const canRemove = + !!installation && (!isShared || isOwner || isAdmin === true); + const canManage = !!installation && (!isShared || isOwner); + const canConnectPersonally = + isShared && !isOwner && !!template && !hasPersonalInstall; const { name, description, docsUrl, iconKey, authType } = resolveServerDetails(installation, template); @@ -185,7 +215,47 @@ export function ServerDetailView({ Connect )} - {installation && ( + {canConnectPersonally && ( + + + + )} + {canShare && ( + + )} + {canUnshare && ( + + )} + {installation && canRemove && ( )} - {installation && status === "connected" && ( + {installation && status === "connected" && canManage && ( + + {installation && status === "connected" && ( <> @@ -237,83 +314,89 @@ export function ServerDetailView({ ) : null} - - - Set all: - - - - setBulkApproval( - "approved", - toolSearch ? filteredTools : undefined, - ) - } + {canManage ? ( + + + Set all: + + - - - - - - setBulkApproval( - "needs_approval", - toolSearch ? filteredTools : undefined, - ) - } - > - - - - - - setBulkApproval( - "do_not_use", - toolSearch ? filteredTools : undefined, - ) + + setBulkApproval( + "approved", + toolSearch ? filteredTools : undefined, + ) + } + > + + + + - - - - - - - {refreshPending ? ( - - ) : ( - - )} - - - + + setBulkApproval( + "needs_approval", + toolSearch ? filteredTools : undefined, + ) + } + > + + + + + + setBulkApproval( + "do_not_use", + toolSearch ? filteredTools : undefined, + ) + } + > + + + + + + + {refreshPending ? ( + + ) : ( + + )} + + + + ) : ( + + Tool permissions are managed by the sharer. + + )} {isLoading ? ( @@ -378,6 +461,7 @@ export function ServerDetailView({ setToolApproval({ toolName: tool.tool_name, @@ -427,3 +511,43 @@ export function ServerDetailView({ ); } + +function ShareConfirmDialog({ + open, + serverName, + onOpenChange, + onConfirm, +}: { + open: boolean; + serverName: string; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + return ( + + + Share with project? + + Everyone in this project — including autonomous agents — will be able + to use {serverName} through your + connection. Actions they take are attributed to your account on the + connected service. Consider connecting a service account rather than a + personal one. You can unshare at any time. + + + + + + + + + + + + ); +} diff --git a/packages/ui/src/features/mcp-servers/components/parts/ToolRow.tsx b/packages/ui/src/features/mcp-servers/components/parts/ToolRow.tsx index 7ef5cefbd1..7ca8ab4bc4 100644 --- a/packages/ui/src/features/mcp-servers/components/parts/ToolRow.tsx +++ b/packages/ui/src/features/mcp-servers/components/parts/ToolRow.tsx @@ -10,9 +10,10 @@ import { ToolPolicyToggle } from "./ToolPolicyToggle"; interface ToolRowProps { tool: McpInstallationTool; onChange: (approval_state: McpApprovalState) => void; + disabled?: boolean; } -export function ToolRow({ tool, onChange }: ToolRowProps) { +export function ToolRow({ tool, onChange, disabled }: ToolRowProps) { const [open, setOpen] = useState(false); const hasDescription = !!tool.description?.trim(); const removed = !!tool.removed_at; @@ -69,7 +70,7 @@ export function ToolRow({ tool, onChange }: ToolRowProps) { diff --git a/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts b/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts index 9b0ca12c73..d1cb0cb6af 100644 --- a/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts +++ b/packages/ui/src/features/mcp-servers/hooks/useMcpServers.ts @@ -171,6 +171,34 @@ export function useMcpServers() { }, ); + const shareMutation = useAuthenticatedMutation( + (client, installationId: string) => + client.shareMcpInstallation(installationId), + { + onSuccess: () => { + toast.success("Server shared with project"); + invalidateInstallations(); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to share server"); + }, + }, + ); + + const unshareMutation = useAuthenticatedMutation( + (client, installationId: string) => + client.unshareMcpInstallation(installationId), + { + onSuccess: () => { + toast.success("Server is personal again"); + invalidateInstallations(); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to unshare server"); + }, + }, + ); + const reauthorizeMutation = useAuthenticatedMutation( (client, installationId: string) => reauthorizeWithOAuth(client, oauth, installationId), @@ -214,6 +242,10 @@ export function useMcpServers() { installCustomPending: installCustomMutation.isPending, reauthorize: reauthorizeMutation.mutate, reauthorizePending: reauthorizeMutation.isPending, + share: shareMutation.mutate, + sharePending: shareMutation.isPending, + unshare: unshareMutation.mutate, + unsharePending: unshareMutation.isPending, invalidateInstallations, }; } diff --git a/packages/workspace-server/src/services/agent/auth-adapter.test.ts b/packages/workspace-server/src/services/agent/auth-adapter.test.ts index c7900fae7c..3cec6e33b8 100644 --- a/packages/workspace-server/src/services/agent/auth-adapter.test.ts +++ b/packages/workspace-server/src/services/agent/auth-adapter.test.ts @@ -153,6 +153,72 @@ describe("AgentAuthAdapter", () => { ); }); + it("prefers a personal installation over a shared one for the same URL", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { + id: "inst-shared", + url: "https://linear.example.com/mcp", + proxy_url: "https://proxy.posthog.com/inst-shared/", + name: "linear", + display_name: "Linear (shared)", + auth_type: "oauth", + is_enabled: true, + pending_oauth: false, + needs_reauth: false, + scope: "shared", + }, + { + id: "inst-personal", + url: "https://linear.example.com/mcp", + proxy_url: "https://proxy.posthog.com/inst-personal/", + name: "linear", + display_name: "Linear", + auth_type: "oauth", + is_enabled: true, + pending_oauth: false, + needs_reauth: false, + scope: "personal", + }, + { + id: "inst-shared-only", + url: "https://notion.example.com/mcp", + proxy_url: "https://proxy.posthog.com/inst-shared-only/", + name: "notion", + display_name: "Notion (shared)", + auth_type: "oauth", + is_enabled: true, + pending_oauth: false, + needs_reauth: false, + scope: "shared", + }, + ], + }), + }); + + const { servers } = await adapter.buildMcpServers(baseCredentials); + + // The user's own Linear connection wins; the teammate's shared one is + // dropped. The shared Notion install has no personal counterpart, so it + // is kept. + expect(deps.mcpProxy.register).toHaveBeenCalledWith( + "installation-inst-personal", + "https://proxy.posthog.com/inst-personal/", + ); + expect(deps.mcpProxy.register).not.toHaveBeenCalledWith( + "installation-inst-shared", + expect.anything(), + ); + expect(deps.mcpProxy.register).toHaveBeenCalledWith( + "installation-inst-shared-only", + "https://proxy.posthog.com/inst-shared-only/", + ); + expect(servers.map((s) => s.name)).toEqual(["posthog", "linear", "notion"]); + }); + it("fetches tool approval states for installations", async () => { mockFetch .mockResolvedValueOnce({ diff --git a/packages/workspace-server/src/services/agent/auth-adapter.ts b/packages/workspace-server/src/services/agent/auth-adapter.ts index d8d53baa9a..99bea1ecbd 100644 --- a/packages/workspace-server/src/services/agent/auth-adapter.ts +++ b/packages/workspace-server/src/services/agent/auth-adapter.ts @@ -280,6 +280,7 @@ export class AgentAuthAdapter { name: string; display_name: string; auth_type: string; + scope?: "personal" | "shared"; }> > { const baseUrl = this.getPostHogApiBaseUrl(credentials.apiHost); @@ -310,14 +311,24 @@ export class AgentAuthAdapter { is_enabled?: boolean; pending_oauth: boolean; needs_reauth: boolean; + scope?: "personal" | "shared"; }>; }; const installations = data.results ?? []; - return installations - .filter( - (i) => !i.pending_oauth && !i.needs_reauth && i.is_enabled !== false, - ) + const active = installations.filter( + (i) => !i.pending_oauth && !i.needs_reauth && i.is_enabled !== false, + ); + // Personal wins over shared: when the user has their own connection to + // the same server URL, agent sessions act as the user rather than + // through a teammate's shared credential. Rows without a scope come + // from older backends and are personal by definition. + const personalUrls = new Set( + active.filter((i) => i.scope !== "shared").map((i) => i.url), + ); + + return active + .filter((i) => i.scope !== "shared" || !personalUrls.has(i.url)) .map((i) => ({ ...i, proxy_url: From d4efb733df59b694adb2e8e757d27657694d4d4d Mon Sep 17 00:00:00 2001 From: Chris Volzer Date: Thu, 9 Jul 2026 14:30:29 -0400 Subject: [PATCH 3/5] chore(mcp-servers): tighten share dialog copy --- .../mcp-servers/components/parts/ServerDetailView.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx b/packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx index 859da893ed..8e07640afc 100644 --- a/packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx +++ b/packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx @@ -528,11 +528,10 @@ function ShareConfirmDialog({ Share with project? - Everyone in this project — including autonomous agents — will be able - to use {serverName} through your - connection. Actions they take are attributed to your account on the - connected service. Consider connecting a service account rather than a - personal one. You can unshare at any time. + Everyone in this project, including the PostHog agent, can use{" "} + {serverName} via your connection. + Their actions will be attributed to your account. For better security, + connect a service account. You can unshare anytime. From b1140a7b7babfcf39d43958c2aa1360a4357e9fa Mon Sep 17 00:00:00 2001 From: Chris Volzer Date: Thu, 9 Jul 2026 16:02:32 -0400 Subject: [PATCH 4/5] fix(mcp-store): review fixes for shared installation scope - Clear the pending-install id snapshot when "Connect personally" fails, so a later-appearing row (e.g. a teammate sharing a server) can't hijack the view - Only dedupe personal-over-shared agent installations on truthy URLs; absent URLs no longer collapse unrelated servers. Align local types with the generated schema (url is optional) and end name fallbacks at the always-present id - Prefer the personal row in the marketplace template->installation map so card navigation doesn't depend on API ordering - Don't flash the admins-only visibility hint while the org role loads - Drop a redundant installation guard; document phase-1 template-only scope of "Connect personally" - Tests: share/unshare client methods (paths + error surfaces), missing- URL dedupe case --- .../api-client/src/posthog-client.test.ts | 69 +++++++++++++++++++ .../AddCustomServerForm.tsx | 6 +- .../mcp-servers/components/McpServersView.tsx | 10 ++- .../components/parts/MarketplaceView.tsx | 9 ++- .../components/parts/ServerDetailView.tsx | 5 +- .../mcp-servers/hooks/useMcpServers.ts | 21 ++++-- .../src/services/agent/auth-adapter.test.ts | 43 ++++++++++++ .../src/services/agent/auth-adapter.ts | 26 ++++--- 8 files changed, 168 insertions(+), 21 deletions(-) diff --git a/packages/api-client/src/posthog-client.test.ts b/packages/api-client/src/posthog-client.test.ts index ffb2a9f962..6a4338e27a 100644 --- a/packages/api-client/src/posthog-client.test.ts +++ b/packages/api-client/src/posthog-client.test.ts @@ -361,6 +361,75 @@ describe("PostHogAPIClient", () => { }); }); + describe("share/unshare MCP installation", () => { + function makeClient(fetch: ReturnType) { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { baseUrl: "http://localhost:8000", fetcher: { fetch } }; + return client; + } + + it.each([ + ["shareMcpInstallation", "share"], + ["unshareMcpInstallation", "unshare"], + ] as const)( + "%s POSTs to the %s action and returns the installation", + async (method, action) => { + const installation = { id: "inst-1", scope: "shared" }; + const fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => installation, + }); + const client = makeClient(fetch); + + await expect(client[method]("inst-1")).resolves.toEqual(installation); + + expect(fetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: "post", + path: `/api/environments/123/mcp_server_installations/inst-1/${action}/`, + }), + ); + }, + ); + + it("surfaces the backend detail message on failure", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: false, + statusText: "Forbidden", + json: async () => ({ detail: "Only admins can share installations." }), + }); + const client = makeClient(fetch); + + await expect(client.shareMcpInstallation("inst-1")).rejects.toThrow( + "Only admins can share installations.", + ); + }); + + it("falls back to statusText when the error body is not JSON", async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: false, + statusText: "Bad Gateway", + json: async () => { + throw new Error("not json"); + }, + }); + const client = makeClient(fetch); + + await expect(client.unshareMcpInstallation("inst-1")).rejects.toThrow( + "Failed to unshare MCP server: Bad Gateway", + ); + }); + }); + describe("getSignalReport", () => { function makeClient(fetch: ReturnType) { const client = new PostHogAPIClient( diff --git a/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx b/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx index 3d6abb325e..8b92d9c7e7 100644 --- a/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx +++ b/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx @@ -171,9 +171,9 @@ export function AddCustomServerForm({ Visibility - {canAddShared - ? "Shared servers are available to all project members and autonomous agents." - : "Only project admins can add shared servers."} + {isAdmin === false + ? "Only project admins can add shared servers." + : "Shared servers are available to all project members and autonomous agents."} i.id)), ); + installTemplate(template, { + onError: () => setPendingCustomKnownIds(null), + }); } else { setPendingTemplateId(template.id); + installTemplate(template); } - installTemplate(template); }} onReauthorize={() => { if (install) reauthorize(install.id); diff --git a/packages/ui/src/features/mcp-servers/components/parts/MarketplaceView.tsx b/packages/ui/src/features/mcp-servers/components/parts/MarketplaceView.tsx index 861754946e..fe677ed03f 100644 --- a/packages/ui/src/features/mcp-servers/components/parts/MarketplaceView.tsx +++ b/packages/ui/src/features/mcp-servers/components/parts/MarketplaceView.tsx @@ -53,7 +53,14 @@ export function MarketplaceView({ const installationByTemplateId = useMemo(() => { const map = new Map(); for (const installation of installations) { - if (installation.template_id) { + if (!installation.template_id) continue; + // A template can have both the user's personal row and a teammate's + // shared one; prefer the personal row so the card opens the same + // detail regardless of API ordering. + if ( + !map.has(installation.template_id) || + installation.scope !== "shared" + ) { map.set(installation.template_id, installation.id); } } diff --git a/packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx b/packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx index 8e07640afc..5d33a32d52 100644 --- a/packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx +++ b/packages/ui/src/features/mcp-servers/components/parts/ServerDetailView.tsx @@ -99,6 +99,9 @@ export function ServerDetailView({ const canRemove = !!installation && (!isShared || isOwner || isAdmin === true); const canManage = !!installation && (!isShared || isOwner); + // Template-backed servers only for phase 1: "Connect personally" re-runs + // the template install. A shared custom server has no template to install + // from — members recreate it via "Add custom" instead. const canConnectPersonally = isShared && !isOwner && !!template && !hasPersonalInstall; @@ -255,7 +258,7 @@ export function ServerDetailView({ Unshare )} - {installation && canRemove && ( + {canRemove && ( void; + }, ) => { setInstallingId(template.id); - installTemplateMutation.mutate({ - template_id: template.id, - api_key: opts?.api_key, - scope: opts?.scope, - }); + installTemplateMutation.mutate( + { + template_id: template.id, + api_key: opts?.api_key, + scope: opts?.scope, + }, + { onError: opts?.onError }, + ); }, [installTemplateMutation], ); diff --git a/packages/workspace-server/src/services/agent/auth-adapter.test.ts b/packages/workspace-server/src/services/agent/auth-adapter.test.ts index 3cec6e33b8..6be55ba34d 100644 --- a/packages/workspace-server/src/services/agent/auth-adapter.test.ts +++ b/packages/workspace-server/src/services/agent/auth-adapter.test.ts @@ -219,6 +219,49 @@ describe("AgentAuthAdapter", () => { expect(servers.map((s) => s.name)).toEqual(["posthog", "linear", "notion"]); }); + it("keeps a shared installation without a URL even when a personal one also lacks a URL", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + results: [ + { + id: "inst-personal-no-url", + proxy_url: "https://proxy.posthog.com/inst-personal-no-url/", + name: "asana", + display_name: "Asana", + auth_type: "oauth", + is_enabled: true, + pending_oauth: false, + needs_reauth: false, + scope: "personal", + }, + { + id: "inst-shared-no-url", + proxy_url: "https://proxy.posthog.com/inst-shared-no-url/", + name: "jira", + display_name: "Jira (shared)", + auth_type: "oauth", + is_enabled: true, + pending_oauth: false, + needs_reauth: false, + scope: "shared", + }, + ], + }), + }); + + const { servers } = await adapter.buildMcpServers(baseCredentials); + + // Absent URLs say nothing about the servers being the same, so the + // personal-over-shared dedupe must not collapse these two rows. + expect(deps.mcpProxy.register).toHaveBeenCalledWith( + "installation-inst-shared-no-url", + "https://proxy.posthog.com/inst-shared-no-url/", + ); + expect(servers.map((s) => s.name)).toEqual(["posthog", "asana", "jira"]); + }); + it("fetches tool approval states for installations", async () => { mockFetch .mockResolvedValueOnce({ diff --git a/packages/workspace-server/src/services/agent/auth-adapter.ts b/packages/workspace-server/src/services/agent/auth-adapter.ts index 99bea1ecbd..5e21a41444 100644 --- a/packages/workspace-server/src/services/agent/auth-adapter.ts +++ b/packages/workspace-server/src/services/agent/auth-adapter.ts @@ -112,7 +112,10 @@ export class AgentAuthAdapter { if (installation.url === mcpUrl) continue; const name = - installation.name || installation.display_name || installation.url; + installation.name || + installation.display_name || + installation.url || + installation.id; const proxiedUrl = this.mcpProxy.register( `installation-${installation.id}`, @@ -206,7 +209,7 @@ export class AgentAuthAdapter { credentials: Credentials, installations: Array<{ id: string; - url: string; + url?: string; name: string; display_name: string; }>, @@ -221,7 +224,10 @@ export class AgentAuthAdapter { const results = await Promise.allSettled( installations.map(async (installation) => { const serverName = sanitizeMcpServerName( - installation.name || installation.display_name || installation.url, + installation.name || + installation.display_name || + installation.url || + installation.id, ); const toolsUrl = `${baseUrl}/api/environments/${credentials.projectId}/mcp_server_installations/${installation.id}/tools/`; @@ -275,7 +281,7 @@ export class AgentAuthAdapter { private async fetchMcpInstallations(credentials: Credentials): Promise< Array<{ id: string; - url: string; + url?: string; proxy_url: string; name: string; display_name: string; @@ -303,7 +309,7 @@ export class AgentAuthAdapter { const data = (await response.json()) as { results?: Array<{ id: string; - url: string; + url?: string; proxy_url?: string; name: string; display_name: string; @@ -322,13 +328,17 @@ export class AgentAuthAdapter { // Personal wins over shared: when the user has their own connection to // the same server URL, agent sessions act as the user rather than // through a teammate's shared credential. Rows without a scope come - // from older backends and are personal by definition. + // from older backends and are personal by definition. Rows without a + // URL never dedupe against each other — they are not known to be the + // same server. const personalUrls = new Set( - active.filter((i) => i.scope !== "shared").map((i) => i.url), + active.filter((i) => i.scope !== "shared" && i.url).map((i) => i.url), ); return active - .filter((i) => i.scope !== "shared" || !personalUrls.has(i.url)) + .filter( + (i) => i.scope !== "shared" || !i.url || !personalUrls.has(i.url), + ) .map((i) => ({ ...i, proxy_url: From 5665ba439d42accdfd7f7970c0acd5b473f939aa Mon Sep 17 00:00:00 2001 From: Chris Volzer Date: Fri, 10 Jul 2026 09:52:21 -0400 Subject: [PATCH 5/5] fix(api-client): mark MCPServerInstallation.scope optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Older backends without PostHog/posthog#69658 omit scope from the serializer, so the field is undefined at runtime even though the hand-mirrored type asserted it was always present. Type it optional — matching is_owner, which shipped with the same forward-compat treatment — so future call sites can't assume it's guaranteed. Becomes required again on the next regen once the backend is deployed everywhere. --- packages/api-client/src/generated.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api-client/src/generated.ts b/packages/api-client/src/generated.ts index e6d8562f81..42626d78c6 100644 --- a/packages/api-client/src/generated.ts +++ b/packages/api-client/src/generated.ts @@ -9721,7 +9721,7 @@ export namespace Schemas { description?: string | undefined; auth_type?: MCPAuthTypeEnum | undefined; is_enabled?: boolean | undefined; - scope: MCPServerInstallationScopeEnum; + scope?: MCPServerInstallationScopeEnum | undefined; is_owner?: boolean | undefined; needs_reauth: boolean; pending_oauth: boolean;