diff --git a/apps/mobile/src/app/mcp-servers/add-custom.tsx b/apps/mobile/src/app/mcp-servers/add-custom.tsx index fff6f5e3bf..812f6002f7 100644 --- a/apps/mobile/src/app/mcp-servers/add-custom.tsx +++ b/apps/mobile/src/app/mcp-servers/add-custom.tsx @@ -25,6 +25,7 @@ const AUTH_OPTIONS: { value: McpAuthType; label: string }[] = [ { value: "none", label: "None" }, { value: "api_key", label: "API key" }, { value: "oauth", label: "OAuth" }, + { value: "basic", label: "Basic Auth" }, ]; export default function AddCustomMcpServerScreen() { @@ -39,6 +40,8 @@ export default function AddCustomMcpServerScreen() { const [apiKey, setApiKey] = useState(""); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -47,6 +50,12 @@ export default function AddCustomMcpServerScreen() { setError(null); if (!name.trim()) return setError("Name is required"); if (!url.trim()) return setError("URL is required"); + if ( + authType === "basic" && + Boolean(username.trim()) !== Boolean(password) + ) { + return setError("Username and password are both required"); + } setSubmitting(true); try { @@ -63,6 +72,9 @@ export default function AddCustomMcpServerScreen() { authType === "oauth" && clientSecret.trim() ? clientSecret.trim() : undefined, + username: + authType === "basic" && username.trim() ? username.trim() : undefined, + password: authType === "basic" && password ? password : undefined, }); await installations.refetch(); router.back(); @@ -178,6 +190,28 @@ export default function AddCustomMcpServerScreen() { ) : null} + {authType === "basic" ? ( + <> + Username + + Password + + + ) : null} + {error ? ( {error} ) : null} diff --git a/apps/mobile/src/features/mcp/components/McpServerRow.tsx b/apps/mobile/src/features/mcp/components/McpServerRow.tsx index a2526fa348..93da54d6f0 100644 --- a/apps/mobile/src/features/mcp/components/McpServerRow.tsx +++ b/apps/mobile/src/features/mcp/components/McpServerRow.tsx @@ -11,7 +11,7 @@ interface McpServerRowProps { title: string; subtitle?: string; description?: string; - authType?: "api_key" | "oauth" | "none"; + authType?: "api_key" | "oauth" | "basic" | "none"; badge?: ReactNode; isStdio?: boolean; needsReauth?: boolean; @@ -20,9 +20,10 @@ interface McpServerRowProps { onPress: () => void; } -function authBadge(auth?: "api_key" | "oauth" | "none") { +function authBadge(auth?: "api_key" | "oauth" | "basic" | "none") { if (auth === "oauth") return "OAuth"; if (auth === "api_key") return "API key"; + if (auth === "basic") return "Basic Auth"; return null; } diff --git a/apps/mobile/src/features/mcp/types.ts b/apps/mobile/src/features/mcp/types.ts index b8cdb4c439..322c792d31 100644 --- a/apps/mobile/src/features/mcp/types.ts +++ b/apps/mobile/src/features/mcp/types.ts @@ -1,7 +1,7 @@ // Shared types for MCP server installations and marketplace templates. // Mirrors the PostHog cloud REST schema (see `apps/code/src/renderer/api/generated.ts`). -export type McpAuthType = "api_key" | "oauth" | "none"; +export type McpAuthType = "api_key" | "oauth" | "basic" | "none"; export type McpApprovalState = "approved" | "needs_approval" | "do_not_use"; @@ -81,6 +81,8 @@ export interface InstallCustomMcpServerOptions { description?: string; client_id?: string; client_secret?: string; + username?: string; + password?: string; install_source?: McpInstallSource; posthog_code_callback_url?: string; } diff --git a/packages/api-client/src/generated.ts b/packages/api-client/src/generated.ts index 94b8b7470f..e467cf3a79 100644 --- a/packages/api-client/src/generated.ts +++ b/packages/api-client/src/generated.ts @@ -9348,7 +9348,9 @@ export namespace Schemas { values?: (unknown | null) | undefined; }; export type InsightsToolCall = { query: string; insight_type: InsightTypeEnum }; - export type InstallCustomAuthTypeEnum = "api_key" | "oauth"; + // TODO: hand-patched ahead of a real `pnpm typed-openapi` regen — the + // posthog/posthog backend PR adding "basic" hasn't deployed yet. + export type InstallCustomAuthTypeEnum = "api_key" | "oauth" | "basic"; export type InstallSourceEnum = "posthog" | "posthog-code"; export type InstallCustom = { name: string; @@ -9358,6 +9360,8 @@ export namespace Schemas { description?: string | undefined; client_id?: string | undefined; client_secret?: string | undefined; + username?: string | undefined; + password?: string | undefined; install_source?: (InstallSourceEnum & unknown) | undefined; posthog_code_callback_url?: string | undefined; }; @@ -9706,7 +9710,9 @@ export namespace Schemas { created_by: UserBasic & unknown; updated_at: string | null; }; - export type MCPAuthTypeEnum = "api_key" | "oauth"; + // TODO: hand-patched ahead of a real `pnpm typed-openapi` regen — the + // posthog/posthog backend PR adding "basic" hasn't deployed yet. + export type MCPAuthTypeEnum = "api_key" | "oauth" | "basic"; export type MCPServerInstallation = { id: string; template_id: string | null; diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index e25eada011..4275945f91 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -3884,6 +3884,8 @@ export class PostHogAPIClient { description?: string; client_id?: string; client_secret?: string; + username?: string; + password?: string; install_source?: "posthog" | "posthog-code"; posthog_code_callback_url?: string; }): Promise { diff --git a/packages/core/src/mcp-servers/customServerForm.test.ts b/packages/core/src/mcp-servers/customServerForm.test.ts index 9b48da11a5..5985842707 100644 --- a/packages/core/src/mcp-servers/customServerForm.test.ts +++ b/packages/core/src/mcp-servers/customServerForm.test.ts @@ -17,6 +17,8 @@ function values( apiKey: "", clientId: "", clientSecret: "", + username: "", + password: "", ...overrides, }; } @@ -48,6 +50,38 @@ describe("canSubmitCustomServer", () => { ); expect(canSubmitCustomServer({ name: "X", url: "nope" })).toBe(false); }); + + it.each([ + ["both empty", "", "", true], + ["both present", "user", "pass", true], + ["username only", "user", "", false], + ["password only", "", "pass", false], + ])( + "for basic auth with %s, returns %s", + (_label, username, password, expected) => { + expect( + canSubmitCustomServer({ + name: "X", + url: "https://x.com", + authType: "basic", + username, + password, + }), + ).toBe(expected); + }, + ); + + it("ignores username/password pairing for non-basic auth types", () => { + expect( + canSubmitCustomServer({ + name: "X", + url: "https://x.com", + authType: "oauth", + username: "user", + password: "", + }), + ).toBe(true); + }); }); describe("buildCustomServerRequest", () => { @@ -88,4 +122,27 @@ describe("buildCustomServerRequest", () => { expect(apiKeyReq.client_id).toBeUndefined(); expect(apiKeyReq.client_secret).toBeUndefined(); }); + + it("includes username/password for basic auth when both are present", () => { + const req = buildCustomServerRequest( + values({ authType: "basic", username: " user ", password: "pass" }), + ); + expect(req.username).toBe("user"); + expect(req.password).toBe("pass"); + }); + + it.each([ + ["oauth", "user", "pass"], + ["basic", "", "pass"], + ["basic", "user", ""], + ] as const)( + "excludes username/password when authType is %s with username=%j password=%j", + (authType, username, password) => { + const req = buildCustomServerRequest( + values({ authType, username, password }), + ); + expect(req.username).toBeUndefined(); + expect(req.password).toBeUndefined(); + }, + ); }); diff --git a/packages/core/src/mcp-servers/customServerForm.ts b/packages/core/src/mcp-servers/customServerForm.ts index 6d9452939d..b2222bedb9 100644 --- a/packages/core/src/mcp-servers/customServerForm.ts +++ b/packages/core/src/mcp-servers/customServerForm.ts @@ -8,6 +8,8 @@ export interface CustomServerFormValues { apiKey: string; clientId: string; clientSecret: string; + username: string; + password: string; } export interface CustomServerRequest { @@ -18,6 +20,8 @@ export interface CustomServerRequest { api_key?: string; client_id?: string; client_secret?: string; + username?: string; + password?: string; } export function isValidMcpUrl(url: string): boolean { @@ -25,9 +29,16 @@ export function isValidMcpUrl(url: string): boolean { } export function canSubmitCustomServer( - values: Pick, + values: Pick & + Partial>, ): boolean { - return values.name.trim() !== "" && isValidMcpUrl(values.url); + if (values.name.trim() === "" || !isValidMcpUrl(values.url)) return false; + if (values.authType === "basic") { + const hasUsername = Boolean(values.username?.trim()); + const hasPassword = Boolean(values.password); + if (hasUsername !== hasPassword) return false; + } + return true; } export function buildCustomServerRequest( @@ -47,5 +58,8 @@ export function buildCustomServerRequest( ...(values.authType === "oauth" && values.clientSecret.trim() ? { client_secret: values.clientSecret.trim() } : {}), + ...(values.authType === "basic" && values.username.trim() && values.password + ? { username: values.username.trim(), password: values.password } + : {}), }; } diff --git a/packages/core/src/mcp-servers/installFlow.test.ts b/packages/core/src/mcp-servers/installFlow.test.ts index 0450b3349f..9584adb41f 100644 --- a/packages/core/src/mcp-servers/installFlow.test.ts +++ b/packages/core/src/mcp-servers/installFlow.test.ts @@ -94,6 +94,35 @@ describe("installCustomWithOAuth", () => { }); expect(result).toEqual({ error: "denied" }); }); + + it("forwards username/password for basic auth", async () => { + const oauth = makeOAuth(); + const client: InstallFlowClient = { + installMcpTemplate: vi.fn(), + installCustomMcpServer: vi.fn().mockResolvedValue(installedInstallation), + authorizeMcpInstallation: vi.fn(), + }; + + await installCustomWithOAuth(client, oauth, { + name: "N", + url: "https://x", + description: "d", + auth_type: "basic", + username: "user", + password: "pass", + }); + + expect(client.installCustomMcpServer).toHaveBeenCalledWith({ + name: "N", + url: "https://x", + description: "d", + auth_type: "basic", + username: "user", + password: "pass", + install_source: "posthog-code", + posthog_code_callback_url: "cb://here", + }); + }); }); describe("reauthorizeWithOAuth", () => { diff --git a/packages/core/src/mcp-servers/installFlow.ts b/packages/core/src/mcp-servers/installFlow.ts index 5dbd11d5c8..d7fba88c34 100644 --- a/packages/core/src/mcp-servers/installFlow.ts +++ b/packages/core/src/mcp-servers/installFlow.ts @@ -24,6 +24,8 @@ export interface InstallFlowClient { api_key?: string; client_id?: string; client_secret?: string; + username?: string; + password?: string; install_source?: "posthog" | "posthog-code"; posthog_code_callback_url?: string; }): Promise; @@ -80,6 +82,8 @@ export async function installCustomWithOAuth( api_key?: string; client_id?: string; client_secret?: string; + username?: string; + password?: string; }, ): Promise { const { callbackUrl } = await oauth.getCallbackUrl(); diff --git a/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx b/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx index 0f21ed9e38..d9b6664ea3 100644 --- a/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx +++ b/packages/ui/src/features/mcp-server-manager/AddCustomServerForm.tsx @@ -25,6 +25,8 @@ interface AddCustomServerFormProps { api_key?: string; client_id?: string; client_secret?: string; + username?: string; + password?: string; }) => 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,9 +59,13 @@ export function AddCustomServerForm({ const [apiKey, setApiKey] = useState(""); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); const [showAdvanced, setShowAdvanced] = useState(false); - const canSubmit = canSubmitCustomServer({ name, url }) && !pending; + const canSubmit = + canSubmitCustomServer({ name, url, authType, username, password }) && + !pending; const handleSubmit = useCallback( (e: React.FormEvent) => { @@ -74,6 +80,8 @@ export function AddCustomServerForm({ apiKey, clientId, clientSecret, + username, + password, }), ); }, @@ -86,6 +94,8 @@ export function AddCustomServerForm({ apiKey, clientId, clientSecret, + username, + password, onSubmit, ], ); @@ -161,12 +171,17 @@ export function AddCustomServerForm({ onValueChange={(val) => { setAuthType(val as McpAuthType); if (val !== "api_key") setApiKey(""); + if (val !== "basic") { + setUsername(""); + setPassword(""); + } }} > OAuth API key + Basic Auth @@ -183,6 +198,29 @@ export function AddCustomServerForm({ )} + {authType === "basic" && ( + <> + + Username + setUsername(e.target.value)} + placeholder="Enter username" + spellCheck={false} + /> + + + Password + setPassword(e.target.value)} + placeholder="Enter password" + type="password" + /> + + + )} + {authType === "oauth" && ( <>