Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/api-client/src/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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"
Expand Down Expand Up @@ -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;
Expand All @@ -9717,6 +9721,8 @@ export namespace Schemas {
description?: string | undefined;
auth_type?: MCPAuthTypeEnum | undefined;
Comment on lines 9721 to 9722

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 scope typed as required but absent on older backends

scope: MCPServerInstallationScopeEnum is non-optional in the generated type, but the PR description explicitly notes that older backends won't include it. At runtime scope will be undefined for those environments, even though TypeScript asserts it's always a string union. Every call site in this PR handles this correctly (scope === "shared" evaluates to false for undefined), but the misleading type means future code won't get a compiler warning if it treats scope as guaranteed — e.g. a switch on scope without a default. Marking it scope?: MCPServerInstallationScopeEnum would make the contract accurate. If this file is purely auto-generated it can only be fixed upstream in the schema, but it's worth noting the discrepancy given auth-adapter.ts already declares it as optional in its local type.

is_enabled?: boolean | undefined;
scope?: MCPServerInstallationScopeEnum | undefined;
is_owner?: boolean | undefined;
needs_reauth: boolean;
pending_oauth: boolean;
proxy_url: string;
Expand Down
69 changes: 69 additions & 0 deletions packages/api-client/src/posthog-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,75 @@ describe("PostHogAPIClient", () => {
});
});

describe("share/unshare MCP installation", () => {
function makeClient(fetch: ReturnType<typeof vi.fn>) {
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<typeof vi.fn>) {
const client = new PostHogAPIClient(
Expand Down
41 changes: 41 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ import type {
McpApprovalState,
McpAuthType,
McpCategory,
McpInstallationScope,
McpInstallationTool,
McpRecommendedServer,
McpServerInstallation,
Expand All @@ -177,6 +178,7 @@ export type {
McpApprovalState,
McpAuthType,
McpCategory,
McpInstallationScope,
McpInstallationTool,
McpRecommendedServer,
McpServerInstallation,
Expand Down Expand Up @@ -3959,6 +3961,7 @@ export class PostHogAPIClient {
client_secret?: string;
install_source?: "posthog" | "posthog-code";
posthog_code_callback_url?: string;
scope?: "personal" | "shared";
}): Promise<McpServerInstallation | Schemas.OAuthRedirectResponse> {
const teamId = await this.getTeamId();
const apiUrl = new URL(
Expand Down Expand Up @@ -4039,6 +4042,7 @@ export class PostHogAPIClient {
api_key?: string;
install_source?: "posthog" | "posthog-code";
posthog_code_callback_url?: string;
scope?: "personal" | "shared";
}): Promise<McpServerInstallation | Schemas.OAuthRedirectResponse> {
const teamId = await this.getTeamId();
const path = `/api/environments/${teamId}/mcp_server_installations/install_template/`;
Expand All @@ -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<McpServerInstallation> {
return this.postMcpInstallationScopeAction(installationId, "share");
}

/** Revert a shared installation to personal (owner or admin). */
async unshareMcpInstallation(
installationId: string,
): Promise<McpServerInstallation> {
return this.postMcpInstallationScopeAction(installationId, "unshare");
}

private async postMcpInstallationScopeAction(
installationId: string,
action: "share" | "unshare",
): Promise<McpServerInstallation> {
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";
Expand Down
1 change: 1 addition & 0 deletions packages/api-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
5 changes: 5 additions & 0 deletions packages/core/src/mcp-servers/customServerForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function values(
apiKey: "",
clientId: "",
clientSecret: "",
scope: "personal",
...overrides,
};
}
Expand Down Expand Up @@ -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 " }),
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/mcp-servers/customServerForm.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -8,6 +11,7 @@ export interface CustomServerFormValues {
apiKey: string;
clientId: string;
clientSecret: string;
scope: McpInstallationScope;
}

export interface CustomServerRequest {
Expand All @@ -18,6 +22,7 @@ export interface CustomServerRequest {
api_key?: string;
client_id?: string;
client_secret?: string;
scope: McpInstallationScope;
}

export function isValidMcpUrl(url: string): boolean {
Expand All @@ -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 }
: {}),
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/mcp-servers/installFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
Expand Down Expand Up @@ -82,13 +84,15 @@ describe("installCustomWithOAuth", () => {
url: "https://x",
description: "d",
auth_type: "oauth",
scope: "shared",
});

expect(client.installCustomMcpServer).toHaveBeenCalledWith({
name: "N",
url: "https://x",
description: "d",
auth_type: "oauth",
scope: "shared",
install_source: "posthog-code",
posthog_code_callback_url: "cb://here",
});
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/mcp-servers/installFlow.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
McpAuthType,
McpInstallationScope,
McpServerInstallation,
} from "@posthog/api-client/types";

Expand All @@ -15,6 +16,7 @@ export interface InstallFlowClient {
api_key?: string;
install_source?: "posthog" | "posthog-code";
posthog_code_callback_url?: string;
scope?: McpInstallationScope;
}): Promise<InstallResult>;
installCustomMcpServer(options: {
name: string;
Expand All @@ -26,6 +28,7 @@ export interface InstallFlowClient {
client_secret?: string;
install_source?: "posthog" | "posthog-code";
posthog_code_callback_url?: string;
scope?: McpInstallationScope;
}): Promise<InstallResult>;
authorizeMcpInstallation(options: {
installation_id: string;
Expand Down Expand Up @@ -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<OAuthCallbackResult> {
const { callbackUrl } = await oauth.getCallbackUrl();
const data = await client.installMcpTemplate({
Expand All @@ -80,6 +87,7 @@ export async function installCustomWithOAuth(
api_key?: string;
client_id?: string;
client_secret?: string;
scope?: McpInstallationScope;
},
): Promise<OAuthCallbackResult> {
const { callbackUrl } = await oauth.getCallbackUrl();
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/mcp-servers/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function makeInstallation(
updated_at: "2026-01-01T00:00:00Z",
needs_reauth: false,
pending_oauth: false,
scope: "personal",
...overrides,
};
}
Expand Down
Loading
Loading