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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions app/client/src/api/McpTokenApi.ts
Original file line number Diff line number Diff line change
@@ -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 <date>".
// 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<ApiResponse<CreatedMcpToken>> {
const trimmed = name?.trim();
const response = await Api.post(McpTokenApi.url, {
...(trimmed ? { name: trimmed } : {}),
...(keySpanDays != null ? { keySpanDays } : {}),
});

return response as unknown as ApiResponse<CreatedMcpToken>;
}

// One envelope whose `data` holds the whole list, matching every other list endpoint. (The server used to return
// Flux<ResponseDTO<T>> — a bare array of N envelopes — which had no top-level responseMeta for the shared
// response interceptor to validate.)
static async list(): Promise<ApiResponse<McpTokenMetadata[]>> {
const response = await Api.get(McpTokenApi.url);

return response as unknown as ApiResponse<McpTokenMetadata[]>;
}

static async rotate(tokenId: string): Promise<ApiResponse<CreatedMcpToken>> {
const response = await Api.post(`${McpTokenApi.url}/${tokenId}/rotate`);

return response as unknown as ApiResponse<CreatedMcpToken>;
}

static async revoke(tokenId: string): Promise<ApiResponse<boolean>> {
const response = await Api.delete(`${McpTokenApi.url}/${tokenId}`);

return response as unknown as ApiResponse<boolean>;
}
}

export default McpTokenApi;
5 changes: 5 additions & 0 deletions app/client/src/ce/constants/mcp.ts
Original file line number Diff line number Diff line change
@@ -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`;
73 changes: 73 additions & 0 deletions app/client/src/ce/constants/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
1 change: 1 addition & 0 deletions app/client/src/ce/constants/organizationConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const organizationConfigConnection: string[] = [
"isAtomicPushAllowed",
"isFormLoginEnabled",
"isSignupDisabled",
"mcpConfig",
];

export const RESTART_POLL_TIMEOUT = 2 * 150 * 1000;
Expand Down
5 changes: 4 additions & 1 deletion app/client/src/ce/pages/AdminSettings/WithSuperUserHoc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RouteComponentProps>,
Expand All @@ -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 <Redirect to={APPLICATIONS_URL} />;
Expand Down
14 changes: 14 additions & 0 deletions app/client/src/ce/pages/AdminSettings/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,24 @@ 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);

// Profile categories
ConfigFactory.register(ProfileConfig);

if (isMCPEnabled) ConfigFactory.register(mcpKeys);

// Organisation categories
if (isSuperUser) ConfigFactory.register(GeneralConfig);

Expand All @@ -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);

Expand All @@ -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;
112 changes: 112 additions & 0 deletions app/client/src/ce/pages/AdminSettings/config/mcpServer.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>,
) =>
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,
};
};
5 changes: 5 additions & 0 deletions app/client/src/ce/pages/AdminSettings/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
26 changes: 7 additions & 19 deletions app/client/src/ce/reducers/settingsReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -51,15 +51,9 @@ export const handlers = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
action: ReduxAction<OrganizationReduxState<any>>,
) => {
// 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,
Expand All @@ -78,15 +72,9 @@ export const handlers = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
action: ReduxAction<OrganizationReduxState<any>>,
) => {
// 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,
Expand Down
Loading
Loading