diff --git a/packages/api-client/src/generated.ts b/packages/api-client/src/generated.ts index 94b8b7470f..42626d78c6 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,8 @@ export namespace Schemas { description?: string | undefined; auth_type?: MCPAuthTypeEnum | undefined; is_enabled?: boolean | undefined; + scope?: MCPServerInstallationScopeEnum | undefined; + is_owner?: boolean | undefined; needs_reauth: boolean; pending_oauth: boolean; proxy_url: string; 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/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index c22eee052f..fb4fcf0bed 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/`; @@ -4062,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/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..8b92d9c7e7 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 + + {isAdmin === false + ? "Only project admins can add shared servers." + : "Shared servers are available to all project members and autonomous agents."} + + setScope(val as McpInstallationScope)} + > + + + Personal (only you) + + Shared (everyone in project) + + + + + Auth type { @@ -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,11 +244,29 @@ 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). Clear the + // snapshot on failure, or a row appearing later for any other + // reason (e.g. a teammate sharing a server) would hijack the + // view. + setPendingCustomKnownIds( + new Set(installationList.map((i) => i.id)), + ); + installTemplate(template, { + onError: () => setPendingCustomKnownIds(null), + }); + } else { setPendingTemplateId(template.id); installTemplate(template); } @@ -245,6 +280,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/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/McpInstalledRail.tsx b/packages/ui/src/features/mcp-servers/components/parts/McpInstalledRail.tsx index 6070a189a2..3ff83acdb8 100644 --- a/packages/ui/src/features/mcp-servers/components/parts/McpInstalledRail.tsx +++ b/packages/ui/src/features/mcp-servers/components/parts/McpInstalledRail.tsx @@ -164,6 +164,7 @@ export function McpInstalledRail({ className="text-[10px] leading-none" > {installation.tool_count ?? 0} tools + {installation.scope === "shared" ? " · Shared" : ""} void; onConnect: () => void; onReauthorize: () => void; onToggleEnabled: (enabled: boolean) => void; onUninstall: () => void; + onShare: () => void; + onUnshare: () => void; } export function ServerDetailView({ @@ -63,14 +73,37 @@ 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); + // 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; const { name, description, docsUrl, iconKey, authType } = resolveServerDetails(installation, template); @@ -119,6 +152,13 @@ export function ServerDetailView({ {name} + {installation?.scope === "shared" && ( + + + Shared + + + )} {installation && ( {statusLabel} @@ -178,7 +218,47 @@ export function ServerDetailView({ Connect )} - {installation && ( + {canConnectPersonally && ( + + + + )} + {canShare && ( + + )} + {canUnshare && ( + + )} + {canRemove && ( )} - {installation && status === "connected" && ( + {installation && status === "connected" && canManage && ( + + {installation && status === "connected" && ( <> @@ -230,83 +317,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 ? ( @@ -371,6 +464,7 @@ export function ServerDetailView({ setToolApproval({ toolName: tool.tool_name, @@ -420,3 +514,42 @@ 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 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. + + + + + + + + + + + + ); +} 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 201ce716ba..418c160549 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,12 +128,25 @@ export function useMcpServers() { ); const installTemplate = useCallback( - (template: McpRecommendedServer, opts?: { api_key?: string }) => { + ( + template: McpRecommendedServer, + opts?: { + api_key?: string; + scope?: McpInstallationScope; + /** Per-call failure hook so callers can clear optimistic view state + * (e.g. the pending-install id snapshot in McpServersView). */ + onError?: () => void; + }, + ) => { setInstallingId(template.id); - installTemplateMutation.mutate({ - template_id: template.id, - api_key: opts?.api_key, - }); + installTemplateMutation.mutate( + { + template_id: template.id, + api_key: opts?.api_key, + scope: opts?.scope, + }, + { onError: opts?.onError }, + ); }, [installTemplateMutation], ); @@ -142,6 +162,7 @@ export function useMcpServers() { api_key?: string; client_id?: string; client_secret?: string; + scope?: McpInstallationScope; }, ) => installCustomWithOAuth(client, oauth, vars), { @@ -159,6 +180,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), @@ -202,6 +251,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..6be55ba34d 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,115 @@ 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("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 d8d53baa9a..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,11 +281,12 @@ export class AgentAuthAdapter { private async fetchMcpInstallations(credentials: Credentials): Promise< Array<{ id: string; - url: string; + url?: string; proxy_url: string; name: string; display_name: string; auth_type: string; + scope?: "personal" | "shared"; }> > { const baseUrl = this.getPostHogApiBaseUrl(credentials.apiHost); @@ -302,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; @@ -310,13 +317,27 @@ export class AgentAuthAdapter { is_enabled?: boolean; pending_oauth: boolean; needs_reauth: boolean; + scope?: "personal" | "shared"; }>; }; const installations = data.results ?? []; - return installations + 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. 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" && i.url).map((i) => i.url), + ); + + return active .filter( - (i) => !i.pending_oauth && !i.needs_reauth && i.is_enabled !== false, + (i) => i.scope !== "shared" || !i.url || !personalUrls.has(i.url), ) .map((i) => ({ ...i,