diff --git a/console/e2e-tests/platform.spec.ts b/console/e2e-tests/platform.spec.ts index 5e25204ae621b..89dd0e3f70fb8 100644 --- a/console/e2e-tests/platform.spec.ts +++ b/console/e2e-tests/platform.spec.ts @@ -98,7 +98,7 @@ for (const region of REGIONS) { await page.getByRole("textbox", { name: "Name" }).fill(apiKeyName); await page.getByRole("button", { name: "Create password" }).click(); await page.getByText(`New password "${apiKeyName}"`).waitFor(); - await page.getByRole("button", { name: "visibility" }).click(); + await page.getByRole("button", { name: "visibility" }).first().click(); const passwordField = page.getByLabel("clientId"); const password = await passwordField.evaluate((e) => e.textContent); assert(!!password, "Expected a password to be created"); diff --git a/console/src/access/AppPasswordsPage.tsx b/console/src/access/AppPasswordsPage.tsx index f942b6caf816a..8be5d53f55600 100644 --- a/console/src/access/AppPasswordsPage.tsx +++ b/console/src/access/AppPasswordsPage.tsx @@ -71,6 +71,8 @@ import { formatDate, FRIENDLY_DATETIME_FORMAT_NO_SECONDS, } from "~/utils/dateFormat"; +import { toBase64 } from "~/utils/format"; +import { obfuscateSecret } from "~/utils/format"; const AppPasswordsPage = ({ user }: { user: User }) => { const { isOpen, onOpen, onClose } = useDisclosure(); @@ -143,6 +145,7 @@ const AppPasswordsInner = (props: { }); const [newPasswordClosed, setNewPasswordClosed] = useState(""); + const [newPasswordUser, setNewPasswordUser] = useState(null); const watchType = watch("type"); @@ -159,6 +162,7 @@ const AppPasswordsInner = (props: { name={newPassword.description} password={newPassword.password} obfuscatedContent={newPassword.obfuscatedPassword} + userEmail={newPasswordUser ?? user.email} onClose={() => setNewPasswordClosed(newPassword.clientId)} /> )} @@ -175,6 +179,7 @@ const AppPasswordsInner = (props: {
{ + setNewPasswordUser(data.type === "service" ? data.user : null); createAppPassword({ type: data.type, description: data.name, @@ -462,6 +467,7 @@ type SecretBoxProps = { name: string; password: string; obfuscatedContent: string; + userEmail: string; onClose: () => void; }; @@ -469,9 +475,14 @@ const SecretBox = ({ name, password, obfuscatedContent, + userEmail, onClose, }: SecretBoxProps) => { const { colors } = useTheme(); + + const base64Token = toBase64(`${userEmail}:${password}`); + const obfuscatedBase64Token = obfuscateSecret(base64Token); + return ( - - - New password {`"${name}"`}: - - + + + + New password {`"${name}"`}: + + + + + + MCP Token + + + + Base64-encoded {userEmail}:<password> for MCP + configuration. + + - - Write this down; you will not be able to see your app password again + + Write this down; you will not be able to see your credentials again after you reload! diff --git a/console/src/components/ConnectInstructions.tsx b/console/src/components/ConnectInstructions.tsx index 9f52013dcfffc..139a38a0df067 100644 --- a/console/src/components/ConnectInstructions.tsx +++ b/console/src/components/ConnectInstructions.tsx @@ -15,10 +15,12 @@ import { useParams } from "react-router-dom"; import { User } from "~/external-library-wrappers/frontegg"; import { ClusterDetailParams } from "~/platform/clusters/ClusterRoutes"; import { currentEnvironmentState } from "~/store/environments"; +import ConnectionIcon from "~/svg/ConnectionIcon"; import { MonitorIcon } from "~/svg/Monitor"; import TerminalIcon from "~/svg/Terminal"; import { CopyableBox, TabbedCodeBlock } from "./copyableComponents"; +import McpConnectInstructions from "./McpConnectInstructions"; export interface ConnectInstructionsProps extends BoxProps { /** The user string to display. Falls back to user.email if not provided. */ @@ -29,10 +31,16 @@ export interface ConnectInstructionsProps extends BoxProps { environmentdAddress?: string; /** Override the query params in the psql connection string. */ psqlQueryParams?: string; + /** Pre-computed Base64 token for MCP configuration (cloud only). */ + mcpBase64Token?: string; + /** Called when the active tab changes. */ + onTabChange?: (title: string) => void; } const ConnectInstructions = ({ user, + onTabChange, + mcpBase64Token, ...props }: ConnectInstructionsProps): JSX.Element => { const [currentEnvironment] = useAtom(currentEnvironmentState); @@ -115,7 +123,18 @@ const ConnectInstructions = ({ contents: psqlCopyString, icon: , }, + { + title: "MCP Server", + children: ( + + ), + icon: , + }, ]} + onTabChange={onTabChange} minHeight="208px" {...props} /> diff --git a/console/src/components/ConnectModal.tsx b/console/src/components/ConnectModal.tsx index 26ed772e9d30c..3a99ea3400283 100644 --- a/console/src/components/ConnectModal.tsx +++ b/console/src/components/ConnectModal.tsx @@ -22,7 +22,7 @@ import { useTheme, VStack, } from "@chakra-ui/react"; -import React from "react"; +import React, { useState } from "react"; import ConnectInstructions from "~/components/ConnectInstructions"; import { Modal } from "~/components/Modal"; @@ -31,6 +31,7 @@ import docUrls from "~/mz-doc-urls.json"; import { useCreateApiToken } from "~/queries/frontegg"; import { useListApiTokens } from "~/queries/frontegg"; import { MaterializeTheme } from "~/theme"; +import { obfuscateSecret, toBase64 } from "~/utils/format"; import { SecretCopyableBox } from "./copyableComponents"; import SupportLink from "./SupportLink"; @@ -56,6 +57,17 @@ const ConnectModal = ({ }) => { const { colors } = useTheme(); const showCreateAppPassword = !forAppPassword; + const [activeTab, setActiveTab] = useState("External tools"); + const { + mutate: createAppPassword, + isPending: createInProgress, + data: newPassword, + } = useCreateApiToken(); + + const mcpUser = forAppPassword?.user ?? user.email; + const mcpBase64Token = newPassword?.password + ? toBase64(`${mcpUser}:${newPassword.password}`) + : undefined; return ( @@ -83,11 +95,20 @@ const ConnectModal = ({ {showCreateAppPassword && ( - + )} @@ -96,7 +117,16 @@ const ConnectModal = ({ ); }; -const CreateAppPassword = ({ user }: { user: User }) => { +interface CreateAppPasswordProps { + user: User; + createAppPassword: ReturnType["mutate"]; + createInProgress: boolean; + newPassword: ReturnType["data"]; + mcpBase64Token?: string; + showMcpToken: boolean; +} + +const CreateAppPassword = (props: CreateAppPasswordProps) => { const { colors } = useTheme(); return ( @@ -107,19 +137,21 @@ const CreateAppPassword = ({ user }: { user: User }) => { } > - + ); }; -const CreateAppPasswordInner = ({ user }: { user: User }) => { +const CreateAppPasswordInner = ({ + user, + createAppPassword, + createInProgress, + newPassword, + mcpBase64Token, + showMcpToken, +}: CreateAppPasswordProps) => { const { data: appPasswords } = useListApiTokens({ user }); const { colors } = useTheme(); - const { - mutate: createAppPassword, - isPending: createInProgress, - data: newPassword, - } = useCreateApiToken(); if (createInProgress) { return ( @@ -133,6 +165,26 @@ const CreateAppPasswordInner = ({ user }: { user: User }) => { if (newPassword?.password) { return ( <> + {showMcpToken && mcpBase64Token && ( + + + MCP token + + + + )} { fontWeight={500} color={colors.foreground.primary} > - New app password + App password { fontWeight={400} color={colors.foreground.secondary} > - Copy this app password to somewhere safe. App passwords cannot be - displayed after initial creation. + Copy this somewhere safe. App passwords cannot be displayed after + initial creation. ); diff --git a/console/src/components/McpConnectInstructions.tsx b/console/src/components/McpConnectInstructions.tsx new file mode 100644 index 0000000000000..6a3c465157115 --- /dev/null +++ b/console/src/components/McpConnectInstructions.tsx @@ -0,0 +1,173 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +import { BoxProps, Text, useTheme, VStack } from "@chakra-ui/react"; +import { useAtom } from "jotai"; +import React from "react"; + +import { useAppConfig } from "~/config/useAppConfig"; +import { currentEnvironmentState } from "~/store/environments"; +import { MaterializeTheme } from "~/theme"; + +import { CopyableBox, TabbedCodeBlock } from "./copyableComponents"; + +interface McpConnectInstructionsProps extends BoxProps { + userStr: string; + /** Pre-computed Base64 token for MCP configuration (cloud only). */ + mcpBase64Token?: string; +} + +const mcpConfigJson = ( + baseUrl: string, + endpoint: "agents" | "developer", + opts?: { includeType?: boolean }, +) => + JSON.stringify( + { + mcpServers: { + [`materialize-${endpoint}`]: { + ...(opts?.includeType && { type: "http" }), + url: `${baseUrl}/api/mcp/${endpoint}`, + headers: { + Authorization: "Basic ", + }, + }, + }, + }, + null, + 2, + ); + +const McpConnectInstructions = ({ + userStr, + mcpBase64Token, + ...props +}: McpConnectInstructionsProps) => { + const { colors } = useTheme(); + const [currentEnvironment] = useAtom(currentEnvironmentState); + const appConfig = useAppConfig(); + const isCloud = appConfig.mode === "cloud"; + const endpoint = "developer"; + + const envAddress = + currentEnvironment?.state === "enabled" + ? currentEnvironment.httpAddress + : undefined; + + if (!envAddress) return null; + + // Cloud: HTTPS with the environment's HTTP address hostname. + // Self-managed: Use a placeholder since the MCP endpoint may be behind a + // load balancer or custom domain that we can't determine from the console. + const baseUrl = isCloud + ? `https://${envAddress.split(":")[0]}` + : ""; + + const user = userStr || ""; + const base64Command = `printf '${user}:' | base64 -w0`; + + const endpointUrl = `${baseUrl}/api/mcp/${endpoint}`; + const claudeCodeCliCommand = `claude mcp add --transport http materialize-${endpoint} ${endpointUrl} --header "Authorization: Basic "`; + + return ( + + + Connect your AI agent or coding assistant to Materialize using the + built-in MCP server. + + + + Step 1. Get your MCP token + + {isCloud + ? "Create a new app password and copy the MCP Token, or encode an existing app password by running the following in your terminal:" + : "Use a role with login and password attributes. Run the following in your terminal:"} + + + {isCloud && mcpBase64Token && ( + + Your MCP token is available above, under the app password. + + )} + + + + Step 2. Connect your client + + Replace <base64-token> with the output from Step 1. + + + + Run this command in your terminal: + + + + Or save to .mcp.json in your project directory: + + + + ), + }, + { + title: "Claude Desktop", + children: ( + + + Save to claude_desktop_config.json: + + + + ), + }, + { + title: "Cursor", + children: ( + + + Save to .cursor/mcp.json in your project + directory: + + + + ), + }, + ]} + /> + + + ); +}; + +export default McpConnectInstructions; diff --git a/console/src/components/McpConnectModal.tsx b/console/src/components/McpConnectModal.tsx new file mode 100644 index 0000000000000..aba97296dd57e --- /dev/null +++ b/console/src/components/McpConnectModal.tsx @@ -0,0 +1,47 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +import { + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, +} from "@chakra-ui/react"; +import React from "react"; + +import McpConnectInstructions from "~/components/McpConnectInstructions"; +import { Modal } from "~/components/Modal"; + +export interface McpConnectModalProps { + onClose: () => void; + isOpen: boolean; + userStr?: string; +} + +const McpConnectModal = ({ + onClose, + isOpen, + userStr, +}: McpConnectModalProps) => { + return ( + + + + Connect to MCP Server + + + "} /> + + + + ); +}; + +export default McpConnectModal; diff --git a/console/src/components/OidcConnectModal.tsx b/console/src/components/OidcConnectModal.tsx index 60949fe7ac686..a07dea8c69dfb 100644 --- a/console/src/components/OidcConnectModal.tsx +++ b/console/src/components/OidcConnectModal.tsx @@ -20,9 +20,14 @@ import { import React from "react"; import ConnectInstructions from "~/components/ConnectInstructions"; -import { SecretCopyableBox } from "~/components/copyableComponents"; +import { + SecretCopyableBox, + TabbedCodeBlock, +} from "~/components/copyableComponents"; +import McpConnectInstructions from "~/components/McpConnectInstructions"; import { Modal } from "~/components/Modal"; import { useAuth } from "~/external-library-wrappers/oidc"; +import ConnectionIcon from "~/svg/ConnectionIcon"; import { MaterializeTheme } from "~/theme"; import { obfuscateSecret } from "~/utils/format"; @@ -83,9 +88,26 @@ const OidcConnectModal = ({ ) : ( - - No ID token available. Please sign in again. - + + + No ID token available. Please sign in again to access SQL + connection details. + + + ), + icon: , + }, + ]} + minHeight="208px" + /> + )} diff --git a/console/src/components/PasswordConnectModal.tsx b/console/src/components/PasswordConnectModal.tsx new file mode 100644 index 0000000000000..054a363e7364a --- /dev/null +++ b/console/src/components/PasswordConnectModal.tsx @@ -0,0 +1,74 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +import { + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Text, + useTheme, +} from "@chakra-ui/react"; +import React from "react"; + +import McpConnectInstructions from "~/components/McpConnectInstructions"; +import { Modal } from "~/components/Modal"; +import ConnectionIcon from "~/svg/ConnectionIcon"; +import { MaterializeTheme } from "~/theme"; + +import { TabbedCodeBlock } from "./copyableComponents"; + +const PASSWORD_USERNAME_PLACEHOLDER = ""; + +const PasswordConnectModal = ({ + onClose, + isOpen, +}: { + onClose: () => void; + isOpen: boolean; +}) => { + const { colors } = useTheme(); + + return ( + + + + Connect To Materialize + + + + Use the details below to connect your AI agent to Materialize. + + + ), + icon: , + }, + ]} + minHeight="208px" + /> + + + + ); +}; + +export default PasswordConnectModal; diff --git a/console/src/components/copyableComponents.tsx b/console/src/components/copyableComponents.tsx index 95762be9c04d3..26b8790d45f6f 100644 --- a/console/src/components/copyableComponents.tsx +++ b/console/src/components/copyableComponents.tsx @@ -244,6 +244,7 @@ type CodeBlockExtraProps = { type TabbedCodeBlockProps = CodeBlockExtraProps & { tabs: Tab[]; + onTabChange?: (title: string) => void; }; type CodeBlockProps = CodeBlockTab & CodeBlockExtraProps; @@ -267,6 +268,7 @@ export const TabbedCodeBlock: React.FC< lineNumbers, wrap, headingIcon, + onTabChange, ...props }: TabbedCodeBlockProps & BoxProps) => { const { colors, shadows } = useTheme(); @@ -314,7 +316,10 @@ export const TabbedCodeBlock: React.FC< setActiveTabTitle(title)} + onClick={() => { + setActiveTabTitle(title); + onTabChange?.(title); + }} borderBottom="1px solid" borderColor={ title === activeTabTitle diff --git a/console/src/layouts/NavBar.tsx b/console/src/layouts/NavBar.tsx index adf30680e4bc0..b1450cb5c7395 100644 --- a/console/src/layouts/NavBar.tsx +++ b/console/src/layouts/NavBar.tsx @@ -28,6 +28,7 @@ import ConnectModal from "~/components/ConnectModal"; import FreeTrialNotice from "~/components/FreeTrialNotice"; import { MaterializeLogo } from "~/components/MaterializeLogo"; import OidcConnectModal from "~/components/OidcConnectModal"; +import PasswordConnectModal from "~/components/PasswordConnectModal"; import { AppConfigSwitch } from "~/config/AppConfigSwitch"; import EnvironmentSelectField from "~/layouts/EnvironmentSelect"; import ProfileDropdown from "~/layouts/ProfileDropdown"; @@ -279,19 +280,26 @@ export const NavBar = ({ isCollapsed }: NavBarProps) => { ) } selfManagedConfigElement={({ appConfig }) => - appConfig.authMode === "Oidc" ? ( + appConfig.authMode === "None" ? null : ( - + {appConfig.authMode === "Oidc" ? ( + + ) : ( + + )} - ) : null + ) } /> )} diff --git a/console/src/layouts/NavBar/NavMenu.tsx b/console/src/layouts/NavBar/NavMenu.tsx index e69f93bea9922..56facf2c2a034 100644 --- a/console/src/layouts/NavBar/NavMenu.tsx +++ b/console/src/layouts/NavBar/NavMenu.tsx @@ -24,6 +24,7 @@ import { useCanViewUsage } from "~/api/auth"; import ConnectModal from "~/components/ConnectModal"; import FreeTrialNotice from "~/components/FreeTrialNotice"; import OidcConnectModal from "~/components/OidcConnectModal"; +import PasswordConnectModal from "~/components/PasswordConnectModal"; import { AppConfigSwitch, CloudRuntimeConfig } from "~/config/AppConfigSwitch"; import { useFlags } from "~/hooks/useFlags"; import { useIsSuperUser } from "~/hooks/useIsSuperUser"; @@ -307,19 +308,26 @@ const NavMenuMobile = (props: { ) } selfManagedConfigElement={({ appConfig }) => - appConfig.authMode === "Oidc" ? ( + appConfig.authMode === "None" ? null : ( - + {appConfig.authMode === "Oidc" ? ( + + ) : ( + + )} - ) : null + ) } /> diff --git a/console/src/utils/format.ts b/console/src/utils/format.ts index 5bdf2d5b65f5e..51ecf5af03a9d 100644 --- a/console/src/utils/format.ts +++ b/console/src/utils/format.ts @@ -16,6 +16,16 @@ export function obfuscateSecret(secret: string): string { return "*".repeat(secret.length); } +/** + * Unicode-safe base64 encoding. Unlike `btoa`, this handles + * non-ASCII characters by first encoding the string as UTF-8. + */ +export const toBase64 = (str: string): string => { + const bytes = new TextEncoder().encode(str); + const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join(""); + return btoa(binary); +}; + const kilobyte = 1024n; const megabyte = 1024n * 1024n; const gigabyte = 1024n * 1024n * 1024n;