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); };