From 489e7382556c173fc0759cd7a009ce2621a65be0 Mon Sep 17 00:00:00 2001 From: Manish Kumar <107841575+sondermanish@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:18:09 +0530 Subject: [PATCH] feat: Mcp server client changes (#42187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description > [!TIP] > _Add a TL;DR when the description is longer than 500 words or extremely technical (helps the content, marketing, and DevRel team)._ > > _Please also include relevant motivation and context. List any dependencies that are required for this change. Add links to Notion, Figma or any other documents that might be relevant to the PR._ Fixes https://linear.app/appsmith/issue/APP-15383/create-mcp-server https://linear.app/appsmith/issue/APP-15853/create-client-side-support-for-mcp-token-management Suggested Cypress tags or specs: ## Automation /ok-to-test tags="@tag.All" ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No > [!TIP] > 🟒 🟒 🟒 All cypress tests have passed! πŸŽ‰ πŸŽ‰ πŸŽ‰ > Workflow run: > Commit: 645813f535cb96ea75316a77799ec351306214bb > Cypress dashboard. > Tags: `@tag.All` > Spec: >
Wed, 02 Sep 2026 18:26:07 UTC ## Summary by CodeRabbit * **New Features** * Added MCP server configuration, including enablement, data access, and customizable server URL settings. * Added an MCP keys administration page for creating, listing, searching, filtering, rotating, and revoking tokens. * Added one-time token display, copyable server and client configuration details, expiration tracking, and status indicators. * Added support for organization-level MCP settings and multi-organization administration. * **Bug Fixes** * Improved administration setting dependencies, default values, and dynamic disabled-state handling. --- app/client/src/api/McpTokenApi.ts | 61 + app/client/src/ce/constants/mcp.ts | 5 + app/client/src/ce/constants/messages.ts | 73 + .../src/ce/constants/organizationConstants.ts | 1 + .../pages/AdminSettings/WithSuperUserHoc.tsx | 5 +- .../ce/pages/AdminSettings/config/index.ts | 14 + .../pages/AdminSettings/config/mcpServer.ts | 112 ++ .../ce/pages/AdminSettings/config/types.ts | 5 + app/client/src/ce/reducers/settingsReducer.ts | 26 +- .../ce/selectors/organizationSelectors.tsx | 15 + .../src/ce/utils/adminSettingsHelpers.test.ts | 49 + .../src/ce/utils/adminSettingsHelpers.ts | 71 +- app/client/src/ee/constants/mcp.ts | 1 + .../pages/AdminSettings/config/mcpServer.ts | 1 + .../FormGroup/TextInput.test.tsx | 8 + .../AdminSettings/FormGroup/TextInput.tsx | 12 +- .../AdminSettings/FormGroup/Toggle.test.tsx | 21 + .../pages/AdminSettings/FormGroup/Toggle.tsx | 58 +- .../Profile/McpKeysPage.test.tsx | 564 ++++++++ .../AdminSettings/Profile/McpKeysPage.tsx | 1212 +++++++++++++++++ .../src/pages/AdminSettings/SettingsForm.tsx | 25 +- 21 files changed, 2286 insertions(+), 53 deletions(-) create mode 100644 app/client/src/api/McpTokenApi.ts create mode 100644 app/client/src/ce/constants/mcp.ts create mode 100644 app/client/src/ce/pages/AdminSettings/config/mcpServer.ts create mode 100644 app/client/src/ce/utils/adminSettingsHelpers.test.ts create mode 100644 app/client/src/ee/constants/mcp.ts create mode 100644 app/client/src/ee/pages/AdminSettings/config/mcpServer.ts create mode 100644 app/client/src/pages/AdminSettings/Profile/McpKeysPage.test.tsx create mode 100644 app/client/src/pages/AdminSettings/Profile/McpKeysPage.tsx diff --git a/app/client/src/api/McpTokenApi.ts b/app/client/src/api/McpTokenApi.ts new file mode 100644 index 000000000000..113c31725a68 --- /dev/null +++ b/app/client/src/api/McpTokenApi.ts @@ -0,0 +1,61 @@ +import Api from "api/Api"; +import type { ApiResponse } from "api/ApiResponses"; + +export type McpKeyStatus = "ACTIVE" | "REVOKED" | "EXPIRED"; + +export interface McpTokenMetadata { + // The server serializes Instant fields as epoch seconds (a number); older/other paths may send an ISO string. + createdAt: string | number; + expiresAt: string | number; + id: string; + // User-facing label. Absent on tokens created before naming existed; the UI falls back to the id then. + name?: string; + // Derived on the server. List currently omits revoked keys, so REVOKED will not appear until that query changes. + status?: McpKeyStatus; +} + +export interface CreatedMcpToken extends McpTokenMetadata { + token: string; +} + +class McpTokenApi extends Api { + static url = "v1/users/mcp-tokens"; + + // name is optional; a blank/absent name is defaulted server-side to "Token created ". + // keySpanDays must be one of 30, 60, 90, 180, 365; the server defaults to 30 if omitted. + static async create( + name?: string, + keySpanDays?: number, + ): Promise> { + const trimmed = name?.trim(); + const response = await Api.post(McpTokenApi.url, { + ...(trimmed ? { name: trimmed } : {}), + ...(keySpanDays != null ? { keySpanDays } : {}), + }); + + return response as unknown as ApiResponse; + } + + // One envelope whose `data` holds the whole list, matching every other list endpoint. (The server used to return + // Flux> β€” a bare array of N envelopes β€” which had no top-level responseMeta for the shared + // response interceptor to validate.) + static async list(): Promise> { + const response = await Api.get(McpTokenApi.url); + + return response as unknown as ApiResponse; + } + + static async rotate(tokenId: string): Promise> { + const response = await Api.post(`${McpTokenApi.url}/${tokenId}/rotate`); + + return response as unknown as ApiResponse; + } + + static async revoke(tokenId: string): Promise> { + const response = await Api.delete(`${McpTokenApi.url}/${tokenId}`); + + return response as unknown as ApiResponse; + } +} + +export default McpTokenApi; diff --git a/app/client/src/ce/constants/mcp.ts b/app/client/src/ce/constants/mcp.ts new file mode 100644 index 000000000000..61bf3cb3d672 --- /dev/null +++ b/app/client/src/ce/constants/mcp.ts @@ -0,0 +1,5 @@ +/** + * Default MCP endpoint advertised to clients. The /mcp route is served from the app origin (via Caddy). + */ +export const getDefaultMcpServerUrl = (): string => + `${window.location.origin}/mcp`; diff --git a/app/client/src/ce/constants/messages.ts b/app/client/src/ce/constants/messages.ts index 02ea792ba819..43a97cd0cd08 100644 --- a/app/client/src/ce/constants/messages.ts +++ b/app/client/src/ce/constants/messages.ts @@ -238,6 +238,79 @@ export const USER_DISPLAY_NAME_PLACEHOLDER = () => "Display name"; export const USER_DISPLAY_PICTURE_PLACEHOLDER = () => "Display picture"; export const USER_EMAIL_PLACEHOLDER = () => "Email"; export const USER_RESET_PASSWORD = () => "Reset password"; +export const MCP_KEYS = () => "MCP keys"; +export const MCP_TOKENS = () => "MCP tokens"; +export const MCP_TOKENS_DESCRIPTION = () => + "A key authenticates an MCP client as you. It is shown only once after you create or rotate it."; +export const MCP_SERVER_URL_LABEL = () => "MCP server URL"; +export const MCP_SERVER_URL_HELP = () => + "Point your MCP client (e.g. ChatGPT or Claude) at this URL and authenticate with a key from this page."; +export const COPY_MCP_SERVER_URL = () => "Copy server URL"; +export const MCP_SERVER_URL_COPIED = () => "Server URL copied"; +export const MCP_SERVER_URL_COPY_FAILED = () => "Unable to copy server URL."; +export const MCP_KEYS_HOW_TO_CONNECT = () => "How to connect"; +export const MCP_KEYS_CONNECT_TITLE = () => "Connect an MCP client"; +export const MCP_KEYS_CONNECT_DESCRIPTION = () => + "Use this server URL in your MCP client and authenticate with a key from this page as a bearer token."; +export const MCP_KEYS_CONNECT_CONFIG_HELP = () => + "Replace the placeholder with a key from this page. A key is shown only once when you create or rotate it."; +export const CREATE_MCP_TOKEN = () => "Create Key"; +export const CREATE_MCP_KEY_CONFIRM = () => "Create"; +export const CREATE_MCP_KEY_TITLE = () => "Create Key"; +export const MCP_TOKEN_NAME_LABEL = () => "Name"; +export const MCP_TOKEN_NAME_PLACEHOLDER = () => "Optional, e.g. Claude Desktop"; +export const MCP_KEY_VALIDITY_LABEL = () => "Key validity in days"; +export const MCP_TOKEN_CREATED = () => "MCP token created"; +export const MCP_TOKEN_CREATED_DESCRIPTION = () => + "Copy this token now. You will not be able to view it again."; +export const MCP_TOKEN_CREATED_DONE = () => "I've copied it"; +export const MCP_TOKEN_CREATED_DISMISS_WARNING = () => + "This is the only time this token is shown. If you close without copying it, you'll need to rotate the token to get a new one."; +export const MCP_TOKEN_VALUE_LABEL = () => "MCP token"; +export const COPY_MCP_TOKEN = () => "Copy token"; +export const MCP_TOKEN_COPIED = () => "MCP token copied"; +export const MCP_TOKEN_COPY_FAILED = () => "Unable to copy MCP token."; +export const MCP_CLIENT_CONFIG_LABEL = () => "Client configuration"; +export const MCP_CLIENT_CONFIG_HELP = () => + "Paste this into your MCP client's config to connect (server URL + this token). Store it securely β€” it grants access as you."; +export const COPY_MCP_CLIENT_CONFIG = () => "Copy client configuration"; +export const MCP_CLIENT_CONFIG_COPIED = () => "Client configuration copied"; +export const MCP_CLIENT_CONFIG_COPY_FAILED = () => + "Unable to copy client configuration."; +export const MCP_TOKENS_LOADING = () => "Loading MCP tokens…"; +export const MCP_TOKENS_EMPTY = () => "No MCP tokens have been created."; +export const MCP_TOKEN_CREATED_AT = () => "Created"; +export const MCP_TOKEN_EXPIRES_AT = () => "Expires"; +export const MCP_KEY_COLUMN_NAME = () => "Key name"; +export const MCP_KEY_COLUMN_STATUS = () => "Status"; +export const MCP_KEY_STATUS_ACTIVE = () => "Active"; +export const MCP_KEY_STATUS_REVOKED = () => "Revoked"; +export const MCP_KEY_STATUS_EXPIRED = () => "Expired"; +export const MCP_KEY_MORE_ACTIONS = (name: string) => + `More actions for ${name}`; +export const MCP_KEYS_SEARCH_PLACEHOLDER = () => "Search keys"; +export const MCP_KEY_STATUS_FILTER_ALL = () => "All"; +export const MCP_KEYS_NO_MATCH = () => "No keys match this search."; +export const MCP_KEYS_PREVIOUS_PAGE = () => "Previous"; +export const MCP_KEYS_NEXT_PAGE = () => "Next"; +export const MCP_KEYS_PAGE_STATUS = (page: number, total: number) => + `Page ${page} of ${total}`; +export const ROTATE_MCP_TOKEN = () => "Rotate"; +export const ROTATE_MCP_TOKEN_CONFIRM = () => "Rotate token"; +export const ROTATE_MCP_TOKEN_CONFIRMATION = () => + "Rotate this MCP token? The current secret will stop working immediately."; +export const MCP_TOKEN_ROTATED = () => "MCP token rotated"; +// The post-rotation modal reuses the created-token layout, but calling it "created" misdescribes what happened. +export const MCP_TOKEN_ROTATED_TITLE = () => "MCP token rotated"; +export const REVOKE_MCP_TOKEN = () => "Revoke"; +export const REVOKE_MCP_TOKEN_CONFIRM = () => "Revoke token"; +export const REVOKE_MCP_TOKEN_CONFIRMATION = () => + "Revoke this MCP token? Connected MCP clients will no longer be able to use it."; +export const MCP_TOKEN_REVOKED = () => "MCP token revoked"; +export const MCP_TOKENS_LOAD_FAILED = () => "Unable to load MCP tokens."; +export const MCP_TOKEN_CREATE_FAILED = () => "Unable to create MCP token."; +export const MCP_TOKEN_ROTATE_FAILED = () => "Unable to rotate MCP token."; +export const MCP_TOKEN_REVOKE_FAILED = () => "Unable to revoke MCP token."; export const CREATE_PASSWORD_RESET_SUCCESS = () => `Your password has been set`; export const CREATE_PASSWORD_RESET_SUCCESS_LOGIN_LINK = () => `Login`; diff --git a/app/client/src/ce/constants/organizationConstants.ts b/app/client/src/ce/constants/organizationConstants.ts index c9a394d6532c..d9d524a718e0 100644 --- a/app/client/src/ce/constants/organizationConstants.ts +++ b/app/client/src/ce/constants/organizationConstants.ts @@ -9,6 +9,7 @@ export const organizationConfigConnection: string[] = [ "isAtomicPushAllowed", "isFormLoginEnabled", "isSignupDisabled", + "mcpConfig", ]; export const RESTART_POLL_TIMEOUT = 2 * 150 * 1000; diff --git a/app/client/src/ce/pages/AdminSettings/WithSuperUserHoc.tsx b/app/client/src/ce/pages/AdminSettings/WithSuperUserHoc.tsx index f47557dc3d57..a2f20c4928f8 100644 --- a/app/client/src/ce/pages/AdminSettings/WithSuperUserHoc.tsx +++ b/app/client/src/ce/pages/AdminSettings/WithSuperUserHoc.tsx @@ -7,6 +7,7 @@ import { getShowAdminSettings } from "ee/utils/BusinessFeatures/adminSettingsHel import { useFeatureFlag } from "utils/hooks/useFeatureFlag"; import { FEATURE_FLAG } from "ee/entities/FeatureFlag"; import { APPLICATIONS_URL } from "constants/routes"; +import { SettingCategories } from "ee/pages/AdminSettings/config/types"; export default function WithSuperUserHOC( Component: React.ComponentType, @@ -22,7 +23,9 @@ export default function WithSuperUserHOC( if (!user) return null; if ( - ["profile"].indexOf(category) === -1 && + [SettingCategories.PROFILE, SettingCategories.MCP_KEYS].indexOf( + category, + ) === -1 && !getShowAdminSettings(isFeatureEnabled, user) ) { return ; diff --git a/app/client/src/ce/pages/AdminSettings/config/index.ts b/app/client/src/ce/pages/AdminSettings/config/index.ts index 5a9b171fab2c..d73eafd9b701 100644 --- a/app/client/src/ce/pages/AdminSettings/config/index.ts +++ b/app/client/src/ce/pages/AdminSettings/config/index.ts @@ -20,9 +20,15 @@ import store from "store"; import { isMultiOrgFFEnabled } from "ee/utils/planHelpers"; import { getCurrentUser } from "selectors/usersSelectors"; import { getShowAdminSettings } from "ee/utils/BusinessFeatures/adminSettingsHelpers"; +import { + getMcpServerConfig, + mcpKeys, +} from "ee/pages/AdminSettings/config/mcpServer"; +import { getIsMcpEnabled } from "ee/selectors/organizationSelectors"; const featureFlags = selectFeatureFlags(store.getState()); const isMultiOrgEnabled = isMultiOrgFFEnabled(featureFlags); +const isMCPEnabled = getIsMcpEnabled(store.getState()); const user = getCurrentUser(store.getState()); const isFeatureEnabled = featureFlags.license_gac_enabled; const isSuperUser = getShowAdminSettings(isFeatureEnabled, user); @@ -30,6 +36,8 @@ const isSuperUser = getShowAdminSettings(isFeatureEnabled, user); // Profile categories ConfigFactory.register(ProfileConfig); +if (isMCPEnabled) ConfigFactory.register(mcpKeys); + // Organisation categories if (isSuperUser) ConfigFactory.register(GeneralConfig); @@ -41,6 +49,9 @@ if (isSuperUser) ConfigFactory.register(AuditLogsConfig); if (isSuperUser) ConfigFactory.register(AIConfig); +if (isSuperUser && isMultiOrgEnabled) + ConfigFactory.register(getMcpServerConfig(isMultiOrgEnabled)); + // User management categories if (isSuperUser) ConfigFactory.register(UserSettings); @@ -55,6 +66,9 @@ if (isSuperUser && !isMultiOrgEnabled) ConfigFactory.register(InstanceSettings); if (isSuperUser && !isMultiOrgEnabled) ConfigFactory.register(Configuration); +if (isSuperUser && !isMultiOrgEnabled) + ConfigFactory.register(getMcpServerConfig(isMultiOrgEnabled)); + if (isSuperUser && !isMultiOrgEnabled) ConfigFactory.register(VersionConfig); export default ConfigFactory; diff --git a/app/client/src/ce/pages/AdminSettings/config/mcpServer.ts b/app/client/src/ce/pages/AdminSettings/config/mcpServer.ts new file mode 100644 index 000000000000..a7b98859d567 --- /dev/null +++ b/app/client/src/ce/pages/AdminSettings/config/mcpServer.ts @@ -0,0 +1,112 @@ +import type { + AdminConfigType, + Setting, +} from "ee/pages/AdminSettings/config/types"; +import { + CategoryType, + SettingCategories, + SettingTypes, + SettingSubtype, +} from "ee/pages/AdminSettings/config/types"; +import { getDefaultMcpServerUrl } from "ee/constants/mcp"; +import McpKeysPage from "pages/AdminSettings/Profile/McpKeysPage"; + +const isMcpServerOff = ( + // TODO: Fix this the next time the file is edited + // eslint-disable-next-line @typescript-eslint/no-explicit-any + values?: Record, +) => + values?.mcpConfig?.enabled !== true && values?.["mcpConfig.enabled"] !== true; + +export const MCP_ENABLED_SETTING: Setting = { + id: "mcpConfig.enabled", + name: "mcpConfig.enabled", + category: SettingCategories.MCP_SERVER, + controlType: SettingTypes.TOGGLE, + label: "Enable MCP server", + text: "Allow AI agents to connect to this organization over MCP (Model Context Protocol)", + helpText: + "* Agents authenticate with per-user MCP keys (Settings β†’ MCP Keys) and act with that user's permissions. Disabled by default β€” turning this on exposes the /mcp endpoint and lets users create MCP keys. Turning it off removes the endpoint, blocks new keys, and rejects existing ones.", + defaultValue: false, +}; + +export const MCP_DATA_ENABLED_SETTING: Setting = { + id: "mcpConfig.dataEnabled", + name: "mcpConfig.dataEnabled", + category: SettingCategories.MCP_SERVER, + controlType: SettingTypes.TOGGLE, + label: "MCP data tools", + text: "Let agents work with datasources and queries (create datasources/queries, run read-only actions)", + helpText: + "* Disabled by default. Requires the MCP server above. All operations run under the connecting user's existing permissions; credentials are never exposed to agents.", + defaultValue: false, + isDisabled: isMcpServerOff, +}; + +export const MCP_SERVER_URL_SETTING: Setting = { + id: "mcpConfig.serverUrl", + name: "mcpConfig.serverUrl", + category: SettingCategories.MCP_SERVER, + controlType: SettingTypes.TEXTINPUT, + controlSubType: SettingSubtype.TEXT, + label: "MCP server URL", + subText: "MCP server URL which MCP clients should use to reach this instance", + placeholder: getDefaultMcpServerUrl(), + helpText: + "* URL MCP clients should use to reach this instance. Leave blank to use the default (this origin + /mcp). Set a custom value if Appsmith is behind a reverse proxy or a different public hostname.", + isDisabled: isMcpServerOff, + + validate: (value: string) => { + if (value === undefined || value === "") { + return; + } + + try { + const url = new URL(value); + + if (url.protocol !== "http:" && url.protocol !== "https:") { + return "Enter an http(s) URL."; + } + } catch { + return "Enter a valid URL."; + } + }, +}; + +const instanceSettings = [ + MCP_ENABLED_SETTING, + MCP_DATA_ENABLED_SETTING, + MCP_SERVER_URL_SETTING, +]; + +export const mcpKeys: AdminConfigType = { + icon: "robot-2", + type: SettingCategories.MCP_KEYS, + categoryType: CategoryType.PROFILE, + controlType: SettingTypes.PAGE, + component: McpKeysPage, + title: "MCP keys", + canSave: false, +} as AdminConfigType; + +export const config: AdminConfigType = { + icon: "robot-2", + type: SettingCategories.MCP_SERVER, + categoryType: CategoryType.ORGANIZATION, + controlType: SettingTypes.GROUP, + title: "MCP Server (BETA)", + canSave: true, + settings: [MCP_ENABLED_SETTING, MCP_DATA_ENABLED_SETTING], +}; + +export const getMcpServerConfig = ( + isMultiOrgEnabled: boolean, +): AdminConfigType => { + return isMultiOrgEnabled + ? { ...config, settings: [MCP_ENABLED_SETTING] } + : { + ...config, + categoryType: CategoryType.INSTANCE, + settings: instanceSettings, + }; +}; diff --git a/app/client/src/ce/pages/AdminSettings/config/types.ts b/app/client/src/ce/pages/AdminSettings/config/types.ts index 8c41e1fe7da4..cea36638889b 100644 --- a/app/client/src/ce/pages/AdminSettings/config/types.ts +++ b/app/client/src/ce/pages/AdminSettings/config/types.ts @@ -88,6 +88,9 @@ export type Setting = ControlType & { sortOrder?: number; subText?: string; subTextLink?: string; + // For TOGGLE/CHECKBOX settings backed by an env variable: the state to show when the variable is absent from the + // fetched admin settings (e.g. an env file that predates the setting). Mirrors the runtime default. + defaultValue?: boolean; toggleText?: (value: boolean) => string; // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -136,6 +139,8 @@ export const SettingCategories = { OIDC_AUTH: "oidc-auth", INSTANCE_SETTINGS: "instance-settings", CONFIGURATION: "configuration", + MCP_KEYS: "mcp-keys", + MCP_SERVER: "mcp-server", VERSION: "version", USER_SETTINGS: "user-settings", PROFILE: "profile", diff --git a/app/client/src/ce/reducers/settingsReducer.ts b/app/client/src/ce/reducers/settingsReducer.ts index db3164de752e..46b98768ac35 100644 --- a/app/client/src/ce/reducers/settingsReducer.ts +++ b/app/client/src/ce/reducers/settingsReducer.ts @@ -5,7 +5,7 @@ import { } from "ee/constants/ReduxActionConstants"; import { createReducer } from "utils/ReducerUtils"; import type { OrganizationReduxState } from "ee/reducers/organizationReducer"; -import { organizationConfigConnection } from "ee/constants/organizationConstants"; +import { flattenOrganizationConfigForSettingsForm } from "ee/utils/adminSettingsHelpers"; export const initialState: SettingsReduxState = { isLoading: true, @@ -51,15 +51,9 @@ export const handlers = { // eslint-disable-next-line @typescript-eslint/no-explicit-any action: ReduxAction>, ) => { - // TODO: Fix this the next time the file is edited - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const configs: any = {}; - - organizationConfigConnection.forEach((key: string) => { - if (action.payload?.organizationConfiguration?.hasOwnProperty(key)) { - configs[key] = action.payload?.organizationConfiguration?.[key]; - } - }); + const configs = flattenOrganizationConfigForSettingsForm( + action.payload?.organizationConfiguration, + ); return { ...state, @@ -78,15 +72,9 @@ export const handlers = { // eslint-disable-next-line @typescript-eslint/no-explicit-any action: ReduxAction>, ) => { - // TODO: Fix this the next time the file is edited - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const configs: any = {}; - - organizationConfigConnection.forEach((key: string) => { - if (action.payload?.organizationConfiguration?.hasOwnProperty(key)) { - configs[key] = action.payload?.organizationConfiguration?.[key]; - } - }); + const configs = flattenOrganizationConfigForSettingsForm( + action.payload?.organizationConfiguration, + ); return { ...state, diff --git a/app/client/src/ce/selectors/organizationSelectors.tsx b/app/client/src/ce/selectors/organizationSelectors.tsx index 93a6d4112e09..825f3e26598b 100644 --- a/app/client/src/ce/selectors/organizationSelectors.tsx +++ b/app/client/src/ce/selectors/organizationSelectors.tsx @@ -1,4 +1,5 @@ import type { DefaultRootState } from "react-redux"; +import { getDefaultMcpServerUrl } from "ee/constants/mcp"; /** * selects the organization permissions @@ -50,6 +51,20 @@ export const getThirdPartyAuths = (state: DefaultRootState): string[] => export const getIsFormLoginEnabled = (state: DefaultRootState): boolean => state.organization?.organizationConfiguration?.isFormLoginEnabled ?? true; +export const getIsMcpEnabled = (state: DefaultRootState): boolean => + state.organization?.organizationConfiguration?.mcpConfig?.enabled === true; + +export const getMcpServerUrl = (state: DefaultRootState): string => { + const configured = + state.organization?.organizationConfiguration?.mcpConfig?.serverUrl; + + if (typeof configured === "string" && configured.trim() !== "") { + return configured.trim(); + } + + return getDefaultMcpServerUrl(); +}; + export const getIsSignupDisabled = (state: DefaultRootState): boolean => state.organization?.organizationConfiguration?.isSignupDisabled ?? false; diff --git a/app/client/src/ce/utils/adminSettingsHelpers.test.ts b/app/client/src/ce/utils/adminSettingsHelpers.test.ts new file mode 100644 index 000000000000..2e5e8e8f9bd7 --- /dev/null +++ b/app/client/src/ce/utils/adminSettingsHelpers.test.ts @@ -0,0 +1,49 @@ +import { + flattenOrganizationConfigForSettingsForm, + isOrganizationConfig, + nestOrganizationConfigFromSettingsForm, +} from "./adminSettingsHelpers"; + +describe("MCP org config form mapping", () => { + it("treats nested mcpConfig form fields as organization settings", () => { + expect(isOrganizationConfig("mcpConfig.enabled")).toBe(true); + expect(isOrganizationConfig("mcpConfig.dataEnabled")).toBe(true); + expect(isOrganizationConfig("mcpConfig.serverUrl")).toBe(true); + expect(isOrganizationConfig("APPSMITH_MCP_ENABLED")).toBe(false); + }); + + it("flattens mcpConfig onto dotted form keys and fail-closes missing flags", () => { + expect( + flattenOrganizationConfigForSettingsForm({ + instanceName: "Appsmith", + mcpConfig: { + enabled: true, + dataEnabled: false, + serverUrl: "https://appsmith.example/mcp", + }, + }), + ).toEqual({ + instanceName: "Appsmith", + "mcpConfig.enabled": true, + "mcpConfig.dataEnabled": false, + "mcpConfig.serverUrl": "https://appsmith.example/mcp", + mcpConfig: { + enabled: true, + dataEnabled: false, + serverUrl: "https://appsmith.example/mcp", + }, + }); + }); + + it("nests only changed mcpConfig fields on save", () => { + expect( + nestOrganizationConfigFromSettingsForm({ + "mcpConfig.dataEnabled": true, + hideWatermark: true, + }), + ).toEqual({ + hideWatermark: true, + mcpConfig: { dataEnabled: true }, + }); + }); +}); diff --git a/app/client/src/ce/utils/adminSettingsHelpers.ts b/app/client/src/ce/utils/adminSettingsHelpers.ts index fd0941c14125..11a9a4814640 100644 --- a/app/client/src/ce/utils/adminSettingsHelpers.ts +++ b/app/client/src/ce/utils/adminSettingsHelpers.ts @@ -8,6 +8,7 @@ import { ADMIN_SETTINGS_CATEGORY_PROFILE_PATH, } from "constants/routes"; import type { User } from "constants/userConstants"; +import set from "lodash/set"; /* settings is the updated & unsaved settings on Admin settings page */ export const saveAllowed = ( @@ -63,10 +64,78 @@ export const getLoginUrl = (method: string): string => { return urls[method]; }; +const MCP_CONFIG_FORM_PREFIX = "mcpConfig."; + export const isOrganizationConfig = (name: string): boolean => { const fields: string[] = organizationConfigConnection; - return fields.includes(name); + return fields.includes(name) || name.startsWith(MCP_CONFIG_FORM_PREFIX); +}; + +export const flattenOrganizationConfigForSettingsForm = ( + organizationConfiguration?: Record | null, +): Record => { + const configs: Record = {}; + + if (!organizationConfiguration) { + return configs; + } + + organizationConfigConnection.forEach((key: string) => { + if (key === "mcpConfig") { + const mcpConfig = organizationConfiguration.mcpConfig; + + if (mcpConfig && typeof mcpConfig === "object") { + const mcp = mcpConfig as Record; + const enabled = mcp.enabled === true; + const dataEnabled = mcp.dataEnabled === true; + + // Nested paths first: redux-form Field names like "mcpConfig.enabled" use lodash path get. + // Dotted keys second: settingsMap / settingsConfig[id] lookups. lodash.set skips nesting + // when the dotted own-property already exists. + set(configs, "mcpConfig.enabled", enabled); + set(configs, "mcpConfig.dataEnabled", dataEnabled); + configs["mcpConfig.enabled"] = enabled; + configs["mcpConfig.dataEnabled"] = dataEnabled; + + if (typeof mcp.serverUrl === "string") { + set(configs, "mcpConfig.serverUrl", mcp.serverUrl); + configs["mcpConfig.serverUrl"] = mcp.serverUrl; + } + } + + return; + } + + if (Object.prototype.hasOwnProperty.call(organizationConfiguration, key)) { + configs[key] = organizationConfiguration[key]; + } + }); + + return configs; +}; + +export const nestOrganizationConfigFromSettingsForm = ( + settings: Record, +): Record => { + const config: Record = {}; + const mcpConfig: Record = {}; + let hasMcpConfig = false; + + for (const each in settings) { + if (each.startsWith(MCP_CONFIG_FORM_PREFIX)) { + hasMcpConfig = true; + mcpConfig[each.slice(MCP_CONFIG_FORM_PREFIX.length)] = settings[each]; + } else if (organizationConfigConnection.includes(each)) { + config[each] = settings[each]; + } + } + + if (hasMcpConfig) { + config.mcpConfig = mcpConfig; + } + + return config; }; export const getWrapperCategory = ( diff --git a/app/client/src/ee/constants/mcp.ts b/app/client/src/ee/constants/mcp.ts new file mode 100644 index 000000000000..f94432c2cda0 --- /dev/null +++ b/app/client/src/ee/constants/mcp.ts @@ -0,0 +1 @@ +export * from "ce/constants/mcp"; diff --git a/app/client/src/ee/pages/AdminSettings/config/mcpServer.ts b/app/client/src/ee/pages/AdminSettings/config/mcpServer.ts new file mode 100644 index 000000000000..edd9fd3358db --- /dev/null +++ b/app/client/src/ee/pages/AdminSettings/config/mcpServer.ts @@ -0,0 +1 @@ +export * from "ce/pages/AdminSettings/config/mcpServer"; diff --git a/app/client/src/pages/AdminSettings/FormGroup/TextInput.test.tsx b/app/client/src/pages/AdminSettings/FormGroup/TextInput.test.tsx index a45ea6ef6987..6aef507b10dd 100644 --- a/app/client/src/pages/AdminSettings/FormGroup/TextInput.test.tsx +++ b/app/client/src/pages/AdminSettings/FormGroup/TextInput.test.tsx @@ -62,4 +62,12 @@ describe("Text Input", () => { expect(inputEl?.value).toBeDefined(); expect(inputEl?.value).toEqual("test value"); }); + + it("is disabled when isDisabled returns true", () => { + setting.isDisabled = () => true; + renderComponent(); + + expect(document.querySelector("input")?.disabled).toBe(true); + delete setting.isDisabled; + }); }); diff --git a/app/client/src/pages/AdminSettings/FormGroup/TextInput.tsx b/app/client/src/pages/AdminSettings/FormGroup/TextInput.tsx index 7443e69fff50..58b409a15544 100644 --- a/app/client/src/pages/AdminSettings/FormGroup/TextInput.tsx +++ b/app/client/src/pages/AdminSettings/FormGroup/TextInput.tsx @@ -2,8 +2,18 @@ import FormTextField from "components/utils/ReduxFormTextField"; import { createMessage } from "ee/constants/messages"; import React from "react"; import { FormGroup, type SettingComponentProps } from "./Common"; +import { getFormValues } from "redux-form"; +import { SETTINGS_FORM_NAME } from "ee/constants/forms"; +import { useSelector } from "react-redux"; + +const formValuesSelector = getFormValues(SETTINGS_FORM_NAME); export default function TextInput({ setting }: SettingComponentProps) { + const settings = useSelector(formValuesSelector); + const isDisabled = + setting.isFeatureEnabled === false || + Boolean(setting.isDisabled && setting.isDisabled(settings)); + return ( { beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); + setting.label = "test label"; + delete setting.text; + delete setting.subText; }); it("is rendered", () => { @@ -67,4 +70,22 @@ describe("Toggle", () => { inputEl?.click(); expect(inputEl?.checked).toEqual(false); }); + + it("places setting text beside the switch and subtext below", () => { + delete setting.label; + setting.text = "Allow AI agents to connect"; + setting.subText = "* Agents authenticate with per-user MCP tokens"; + + renderComponent(); + + expect(document.body.textContent).toContain("Allow AI agents to connect"); + expect( + document.querySelector( + "[data-testid='admin-settings-form-group-subtext']", + )?.textContent, + ).toBe("* Agents authenticate with per-user MCP tokens"); + expect( + document.querySelector("[data-testid='admin-settings-form-group-label']"), + ).toBeNull(); + }); }); diff --git a/app/client/src/pages/AdminSettings/FormGroup/Toggle.tsx b/app/client/src/pages/AdminSettings/FormGroup/Toggle.tsx index 2d9e5cd1ff24..cd576c9eedb9 100644 --- a/app/client/src/pages/AdminSettings/FormGroup/Toggle.tsx +++ b/app/client/src/pages/AdminSettings/FormGroup/Toggle.tsx @@ -2,15 +2,15 @@ import React, { memo } from "react"; import type { WrappedFieldInputProps, WrappedFieldMetaProps } from "redux-form"; import { Field, getFormValues } from "redux-form"; import styled from "styled-components"; -import type { SettingComponentProps } from "./Common"; +import { FormGroup, type SettingComponentProps } from "./Common"; import type { FormTextFieldProps } from "components/utils/ReduxFormTextField"; import { createMessage } from "ee/constants/messages"; import { Switch, Text } from "@appsmith/ads"; import { SETTINGS_FORM_NAME } from "ee/constants/forms"; import { useSelector } from "react-redux"; -const ToggleWrapper = styled.div` - margin-bottom: 16px; +const ToggleWrapper = styled.div<{ $compact?: boolean }>` + margin-bottom: ${(props) => (props.$compact ? 0 : 16)}px; `; const ToggleStatus = styled(Text)``; @@ -21,6 +21,7 @@ function FieldToggleWithToggleText( isPropertyDisabled?: boolean, label?: React.ReactNode, isDisabled = false, + text?: string, ) { return function FieldToggle( componentProps: FormTextFieldProps & { @@ -37,24 +38,22 @@ function FieldToggleWithToggleText( componentProps.input.onChange(toggleValue); componentProps.input.onBlur && componentProps.input.onBlur(toggleValue); } - /* Value = !ENV_VARIABLE - This has been done intentionally as naming convention used contains the word disabled but the UI should show the button enabled by default. - */ - //TODO: This should be refactored to utilize the functionality of the switch component for state + const switchLabel = text + ? text + : typeof toggleText == "function" + ? createMessage(() => toggleText(val)) + : createMessage(() => `${label ? `Enable ${label}` : "Enable"}`); + return ( - + - - {typeof toggleText == "function" - ? createMessage(() => toggleText(val)) - : createMessage(() => `${label ? `Enable ${label}` : "Enable"}`)} - + {switchLabel} ); @@ -69,19 +68,32 @@ const formValuesSelector = getFormValues(SETTINGS_FORM_NAME); export function ToggleComponent({ setting }: SettingComponentProps) { const settings = useSelector(formValuesSelector); + const field = ( + + ); return ( - + {setting.text || setting.subText ? ( + + {field} + + ) : ( + field + )} ); } diff --git a/app/client/src/pages/AdminSettings/Profile/McpKeysPage.test.tsx b/app/client/src/pages/AdminSettings/Profile/McpKeysPage.test.tsx new file mode 100644 index 000000000000..3d603af49e17 --- /dev/null +++ b/app/client/src/pages/AdminSettings/Profile/McpKeysPage.test.tsx @@ -0,0 +1,564 @@ +import "@testing-library/jest-dom/extend-expect"; +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ThemeProvider } from "styled-components"; +import { lightTheme } from "selectors/themeSelectors"; +import McpTokenApi from "api/McpTokenApi"; +import McpKeysPage from "./McpKeysPage"; +import { useDispatch, useSelector } from "react-redux"; + +jest.mock("api/McpTokenApi", () => ({ + __esModule: true, + default: { + create: jest.fn(), + list: jest.fn(), + rotate: jest.fn(), + revoke: jest.fn(), + }, +})); + +jest.mock("react-redux", () => ({ + useDispatch: jest.fn(), + useSelector: jest.fn(), +})); + +jest.mock("ee/actions/organizationActions", () => ({ + getCurrentOrganization: () => ({ type: "FETCH_CURRENT_ORGANIZATION_CONFIG" }), +})); + +const mockDispatch = jest.fn(); + +const successResponse = (data: T) => ({ + responseMeta: { success: true, status: 200 }, + data, +}); + +const renderComponent = () => + render( + + + , + ); + +const openCreateModal = async () => { + await screen.findByText("Claude Desktop"); + fireEvent.click(screen.getByRole("button", { name: "Create Key" })); + expect(await screen.findByLabelText("Name")).toBeInTheDocument(); +}; + +const submitCreateModal = () => { + fireEvent.click(screen.getByRole("button", { name: "Create" })); +}; + +const openRowMenu = async (name = "Claude Desktop") => { + await screen.findByText(name); + fireEvent.click( + screen.getByRole("button", { name: `More actions for ${name}` }), + ); + expect( + await screen.findByRole("menuitem", { name: "Rotate" }), + ).toBeInTheDocument(); +}; + +describe("McpKeysPage", () => { + beforeEach(() => { + (useDispatch as jest.Mock).mockReturnValue(mockDispatch); + (useSelector as jest.Mock).mockImplementation((selector) => + selector({ + organization: { + organizationConfiguration: { + mcpConfig: { enabled: true }, + }, + }, + }), + ); + mockDispatch.mockClear(); + // One envelope whose data is the whole list, matching the server's Mono>>. + (McpTokenApi.list as jest.Mock).mockResolvedValue( + successResponse([ + { + id: "token-1", + name: "Claude Desktop", + createdAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-10-08T12:00:00.000Z", + status: "ACTIVE", + }, + ]), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("fetches tenant config and skips listing tokens when MCP is not enabled", () => { + (useSelector as jest.Mock).mockImplementation((selector) => + selector({ + organization: { + organizationConfiguration: { + mcpConfig: { enabled: false }, + }, + }, + }), + ); + renderComponent(); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: "FETCH_CURRENT_ORGANIZATION_CONFIG", + }); + expect(McpTokenApi.list).not.toHaveBeenCalled(); + expect(screen.queryByText("token-1")).not.toBeInTheDocument(); + }); + + it("lists metadata without displaying token plaintext", async () => { + renderComponent(); + + expect(await screen.findByText("Claude Desktop")).toBeInTheDocument(); + expect(screen.getByText("mcp_token-1....")).toBeInTheDocument(); + expect(screen.getByText("Active")).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: "Key name" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: "Status" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: "Created" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("columnheader", { name: "Expires" }), + ).toBeInTheDocument(); + expect(screen.queryByText("secret-token")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Revoke Claude Desktop" }), + ).not.toBeInTheDocument(); + }); + + it("shows the header title, description, and Create Key without an always-on name field", async () => { + renderComponent(); + + expect(await screen.findByTestId("t--mcp-keys-header")).toHaveTextContent( + "MCP keys", + ); + expect( + screen.getByText( + "A key authenticates an MCP client as you. It is shown only once after you create or rotate it.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "How to connect" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Create Key" }), + ).toBeInTheDocument(); + expect(screen.queryByLabelText("Name")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("MCP server URL")).not.toBeInTheDocument(); + expect( + screen + .getByRole("button", { name: "How to connect" }) + .compareDocumentPosition( + screen.getByRole("button", { name: "Create Key" }), + ) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("opens a create modal and sends name plus keySpanDays", async () => { + (McpTokenApi.create as jest.Mock).mockResolvedValue( + successResponse({ + id: "token-2", + token: "secret-token", + createdAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-10-08T12:00:00.000Z", + status: "ACTIVE", + }), + ); + renderComponent(); + + await openCreateModal(); + fireEvent.change(screen.getByLabelText("Name"), { + target: { value: "Claude Desktop" }, + }); + expect(screen.getByText("Key validity in days")).toBeInTheDocument(); + submitCreateModal(); + + await waitFor(() => + expect(McpTokenApi.create).toHaveBeenCalledWith("Claude Desktop", 30), + ); + expect(await screen.findByLabelText("MCP token")).toHaveValue( + "secret-token", + ); + expect(screen.queryByLabelText("Name")).not.toBeInTheDocument(); + }); + + it("does not create a key when the create modal is cancelled", async () => { + renderComponent(); + + await openCreateModal(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + await waitFor(() => + expect(screen.queryByLabelText("Name")).not.toBeInTheDocument(), + ); + expect(McpTokenApi.create).not.toHaveBeenCalled(); + }); + + it("shows a labeled, read-only monospace token field after creation", async () => { + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + (McpTokenApi.create as jest.Mock).mockResolvedValue( + successResponse({ + id: "token-2", + token: "secret-token", + createdAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-10-08T12:00:00.000Z", + }), + ); + renderComponent(); + + await openCreateModal(); + submitCreateModal(); + + const tokenField = await screen.findByLabelText("MCP token"); + + expect(tokenField).toHaveValue("secret-token"); + expect(tokenField).toHaveAttribute("readonly"); + expect(tokenField).toHaveStyle( + "font-family: var(--ads-v2-font-family-code)", + ); + fireEvent.click(screen.getByRole("button", { name: "Copy token" })); + + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "secret-token", + ), + ); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + + await waitFor(() => + expect(screen.queryByLabelText("MCP token")).not.toBeInTheDocument(), + ); + }); + + it("renders a copyable client-config snippet (server URL + token) after creation (M4-T3)", async () => { + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + (McpTokenApi.create as jest.Mock).mockResolvedValue( + successResponse({ + id: "token-3", + token: "secret-token", + createdAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-10-08T12:00:00.000Z", + }), + ); + renderComponent(); + + await openCreateModal(); + submitCreateModal(); + await screen.findByLabelText("MCP token"); + + // The Modal renders in a portal, so query the whole document, not the render container. + const snippet = document.querySelector(".t--mcp-client-config"); + + expect(snippet).toBeTruthy(); + // The snippet embeds the server URL (origin + /mcp) and the one-time token as a bearer credential. + expect(snippet?.textContent).toContain("/mcp"); + expect(snippet?.textContent).toContain("Bearer secret-token"); + expect(snippet?.textContent).toContain("mcpServers"); + + fireEvent.click( + screen.getByRole("button", { name: "Copy client configuration" }), + ); + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalled(), + ); + + const copied = (navigator.clipboard.writeText as jest.Mock).mock + .calls[0][0] as string; + + expect(copied).toContain("secret-token"); + expect(JSON.parse(copied).mcpServers.appsmith.url).toContain("/mcp"); + }); + + it("shows the MCP server URL (origin + /mcp) with a working copy button", async () => { + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + renderComponent(); + + await screen.findByText("Claude Desktop"); + fireEvent.click(screen.getByRole("button", { name: "How to connect" })); + + const urlField = await screen.findByLabelText("MCP server URL"); + + expect(urlField).toHaveValue(`${window.location.origin}/mcp`); + expect(urlField).toHaveAttribute("readonly"); + expect( + document.querySelector(".t--mcp-client-config")?.textContent, + ).toContain(""); + + fireEvent.click(screen.getByRole("button", { name: "Copy server URL" })); + + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + `${window.location.origin}/mcp`, + ), + ); + }); + + it("confirms a revoke request before calling the API", async () => { + (McpTokenApi.revoke as jest.Mock).mockResolvedValue(successResponse(true)); + renderComponent(); + + await screen.findByText("Claude Desktop"); + await openRowMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Revoke" })); + expect(McpTokenApi.revoke).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Revoke token" })); + + await waitFor(() => + expect(McpTokenApi.revoke).toHaveBeenCalledWith("token-1"), + ); + expect(screen.queryByText("Claude Desktop")).not.toBeInTheDocument(); + }); + + it("rotates a token only after confirmation and shows its replacement once", async () => { + (McpTokenApi.rotate as jest.Mock).mockResolvedValue( + successResponse({ + id: "token-1", + token: "rotated-secret", + createdAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-10-08T12:00:00.000Z", + }), + ); + renderComponent(); + + await screen.findByText("Claude Desktop"); + await openRowMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Rotate" })); + expect(McpTokenApi.rotate).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Rotate token" })); + + await waitFor(() => + expect(McpTokenApi.rotate).toHaveBeenCalledWith("token-1"), + ); + expect(await screen.findByLabelText("MCP token")).toHaveValue( + "rotated-secret", + ); + }); + + it("keeps the one-time token on screen when Escape is pressed", async () => { + // The secret is unrecoverable once dismissed, so an accidental Escape must NOT destroy it. Recovery would + // otherwise mean rotating β€” a second destructive action β€” and on plain-HTTP instances navigator.clipboard is + // undefined, so the user may still be copying by hand when they hit a stray key. + (McpTokenApi.create as jest.Mock).mockResolvedValue( + successResponse({ + id: "token-2", + token: "secret-token", + createdAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-10-08T12:00:00.000Z", + }), + ); + renderComponent(); + + await openCreateModal(); + submitCreateModal(); + + const tokenField = await screen.findByLabelText("MCP token"); + + expect(tokenField).toHaveValue("secret-token"); + + fireEvent.keyDown(document.activeElement ?? document.body, { + key: "Escape", + code: "Escape", + }); + + // Still there. + expect(screen.getByLabelText("MCP token")).toHaveValue("secret-token"); + + // Only the explicit acknowledgement dismisses it. + fireEvent.click(screen.getByRole("button", { name: "I've copied it" })); + + await waitFor(() => + expect(screen.queryByLabelText("MCP token")).not.toBeInTheDocument(), + ); + }); + + it("does not claim the user has no tokens when the list failed to load", async () => { + // On a failed load `tokens` is empty for a reason that is NOT "you have none". Rendering the empty-state copy + // alongside the error reads as "your credentials were deleted". + (McpTokenApi.list as jest.Mock).mockRejectedValue( + new Error("Request failed"), + ); + renderComponent(); + + await screen.findByRole("alert"); + + expect( + screen.queryByText("No MCP tokens have been created."), + ).not.toBeInTheDocument(); + }); + + it("renders an accessible error when the token list fails", async () => { + (McpTokenApi.list as jest.Mock).mockRejectedValue( + new Error("Request failed"), + ); + renderComponent(); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Request failed", + ); + }); + + it("announces token loading status", () => { + (McpTokenApi.list as jest.Mock).mockImplementation( + async () => new Promise(() => undefined), + ); + renderComponent(); + + expect(screen.getByRole("status")).toHaveTextContent("Loading MCP tokens"); + }); + + it("closes the revoke confirmation and shows a page error on failure", async () => { + (McpTokenApi.revoke as jest.Mock).mockRejectedValue( + new Error("Revocation failed"), + ); + renderComponent(); + + await openRowMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Revoke" })); + fireEvent.click(screen.getByRole("button", { name: "Revoke token" })); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "Revocation failed", + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("provides a clearly named cancel action for revoke confirmation", async () => { + renderComponent(); + + await openRowMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Revoke" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(McpTokenApi.revoke).not.toHaveBeenCalled(); + }); + + it("shows Expired status for keys that have passed their expiry", async () => { + (McpTokenApi.list as jest.Mock).mockResolvedValue( + successResponse([ + { + id: "token-1", + name: "Claude Desktop", + createdAt: "2026-01-10T12:00:00.000Z", + expiresAt: "2026-01-11T12:00:00.000Z", + status: "EXPIRED", + }, + ]), + ); + renderComponent(); + + expect(await screen.findByText("Expired")).toBeInTheDocument(); + expect(screen.queryByText("Active")).not.toBeInTheDocument(); + }); + + it("filters the table by key name search", async () => { + (McpTokenApi.list as jest.Mock).mockResolvedValue( + successResponse([ + { + id: "token-1", + name: "Claude Desktop", + createdAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-10-08T12:00:00.000Z", + status: "ACTIVE", + }, + { + id: "token-2", + name: "CI pipeline", + createdAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-10-08T12:00:00.000Z", + status: "ACTIVE", + }, + ]), + ); + renderComponent(); + + expect(await screen.findByText("Claude Desktop")).toBeInTheDocument(); + expect(screen.getByText("CI pipeline")).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText("Search keys"), { + target: { value: "pipeline" }, + }); + + expect(screen.getByText("CI pipeline")).toBeInTheDocument(); + expect(screen.queryByText("Claude Desktop")).not.toBeInTheDocument(); + }); + + it("filters the table by status", async () => { + (McpTokenApi.list as jest.Mock).mockResolvedValue( + successResponse([ + { + id: "token-1", + name: "Claude Desktop", + createdAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-10-08T12:00:00.000Z", + status: "ACTIVE", + }, + { + id: "token-2", + name: "Old key", + createdAt: "2026-01-10T12:00:00.000Z", + expiresAt: "2026-01-11T12:00:00.000Z", + status: "EXPIRED", + }, + ]), + ); + renderComponent(); + + expect(await screen.findByText("Claude Desktop")).toBeInTheDocument(); + expect(screen.getByText("Old key")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Status: All" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Expired" })); + + expect(await screen.findByText("Old key")).toBeInTheDocument(); + expect(screen.queryByText("Claude Desktop")).not.toBeInTheDocument(); + }); + + it("paginates keys ten at a time", async () => { + (McpTokenApi.list as jest.Mock).mockResolvedValue( + successResponse( + Array.from({ length: 11 }, (_, index) => ({ + id: `token-${index + 1}`, + name: `Key ${index + 1}`, + createdAt: "2026-07-10T12:00:00.000Z", + expiresAt: "2026-10-08T12:00:00.000Z", + status: "ACTIVE", + })), + ), + ); + renderComponent(); + + expect(await screen.findByText("Key 1")).toBeInTheDocument(); + expect(screen.getByText("Key 10")).toBeInTheDocument(); + expect(screen.queryByText("Key 11")).not.toBeInTheDocument(); + expect(screen.getByText("Page 1 of 2")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Next" })); + + expect(await screen.findByText("Key 11")).toBeInTheDocument(); + expect(screen.queryByText("Key 1")).not.toBeInTheDocument(); + expect(screen.getByText("Page 2 of 2")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Previous" })); + + expect(await screen.findByText("Key 1")).toBeInTheDocument(); + expect(screen.queryByText("Key 11")).not.toBeInTheDocument(); + }); +}); diff --git a/app/client/src/pages/AdminSettings/Profile/McpKeysPage.tsx b/app/client/src/pages/AdminSettings/Profile/McpKeysPage.tsx new file mode 100644 index 000000000000..bf3e03fcc825 --- /dev/null +++ b/app/client/src/pages/AdminSettings/Profile/McpKeysPage.tsx @@ -0,0 +1,1212 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Button, + Callout, + Flex, + Input, + Menu, + MenuContent, + MenuItem, + MenuTrigger, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + Option, + SearchInput, + Select, + Table, + Tag, + Text, + Tooltip, + toast, +} from "@appsmith/ads"; +import { + COPY_MCP_CLIENT_CONFIG, + COPY_MCP_SERVER_URL, + COPY_MCP_TOKEN, + CREATE_MCP_TOKEN, + CREATE_MCP_KEY_CONFIRM, + CREATE_MCP_KEY_TITLE, + MCP_CLIENT_CONFIG_COPIED, + MCP_CLIENT_CONFIG_COPY_FAILED, + MCP_CLIENT_CONFIG_HELP, + MCP_CLIENT_CONFIG_LABEL, + MCP_SERVER_URL_COPIED, + MCP_SERVER_URL_COPY_FAILED, + MCP_SERVER_URL_LABEL, + MCP_TOKEN_COPIED, + MCP_TOKEN_COPY_FAILED, + MCP_TOKEN_CREATE_FAILED, + MCP_KEYS_HOW_TO_CONNECT, + MCP_KEYS_CONNECT_TITLE, + MCP_KEYS_CONNECT_DESCRIPTION, + MCP_KEYS_CONNECT_CONFIG_HELP, + MCP_TOKEN_CREATED, + MCP_TOKEN_CREATED_AT, + MCP_TOKEN_EXPIRES_AT, + MCP_KEY_COLUMN_NAME, + MCP_KEY_COLUMN_STATUS, + MCP_KEY_STATUS_ACTIVE, + MCP_KEY_STATUS_REVOKED, + MCP_KEY_STATUS_EXPIRED, + MCP_KEY_MORE_ACTIONS, + MCP_KEYS_SEARCH_PLACEHOLDER, + MCP_KEY_STATUS_FILTER_ALL, + MCP_KEYS_NO_MATCH, + MCP_KEYS_PREVIOUS_PAGE, + MCP_KEYS_NEXT_PAGE, + MCP_KEYS_PAGE_STATUS, + MCP_TOKEN_CREATED_DESCRIPTION, + MCP_TOKEN_CREATED_DISMISS_WARNING, + MCP_TOKEN_CREATED_DONE, + MCP_TOKEN_VALUE_LABEL, + MCP_TOKEN_REVOKE_FAILED, + MCP_TOKEN_REVOKED, + MCP_TOKEN_ROTATE_FAILED, + MCP_TOKEN_ROTATED, + MCP_TOKEN_ROTATED_TITLE, + MCP_KEYS, + MCP_TOKENS, + MCP_TOKENS_DESCRIPTION, + MCP_TOKENS_EMPTY, + MCP_TOKENS_LOAD_FAILED, + MCP_TOKENS_LOADING, + CANCEL, + CLOSE, + REVOKE_MCP_TOKEN, + REVOKE_MCP_TOKEN_CONFIRM, + REVOKE_MCP_TOKEN_CONFIRMATION, + ROTATE_MCP_TOKEN, + ROTATE_MCP_TOKEN_CONFIRM, + ROTATE_MCP_TOKEN_CONFIRMATION, + MCP_TOKEN_NAME_LABEL, + MCP_TOKEN_NAME_PLACEHOLDER, + MCP_KEY_VALIDITY_LABEL, + createMessage, +} from "ee/constants/messages"; +import McpTokenApi, { + type CreatedMcpToken, + type McpKeyStatus, + type McpTokenMetadata, +} from "api/McpTokenApi"; +import type { ApiResponse } from "api/ApiResponses"; +import styled from "styled-components"; +import { useDispatch, useSelector } from "react-redux"; +import { getCurrentOrganization } from "ee/actions/organizationActions"; +import { + getIsMcpEnabled, + getMcpServerUrl, +} from "ee/selectors/organizationSelectors"; +import type { AdminConfigType } from "ee/pages/AdminSettings/config/types"; +import { + BottomSpace, + HeaderWrapper, + SettingsFormWrapper, + SettingsHeader, + SettingsSubHeader, + Wrapper, +} from "../components"; + +const TokensWrapper = styled.div` + width: 100%; + max-width: 100%; + & > div { + margin-bottom: 16px; + } +`; + +const KeysFormWrapper = styled(SettingsFormWrapper)` + max-width: 56rem; +`; + +const KeysTableWrapper = styled.div` + width: 100%; + + table { + width: 100%; + } +`; + +const MAX_MCP_TOKEN_NAME_LENGTH = 50; +const MCP_KEY_SPAN_DAYS = [30, 60, 90, 180, 365] as const; +const DEFAULT_MCP_KEY_SPAN_DAYS = 30; +const MCP_KEYS_PAGE_SIZE = 10; + +type StatusFilter = "ALL" | McpKeyStatus; + +// A ready-to-paste MCP client configuration (the common `mcpServers` shape used by Claude Desktop and compatible +// clients): the server URL plus this token as a bearer credential. Rendered once, in the token-created modal. +const buildClientConfig = (serverUrl: string, token: string) => + JSON.stringify( + { + mcpServers: { + appsmith: { + url: serverUrl, + headers: { Authorization: `Bearer ${token}` }, + }, + }, + }, + null, + 2, + ); + +const CLIENT_CONFIG_KEY_PLACEHOLDER = ""; + +// A read-only, monospaced value with a copy-to-clipboard button β€” used for both the server URL and the one-time token. +// `description` (when set) renders as the field's helper text, which the design system links via aria-describedby. +function ReadOnlyCopyField(props: { + label: string; + value: string; + copyLabel: string; + onCopy: () => void; + className?: string; + description?: string; +}) { + return ( + + + + + + + + + + + + ); +} + +const getErrorMessage = (error: unknown, fallback: string) => { + const response = error as Partial & { message?: string }; + + return response.responseMeta?.error?.message || response.message || fallback; +}; + +const ensureSuccess = (response: ApiResponse) => { + if (!response.responseMeta?.success) { + throw response; + } + + return response.data; +}; + +// Timestamps arrive as epoch seconds (Jackson's Instant serialization). Detect and normalize to milliseconds so +// they don't render as 1970; ISO strings pass through unchanged. +const parseTimestamp = (value: string | number) => { + const numeric = typeof value === "number" ? value : Number(value); + let date: Date; + + if (!Number.isNaN(numeric) && String(value).trim() !== "") { + // Values below ~year 2286 in ms are actually seconds; scale them up. + date = new Date(numeric < 1e12 ? numeric * 1000 : numeric); + } else { + date = new Date(value); + } + + return Number.isNaN(date.getTime()) ? null : date; +}; + +const formatTimestamp = (value: string | number) => { + const date = parseTimestamp(value); + + return date ? date.toLocaleString() : String(value); +}; + +const formatDate = (value: string | number) => { + const date = parseTimestamp(value); + + if (!date) { + return "β€”"; + } + + return date.toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + }); +}; + +const formatKeyPrefix = (id: string) => `mcp_${id}....`; + +const matchesKeySearch = (token: McpTokenMetadata, query: string) => { + const needle = query.trim().toLowerCase(); + + if (!needle) { + return true; + } + + const haystack = [token.name, token.id, formatKeyPrefix(token.id)] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + return haystack.includes(needle); +}; + +const resolveKeyStatus = (token: McpTokenMetadata): McpKeyStatus => { + if ( + token.status === "ACTIVE" || + token.status === "REVOKED" || + token.status === "EXPIRED" + ) { + return token.status; + } + + const expiresAt = parseTimestamp(token.expiresAt); + + if (!expiresAt || expiresAt.getTime() <= Date.now()) { + return "EXPIRED"; + } + + return "ACTIVE"; +}; + +const STATUS_LABEL: Record string> = { + ACTIVE: MCP_KEY_STATUS_ACTIVE, + REVOKED: MCP_KEY_STATUS_REVOKED, + EXPIRED: MCP_KEY_STATUS_EXPIRED, +}; + +const STATUS_TAG_KIND: Record = { + ACTIVE: "info", + REVOKED: "special", + EXPIRED: "premium", +}; + +function McpKeyRowMenu(props: { + token: McpTokenMetadata; + isBusy: boolean; + onRotate: (id: string) => void; + onRevoke: (id: string) => void; +}) { + const [open, setOpen] = useState(false); + const label = props.token.name || props.token.id; + const isRevoked = resolveKeyStatus(props.token) === "REVOKED"; + + return ( + + + + ); +} + +function McpKeysTable(props: { + keys: McpTokenMetadata[]; + isBusy: boolean; + emptyText: string; + onRotate: (id: string) => void; + onRevoke: (id: string) => void; +}) { + const columns = useMemo( + () => [ + { + title: createMessage(MCP_KEY_COLUMN_NAME), + dataIndex: "name", + key: "name", + render: (_: string, token: McpTokenMetadata) => ( + + {token.name || token.id} + + {formatKeyPrefix(token.id)} + + + ), + }, + { + title: createMessage(MCP_KEY_COLUMN_STATUS), + dataIndex: "status", + key: "status", + width: 100, + render: (_: string, token: McpTokenMetadata) => { + const status = resolveKeyStatus(token); + + return ( + + + {createMessage(STATUS_LABEL[status])} + + + ); + }, + }, + { + title: createMessage(MCP_TOKEN_CREATED_AT), + dataIndex: "createdAt", + key: "createdAt", + width: 140, + render: (value: string | number) => ( + {formatDate(value)} + ), + }, + { + title: createMessage(MCP_TOKEN_EXPIRES_AT), + dataIndex: "expiresAt", + key: "expiresAt", + width: 140, + render: (value: string | number) => ( + {formatDate(value)} + ), + }, + { + title: "", + dataIndex: "id", + key: "actions", + width: 56, + align: "right" as const, + render: (_: string, token: McpTokenMetadata) => ( + + ), + }, + ], + [props.isBusy, props.onRotate, props.onRevoke], + ); + + return ( + + + + ); +} + +function StatusFilterMenu(props: { + value: StatusFilter; + onChange: (value: StatusFilter) => void; +}) { + const [open, setOpen] = useState(false); + const selectedLabel = + props.value === "ALL" + ? createMessage(MCP_KEY_STATUS_FILTER_ALL) + : createMessage(STATUS_LABEL[props.value]); + + return ( + + + + + + props.onChange("ALL")}> + {createMessage(MCP_KEY_STATUS_FILTER_ALL)} + + props.onChange("ACTIVE")}> + {createMessage(MCP_KEY_STATUS_ACTIVE)} + + props.onChange("REVOKED")}> + {createMessage(MCP_KEY_STATUS_REVOKED)} + + props.onChange("EXPIRED")}> + {createMessage(MCP_KEY_STATUS_EXPIRED)} + + + + ); +} + +function McpKeysToolbar(props: { + search: string; + statusFilter: StatusFilter; + onSearchChange: (value: string) => void; + onStatusFilterChange: (value: StatusFilter) => void; +}) { + return ( + + + + + + + + + ); +} + +function McpKeysPagination(props: { + page: number; + totalPages: number; + onPageChange: (page: number) => void; +}) { + if (props.totalPages <= 1) { + return null; + } + + return ( + + + {createMessage(MCP_KEYS_PAGE_STATUS, props.page, props.totalPages)} + + + + + + + ); +} + +function McpKeysPage({ category }: { category?: AdminConfigType }) { + const dispatch = useDispatch(); + const isMcpEnabled = useSelector(getIsMcpEnabled); + const mcpServerUrl = useSelector(getMcpServerUrl); + const [tokens, setTokens] = useState([]); + const [createdToken, setCreatedToken] = useState( + null, + ); + // The reveal modal is shared by create and rotate; only the header copy differs. + const [createdViaRotation, setCreatedViaRotation] = useState(false); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isCreating, setIsCreating] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isConnectHelpOpen, setIsConnectHelpOpen] = useState(false); + const [createError, setCreateError] = useState(null); + const [rotateTokenId, setRotateTokenId] = useState(null); + const [isRotating, setIsRotating] = useState(false); + const [revokeTokenId, setRevokeTokenId] = useState(null); + const [isRevoking, setIsRevoking] = useState(false); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("ALL"); + const [page, setPage] = useState(1); + + const loadTokens = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const response = await McpTokenApi.list(); + + setTokens(ensureSuccess(response)); + } catch (error) { + setError(getErrorMessage(error, createMessage(MCP_TOKENS_LOAD_FAILED))); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + dispatch(getCurrentOrganization()); + }, [dispatch]); + + useEffect(() => { + if (isMcpEnabled) { + loadTokens(); + } + }, [isMcpEnabled, loadTokens]); + + const filteredKeys = useMemo( + () => + tokens.filter((token) => { + if (!matchesKeySearch(token, search)) { + return false; + } + + if (statusFilter === "ALL") { + return true; + } + + return resolveKeyStatus(token) === statusFilter; + }), + [tokens, search, statusFilter], + ); + + const totalPages = Math.max( + 1, + Math.ceil(filteredKeys.length / MCP_KEYS_PAGE_SIZE), + ); + const currentPage = Math.min(page, totalPages); + const pagedKeys = filteredKeys.slice( + (currentPage - 1) * MCP_KEYS_PAGE_SIZE, + currentPage * MCP_KEYS_PAGE_SIZE, + ); + + useEffect( + function clampKeysPage() { + if (page !== currentPage) { + setPage(currentPage); + } + }, + [page, currentPage], + ); + + if (!isMcpEnabled) { + return null; + } + + const createToken = async (name: string, keySpanDays: number) => { + setIsCreating(true); + setCreateError(null); + + try { + const response = await McpTokenApi.create(name, keySpanDays); + const token = ensureSuccess(response); + + setCreatedViaRotation(false); + setCreatedToken(token); + setTokens((tokens) => [ + { + id: token.id, + name: token.name, + createdAt: token.createdAt, + expiresAt: token.expiresAt, + status: token.status, + }, + ...tokens, + ]); + setIsCreateModalOpen(false); + setPage(1); + } catch (error) { + setCreateError( + getErrorMessage(error, createMessage(MCP_TOKEN_CREATE_FAILED)), + ); + } finally { + setIsCreating(false); + } + }; + + const copyCreatedToken = async () => { + if (!createdToken) { + return; + } + + try { + await navigator.clipboard.writeText(createdToken.token); + toast.show(createMessage(MCP_TOKEN_COPIED), { kind: "success" }); + } catch { + toast.show(createMessage(MCP_TOKEN_COPY_FAILED), { kind: "error" }); + } + }; + + const copyServerUrl = async () => { + try { + await navigator.clipboard.writeText(mcpServerUrl); + toast.show(createMessage(MCP_SERVER_URL_COPIED), { kind: "success" }); + } catch { + toast.show(createMessage(MCP_SERVER_URL_COPY_FAILED), { kind: "error" }); + } + }; + + const copyConnectClientConfig = async () => { + try { + await navigator.clipboard.writeText( + buildClientConfig(mcpServerUrl, CLIENT_CONFIG_KEY_PLACEHOLDER), + ); + toast.show(createMessage(MCP_CLIENT_CONFIG_COPIED), { kind: "success" }); + } catch { + toast.show(createMessage(MCP_CLIENT_CONFIG_COPY_FAILED), { + kind: "error", + }); + } + }; + + const copyClientConfig = async () => { + if (!createdToken) { + return; + } + + try { + await navigator.clipboard.writeText( + buildClientConfig(mcpServerUrl, createdToken.token), + ); + toast.show(createMessage(MCP_CLIENT_CONFIG_COPIED), { kind: "success" }); + } catch { + toast.show(createMessage(MCP_CLIENT_CONFIG_COPY_FAILED), { + kind: "error", + }); + } + }; + + const revokeToken = async () => { + if (!revokeTokenId) { + return; + } + + setIsRevoking(true); + setError(null); + + try { + ensureSuccess(await McpTokenApi.revoke(revokeTokenId)); + setTokens((tokens) => + tokens.filter((token) => token.id !== revokeTokenId), + ); + setRevokeTokenId(null); + toast.show(createMessage(MCP_TOKEN_REVOKED), { kind: "success" }); + } catch (error) { + setRevokeTokenId(null); + setError(getErrorMessage(error, createMessage(MCP_TOKEN_REVOKE_FAILED))); + } finally { + setIsRevoking(false); + } + }; + + const rotateToken = async () => { + if (!rotateTokenId) { + return; + } + + setIsRotating(true); + setError(null); + + try { + const token = ensureSuccess(await McpTokenApi.rotate(rotateTokenId)); + + setCreatedViaRotation(true); + setCreatedToken(token); + setTokens((tokens) => + tokens.map((existing) => + existing.id === token.id + ? { + id: token.id, + name: token.name, + createdAt: token.createdAt, + expiresAt: token.expiresAt, + status: token.status, + } + : existing, + ), + ); + setRotateTokenId(null); + toast.show(createMessage(MCP_TOKEN_ROTATED), { kind: "success" }); + } catch (error) { + setRotateTokenId(null); + setError(getErrorMessage(error, createMessage(MCP_TOKEN_ROTATE_FAILED))); + } finally { + setIsRotating(false); + } + }; + + return ( + + + + + + + {category?.title ?? createMessage(MCP_KEYS)} + + + {createMessage(MCP_TOKENS_DESCRIPTION)} + + + + + + + + + + {error && ( + + {error} + + )} + {isLoading ? ( + + {createMessage(MCP_TOKENS_LOADING)} + + ) : tokens.length === 0 ? ( + // Only claim "no tokens exist" when the list actually loaded. On a failed load `tokens` is also empty, + // and showing both the error and the empty state reads as "your credentials were deleted". + error ? null : ( + {createMessage(MCP_TOKENS_EMPTY)} + ) + ) : ( + + { + setSearch(value); + setPage(1); + }} + onStatusFilterChange={(value) => { + setStatusFilter(value); + setPage(1); + }} + search={search} + statusFilter={statusFilter} + /> + + + + )} + + + { + setIsCreateModalOpen(false); + setCreateError(null); + }} + onCreate={createToken} + /> + + { + if (!open) { + setCreatedToken(null); + } + }} + open={Boolean(createdToken)} + > + {/* + The secret is shown exactly once and is unrecoverable afterwards, so this modal must not be dismissable + by accident. Radix closes on Escape and on an outside click by default, and onOpenChange nulls the token + unconditionally β€” one stray keystroke destroyed the credential with no warning and no undo, leaving a + rotate (a second destructive action) as the only recovery. Both paths are suppressed; the explicit + footer action below is the only way out. This also covers the plain-HTTP case where navigator.clipboard + is undefined and every copy button fails: the user keeps the token on screen to copy by hand. + */} + event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + style={{ width: "640px" }} + > + + {createdViaRotation + ? createMessage(MCP_TOKEN_ROTATED_TITLE) + : createMessage(MCP_TOKEN_CREATED)} + + + + {createMessage(MCP_TOKEN_CREATED_DESCRIPTION)} + + + + + + {createMessage(MCP_TOKEN_EXPIRES_AT)}:{" "} + {formatTimestamp(createdToken?.expiresAt ?? "")} + + + {createMessage(MCP_TOKEN_CREATED_DISMISS_WARNING)} + + + + + + + + + + + {createMessage(MCP_KEYS_CONNECT_TITLE)} + + + {createMessage(MCP_KEYS_CONNECT_DESCRIPTION)} + + + + + + + + + + + { + if (!open) { + setRotateTokenId(null); + } + }} + open={Boolean(rotateTokenId)} + > + + {createMessage(ROTATE_MCP_TOKEN)} + + + {createMessage(ROTATE_MCP_TOKEN_CONFIRMATION)} + + + + + + + + + + { + if (!open) { + setRevokeTokenId(null); + } + }} + open={Boolean(revokeTokenId)} + > + + {createMessage(REVOKE_MCP_TOKEN)} + + + {createMessage(REVOKE_MCP_TOKEN_CONFIRMATION)} + + + + + + + + + + + + ); +} + +export default McpKeysPage; diff --git a/app/client/src/pages/AdminSettings/SettingsForm.tsx b/app/client/src/pages/AdminSettings/SettingsForm.tsx index c8197ce462d8..03688e74a2d9 100644 --- a/app/client/src/pages/AdminSettings/SettingsForm.tsx +++ b/app/client/src/pages/AdminSettings/SettingsForm.tsx @@ -31,6 +31,7 @@ import { } from "ee/constants/messages"; import { isOrganizationConfig, + nestOrganizationConfigFromSettingsForm, saveAllowed, } from "ee/utils/adminSettingsHelpers"; import AnalyticsUtil from "ee/utils/AnalyticsUtil"; @@ -49,7 +50,6 @@ import { getThirdPartyAuths, } from "ee/selectors/organizationSelectors"; import { updateOrganizationConfig } from "ee/actions/organizationActions"; -import { organizationConfigConnection } from "ee/constants/organizationConstants"; import { useIsCloudBillingEnabled } from "hooks"; interface FormProps { @@ -120,13 +120,9 @@ export function SettingsForm( // only organization settings // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any - const config: any = {}; - - for (const each in props.settings) { - if (organizationConfigConnection.includes(each)) { - config[each] = props.settings[each]; - } - } + const config: any = nestOrganizationConfigFromSettingsForm( + props.settings, + ); dispatch( updateOrganizationConfig({ @@ -221,6 +217,19 @@ export function SettingsForm( } } }); + + // A toggle/checkbox backed by an env variable that is ABSENT from the fetched settings (an env file predating + // it) renders from its declared default, so the UI mirrors the runtime default instead of showing unchecked. + _.forEach(AdminConfig.settingsMap, (setting, settingName) => { + if ( + (setting.controlType == SettingTypes.TOGGLE || + setting.controlType == SettingTypes.CHECKBOX) && + setting.defaultValue !== undefined && + props.settingsConfig[settingName] === undefined + ) { + props.settingsConfig[settingName] = setting.defaultValue; + } + }); props.initialize(props.settingsConfig); };