From 18f5eb45d098ca64d403fdae4704211000efba29 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Tue, 18 Aug 2026 08:15:11 +0000 Subject: [PATCH 1/3] feat(api): native MCP (Model Context Protocol) server (#41082) Co-authored-by: Claude Opus 4.8 Co-authored-by: Dnouv --- .changeset/native-mcp-server.md | 7 + .../views/admin/aiCenter/AICenterOverview.tsx | 13 + .../views/admin/aiCenter/AICenterRoute.tsx | 4 + .../admin/aiCenter/AISettingsSection.tsx | 10 +- apps/meteor/ee/server/api/index.ts | 1 + apps/meteor/ee/server/api/mcp/catalog.spec.ts | 235 ++++++++++ apps/meteor/ee/server/api/mcp/catalog.ts | 420 ++++++++++++++++++ .../meteor/ee/server/api/mcp/dispatch.spec.ts | 156 +++++++ apps/meteor/ee/server/api/mcp/dispatch.ts | 147 ++++++ apps/meteor/ee/server/api/mcp/index.spec.ts | 377 ++++++++++++++++ apps/meteor/ee/server/api/mcp/index.ts | 256 +++++++++++ apps/meteor/ee/server/api/mcp/server.spec.ts | 174 ++++++++ apps/meteor/ee/server/api/mcp/server.ts | 150 +++++++ .../ee/server/api/mcp/transport.spec.ts | 63 +++ apps/meteor/ee/server/api/mcp/transport.ts | 52 +++ apps/meteor/ee/server/startup/index.ts | 1 + apps/meteor/ee/server/startup/mcp.ts | 7 + apps/meteor/jest.config.ts | 1 + apps/meteor/server/api/ApiClass.ts | 34 ++ .../server/api/v1/middlewares/cors.spec.ts | 45 +- apps/meteor/server/api/v1/middlewares/cors.ts | 10 +- apps/meteor/server/settings/ai.ts | 27 ++ docs/features/mcp-server.md | 150 +++++++ packages/i18n/src/locales/en.i18n.json | 10 + .../model-typings/src/models/IUsersModel.ts | 7 + packages/models/src/models/Users.ts | 10 + packages/rest-typings/src/v1/chat.ts | 23 + .../src/v1/users/UsersInfoParamsGet.ts | 20 + 28 files changed, 2401 insertions(+), 9 deletions(-) create mode 100644 .changeset/native-mcp-server.md create mode 100644 apps/meteor/ee/server/api/mcp/catalog.spec.ts create mode 100644 apps/meteor/ee/server/api/mcp/catalog.ts create mode 100644 apps/meteor/ee/server/api/mcp/dispatch.spec.ts create mode 100644 apps/meteor/ee/server/api/mcp/dispatch.ts create mode 100644 apps/meteor/ee/server/api/mcp/index.spec.ts create mode 100644 apps/meteor/ee/server/api/mcp/index.ts create mode 100644 apps/meteor/ee/server/api/mcp/server.spec.ts create mode 100644 apps/meteor/ee/server/api/mcp/server.ts create mode 100644 apps/meteor/ee/server/api/mcp/transport.spec.ts create mode 100644 apps/meteor/ee/server/api/mcp/transport.ts create mode 100644 apps/meteor/ee/server/startup/mcp.ts create mode 100644 docs/features/mcp-server.md diff --git a/.changeset/native-mcp-server.md b/.changeset/native-mcp-server.md new file mode 100644 index 0000000000000..b574e9942c891 --- /dev/null +++ b/.changeset/native-mcp-server.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +'@rocket.chat/rest-typings': minor +--- + +Adds an AI add-on-gated native Model Context Protocol endpoint and its administration controls in AI Center diff --git a/apps/meteor/client/views/admin/aiCenter/AICenterOverview.tsx b/apps/meteor/client/views/admin/aiCenter/AICenterOverview.tsx index 79bdb6e8fa380..ba75fc9ea58e4 100644 --- a/apps/meteor/client/views/admin/aiCenter/AICenterOverview.tsx +++ b/apps/meteor/client/views/admin/aiCenter/AICenterOverview.tsx @@ -14,8 +14,10 @@ const AICenterOverview = (): ReactElement => { const router = useRouter(); const { data: hasAILicense, isPending } = useHasLicenseModule(AI_LICENSE_MODULE); const intelligentSearchEnabled = useSetting('AI_Intelligent_Search_Enabled', false); + const mcpEnabled = useSetting('MCP_Enabled', false); const searchSettingsHref = router.buildRoutePath({ name: 'admin-ai-center', params: { section: 'search' } }); const llmSettingsHref = router.buildRoutePath({ name: 'admin-ai-center', params: { section: 'llm-providers' } }); + const mcpSettingsHref = router.buildRoutePath({ name: 'admin-ai-center', params: { section: 'mcp' } }); const subscriptionHref = router.buildRoutePath({ name: 'subscription' }); if (isPending) { @@ -24,12 +26,15 @@ const AICenterOverview = (): ReactElement => { let aiSearchStatus: ReactNode; let llmProviderStatus: ReactNode; + let mcpStatus: ReactNode; if (hasAILicense === false) { aiSearchStatus = {t('Locked')}; llmProviderStatus = {t('Locked')}; + mcpStatus = {t('Locked')}; } else if (hasAILicense) { aiSearchStatus = intelligentSearchEnabled ? {t('Enabled')} : {t('Disabled')}; llmProviderStatus = {t('Available')}; + mcpStatus = mcpEnabled ? {t('Enabled')} : {t('Disabled')}; } return ( @@ -65,6 +70,14 @@ const AICenterOverview = (): ReactElement => { actionLabel={t('Manage')} href={llmSettingsHref} /> + diff --git a/apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx b/apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx index baf0803e6f168..8e4464523f594 100644 --- a/apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx +++ b/apps/meteor/client/views/admin/aiCenter/AICenterRoute.tsx @@ -21,6 +21,10 @@ const AICenterRoute = (): ReactElement => { return ; } + if (section === 'mcp') { + return ; + } + return ; }; diff --git a/apps/meteor/client/views/admin/aiCenter/AISettingsSection.tsx b/apps/meteor/client/views/admin/aiCenter/AISettingsSection.tsx index 55a7d4cf703dd..d5a88554846aa 100644 --- a/apps/meteor/client/views/admin/aiCenter/AISettingsSection.tsx +++ b/apps/meteor/client/views/admin/aiCenter/AISettingsSection.tsx @@ -4,15 +4,21 @@ import type { ReactElement } from 'react'; import EditableSettingsProvider from '../settings/EditableSettingsProvider'; import GenericGroupPage from '../settings/groups/GenericGroupPage'; -export type AISettingsSectionName = 'Intelligent_Search' | 'AI_LLM_Provider'; +export type AISettingsSectionName = 'Intelligent_Search' | 'AI_LLM_Provider' | 'MCP'; export type AISettingsSectionProps = { section: AISettingsSectionName; }; +const sectionTitles: Record = { + Intelligent_Search: 'Intelligent_Search', + AI_LLM_Provider: 'AI_Center_LLM_Providers', + MCP: 'MCP', +}; + const AISettingsSection = ({ section }: AISettingsSectionProps): ReactElement => { const router = useRouter(); - const title = section === 'Intelligent_Search' ? 'Intelligent_Search' : 'AI_Center_LLM_Providers'; + const title = sectionTitles[section]; return ( diff --git a/apps/meteor/ee/server/api/index.ts b/apps/meteor/ee/server/api/index.ts index 35e8b0f0f60df..290b451eeb768 100644 --- a/apps/meteor/ee/server/api/index.ts +++ b/apps/meteor/ee/server/api/index.ts @@ -8,3 +8,4 @@ import '../apps/communication/uikit'; import './engagementDashboard'; import './audit'; import './abac'; +import './mcp'; diff --git a/apps/meteor/ee/server/api/mcp/catalog.spec.ts b/apps/meteor/ee/server/api/mcp/catalog.spec.ts new file mode 100644 index 0000000000000..8d685553086a3 --- /dev/null +++ b/apps/meteor/ee/server/api/mcp/catalog.spec.ts @@ -0,0 +1,235 @@ +import { getCuratedTools, getExtendedTools } from './catalog'; + +jest.mock('../../../../server/api', () => ({ + API: { + api: { + typedRoutes: { + '/api/v1/chat.postMessage': { + post: { + tags: ['Chat'], + requestBody: { + content: { + 'application/json': { + schema: { + oneOf: [ + { + type: 'object', + description: 'Post by room id', + properties: { roomId: { type: 'string' }, text: { type: 'string', nullable: true } }, + required: ['roomId'], + }, + { + type: 'object', + description: 'Post by channel', + properties: { channel: { type: 'string' }, text: { type: 'string', nullable: true } }, + required: ['channel'], + }, + ], + }, + }, + }, + }, + }, + }, + '/api/v1/rooms.get': { + get: { + tags: ['Rooms'], + parameters: [ + { + schema: { + type: 'object', + properties: { updatedSince: { type: 'string' } }, + additionalProperties: { type: 'string', nullable: true }, + }, + }, + ], + }, + }, + '/api/v1/channels.create': { + post: { + tags: ['Missing Documentation'], + requestBody: { + content: { + 'application/json': { + schema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, + }, + }, + }, + }, + }, + '/api/v1/channels.list.joined': { + get: { + tags: ['Missing Documentation'], + parameters: [{ schema: { type: 'object', properties: { count: { type: 'number' } } } }], + }, + }, + '/api/v1/rooms.isMember': { + get: { + tags: ['Rooms'], + parameters: [ + { + schema: { + type: 'object', + properties: { + roomId: { type: 'string' }, + userId: { type: 'string' }, + username: { type: 'string' }, + }, + oneOf: [ + { type: 'object', required: ['roomId', 'userId'] }, + { type: 'object', required: ['roomId', 'username'] }, + ], + additionalProperties: false, + }, + }, + ], + }, + }, + '/api/v1/teams.listRoomsOfUser': { + get: { + tags: ['Teams'], + parameters: [ + { + schema: { + type: 'object', + properties: { + teamId: { type: 'string' }, + teamName: { type: 'string' }, + userId: { type: 'string' }, + }, + oneOf: [ + { type: 'object', required: ['teamId'] }, + { type: 'object', required: ['teamName'] }, + ], + required: ['userId'], + additionalProperties: false, + }, + }, + ], + }, + }, + '/api/v1/dm.files': { + get: { + tags: ['DM'], + parameters: [ + { + schema: { + oneOf: [ + { + type: 'object', + properties: { thisIsAnExtremelyLongDiscriminatorNameThatEndsInAlpha: { type: 'string' } }, + required: ['thisIsAnExtremelyLongDiscriminatorNameThatEndsInAlpha'], + }, + { + type: 'object', + properties: { thisIsAnExtremelyLongDiscriminatorNameThatEndsInBeta: { type: 'string' } }, + required: ['thisIsAnExtremelyLongDiscriminatorNameThatEndsInBeta'], + }, + ], + }, + }, + ], + }, + }, + '/api/v1/users.delete': { + post: { + tags: ['Users'], + requestBody: { content: { 'application/json': { schema: { type: 'object' } } } }, + }, + }, + '/api/v1/users.register': { + post: { + tags: ['Users'], + requestBody: { + content: { + 'application/json': { + schema: { type: 'object', properties: { username: { type: 'string' } }, required: ['username'] }, + }, + }, + }, + }, + }, + }, + }, + }, +})); + +describe('MCP tool catalog', () => { + it('creates one curated tool per discriminated request variant', () => { + const tools = getCuratedTools(); + + expect(tools.map(({ name }) => name)).toEqual([ + 'post_chat_postMessage_by_roomId', + 'post_chat_postMessage_by_channel', + 'post_channels_create', + 'get_channels_list_joined', + 'get_rooms_get', + ]); + expect(tools[0]?.inputSchema).toEqual({ + type: 'object', + description: 'Post by room id', + properties: { roomId: { type: 'string' }, text: { type: 'string' } }, + required: ['roomId'], + }); + expect(tools[4]?.inputSchema).toMatchObject({ additionalProperties: { type: 'string' } }); + }); + + it('only exposes allow-listed routes in the extended catalog', () => { + const tools = getExtendedTools(); + + expect(tools.map(({ name }) => name)).toEqual( + expect.arrayContaining([ + 'post_chat_postMessage_by_roomId', + 'post_chat_postMessage_by_channel', + 'post_channels_create', + 'get_channels_list_joined', + 'get_rooms_get', + 'get_rooms_isMember_by_roomId_userId', + 'get_rooms_isMember_by_roomId_username', + ]), + ); + expect(tools.some(({ name }) => name.includes('users_delete'))).toBe(false); + expect(tools.some(({ name }) => name.includes('users_register'))).toBe(false); + }); + + it('keeps curated tool names stable when the extended catalog is enabled', () => { + const extendedNames = new Set(getExtendedTools().map(({ name }) => name)); + + expect(getCuratedTools().every(({ name }) => extendedNames.has(name))).toBe(true); + }); + + it('generates unique valid names when variant discriminators exceed the MCP limit', () => { + const tools = getExtendedTools(); + const names = tools.map(({ name }) => name); + const dmFileNames = tools.filter(({ path }) => path === '/api/v1/dm.files').map(({ name }) => name); + + expect(dmFileNames).toHaveLength(2); + expect(new Set(names).size).toBe(names.length); + expect(names.every((name) => name.length <= 64 && /^[a-zA-Z0-9_-]+$/.test(name))).toBe(true); + }); + + it('preserves parent properties when variants only declare required fields', () => { + const tools = getExtendedTools().filter(({ name }) => name.startsWith('get_rooms_isMember')); + + for (const { inputSchema } of tools) { + const properties = inputSchema.properties as Record; + for (const requiredProperty of inputSchema.required as string[]) { + expect(properties).toHaveProperty(requiredProperty); + } + } + }); + + it('preserves shared required fields without changing variant discriminators', () => { + const tools = getExtendedTools().filter(({ name }) => name.startsWith('get_teams_listRoomsOfUser')); + + expect(tools.map(({ name }) => name)).toEqual(['get_teams_listRoomsOfUser_by_teamId', 'get_teams_listRoomsOfUser_by_teamName']); + for (const { inputSchema } of tools) { + expect(inputSchema.required).toEqual(expect.arrayContaining(['userId'])); + } + }); + + it('reuses the generated catalogs between requests', () => { + expect(getCuratedTools()).toBe(getCuratedTools()); + expect(getExtendedTools()).toBe(getExtendedTools()); + }); +}); diff --git a/apps/meteor/ee/server/api/mcp/catalog.ts b/apps/meteor/ee/server/api/mcp/catalog.ts new file mode 100644 index 0000000000000..f6b61c4a7d42a --- /dev/null +++ b/apps/meteor/ee/server/api/mcp/catalog.ts @@ -0,0 +1,420 @@ +import { createHash } from 'node:crypto'; + +import { API } from '../../../../server/api'; + +export type McpMethod = 'get' | 'post' | 'put' | 'delete'; + +export type McpTool = { + /** MCP tool name exposed to the client (must match ^[a-zA-Z0-9_-]{1,64}$). */ + name: string; + description: string; + inputSchema: Record; + /** Internal: the REST route this tool maps to. */ + path: string; + method: McpMethod; +}; + +const CURATED: { path: string; method: McpMethod; fallbackDescription?: string }[] = [ + { path: '/api/v1/chat.postMessage', method: 'post' }, + { path: '/api/v1/chat.getMessage', method: 'get' }, + { + path: '/api/v1/channels.create', + method: 'post', + fallbackDescription: 'Create a public channel. Requires `name`; optional `members` (array of usernames).', + }, + { + path: '/api/v1/channels.list.joined', + method: 'get', + fallbackDescription: 'List the public channels the authenticated user has joined.', + }, + { + path: '/api/v1/rooms.get', + method: 'get', + fallbackDescription: 'List rooms the authenticated user has access to (optionally updated since a timestamp).', + }, + { path: '/api/v1/users.info', method: 'get' }, +]; + +/** + * Allow-list for the *extended* toolset, expressed as the base tool name per route — i.e. + * the `toolNameFor(path, method)` value, WITHOUT any `_by_` variant suffix. + * Matched at the route level, so a single entry (e.g. `get_users_info`) exposes every + * variant of that route (`get_users_info_by_userId`, `_by_username`, …). + * + * The extended set is the full catalog filtered by these names — the entire API is never + * exposed. + */ +const ALLOWED_TOOL_NAMES = new Set([ + // chat — reading + 'get_chat_getDiscussions', + 'get_chat_getMentionedMessages', + 'get_chat_getMessage', + 'get_chat_getPinnedMessages', + 'get_chat_getStarredMessages', + 'get_chat_getThreadMessages', + 'get_chat_getThreadsList', + 'get_chat_search', + 'get_chat_syncMessages', + 'get_chat_syncThreadMessages', + 'get_chat_syncThreadsList', + // custom user status + 'get_custom_user_status_list', + // direct messages — reading + 'get_dm_files', + 'get_dm_history', + 'get_dm_list', + 'get_dm_list_everyone', + 'get_dm_members', + 'get_dm_messages', + 'get_dm_messages_others', + // me + 'get_me', + // rooms — reading + 'get_rooms_autocomplete_availableForTeams', + 'get_rooms_autocomplete_channelAndPrivate', + 'get_rooms_get', + 'get_rooms_getDiscussions', + 'get_rooms_info', + 'get_rooms_isMember', + 'get_rooms_membersOrderedByRole', + 'get_rooms_nameExists', + // search + 'get_spotlight', + // subscriptions — reading + 'get_subscriptions_get', + 'get_subscriptions_getOne', + // teams — reading + 'get_teams_autocomplete', + 'get_teams_info', + 'get_teams_list', + 'get_teams_listAll', + 'get_teams_listChildren', + 'get_teams_listRooms', + 'get_teams_listRoomsOfUser', + 'get_teams_members', + // users — reading + 'get_users_autocomplete', + 'get_users_checkUsernameAvailability', + 'get_users_getPreferences', + 'get_users_getPresence', + 'get_users_getStatus', + 'get_users_info', + 'get_users_listTeams', + // chat — writing + // 'post_chat_delete', + 'post_chat_followMessage', + 'post_chat_pinMessage', + 'post_chat_postMessage', + 'post_chat_react', + 'post_chat_reportMessage', + 'post_chat_sendMessage', + 'post_chat_starMessage', + 'post_chat_unfollowMessage', + 'post_chat_unPinMessage', + 'post_chat_unStarMessage', + 'post_chat_update', + // custom user status — writing + 'post_custom_user_status_create', + // 'post_custom_user_status_delete', + 'post_custom_user_status_update', + // direct messages — writing + 'post_dm_close', + 'post_dm_create', + // 'post_dm_delete', + 'post_dm_open', + 'post_dm_setTopic', + 'post_im_blockUser', + // rooms — writing + 'post_rooms_banUser', + 'post_rooms_changeArchivationState', + 'post_rooms_createDiscussion', + // 'post_rooms_delete', + 'post_rooms_favorite', + 'post_rooms_hide', + 'post_rooms_invite', + 'post_rooms_join', + 'post_rooms_leave', + 'post_rooms_muteUser', + 'post_rooms_open', + 'post_rooms_saveRoomSettings', + 'post_rooms_unbanUser', + 'post_rooms_unmuteUser', + // subscriptions — writing + 'post_subscriptions_read', + 'post_subscriptions_unread', + // teams — writing + 'post_teams_addMembers', + 'post_teams_addRooms', + 'post_teams_convertToChannel', + 'post_teams_create', + // 'post_teams_delete', + 'post_teams_leave', + 'post_teams_removeMember', + 'post_teams_removeRoom', + 'post_teams_update', + 'post_teams_updateMember', + 'post_teams_updateRoom', + // uploads + // 'post_uploads_delete', + // users — writing + 'post_users_create', + // `users.register` rejects authenticated callers, while every MCP call is authenticated. + 'post_users_setStatus', + 'post_users_update', + 'post_users_updateOwnBasicInfo', +]); + +const FALLBACK_SCHEMA: Record = { type: 'object', additionalProperties: true }; + +const COMBINATOR_KEYS = ['oneOf', 'anyOf', 'allOf'] as const; + +/** + * Transform a route's JSON Schema into one accepted by MCP clients / the Anthropic tools + * API, which require a plain object schema and reject combinator keywords: + * - strip OpenAPI-only `nullable` (not valid JSON Schema 2020-12) and `not`, + * - resolve `oneOf`/`anyOf`/`allOf` by adopting the FIRST branch. For a schema built as + * `{ oneOf: [subSchemaA, subSchemaB] }` this means the MCP tool uses one named + * sub-schema (e.g. post-by-channel); for a bare value union like `string | string[]` + * it keeps a concrete type. The REST layer still enforces the full rule on dispatch, + * so this only shapes the advertised tool schema — the validator is untouched. + */ +const mcpSafeSchema = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(mcpSafeSchema); + } + if (!value || typeof value !== 'object') { + return value; + } + + let node: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + if (key === 'nullable' || key === 'not') { + continue; + } + node[key] = mcpSafeSchema(val); + } + + const combinator = COMBINATOR_KEYS.find((key) => Array.isArray(node[key]) && (node[key] as unknown[]).length > 0); + if (combinator) { + const firstBranch = mcpSafeSchema((node[combinator] as unknown[])[0]) as Record; + for (const key of COMBINATOR_KEYS) { + delete node[key]; + } + // The first branch supplies the structure (type/properties/required); this node's + // own keys (e.g. a top-level `description`) win on top. + node = { ...firstBranch, ...node }; + } + + return node; +}; + +const ensureObjectSchema = (schema: unknown): Record => { + if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { + return FALLBACK_SCHEMA; + } + const obj = mcpSafeSchema(schema) as Record; + return obj.type === 'object' ? obj : { ...obj, type: 'object' }; +}; + +/** Pull the raw request JSON-Schema for a route (the main schema, before flattening). */ +const rawSchemaForRoute = (path: string, method: McpMethod): unknown => { + const route = API.api.typedRoutes?.[path]?.[method]; + if (!route) { + return undefined; + } + return method === 'post' || method === 'put' ? route.requestBody?.content?.['application/json']?.schema : route.parameters?.[0]?.schema; +}; + +const VARIANT_KEYS = ['oneOf', 'anyOf'] as const; +const MAX_TOOL_NAME_LENGTH = 64; + +const mergeSchemaVariant = ( + root: Record, + variantKey: (typeof VARIANT_KEYS)[number], + variant: unknown, +): Record => { + const { [variantKey]: _variants, ...baseSchema } = root; + if (!variant || typeof variant !== 'object' || Array.isArray(variant)) { + return ensureObjectSchema(baseSchema); + } + + const branch = variant as Record; + const baseProperties = + baseSchema.properties && typeof baseSchema.properties === 'object' && !Array.isArray(baseSchema.properties) + ? (baseSchema.properties as Record) + : undefined; + const branchProperties = + branch.properties && typeof branch.properties === 'object' && !Array.isArray(branch.properties) + ? (branch.properties as Record) + : undefined; + const baseRequired = Array.isArray(baseSchema.required) ? baseSchema.required : []; + const branchRequired = Array.isArray(branch.required) ? branch.required : []; + const required = [...new Set([...baseRequired, ...branchRequired])]; + + return ensureObjectSchema({ + ...baseSchema, + ...branch, + ...((baseProperties || branchProperties) && { properties: { ...baseProperties, ...branchProperties } }), + ...(required.length > 0 && { required }), + }); +}; + +/** A branch's discriminator = its `required` keys (e.g. `channel`, `roomId`, `userId`). */ +const discriminatorOf = (schema: Record): string | undefined => { + const { required } = schema; + if (Array.isArray(required) && required.length > 0 && required.every((r) => typeof r === 'string')) { + return required.join('_'); + } + return undefined; +}; + +type SchemaVariant = { discriminator?: string; description?: string; schema: Record }; + +/** + * Split a route's request schema into MCP-ready variants. When the schema is a + * `oneOf`/`anyOf` of object sub-schemas with distinct discriminators (e.g. + * post-by-channel vs post-by-roomId), each becomes its own variant; otherwise a single + * flattened object schema is returned. + */ +const variantsForRoute = (path: string, method: McpMethod): SchemaVariant[] => { + const raw = rawSchemaForRoute(path, method); + + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + const root = raw as Record; + const key = VARIANT_KEYS.find((k) => Array.isArray(root[k]) && (root[k] as unknown[]).length > 1); + if (key) { + const mainDescription = typeof root.description === 'string' ? root.description : undefined; + const rawBranches = root[key] as unknown[]; + const branches = rawBranches.map((branch) => mergeSchemaVariant(root, key, branch)); + const discriminators = rawBranches.map((branch) => discriminatorOf(ensureObjectSchema(branch))); + const allDistinct = discriminators.every(Boolean) && new Set(discriminators).size === discriminators.length; + if (allDistinct) { + return branches.map((schema, i) => ({ + discriminator: discriminators[i], + description: typeof schema.description === 'string' ? schema.description : mainDescription, + schema, + })); + } + } + } + + const schema = ensureObjectSchema(raw); + return [{ description: typeof schema.description === 'string' ? schema.description : undefined, schema }]; +}; + +const fitToolName = (baseName: string, discriminator?: string): string => { + const suffix = discriminator ? `_by_${discriminator.replace(/[^a-zA-Z0-9_-]+/g, '_')}` : ''; + const fullName = `${baseName}${suffix}`; + if (fullName.length <= MAX_TOOL_NAME_LENGTH) { + return fullName; + } + + const minimumBaseLength = Math.min(baseName.length, 16); + if (suffix.length <= MAX_TOOL_NAME_LENGTH - minimumBaseLength) { + return `${baseName.slice(0, MAX_TOOL_NAME_LENGTH - suffix.length)}${suffix}`; + } + + const hash = createHash('sha256').update(fullName).digest('hex').slice(0, 8); + return `${fullName.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`; +}; + +const ensureUniqueToolNames = (tools: McpTool[]): McpTool[] => { + const usedNames = new Set(); + + return tools.map((tool, index) => { + if (!usedNames.has(tool.name)) { + usedNames.add(tool.name); + return tool; + } + + let attempt = 0; + let candidate: string; + do { + const hash = createHash('sha256') + .update(`${tool.method}:${tool.path}:${tool.name}:${JSON.stringify(tool.inputSchema)}:${index}:${attempt}`) + .digest('hex') + .slice(0, 8); + candidate = `${tool.name.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`; + attempt += 1; + } while (usedNames.has(candidate)); + + usedNames.add(candidate); + return { ...tool, name: candidate }; + }); +}; + +const toolsForRoute = (baseName: string, path: string, method: McpMethod, fallbackDescription?: string): McpTool[] => + variantsForRoute(path, method).map((variant) => ({ + name: fitToolName(baseName, variant.discriminator), + description: variant.description ?? fallbackDescription ?? `${method.toUpperCase()} ${path}`, + path, + method, + inputSchema: variant.schema, + })); + +const toolNameFor = (path: string, method: McpMethod): string => { + const slug = path.replace(/^\/api\/v\d+\//, '').replace(/[^a-zA-Z0-9]+/g, '_'); + return `${method}_${slug}`; +}; + +/** + * Walk the documented routes (excluding `Missing Documentation`, mirroring the OpenAPI + * filter) and emit one or more tools per route, keeping only routes whose base tool name + * passes `isRouteAllowed`. The filter is applied to the base name, so all `_by_` variants + * of an allowed route are included together. + */ +const collectTools = (isRouteAllowed: (baseName: string) => boolean): McpTool[] => { + const tools: McpTool[] = []; + + for (const [path, methods] of Object.entries(API.api.typedRoutes ?? {})) { + for (const [method, route] of Object.entries(methods)) { + if (route?.tags?.includes('Missing Documentation')) { + continue; + } + if (!['get', 'post', 'put', 'delete'].includes(method)) { + continue; + } + const baseName = toolNameFor(path, method as McpMethod); + if (!isRouteAllowed(baseName)) { + continue; + } + const fallback = route?.tags?.length + ? `${method.toUpperCase()} ${path} (${route.tags.join(', ')})` + : `${method.toUpperCase()} ${path}`; + tools.push(...toolsForRoute(baseName, path, method as McpMethod, fallback)); + } + } + + return ensureUniqueToolNames(tools); +}; + +let curatedTools: McpTool[] | undefined; + +export const getCuratedTools = (): McpTool[] => { + curatedTools ??= ensureUniqueToolNames( + CURATED.filter(({ path, method }) => Boolean(API.api.typedRoutes?.[path]?.[method])).flatMap(({ path, method, fallbackDescription }) => + toolsForRoute(toolNameFor(path, method), path, method, fallbackDescription), + ), + ); + + return curatedTools; +}; + +/** + * The extended toolset — the full catalog filtered by {@link ALLOWED_TOOL_NAMES}. The + * entire API is never exposed; routes outside the allow-list (and `Missing Documentation` + * routes) are excluded. + */ +let extendedTools: McpTool[] | undefined; + +export const getExtendedTools = (): McpTool[] => { + if (!extendedTools) { + const toolsByName = new Map(getCuratedTools().map((tool) => [tool.name, tool])); + for (const tool of collectTools((baseName) => ALLOWED_TOOL_NAMES.has(baseName))) { + toolsByName.set(tool.name, tool); + } + extendedTools = [...toolsByName.values()]; + } + + return extendedTools; +}; diff --git a/apps/meteor/ee/server/api/mcp/dispatch.spec.ts b/apps/meteor/ee/server/api/mcp/dispatch.spec.ts new file mode 100644 index 0000000000000..0e31b8d98b3bb --- /dev/null +++ b/apps/meteor/ee/server/api/mcp/dispatch.spec.ts @@ -0,0 +1,156 @@ +import { createMcpResponseBudget, dispatchTool } from './dispatch'; +import type { McpAuth } from './server'; + +const auth: McpAuth = { + userId: 'user-id', + authToken: 'auth-token', +}; + +describe('MCP tool dispatch', () => { + const originalPort = process.env.PORT; + const runtimeGlobal = globalThis as typeof globalThis & { __meteor_runtime_config__?: { ROOT_URL_PATH_PREFIX?: string } }; + const originalRuntimeConfig = runtimeGlobal.__meteor_runtime_config__; + + afterEach(() => { + jest.restoreAllMocks(); + if (originalPort === undefined) { + delete process.env.PORT; + } else { + process.env.PORT = originalPort; + } + if (originalRuntimeConfig === undefined) { + delete runtimeGlobal.__meteor_runtime_config__; + } else { + runtimeGlobal.__meteor_runtime_config__ = originalRuntimeConfig; + } + }); + + it('forwards GET arguments and caller identity to the local REST API', async () => { + process.env.PORT = '3100'; + runtimeGlobal.__meteor_runtime_config__ = { ROOT_URL_PATH_PREFIX: '/chat' }; + const fetchMock = jest + .spyOn(global, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({ message: { _id: 'message-id' } }), { status: 200 })); + + const result = await dispatchTool( + { + name: 'chat_getMessage', + description: 'Get a message', + inputSchema: { type: 'object' }, + path: '/api/v1/chat.getMessage', + method: 'get', + }, + { msgId: 'message-id', fields: { msg: 1 }, optional: undefined }, + auth, + '192.0.2.1', + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://127.0.0.1:3100/chat/api/v1/chat.getMessage?msgId=message-id&fields=%7B%22msg%22%3A1%7D', + expect.objectContaining({ + method: 'GET', + redirect: 'error', + headers: { + 'Content-Type': 'application/json', + 'X-User-Id': 'user-id', + 'X-Auth-Token': 'auth-token', + 'X-Real-IP': '192.0.2.1', + }, + signal: expect.any(AbortSignal), + }), + ); + expect(result).toEqual({ ok: true, status: 200, body: { message: { _id: 'message-id' } } }); + }); + + it('preserves non-JSON error responses', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue(new Response('Service unavailable', { status: 503 })); + + await expect( + dispatchTool( + { + name: 'chat_postMessage', + description: 'Post a message', + inputSchema: { type: 'object' }, + path: '/api/v1/chat.postMessage', + method: 'post', + }, + { roomId: 'room-id', text: 'Hello' }, + auth, + ), + ).resolves.toEqual({ ok: false, status: 503, body: 'Service unavailable' }); + }); + + it('handles empty REST responses', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 204 })); + + await expect( + dispatchTool( + { + name: 'subscriptions_read', + description: 'Mark a subscription as read', + inputSchema: { type: 'object' }, + path: '/api/v1/subscriptions.read', + method: 'post', + }, + { rid: 'room-id' }, + auth, + ), + ).resolves.toEqual({ ok: true, status: 204, body: '' }); + }); + + it('rejects responses whose content length exceeds the MCP result size limit', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue( + new Response(null, { + status: 200, + headers: { 'content-length': String(5 * 1024 * 1024 + 1) }, + }), + ); + + await expect( + dispatchTool( + { + name: 'rooms_get', + description: 'Get rooms', + inputSchema: { type: 'object' }, + path: '/api/v1/rooms.get', + method: 'get', + }, + {}, + auth, + ), + ).rejects.toThrow('MCP tool response exceeds the 5 MiB limit'); + }); + + it('stops streaming responses that exceed the MCP result size limit', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue(new Response(new Uint8Array(5 * 1024 * 1024 + 1), { status: 200 })); + + await expect( + dispatchTool( + { + name: 'rooms_get', + description: 'Get rooms', + inputSchema: { type: 'object' }, + path: '/api/v1/rooms.get', + method: 'get', + }, + {}, + auth, + ), + ).rejects.toThrow('MCP tool response exceeds the 5 MiB limit'); + }); + + it('shares the response size budget across batched tool calls', async () => { + jest.spyOn(global, 'fetch').mockImplementation(async () => new Response('abc', { status: 200 })); + const responseBudget = createMcpResponseBudget(5); + const tool = { + name: 'rooms_get', + description: 'Get rooms', + inputSchema: { type: 'object' }, + path: '/api/v1/rooms.get', + method: 'get' as const, + }; + + await expect(dispatchTool(tool, {}, auth, undefined, responseBudget)).resolves.toMatchObject({ body: 'abc' }); + await expect(dispatchTool(tool, {}, auth, undefined, responseBudget)).rejects.toThrow('MCP batch response exceeds the 5 bytes limit'); + }); +}); diff --git a/apps/meteor/ee/server/api/mcp/dispatch.ts b/apps/meteor/ee/server/api/mcp/dispatch.ts new file mode 100644 index 0000000000000..032654599a799 --- /dev/null +++ b/apps/meteor/ee/server/api/mcp/dispatch.ts @@ -0,0 +1,147 @@ +import type { McpTool } from './catalog'; +import type { McpAuth } from './server'; + +export type DispatchResult = { + ok: boolean; + status: number; + body: unknown; +}; + +export type McpResponseBudget = { + consume: (bytes: number) => void; +}; + +const TOOL_CALL_TIMEOUT_MS = 20_000; +const MAX_TOOL_RESPONSE_BYTES = 5 * 1024 * 1024; +const BYTES_PER_MEBIBYTE = 1024 * 1024; + +const formatByteLimit = (bytes: number): string => + bytes % BYTES_PER_MEBIBYTE === 0 ? `${bytes / BYTES_PER_MEBIBYTE} MiB` : `${bytes} bytes`; + +export const createMcpResponseBudget = (maxBytes = MAX_TOOL_RESPONSE_BYTES): McpResponseBudget => { + let remainingBytes = maxBytes; + const formattedLimit = formatByteLimit(maxBytes); + + return { + consume(bytes) { + if (bytes > remainingBytes) { + throw new Error(`MCP batch response exceeds the ${formattedLimit} limit`); + } + + remainingBytes -= bytes; + }, + }; +}; + +const readResponseText = async (response: Response, responseBudget?: McpResponseBudget): Promise => { + const contentLength = Number(response.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > MAX_TOOL_RESPONSE_BYTES) { + throw new Error('MCP tool response exceeds the 5 MiB limit'); + } + + if (!response.body) { + return ''; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const chunks: string[] = []; + let receivedBytes = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + receivedBytes += value.byteLength; + if (receivedBytes > MAX_TOOL_RESPONSE_BYTES) { + await reader.cancel(); + throw new Error('MCP tool response exceeds the 5 MiB limit'); + } + + try { + responseBudget?.consume(value.byteLength); + } catch (error) { + await reader.cancel(); + throw error; + } + chunks.push(decoder.decode(value, { stream: true })); + } + + chunks.push(decoder.decode()); + return chunks.join(''); +}; + +/** + * Execute the REST endpoint a tool maps to, as the authenticated user. + * + * Dispatch is done via a loopback HTTP call to the local REST API, forwarding the + * caller's PAT headers. This guarantees identical behaviour to a real REST client — + * the same auth, permission checks, parameter validation and response shape — with + * zero duplicated business logic. (An in-process Hono dispatch is a possible future + * optimisation to avoid the loopback hop.) + * + * `clientIp` (resolved server-side from the MCP connection) is forwarded as `X-Real-IP` + * so the REST per-route rate limiter keys on the real client rather than the loopback + * address — otherwise every MCP caller would share a single `127.0.0.1` bucket. + */ +export const dispatchTool = async ( + tool: McpTool, + args: Record, + auth: McpAuth, + clientIp?: string, + responseBudget?: McpResponseBudget, +): Promise => { + const port = process.env.PORT || '3000'; + const runtimeConfig = ( + globalThis as typeof globalThis & { + __meteor_runtime_config__?: { ROOT_URL_PATH_PREFIX?: string }; + } + ).__meteor_runtime_config__; + const base = `http://127.0.0.1:${port}${runtimeConfig?.ROOT_URL_PATH_PREFIX ?? ''}`; + + const headers: Record = { + 'Content-Type': 'application/json', + 'X-User-Id': auth.userId, + 'X-Auth-Token': auth.authToken, + ...(clientIp && { 'X-Real-IP': clientIp }), + }; + + let url = base + tool.path; + const init: RequestInit = { + method: tool.method.toUpperCase(), + headers, + redirect: 'error', + signal: AbortSignal.timeout(TOOL_CALL_TIMEOUT_MS), + }; + + if (tool.method === 'get' || tool.method === 'delete') { + const qs = new URLSearchParams(); + for (const [key, value] of Object.entries(args ?? {})) { + if (value === undefined) { + continue; + } + qs.append(key, typeof value === 'string' ? value : JSON.stringify(value)); + } + const query = qs.toString(); + if (query) { + url += `?${query}`; + } + } else { + init.body = JSON.stringify(args ?? {}); + } + + const res = await fetch(url, init); + const responseText = await readResponseText(res, responseBudget); + let body: unknown = responseText; + if (responseText) { + try { + body = JSON.parse(responseText); + } catch { + // Keep non-JSON REST responses as text so callers receive the actual result. + } + } + + return { ok: res.ok, status: res.status, body }; +}; diff --git a/apps/meteor/ee/server/api/mcp/index.spec.ts b/apps/meteor/ee/server/api/mcp/index.spec.ts new file mode 100644 index 0000000000000..ab2e01fd404af --- /dev/null +++ b/apps/meteor/ee/server/api/mcp/index.spec.ts @@ -0,0 +1,377 @@ +import { AI_LICENSE_MODULE } from '@rocket.chat/ai-search'; +import { Users } from '@rocket.chat/models'; +import { Accounts } from 'meteor/accounts-base'; + +import { handleMcpGet, handleMcpPost } from './index'; +import { handleRpcMessage } from './server'; +import { API } from '../../../../server/api'; +import { authenticationMiddlewareForHono } from '../../../../server/api/v1/middlewares/authenticationHono'; +import { permissionsMiddleware } from '../../../../server/api/v1/middlewares/permissions'; +import { settings } from '../../../../server/settings/cached'; +import { license } from '../v1/middlewares/license'; + +jest.mock('meteor/accounts-base', () => ({ Accounts: { _hashLoginToken: jest.fn((token: string) => `hashed-${token}`) } }), { + virtual: true, +}); + +jest.mock('@rocket.chat/models', () => ({ + Users: { findPersonalAccessTokenByHashedTokenAndUserId: jest.fn() }, +})); + +jest.mock('./server', () => ({ + handleRpcMessage: jest.fn(), + isJsonRpcRequest: jest.fn( + (value: unknown) => typeof value === 'object' && value !== null && 'jsonrpc' in value && value.jsonrpc === '2.0' && 'method' in value, + ), +})); + +jest.mock('../../../../server/api', () => ({ + API: { + v1: { + registerRateLimiterForRoute: jest.fn(), + enforceRateLimitForRoute: jest.fn(), + router: { + getHonoRouter: jest.fn(() => ({ use: jest.fn(), post: jest.fn(), get: jest.fn() })), + }, + }, + }, +})); + +jest.mock('../../../../server/api/v1/middlewares/authenticationHono', () => ({ + authenticationMiddlewareForHono: jest.fn(() => jest.fn()), +})); + +jest.mock('../../../../server/api/v1/middlewares/permissions', () => ({ + permissionsMiddleware: jest.fn(() => jest.fn()), +})); + +jest.mock('../v1/middlewares/license', () => ({ + license: jest.fn(() => jest.fn()), +})); + +jest.mock('../../../../server/settings/cached', () => ({ + settings: { get: jest.fn() }, +})); + +const mockRouter = jest.mocked(API.v1.router.getHonoRouter).mock.results[0]?.value; + +const context = { + bodyParams: { jsonrpc: '2.0', id: 1, method: 'ping' }, + userId: 'user-id', + token: 'hashed-auth-token', + requestIp: '192.0.2.1', + request: new Request('http://localhost/api/v1/mcp', { headers: { 'x-auth-token': 'auth-token' } }), +}; + +describe('MCP HTTP route', () => { + beforeEach(() => { + jest.mocked(settings.get).mockReturnValue(true); + jest.mocked(handleRpcMessage).mockReset(); + jest.mocked(API.v1.enforceRateLimitForRoute).mockReset().mockResolvedValue(undefined); + jest + .mocked(Users.findPersonalAccessTokenByHashedTokenAndUserId) + .mockReset() + .mockResolvedValue({ _id: 'user-id' } as never); + }); + + it('applies the API rate limiter before handling MCP requests', async () => { + const middleware = mockRouter.use.mock.calls[0]?.[2]; + expect(middleware).toBeDefined(); + if (!middleware) { + throw new Error('MCP rate-limit middleware was not registered'); + } + + const request = new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { 'x-user-id': 'user-id' }, + }); + const response = new Response(); + const next = jest.fn().mockResolvedValue(undefined); + await middleware( + { + req: { method: 'POST', raw: request, header: (name: string) => request.headers.get(name) ?? undefined }, + res: response, + get: () => '192.0.2.1', + }, + next, + ); + + expect(API.v1.enforceRateLimitForRoute).toHaveBeenCalledWith({ + route: 'mcp', + method: 'post', + request, + response, + requestIp: '192.0.2.1', + userId: 'user-id', + }); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('returns a JSON-RPC error with rate-limit headers when the limit is exceeded', async () => { + const middleware = mockRouter.use.mock.calls[0]?.[2]; + expect(middleware).toBeDefined(); + if (!middleware) { + throw new Error('MCP rate-limit middleware was not registered'); + } + + jest.mocked(API.v1.enforceRateLimitForRoute).mockImplementationOnce(async ({ response }: { response: Response }) => { + response.headers.set('X-RateLimit-Remaining', '0'); + throw Object.assign(new Error('Please slow down'), { error: 'error-too-many-requests', reason: 'Please slow down' }); + }); + const request = new Request('http://localhost/api/v1/mcp', { method: 'POST', headers: { 'x-user-id': 'user-id' } }); + const result = await middleware( + { + req: { method: 'POST', raw: request, header: (name: string) => request.headers.get(name) ?? undefined }, + res: new Response(), + get: () => '192.0.2.1', + }, + jest.fn(), + ); + + expect(result).toBeInstanceOf(Response); + expect(result?.status).toBe(429); + expect(result?.headers.get('X-RateLimit-Remaining')).toBe('0'); + await expect(result?.json()).resolves.toMatchObject({ error: { message: 'Please slow down' } }); + }); + + it('registers the endpoint with authentication, permission, and license gates', () => { + expect(API.v1.router.getHonoRouter).toHaveBeenCalledTimes(1); + expect(authenticationMiddlewareForHono).toHaveBeenCalledWith(API.v1, expect.objectContaining({ authRequired: true })); + expect(API.v1.registerRateLimiterForRoute).toHaveBeenCalledWith({ + route: 'mcp', + rateLimiterOptions: { numRequestsAllowed: 60, intervalTimeInMS: 60_000 }, + methods: ['post'], + }); + expect(permissionsMiddleware).toHaveBeenCalledWith( + expect.objectContaining({ + permissionsRequired: { '*': { permissions: ['access-mcp'], operation: 'hasAll' } }, + }), + ); + expect(license).toHaveBeenCalledWith(expect.objectContaining({ license: [AI_LICENSE_MODULE] }), expect.anything()); + expect(mockRouter.use).toHaveBeenCalledWith( + '/mcp', + expect.any(Function), + expect.any(Function), + expect.any(Function), + expect.any(Function), + ); + expect(mockRouter.post).toHaveBeenCalledWith('/mcp', expect.any(Function)); + expect(mockRouter.get).toHaveBeenCalledWith('/mcp', expect.any(Function)); + }); + + it('adapts Hono POST requests to exact JSON-RPC responses', async () => { + const response = { jsonrpc: '2.0' as const, id: 7, result: {} }; + jest.mocked(handleRpcMessage).mockResolvedValue(response); + const request = new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-auth-token': 'auth-token', 'x-user-id': 'user-id' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'ping' }), + }); + const handler = mockRouter.post.mock.calls[0]?.[1]; + expect(handler).toBeDefined(); + if (!handler) { + throw new Error('MCP POST handler was not registered'); + } + + const result = await handler({ + req: { raw: request }, + get: (key: string) => (key === 'user' ? { _id: 'user-id' } : '192.0.2.1'), + }); + + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(200); + await expect(result.json()).resolves.toEqual(response); + expect(Accounts._hashLoginToken).toHaveBeenCalledWith('auth-token'); + expect(Users.findPersonalAccessTokenByHashedTokenAndUserId).toHaveBeenCalledWith({ + userId: 'user-id', + hashedToken: 'hashed-auth-token', + }); + }); + + it('returns a JSON-RPC parse error for malformed JSON after validating the personal access token', async () => { + const handler = mockRouter.post.mock.calls[0]?.[1]; + expect(handler).toBeDefined(); + if (!handler) { + throw new Error('MCP POST handler was not registered'); + } + + const result = await handler({ + req: { + raw: new Request('http://localhost/api/v1/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-auth-token': 'auth-token', 'x-user-id': 'user-id' }, + body: '{', + }), + }, + get: (key: string) => (key === 'user' ? { _id: 'user-id' } : '192.0.2.1'), + }); + + expect(result.status).toBe(400); + await expect(result.json()).resolves.toMatchObject({ id: null, error: { code: -32700, message: 'Parse error' } }); + expect(Users.findPersonalAccessTokenByHashedTokenAndUserId).toHaveBeenCalled(); + expect(handleRpcMessage).not.toHaveBeenCalled(); + }); + + it('returns not found while MCP is disabled', async () => { + jest.mocked(settings.get).mockReturnValue(false); + + await expect(handleMcpPost(context)).resolves.toMatchObject({ statusCode: 404 }); + expect(handleMcpGet(context)).toMatchObject({ statusCode: 404 }); + }); + + it('rejects empty and oversized JSON-RPC batches', async () => { + await expect(handleMcpPost({ ...context, bodyParams: [] })).resolves.toMatchObject({ + statusCode: 400, + body: { error: { code: -32600 } }, + }); + await expect(handleMcpPost({ ...context, bodyParams: Array.from({ length: 21 }, () => context.bodyParams) })).resolves.toMatchObject({ + statusCode: 400, + body: { error: { code: -32600 } }, + }); + expect(handleRpcMessage).not.toHaveBeenCalled(); + }); + + it('rejects JSON-RPC batches for protocol revisions that require one message per POST', async () => { + await expect( + handleMcpPost({ + ...context, + bodyParams: [context.bodyParams], + request: new Request('http://localhost/api/v1/mcp', { + headers: { 'mcp-protocol-version': '2025-11-25', 'x-auth-token': 'auth-token' }, + }), + }), + ).resolves.toMatchObject({ statusCode: 400, body: { error: { code: -32600 } } }); + expect(handleRpcMessage).not.toHaveBeenCalled(); + }); + + it('rejects a JSON-RPC response because this server does not issue client requests', async () => { + const response = { jsonrpc: '2.0', id: 1, result: {} }; + + await expect(handleMcpPost({ ...context, bodyParams: response })).resolves.toMatchObject({ + statusCode: 400, + body: { error: { code: -32600 } }, + }); + expect(handleRpcMessage).not.toHaveBeenCalled(); + }); + + it('processes valid and malformed entries independently in legacy JSON-RPC batches', async () => { + const validResponse = { jsonrpc: '2.0' as const, id: 1, result: {} }; + const invalidResponse = { jsonrpc: '2.0' as const, id: null, error: { code: -32600, message: 'Invalid Request' } }; + const malformedEntry = { jsonrpc: '2.0', id: 2, result: {} }; + jest.mocked(handleRpcMessage).mockResolvedValueOnce(validResponse).mockResolvedValueOnce(invalidResponse); + + await expect(handleMcpPost({ ...context, bodyParams: [context.bodyParams, malformedEntry] })).resolves.toEqual({ + statusCode: 200, + body: [validResponse, invalidResponse], + }); + expect(handleRpcMessage).toHaveBeenNthCalledWith(1, context.bodyParams, expect.anything(), context.requestIp, expect.anything()); + expect(handleRpcMessage).toHaveBeenNthCalledWith(2, malformedEntry, expect.anything(), context.requestIp, expect.anything()); + }); + + it('acknowledges notifications without a response body', async () => { + jest.mocked(handleRpcMessage).mockResolvedValue(null); + + await expect(handleMcpPost(context)).resolves.toEqual({ statusCode: 202, body: undefined }); + await expect(handleMcpPost({ ...context, bodyParams: [context.bodyParams] })).resolves.toEqual({ + statusCode: 202, + body: undefined, + }); + }); + + it('omits notification entries from batch responses', async () => { + const response = { jsonrpc: '2.0' as const, id: 1, result: {} }; + jest.mocked(handleRpcMessage).mockResolvedValueOnce(response).mockResolvedValueOnce(null); + + await expect(handleMcpPost({ ...context, bodyParams: [context.bodyParams, context.bodyParams] })).resolves.toEqual({ + statusCode: 200, + body: [response], + }); + const [firstCall, secondCall] = jest.mocked(handleRpcMessage).mock.calls; + expect(firstCall?.[3]).toBeDefined(); + expect(firstCall?.[3]).toBe(secondCall?.[3]); + }); + + it('limits concurrent calls while preserving batch response order', async () => { + let inFlight = 0; + let maxInFlight = 0; + jest.mocked(handleRpcMessage).mockImplementation(async (message) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await Promise.resolve(); + inFlight -= 1; + if (typeof message !== 'object' || message === null || !('id' in message) || typeof message.id !== 'number') { + throw new Error('Expected a numbered JSON-RPC request'); + } + return { jsonrpc: '2.0', id: message.id, result: {} }; + }); + const bodyParams = Array.from({ length: 20 }, (_, id) => ({ jsonrpc: '2.0', id, method: 'ping' })); + + const response = await handleMcpPost({ ...context, bodyParams }); + + expect(maxInFlight).toBe(4); + expect(response.body).toEqual(bodyParams.map(({ id }) => ({ jsonrpc: '2.0', id, result: {} }))); + }); + + it('returns method not allowed for GET requests', () => { + expect(handleMcpGet(context)).toMatchObject({ statusCode: 405, headers: { Allow: 'POST' } }); + expect(Users.findPersonalAccessTokenByHashedTokenAndUserId).not.toHaveBeenCalled(); + }); + + it('rejects authenticated sessions that are not personal access tokens', async () => { + jest.mocked(Users.findPersonalAccessTokenByHashedTokenAndUserId).mockResolvedValue(null); + + await expect(handleMcpPost(context)).resolves.toMatchObject({ + statusCode: 401, + body: { error: { message: 'Personal Access Token required' } }, + }); + expect(handleRpcMessage).not.toHaveBeenCalled(); + }); + + it('rejects final encoded responses that exceed the MCP response limit', async () => { + jest.mocked(handleRpcMessage).mockResolvedValue({ jsonrpc: '2.0', id: 1, result: 'x'.repeat(5 * 1024 * 1024) }); + + await expect(handleMcpPost(context)).resolves.toMatchObject({ + statusCode: 413, + body: { error: { message: 'MCP response exceeds the 5 MiB limit' } }, + }); + }); + + it('rejects browser requests from untrusted origins', async () => { + jest.mocked(settings.get).mockImplementation((setting) => { + if (setting === 'MCP_Enabled') { + return true; + } + if (setting === 'Site_Url') { + return 'https://chat.example.com'; + } + return false; + }); + + await expect( + handleMcpPost({ + ...context, + request: new Request('https://chat.example.com/api/v1/mcp', { + headers: { 'origin': 'https://attacker.example', 'x-auth-token': 'auth-token' }, + }), + }), + ).resolves.toMatchObject({ statusCode: 403 }); + expect(handleRpcMessage).not.toHaveBeenCalled(); + expect( + handleMcpGet({ + request: new Request('https://chat.example.com/api/v1/mcp', { headers: { origin: 'https://attacker.example' } }), + }), + ).toMatchObject({ statusCode: 403 }); + }); + + it('rejects unsupported protocol-version headers', async () => { + await expect( + handleMcpPost({ + ...context, + request: new Request('http://localhost/api/v1/mcp', { + headers: { 'mcp-protocol-version': '2099-01-01', 'x-auth-token': 'auth-token' }, + }), + }), + ).resolves.toMatchObject({ statusCode: 400 }); + expect(handleRpcMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/meteor/ee/server/api/mcp/index.ts b/apps/meteor/ee/server/api/mcp/index.ts new file mode 100644 index 0000000000000..0fe2c2543354e --- /dev/null +++ b/apps/meteor/ee/server/api/mcp/index.ts @@ -0,0 +1,256 @@ +import { AI_LICENSE_MODULE } from '@rocket.chat/ai-search'; +import { License } from '@rocket.chat/license'; +import { Logger } from '@rocket.chat/logger'; +import { Users } from '@rocket.chat/models'; +import type { MiddlewareHandler } from 'hono'; +import type { StatusCode } from 'hono/utils/http-status'; +import { Accounts } from 'meteor/accounts-base'; + +import { createMcpResponseBudget } from './dispatch'; +import { handleRpcMessage, isJsonRpcRequest, type JsonRpcResponse, type McpAuth } from './server'; +import { isMcpOriginAllowed, isMcpProtocolVersionSupported, supportsMcpBatching } from './transport'; +import { API } from '../../../../server/api'; +import type { TypedOptions } from '../../../../server/api/definition'; +import { authenticationMiddlewareForHono } from '../../../../server/api/v1/middlewares/authenticationHono'; +import { permissionsMiddleware } from '../../../../server/api/v1/middlewares/permissions'; +import { settings } from '../../../../server/settings/cached'; +import { license } from '../v1/middlewares/license'; + +const logger = new Logger('MCP'); + +type McpHttpResponse = { + statusCode: StatusCode; + body: JsonRpcResponse | JsonRpcResponse[] | undefined; + headers?: Record; +}; + +const disabledResponse: McpHttpResponse = { + statusCode: 404, + body: { jsonrpc: '2.0', id: null, error: { code: -32601, message: 'MCP endpoint is disabled' } }, +}; + +type McpActionContext = { + bodyParams: unknown; + bodyParseError?: boolean; + userId: string; + token: string; + requestIp: string; + request: Request; +}; + +const MAX_BATCH_SIZE = 20; +const MAX_BATCH_CONCURRENCY = 4; +const MAX_MCP_RESPONSE_BYTES = 5 * 1024 * 1024; +const MCP_ROUTE = 'mcp'; +const MCP_RATE_LIMIT_OPTIONS = { numRequestsAllowed: 60, intervalTimeInMS: 60_000 }; + +const personalAccessTokenRequiredResponse: McpHttpResponse = { + statusCode: 401, + body: { jsonrpc: '2.0', id: null, error: { code: -32001, message: 'Personal Access Token required' } }, +}; + +const invalidRequestResponse: McpHttpResponse = { + statusCode: 400, + body: { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } }, +}; + +const parseErrorResponse: McpHttpResponse = { + statusCode: 400, + body: { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, +}; + +const hasPersonalAccessToken = async ({ userId, token }: Pick): Promise => + Boolean(await Users.findPersonalAccessTokenByHashedTokenAndUserId({ userId, hashedToken: token })); + +const jsonResponse = (body: JsonRpcResponse | JsonRpcResponse[]): McpHttpResponse => { + if (Buffer.byteLength(JSON.stringify(body), 'utf8') > MAX_MCP_RESPONSE_BYTES) { + return { + statusCode: 413, + body: { jsonrpc: '2.0', id: null, error: { code: -32000, message: 'MCP response exceeds the 5 MiB limit' } }, + }; + } + + return { statusCode: 200, body }; +}; + +const validateTransportRequest = (request: Request): McpHttpResponse | undefined => { + if (!isMcpOriginAllowed(request.headers.get('origin'))) { + return { + statusCode: 403, + body: { jsonrpc: '2.0', id: null, error: { code: -32000, message: 'Origin is not allowed' } }, + }; + } + if (!isMcpProtocolVersionSupported(request.headers.get('mcp-protocol-version'))) { + return { + statusCode: 400, + body: { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Unsupported MCP protocol version' } }, + }; + } + return undefined; +}; + +const handleBatch = async (messages: unknown[], auth: McpAuth, clientIp: string): Promise<(JsonRpcResponse | null)[]> => { + const responseBudget = createMcpResponseBudget(MAX_MCP_RESPONSE_BYTES); + const responses = new Array(messages.length); + let nextIndex = 0; + + const processNext = async (): Promise => { + while (nextIndex < messages.length) { + const index = nextIndex++; + responses[index] = await handleRpcMessage(messages[index], auth, clientIp, responseBudget); + } + }; + + await Promise.all(Array.from({ length: Math.min(messages.length, MAX_BATCH_CONCURRENCY) }, () => processNext())); + return responses; +}; + +export const handleMcpPost = async (context: McpActionContext): Promise => { + if (!settings.get('MCP_Enabled')) { + return disabledResponse; + } + + const transportError = validateTransportRequest(context.request); + if (transportError) { + return transportError; + } + if (!(await hasPersonalAccessToken(context))) { + return personalAccessTokenRequiredResponse; + } + if (context.bodyParseError) { + return parseErrorResponse; + } + + const message = context.bodyParams; + const auth: McpAuth = { + userId: context.userId, + authToken: String(context.request.headers.get('x-auth-token') ?? ''), + }; + const clientIp = context.requestIp; + + if (Array.isArray(message)) { + if (!supportsMcpBatching(context.request.headers.get('mcp-protocol-version'))) { + return invalidRequestResponse; + } + + if (message.length === 0 || message.length > MAX_BATCH_SIZE) { + return invalidRequestResponse; + } + + const responses = (await handleBatch(message, auth, clientIp)).filter((response): response is JsonRpcResponse => response !== null); + return responses.length ? jsonResponse(responses) : { statusCode: 202, body: undefined }; + } + + if (!isJsonRpcRequest(message)) { + return invalidRequestResponse; + } + + const response = await handleRpcMessage(message, auth, clientIp); + if (!response) { + return { statusCode: 202, body: undefined }; + } + + return jsonResponse(response); +}; + +export const handleMcpGet = (context: Pick): McpHttpResponse => { + if (!settings.get('MCP_Enabled')) { + return disabledResponse; + } + + const transportError = validateTransportRequest(context.request); + if (transportError) { + return transportError; + } + return { + statusCode: 405, + headers: { Allow: 'POST' }, + body: { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Only POST is supported' } }, + }; +}; + +const routeOptions = { + response: {}, + authRequired: true, + permissionsRequired: { + '*': { permissions: ['access-mcp'], operation: 'hasAll' }, + }, + license: [AI_LICENSE_MODULE], +} satisfies TypedOptions; + +const sendResponse = (response: McpHttpResponse): Response => { + const headers = { 'Content-Type': 'application/json', ...response.headers }; + return new Response(response.body === undefined ? null : JSON.stringify(response.body), { status: response.statusCode, headers }); +}; + +const isRateLimitError = (error: unknown): error is { error: 'error-too-many-requests'; reason?: string } => + typeof error === 'object' && error !== null && 'error' in error && error.error === 'error-too-many-requests'; + +const rateLimitMiddleware: MiddlewareHandler = async (c, next) => { + try { + await API.v1.enforceRateLimitForRoute({ + route: MCP_ROUTE, + method: c.req.method.toLowerCase(), + request: c.req.raw, + response: c.res, + requestIp: c.get('remoteAddress'), + userId: c.req.header('x-user-id'), + }); + } catch (error) { + if (!isRateLimitError(error)) { + throw error; + } + + return sendResponse({ + statusCode: 429, + body: { jsonrpc: '2.0', id: null, error: { code: -32000, message: error.reason ?? 'Too many requests' } }, + headers: Object.fromEntries([...c.res.headers].filter(([name]) => name.toLowerCase().startsWith('x-ratelimit-'))), + }); + } + + const rateLimitHeaders = [...c.res.headers].filter(([name]) => name.toLowerCase().startsWith('x-ratelimit-')); + await next(); + for (const [name, value] of rateLimitHeaders) { + c.res.headers.set(name, value); + } +}; + +const router = API.v1.router.getHonoRouter(); +API.v1.registerRateLimiterForRoute({ route: MCP_ROUTE, rateLimiterOptions: MCP_RATE_LIMIT_OPTIONS, methods: ['post'] }); +router.use( + '/mcp', + authenticationMiddlewareForHono(API.v1, { authRequired: true, logger }), + rateLimitMiddleware, + permissionsMiddleware(routeOptions), + license(routeOptions, License), +); +router.post('/mcp', async (c) => { + const request = c.req.raw; + const rawToken = request.headers.get('x-auth-token') ?? ''; + const userId = request.headers.get('x-user-id'); + + if (!userId) { + return sendResponse(personalAccessTokenRequiredResponse); + } + + let bodyParams: unknown; + let bodyParseError = false; + + try { + bodyParams = await request.clone().json(); + } catch { + bodyParseError = true; + } + + return sendResponse( + await handleMcpPost({ + bodyParams, + bodyParseError, + userId, + token: Accounts._hashLoginToken(rawToken) ?? '', + requestIp: c.get('remoteAddress'), + request, + }), + ); +}); +router.get('/mcp', (c) => sendResponse(handleMcpGet({ request: c.req.raw }))); diff --git a/apps/meteor/ee/server/api/mcp/server.spec.ts b/apps/meteor/ee/server/api/mcp/server.spec.ts new file mode 100644 index 0000000000000..4a2d2a4df0705 --- /dev/null +++ b/apps/meteor/ee/server/api/mcp/server.spec.ts @@ -0,0 +1,174 @@ +import { getCuratedTools, getExtendedTools } from './catalog'; +import { dispatchTool } from './dispatch'; +import { handleRpcMessage, isJsonRpcRequest, type McpAuth } from './server'; +import { settings } from '../../../../server/settings/cached'; + +jest.mock('./catalog', () => ({ + getCuratedTools: jest.fn(), + getExtendedTools: jest.fn(), +})); + +jest.mock('./dispatch', () => ({ + dispatchTool: jest.fn(), +})); + +jest.mock('../../../../server/api/lib/getTrimmedServerVersion', () => ({ + getTrimmedServerVersion: () => '9.0', +})); + +jest.mock('../../../../server/settings/cached', () => ({ + settings: { get: jest.fn() }, +})); + +const auth: McpAuth = { + userId: 'user-id', + authToken: 'auth-token', +}; + +const tool = { + name: 'get_chat_getMessage', + description: 'Get a message', + inputSchema: { type: 'object' }, + path: '/api/v1/chat.getMessage', + method: 'get' as const, +}; + +const extendedTool = { + ...tool, + name: 'get_chat_search', + description: 'Search messages', + path: '/api/v1/chat.search', +}; + +const initializeParams = { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' }, +}; + +describe('MCP JSON-RPC server', () => { + beforeEach(() => { + jest.mocked(settings.get).mockReturnValue(false); + jest.mocked(getCuratedTools).mockReset().mockReturnValue([tool]); + jest.mocked(getExtendedTools).mockReset().mockReturnValue([]); + jest.mocked(dispatchTool).mockReset(); + }); + + it('rejects malformed JSON-RPC messages without throwing', async () => { + expect(isJsonRpcRequest({ jsonrpc: '2.0', method: 'ping' })).toBe(true); + expect(isJsonRpcRequest({ jsonrpc: '1.0', method: 'ping' })).toBe(false); + expect(isJsonRpcRequest({ jsonrpc: '2.0', id: null, method: 'ping' })).toBe(false); + + await expect(handleRpcMessage(null, auth)).resolves.toEqual({ + jsonrpc: '2.0', + id: null, + error: { code: -32600, message: 'Invalid Request' }, + }); + }); + + it('returns server capabilities during initialization', async () => { + await expect(handleRpcMessage({ jsonrpc: '2.0', id: 1, method: 'initialize', params: initializeParams }, auth)).resolves.toEqual({ + jsonrpc: '2.0', + id: 1, + result: { + protocolVersion: '2025-06-18', + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'rocketchat', version: '9.0' }, + }, + }); + }); + + it('falls back to the latest supported version when negotiation fails', async () => { + await expect( + handleRpcMessage( + { + jsonrpc: '2.0', + id: 2, + method: 'initialize', + params: { ...initializeParams, protocolVersion: 'unsupported' }, + }, + auth, + ), + ).resolves.toMatchObject({ result: { protocolVersion: '2025-11-25' } }); + }); + + it('rejects initialize requests without the required client information', async () => { + await expect( + handleRpcMessage({ jsonrpc: '2.0', id: 2, method: 'initialize', params: { protocolVersion: '2025-11-25' } }, auth), + ).resolves.toMatchObject({ error: { code: -32602, message: 'Invalid initialize parameters' } }); + }); + + it('lists the curated toolset by default', async () => { + const response = await handleRpcMessage({ jsonrpc: '2.0', id: 3, method: 'tools/list' }, auth); + + expect(getCuratedTools).toHaveBeenCalledTimes(1); + expect(getExtendedTools).not.toHaveBeenCalled(); + expect(response).toMatchObject({ result: { tools: [{ name: tool.name }] } }); + }); + + it('lists the extended toolset when enabled', async () => { + jest.mocked(settings.get).mockReturnValue(true); + jest.mocked(getExtendedTools).mockReturnValue([extendedTool]); + + const response = await handleRpcMessage({ jsonrpc: '2.0', id: 3, method: 'tools/list' }, auth); + + expect(getExtendedTools).toHaveBeenCalledTimes(1); + expect(getCuratedTools).not.toHaveBeenCalled(); + expect(response).toMatchObject({ result: { tools: [{ name: extendedTool.name }] } }); + }); + + it('validates tool call parameters before dispatch', async () => { + await expect( + handleRpcMessage({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: tool.name, arguments: [] } }, auth), + ).resolves.toMatchObject({ error: { code: -32602 } }); + expect(dispatchTool).not.toHaveBeenCalled(); + }); + + it('dispatches a known tool as the authenticated user', async () => { + jest.mocked(dispatchTool).mockResolvedValue({ ok: true, status: 200, body: { message: { _id: 'message-id' } } }); + + const response = await handleRpcMessage( + { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: tool.name, arguments: { msgId: 'message-id' } } }, + auth, + '192.0.2.1', + ); + + expect(dispatchTool).toHaveBeenCalledWith(tool, { msgId: 'message-id' }, auth, '192.0.2.1', undefined); + expect(response).toEqual({ + jsonrpc: '2.0', + id: 4, + result: { + content: [{ type: 'text', text: JSON.stringify({ message: { _id: 'message-id' } }) }], + isError: false, + }, + }); + }); + + it('marks unsuccessful REST responses as tool errors', async () => { + jest.mocked(dispatchTool).mockResolvedValue({ ok: false, status: 403, body: { error: 'Forbidden' } }); + + await expect( + handleRpcMessage({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: tool.name, arguments: {} } }, auth), + ).resolves.toMatchObject({ result: { isError: true, content: [{ text: JSON.stringify({ error: 'Forbidden' }) }] } }); + }); + + it('returns thrown dispatch failures as tool errors', async () => { + jest.mocked(dispatchTool).mockRejectedValue(new Error('Connection failed')); + + await expect( + handleRpcMessage({ jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: tool.name, arguments: {} } }, auth), + ).resolves.toMatchObject({ result: { isError: true, content: [{ text: 'Tool execution failed: Connection failed' }] } }); + }); + + it('rejects unknown tools without dispatching', async () => { + await expect( + handleRpcMessage({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'unknown', arguments: {} } }, auth), + ).resolves.toMatchObject({ error: { code: -32602, message: 'Unknown tool: unknown' } }); + expect(dispatchTool).not.toHaveBeenCalled(); + }); + + it('does not reply to notifications', async () => { + await expect(handleRpcMessage({ jsonrpc: '2.0', method: 'notifications/initialized' }, auth)).resolves.toBeNull(); + await expect(handleRpcMessage({ jsonrpc: '2.0', method: 'unknown/notification' }, auth)).resolves.toBeNull(); + }); +}); diff --git a/apps/meteor/ee/server/api/mcp/server.ts b/apps/meteor/ee/server/api/mcp/server.ts new file mode 100644 index 0000000000000..723641f420935 --- /dev/null +++ b/apps/meteor/ee/server/api/mcp/server.ts @@ -0,0 +1,150 @@ +import { getCuratedTools, getExtendedTools, type McpTool } from './catalog'; +import { dispatchTool, type McpResponseBudget } from './dispatch'; +import { SUPPORTED_PROTOCOL_VERSIONS } from './transport'; +import { getTrimmedServerVersion } from '../../../../server/api/lib/getTrimmedServerVersion'; +import { settings } from '../../../../server/settings/cached'; + +export type McpAuth = { + userId: string; + /** The raw (unhashed) Personal Access Token, forwarded to the REST layer on dispatch. */ + authToken: string; +}; + +const DEFAULT_PROTOCOL_VERSION = '2025-11-25'; + +const negotiateProtocolVersion = (requestedVersion: unknown): string => + typeof requestedVersion === 'string' && SUPPORTED_PROTOCOL_VERSIONS.has(requestedVersion) ? requestedVersion : DEFAULT_PROTOCOL_VERSION; + +export type JsonRpcRequest = { + jsonrpc: '2.0'; + id?: string | number; + method: string; + params?: Record; +}; + +export type JsonRpcResponse = { + jsonrpc: '2.0'; + id: string | number | null; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +}; + +const result = (id: JsonRpcRequest['id'], value: unknown): JsonRpcResponse => ({ jsonrpc: '2.0', id: id ?? null, result: value }); + +const error = (id: JsonRpcResponse['id'] | undefined, code: number, message: string, data?: unknown): JsonRpcResponse => ({ + jsonrpc: '2.0', + id: id ?? null, + error: { code, message, ...(data !== undefined && { data }) }, +}); + +const isRecord = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value); + +const isInitializeParams = (params: unknown): params is Record => { + if (!isRecord(params) || typeof params.protocolVersion !== 'string' || !isRecord(params.capabilities) || !isRecord(params.clientInfo)) { + return false; + } + + return typeof params.clientInfo.name === 'string' && typeof params.clientInfo.version === 'string'; +}; + +export const isJsonRpcRequest = (value: unknown): value is JsonRpcRequest => { + if (!isRecord(value) || value.jsonrpc !== '2.0' || typeof value.method !== 'string' || value.method.length === 0) { + return false; + } + + if (value.id !== undefined && typeof value.id !== 'string' && typeof value.id !== 'number') { + return false; + } + + return value.params === undefined || isRecord(value.params); +}; + +const listTools = (): McpTool[] => { + if (settings.get('MCP_Expose_Extended_API')) { + return getExtendedTools(); + } + return getCuratedTools(); +}; + +const toToolDefinition = ({ name, description, inputSchema }: McpTool) => ({ name, description, inputSchema }); + +/** + * Handle a single JSON-RPC message. Returns the response object, or `null` for + * notifications (which must not produce a response per the JSON-RPC spec). + */ +export const handleRpcMessage = async ( + message: unknown, + auth: McpAuth, + clientIp?: string, + responseBudget?: McpResponseBudget, +): Promise => { + if (!isJsonRpcRequest(message)) { + return error(null, -32600, 'Invalid Request'); + } + + const { id, method, params } = message; + const respond = (response: JsonRpcResponse): JsonRpcResponse | null => (id === undefined ? null : response); + + switch (method) { + case 'initialize': { + if (!isInitializeParams(params)) { + return respond(error(id, -32602, 'Invalid initialize parameters')); + } + + return respond( + result(id, { + protocolVersion: negotiateProtocolVersion(params.protocolVersion), + capabilities: { tools: { listChanged: false } }, + serverInfo: { + name: 'rocketchat', + version: getTrimmedServerVersion(), + }, + }), + ); + } + + case 'notifications/initialized': + case 'notifications/cancelled': + return null; + + case 'ping': + return respond(result(id, {})); + + case 'tools/list': + return respond(result(id, { tools: listTools().map(toToolDefinition) })); + + case 'tools/call': { + const name = params?.name; + const args = params?.arguments; + if (typeof name !== 'string' || (args !== undefined && !isRecord(args))) { + return respond(error(id, -32602, 'Invalid tools/call parameters')); + } + + const tool = listTools().find((t) => t.name === name); + + if (!tool) { + return respond(error(id, -32602, `Unknown tool: ${name}`)); + } + + try { + const dispatch = await dispatchTool(tool, args ?? {}, auth, clientIp, responseBudget); + return respond( + result(id, { + content: [{ type: 'text', text: JSON.stringify(dispatch.body) }], + isError: !dispatch.ok, + }), + ); + } catch (err) { + return respond( + result(id, { + content: [{ type: 'text', text: `Tool execution failed: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }), + ); + } + } + + default: + return respond(error(id, -32601, `Method not found: ${method}`)); + } +}; diff --git a/apps/meteor/ee/server/api/mcp/transport.spec.ts b/apps/meteor/ee/server/api/mcp/transport.spec.ts new file mode 100644 index 0000000000000..88213ae06b7e6 --- /dev/null +++ b/apps/meteor/ee/server/api/mcp/transport.spec.ts @@ -0,0 +1,63 @@ +import { isMcpOriginAllowed, isMcpProtocolVersionSupported, supportsMcpBatching } from './transport'; +import { settings } from '../../../../server/settings/cached'; + +jest.mock('../../../../server/settings/cached', () => ({ + settings: { get: jest.fn() }, +})); + +describe('MCP HTTP transport validation', () => { + beforeEach(() => { + jest.mocked(settings.get).mockImplementation((setting) => { + switch (setting) { + case 'Site_Url': + return 'https://chat.example.com/'; + case 'API_Enable_CORS': + return true; + case 'API_CORS_Origin': + return 'https://client.example, https://other.example/path'; + default: + return undefined; + } + }); + }); + + it('allows non-browser, same-origin, and explicitly configured browser requests', () => { + expect(isMcpOriginAllowed(null)).toBe(true); + expect(isMcpOriginAllowed('https://chat.example.com')).toBe(true); + expect(isMcpOriginAllowed('https://client.example')).toBe(true); + expect(isMcpOriginAllowed('https://other.example')).toBe(true); + }); + + it('rejects malformed, untrusted, and wildcard-only browser origins', () => { + expect(isMcpOriginAllowed('not-an-origin')).toBe(false); + expect(isMcpOriginAllowed('https://attacker.example')).toBe(false); + + jest.mocked(settings.get).mockImplementation((setting) => { + if (setting === 'Site_Url') { + return 'https://chat.example.com'; + } + if (setting === 'API_Enable_CORS') { + return true; + } + if (setting === 'API_CORS_Origin') { + return '*'; + } + return undefined; + }); + + expect(isMcpOriginAllowed('https://attacker.example')).toBe(false); + }); + + it('accepts an absent or supported protocol version only', () => { + expect(isMcpProtocolVersionSupported(null)).toBe(true); + expect(isMcpProtocolVersionSupported('2025-11-25')).toBe(true); + expect(isMcpProtocolVersionSupported('2099-01-01')).toBe(false); + }); + + it('only accepts JSON-RPC batches for the 2025-03-26 transport revision', () => { + expect(supportsMcpBatching(null)).toBe(true); + expect(supportsMcpBatching('2025-03-26')).toBe(true); + expect(supportsMcpBatching('2025-06-18')).toBe(false); + expect(supportsMcpBatching('2025-11-25')).toBe(false); + }); +}); diff --git a/apps/meteor/ee/server/api/mcp/transport.ts b/apps/meteor/ee/server/api/mcp/transport.ts new file mode 100644 index 0000000000000..9131d6d08b907 --- /dev/null +++ b/apps/meteor/ee/server/api/mcp/transport.ts @@ -0,0 +1,52 @@ +import { settings } from '../../../../server/settings/cached'; + +export const SUPPORTED_PROTOCOL_VERSIONS = new Set(['2025-11-25', '2025-06-18', '2025-03-26']); +export const DEFAULT_PROTOCOL_VERSION = '2025-03-26'; + +const normalizeOrigin = (value: unknown): string | undefined => { + if (typeof value !== 'string' || value.length === 0) { + return undefined; + } + + try { + return new URL(value).origin; + } catch { + return undefined; + } +}; + +/** + * MCP clients running outside a browser do not send Origin. Browser requests must come + * from the workspace itself or an explicitly configured CORS origin. A wildcard CORS + * setting is intentionally not accepted because it cannot protect against DNS rebinding. + */ +export const isMcpOriginAllowed = (origin: string | null): boolean => { + if (origin === null) { + return true; + } + + const normalizedOrigin = normalizeOrigin(origin); + if (!normalizedOrigin) { + return false; + } + + if (normalizedOrigin === normalizeOrigin(settings.get('Site_Url'))) { + return true; + } + + if (!settings.get('API_Enable_CORS')) { + return false; + } + + const configuredOrigins = settings.get('API_CORS_Origin') ?? ''; + if (configuredOrigins === '*') { + return false; + } + + return configuredOrigins.split(',').some((configuredOrigin) => normalizeOrigin(configuredOrigin.trim()) === normalizedOrigin); +}; + +export const isMcpProtocolVersionSupported = (version: string | null): boolean => + version === null || SUPPORTED_PROTOCOL_VERSIONS.has(version); + +export const supportsMcpBatching = (version: string | null): boolean => (version ?? DEFAULT_PROTOCOL_VERSION) === '2025-03-26'; diff --git a/apps/meteor/ee/server/startup/index.ts b/apps/meteor/ee/server/startup/index.ts index c34d8a16fcc71..a0fdaf22fedba 100644 --- a/apps/meteor/ee/server/startup/index.ts +++ b/apps/meteor/ee/server/startup/index.ts @@ -6,6 +6,7 @@ import './maxRoomsPerGuest'; import './upsell'; import './services'; import './readReceiptsArchive'; +import './mcp'; import { api } from '@rocket.chat/core-services'; import { isRunningMs } from '../../../server/lib/isRunningMs'; diff --git a/apps/meteor/ee/server/startup/mcp.ts b/apps/meteor/ee/server/startup/mcp.ts new file mode 100644 index 0000000000000..a4bb287421bb9 --- /dev/null +++ b/apps/meteor/ee/server/startup/mcp.ts @@ -0,0 +1,7 @@ +import { AI_LICENSE_MODULE } from '@rocket.chat/ai-search'; +import { License } from '@rocket.chat/license'; +import { Permissions } from '@rocket.chat/models'; + +await License.onLicense(AI_LICENSE_MODULE, async () => { + await Permissions.create('access-mcp', ['admin']); +}); diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index 23cf27a6152e4..6eca9ca6c4a26 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -38,6 +38,7 @@ export default { '/server/lib/omnichannel/business-hour/**/*.spec.ts?(x)', '/ee/server/lib/authorization/validateUserRoles.spec.ts', '/ee/server/lib/license/**/*.spec.ts', + '/ee/server/api/mcp/**/*.spec.ts', '/ee/server/patches/**/*.spec.ts', '/ee/server/cron/**/*.spec.ts', '/server/lib/cloud/supportedVersionsToken/**.spec.ts', diff --git a/apps/meteor/server/api/ApiClass.ts b/apps/meteor/server/api/ApiClass.ts index 783a9439a905a..453fd94f3568c 100644 --- a/apps/meteor/server/api/ApiClass.ts +++ b/apps/meteor/server/api/ApiClass.ts @@ -456,6 +456,40 @@ export class APIClass { + return this.enforceRateLimit({ IPAddr: requestIp, route: this.getFullRouteName(route, method) }, request, response, userId); + } + public reloadRoutesToRefreshRateLimiter(): void { this._routes.forEach((route) => { if (this.shouldAddRateLimitToRoute(route.options)) { diff --git a/apps/meteor/server/api/v1/middlewares/cors.spec.ts b/apps/meteor/server/api/v1/middlewares/cors.spec.ts index 1eb2f4ed0169c..68e101417b35d 100644 --- a/apps/meteor/server/api/v1/middlewares/cors.spec.ts +++ b/apps/meteor/server/api/v1/middlewares/cors.spec.ts @@ -113,6 +113,10 @@ describe('Cors middleware', () => { _id: 'API_CORS_Origin', value: '*', } as any); + settings.set({ + _id: 'MCP_Enabled', + value: true, + } as any); api.use(cors(settings)).get( '/test', @@ -138,7 +142,11 @@ describe('Cors middleware', () => { app.use(api.router); - const response = await request(app).options('/api/test').set('origin', 'http://localhost'); + const response = await request(app) + .options('/api/test') + .set('origin', 'http://localhost') + .set('access-control-request-method', 'POST') + .set('access-control-request-headers', 'Content-Type, MCP-Protocol-Version'); expect(response.statusCode).toBe(200); expect(response.body).not.toHaveProperty('message', 'CORS test successful'); @@ -146,7 +154,7 @@ describe('Cors middleware', () => { expect(response.headers).toHaveProperty('access-control-allow-methods', 'GET, POST, PUT, DELETE, HEAD, PATCH'); expect(response.headers).toHaveProperty( 'access-control-allow-headers', - 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization', + 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization, MCP-Protocol-Version', ); }); @@ -164,6 +172,10 @@ describe('Cors middleware', () => { _id: 'API_CORS_Origin', value: 'http://localhost', } as any); + settings.set({ + _id: 'MCP_Enabled', + value: true, + } as any); api.use(cors(settings)).get( '/test', @@ -189,7 +201,11 @@ describe('Cors middleware', () => { app.use(api.router); - const response = await request(app).options('/api/test').set('origin', 'http://localhost'); + const response = await request(app) + .options('/api/test') + .set('origin', 'http://localhost') + .set('access-control-request-method', 'POST') + .set('access-control-request-headers', 'Content-Type, MCP-Protocol-Version'); expect(response.statusCode).toBe(200); expect(response.body).not.toHaveProperty('message', 'CORS test successful'); @@ -197,10 +213,31 @@ describe('Cors middleware', () => { expect(response.headers).toHaveProperty('access-control-allow-methods', 'GET, POST, PUT, DELETE, HEAD, PATCH'); expect(response.headers).toHaveProperty( 'access-control-allow-headers', - 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization', + 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization, MCP-Protocol-Version', ); }); + it('should not advertise the MCP protocol header while MCP is disabled', async () => { + const ajv = new Ajv(); + const app = express(); + const api = new Router('/api'); + const settings = new CachedSettings(); + + settings.set({ _id: 'API_Enable_CORS', value: true } as any); + settings.set({ _id: 'API_CORS_Origin', value: '*' } as any); + settings.set({ _id: 'MCP_Enabled', value: false } as any); + + api + .use(cors(settings)) + .get('/test', { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({ statusCode: 200, body: {} })); + app.use(api.router); + + const response = await request(app).options('/api/test').set('origin', 'http://localhost').set('access-control-request-method', 'POST'); + + expect(response.statusCode).toBe(200); + expect(response.headers['access-control-allow-headers']).not.toContain('MCP-Protocol-Version'); + }); + it('should not handle CORS if origin is not allowed', async () => { const ajv = new Ajv(); const app = express(); diff --git a/apps/meteor/server/api/v1/middlewares/cors.ts b/apps/meteor/server/api/v1/middlewares/cors.ts index c5b3c6b453377..1772a4dd9d694 100644 --- a/apps/meteor/server/api/v1/middlewares/cors.ts +++ b/apps/meteor/server/api/v1/middlewares/cors.ts @@ -7,13 +7,17 @@ const defaultHeaders = { 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, X-User-Id, X-Auth-Token, x-visitor-token, Authorization', }; +const getAllowedHeaders = (settings: CachedSettings): string => + `${defaultHeaders['Access-Control-Allow-Headers']}${settings.get('MCP_Enabled') ? ', MCP-Protocol-Version' : ''}`; + export const cors = (settings: CachedSettings): MiddlewareHandler => async (c, next) => { const { req, res } = c; + const allowedHeaders = getAllowedHeaders(settings); if (req.method !== 'OPTIONS') { res.headers.set('Access-Control-Allow-Origin', '*'); - res.headers.set('Access-Control-Allow-Headers', defaultHeaders['Access-Control-Allow-Headers']); + res.headers.set('Access-Control-Allow-Headers', allowedHeaders); await next(); return; @@ -34,7 +38,7 @@ export const cors = if (CORSOriginSetting === '*') { res.headers.set('Access-Control-Allow-Origin', '*'); res.headers.set('Access-Control-Allow-Methods', defaultHeaders['Access-Control-Allow-Methods']); - res.headers.set('Access-Control-Allow-Headers', defaultHeaders['Access-Control-Allow-Headers']); + res.headers.set('Access-Control-Allow-Headers', allowedHeaders); await next(); return; } @@ -53,6 +57,6 @@ export const cors = res.headers.set('Vary', 'Origin'); res.headers.set('Access-Control-Allow-Origin', originHeader); res.headers.set('Access-Control-Allow-Methods', defaultHeaders['Access-Control-Allow-Methods']); - res.headers.set('Access-Control-Allow-Headers', defaultHeaders['Access-Control-Allow-Headers']); + res.headers.set('Access-Control-Allow-Headers', allowedHeaders); await next(); }; diff --git a/apps/meteor/server/settings/ai.ts b/apps/meteor/server/settings/ai.ts index 165801aac2c99..90470bcdf30fb 100644 --- a/apps/meteor/server/settings/ai.ts +++ b/apps/meteor/server/settings/ai.ts @@ -165,4 +165,31 @@ export const createAISettings = async (): Promise => { i18nDescription: 'AI_Intelligent_Search_Answer_System_Prompt_Description', }, ); + + await settingsRegistry.add('MCP_Enabled', false, { + group: AI_SETTINGS_GROUP, + section: 'MCP', + type: 'boolean', + public: false, + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: false, + alert: 'MCP_Alpha_Alert', + i18nLabel: 'MCP_Enabled', + i18nDescription: 'MCP_Enabled_Description', + }); + + await settingsRegistry.add('MCP_Expose_Extended_API', false, { + group: AI_SETTINGS_GROUP, + section: 'MCP', + type: 'boolean', + public: false, + enterprise: true, + modules: [AI_LICENSE_MODULE], + invalidValue: false, + enableQuery: { _id: 'MCP_Enabled', value: true }, + alert: 'MCP_Extended_API_Alert', + i18nLabel: 'MCP_Expose_Extended_API', + i18nDescription: 'MCP_Expose_Extended_API_Description', + }); }; diff --git a/docs/features/mcp-server.md b/docs/features/mcp-server.md new file mode 100644 index 0000000000000..efcc721bd3970 --- /dev/null +++ b/docs/features/mcp-server.md @@ -0,0 +1,150 @@ +# MCP Server (Model Context Protocol) + +> **Status: Alpha.** Off by default. Its capabilities and configuration may evolve as we gather feedback. + +## Overview + +The MCP server exposes the Rocket.Chat REST API to [Model Context Protocol](https://modelcontextprotocol.io) clients (Claude Desktop/Code, IDE agents, custom agents). It speaks **JSON-RPC 2.0 over Streamable HTTP** and turns existing REST endpoints into MCP **tools**, so an AI client can drive a workspace (post messages, search, manage rooms, look up users, …) using the user's own credentials and permissions. + +It is **enterprise (protected) code** under `apps/meteor/ee/`. The design goal is _native + minimum changes_: it mounts on the existing Hono API router and reuses its authentication, permissions, license checks, remote-address resolution, logging, metrics, tracing, CORS, and generated typed-route metadata. Tool execution goes through the REST API, including each target endpoint's validation, authorization, and rate limiting. The endpoint adds MCP-specific Origin validation for DNS-rebinding protection. There is **no new runtime dependency** — the JSON-RPC layer is implemented directly. + +## Endpoint + +| Method | Endpoint | Behavior | +| ------ | ------------- | ------------------------------------------------------------------------------------------------------------- | +| POST | `/api/v1/mcp` | JSON-RPC 2.0 message. The `2025-03-26` revision also accepts batch arrays. Response is `application/json`. | +| GET | `/api/v1/mcp` | `405` with `Allow: POST` — this transport does not offer a server-initiated SSE stream. | + +The endpoint is attached directly to the existing `/api/v1` Hono router because its JSON-RPC envelopes are defined by the MCP specification rather than Rocket.Chat's REST response contract. It still uses the standard authentication, `access-mcp` permission, and AI license middleware. When `MCP_Enabled` is off the action returns `404`. + +## Request lifecycle + +The MCP handshake is the standard JSON-RPC flow: + +1. **`initialize`** — client and server exchange protocol version + capabilities. The server negotiates the handshake-based Streamable HTTP revisions it supports (`2025-03-26`, `2025-06-18`, and `2025-11-25`) and advertises `{ tools: { listChanged: false } }`. The transport is stateless and does not issue a session id. +2. **`tools/list`** — returns the available tools (name, description, `inputSchema`). +3. **`tools/call`** — runs a tool by name with arguments and returns its result as `content`. + +Also handled: `ping`, and the `notifications/initialized` / `notifications/cancelled` notifications (acknowledged with `202`, no body). + +After initialization, clients should send the negotiated version in the `MCP-Protocol-Version` header. Requests with an unsupported version are rejected with `400`; when the header is omitted, the transport follows the specification's `2025-03-26` compatibility default. JSON-RPC batching is accepted only for that revision because later Streamable HTTP revisions require one message per POST. +The shared CORS middleware advertises this request header only while MCP is enabled. + +## Authentication + +Authentication is **reused from the REST layer** — no MCP-specific credential type: + +- The client sends a **Personal Access Token** as `X-User-Id` + `X-Auth-Token` headers on every request (including `initialize`), because the route is `authRequired: true`. +- For POST requests, the standard auth middleware resolves the user; the MCP action then verifies that the matching login-token record is a Personal Access Token. Missing, invalid, and session tokens are rejected with `401`. GET is still covered by route authentication but returns `405` without performing the additional token-type lookup because it cannot execute tools. +- Every MCP action additionally requires the **`access-mcp`** permission (`permissionsRequired: ['access-mcp']`), enforced by the standard permissions middleware. Without it the request is rejected with `403`. The permission is granted to `admin` by default; admins can grant it to other roles from the Permissions admin page. + +> When creating the PAT, tick **"Ignore Two Factor Authentication"**, otherwise header auth is rejected with a 2FA challenge. + +## Transport security + +The endpoint validates the `Origin` header to protect browser-accessible deployments from DNS-rebinding attacks. Requests without `Origin` are accepted for native MCP clients. Browser requests are accepted only when their normalized origin matches `Site_Url` or an explicit entry in `API_CORS_Origin` while CORS is enabled. The wildcard (`*`) does not authorize a browser origin for MCP. + +Tool dispatch is restricted to server-generated, allow-listed REST paths on `127.0.0.1`; client input cannot select a URL. Redirects are rejected, calls time out after 20 seconds, and each REST response and final encoded MCP response is capped at 5 MiB. Batches run at most four calls concurrently and share the same 5 MiB streaming response budget. + +## Licensing + +The feature is gated behind the **Rocket.Chat AI add-on** (`chat.rocket.rc-ai`): + +- The route is registered with `license: [AI_LICENSE_MODULE]`, so requests are rejected unless the workspace license includes the module. +- The `MCP_*` settings use the same module and an `invalidValue` of `false`, so without the add-on they fall back to **off** and the feature cannot be enabled. + +## Tool catalog + +Two **bounded** sets are exposed, selected by the `MCP_Expose_Extended_API` setting. The full unfiltered API is **never** exposed. + +| `MCP_Expose_Extended_API` | Exposed tools | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **off** (default) | **Minimal curated set** — a small hand-picked list (`post_chat_postMessage`, `get_chat_getMessage`, `post_channels_create`, `get_channels_list_joined`, `get_rooms_get`, `get_users_info`). | +| **on** | **Extended set** — the full catalog filtered by the `ALLOWED_TOOL_NAMES` allow-list (~100 routes), still excluding routes tagged `Missing Documentation`. | + +Both sets are built from registered typed-route metadata. The extended set filters generated base names through the allow-list, while the curated set filters routes through its smaller explicit list and supplies fallback descriptions where metadata is incomplete. + +### Tool naming & variant expansion + +A route's base tool name is `toolNameFor(path, method)` — e.g. `GET /api/v1/users.info` → `get_users_info`. Curated and extended catalogs use the same names, so enabling the extended catalog only adds tools. + +When a route's request schema is a `oneOf`/`anyOf` of object sub-schemas with **distinct discriminators** (its `required` keys), each branch becomes its **own tool**, named `_by_`: + +- `chat.postMessage` → `post_chat_postMessage_by_channel`, `post_chat_postMessage_by_roomId` +- `users.info` → `get_users_info_by_userId`, `get_users_info_by_username`, `get_users_info_by_importId`, … + +The allow-list is matched on the **base name**, so a single entry (`get_users_info`) admits all of that route's variants. + +### Schema handling + +MCP / the Anthropic tools API require each `inputSchema` to be a plain object schema and reject several JSON-Schema constructs. `mcpSafeSchema` normalizes a route's schema for the tool view (the REST validator is untouched): + +- strips OpenAPI-only `nullable` and `not`; +- resolves `oneOf`/`anyOf`/`allOf` by adopting the first branch (so a value union like `string | string[]` keeps a concrete type, and a sub-schema union yields a clean object); +- forces a top-level `type: "object"`. + +Tool **descriptions** are sourced from the route schema's own `description` (added in `rest-typings`), falling back to a per-route string. + +## Dispatch + +`tools/call` executes the target endpoint **as the authenticated user** via a loopback HTTP call to the local REST API (`http://127.0.0.1:/api/v1/`), forwarding: + +- `X-User-Id` + `X-Auth-Token` (the caller's PAT), so all validation and permission checks run exactly as for a real REST client — **zero duplicated business logic**; +- `X-Real-IP` set to the resolved client address (`this.requestIp`), so the target endpoint's per-route rate limiter keys on the real client rather than the loopback address. + +The REST response is wrapped as MCP `content` (`type: "text"`); a non-2xx REST response is returned with `isError: true`. Internal calls time out after 20 seconds. REST bodies and final encoded MCP responses are capped at 5 MiB; batched calls use bounded concurrency and share a single streaming body budget to limit retained response data. + +## Rate limiting + +The MCP endpoint uses Rocket.Chat's **built-in per-route rate limiter** with a limit of 60 requests per minute (enabled by `API_Enable_Rate_Limiter`, honoring `api-bypass-rate-limit`). Every tool call is also subject to its target REST endpoint's rate limit. The resolved client address is propagated to that endpoint rather than being counted as loopback traffic. MCP protocol requests are additionally bounded to 20 batch entries, four concurrent dispatches, 20-second calls, and a shared 5 MiB response budget. As with every Rocket.Chat API route, deployments behind a proxy must configure `HTTP_FORWARDED_COUNT` and trusted forwarding headers correctly. + +## Settings + +Registered under **Admin → AI Center → MCP** (`server/settings/ai.ts`): + +| Setting | Default | Description | +| ------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_Enabled` | `false` | Enables the `/api/v1/mcp` endpoint. Flagged **alpha** via an admin warning callout (`MCP_Alpha_Alert`). | +| `MCP_Expose_Extended_API` | `false` | When on, exposes the extended allow-listed toolset instead of the minimal curated one. Gated behind `MCP_Enabled`. The full API is never exposed. | + +## Connecting a client + +```bash +claude mcp add --transport http rocketchat http:///api/v1/mcp \ + --header "X-User-Id: " \ + --header "X-Auth-Token: " +``` + +Raw smoke test: + +```bash +H=(-H "Content-Type: application/json" -H "X-User-Id: " -H "X-Auth-Token: ") +curl -s "${H[@]}" http://localhost:3000/api/v1/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +curl -s "${H[@]}" http://localhost:3000/api/v1/mcp \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"post_chat_postMessage_by_channel","arguments":{"channel":"#general","text":"hello from MCP"}}}' +``` + +> The client **must** send `Content-Type: application/json` on POST — the shared router only parses the body for that content type. + +## Limitations + +- **Alpha**, off by default. +- Implements the handshake-based Streamable HTTP lifecycle through protocol version `2025-11-25`; newer lifecycle methods are not yet supported. +- Requires the **Rocket.Chat AI add-on** — both the route and the settings are gated by it. +- One HTTP response per POST. The `2025-03-26` compatibility revision accepts a bounded batch of up to 20 messages; later revisions accept one message per POST. There is no server-initiated SSE stream (`GET` → `405`). +- The official `@modelcontextprotocol/sdk` is intentionally not used; the files are structured so it can be dropped into the transport/server layer later for SSE and richer session handling. + +## Key Files + +| Layer | File | +| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Hono route registration (`/api/v1/mcp`) + `MCP_Enabled` gate | `apps/meteor/ee/server/api/mcp/index.ts` | +| JSON-RPC handlers (`initialize`/`tools/list`/`tools/call`/…) | `apps/meteor/ee/server/api/mcp/server.ts` | +| Tool catalog (curated + extended allow-list, variants, schema normalization) | `apps/meteor/ee/server/api/mcp/catalog.ts` | +| Tool dispatch (loopback to REST as the user) | `apps/meteor/ee/server/api/mcp/dispatch.ts` | +| License-gated permission seed (`access-mcp`) | `apps/meteor/ee/server/startup/mcp.ts` | +| EE module load | `apps/meteor/ee/server/api/index.ts` | +| Settings (license-gated) | `apps/meteor/server/settings/ai.ts` | +| License module | `packages/ai-search/src/constants.ts` (`AI_LICENSE_MODULE`) | +| Schema descriptions reused as tool docs | `packages/rest-typings/src/v1/chat.ts`, `packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts` | +| i18n | `packages/i18n/src/locales/en.i18n.json` (`MCP_*` keys) | diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index c97709099c01a..27cfc5173c289 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -150,6 +150,7 @@ "AI_Center_Intelligent_Search_card_description": "Find relevant messages across rooms you can access, even when they do not contain the exact search terms.", "AI_Center_LLM_Providers": "LLM Providers", "AI_Center_LLM_Providers_card_description": "Configure one OpenAI-compatible endpoint and select the model used by AI Center features.", + "AI_Center_MCP_card_description": "Connect AI clients to approved Rocket.Chat tools using the Model Context Protocol.", "AI_LLM_Provider": "LLM Providers", "AI_Center_license_required_description": "The chat.rocket.rc-ai add-on is required to enable AI Search and other premium AI capabilities.", "AI_Center_license_required_title": "AI add-on required", @@ -3535,6 +3536,13 @@ "Master_volume": "Master volume", "Master_volume_hint": "Controls the volume for all sounds coming from your workspace", "Max_Retry": "Maximum attemps to reconnect to the server", + "MCP": "MCP", + "MCP_Alpha_Alert": "MCP is currently in alpha. Its capabilities and configuration may evolve as we gather feedback.", + "MCP_Enabled": "Enable MCP endpoint", + "MCP_Enabled_Description": "Exposes a Model Context Protocol (MCP) endpoint at `/api/v1/mcp` so MCP-capable AI clients can call the Rocket.Chat API. Clients authenticate with a Personal Access Token (`X-User-Id` + `X-Auth-Token`).", + "MCP_Extended_API_Alert": "The extended toolset includes actions that can modify users, rooms, messages, and teams. Enable it only for trusted clients and grant the Access MCP permission to the minimum required roles.", + "MCP_Expose_Extended_API": "Expose extended MCP toolset", + "MCP_Expose_Extended_API_Description": "When enabled, a broader allow-listed set of REST endpoints is exposed as MCP tools. When disabled, only the minimal curated toolset is exposed. The full API is never exposed.", "Max_length_is": "Max length is {{limit}}", "Max_logs_export": "Max (2000)", "Max_number_incoming_livechats_displayed": "Max number of items displayed in the queue", @@ -6257,6 +6265,8 @@ "access-mailer_description": "Permission to send mass email to all users.", "access-marketplace": "Access Marketplace", "access-marketplace_description": "Permission to browse and get apps from the marketplace", + "access-mcp": "Access MCP", + "access-mcp_description": "Permission to call the Rocket.Chat API through the MCP (Model Context Protocol) endpoint.", "access-permissions": "Access Permissions Screen", "access-permissions_description": "Modify permissions for various roles.", "access-setting-permissions": "Modify Setting-Based Permissions", diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index e198f5459962f..b7734f0c23721 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -291,6 +291,13 @@ export interface IUsersModel extends IBaseModel { loginTokenObject: AtLeast; }): Promise; findPersonalAccessTokenByTokenNameAndUserId({ userId, tokenName }: { userId: IUser['_id']; tokenName: string }): Promise; + findPersonalAccessTokenByHashedTokenAndUserId({ + userId, + hashedToken, + }: { + userId: IUser['_id']; + hashedToken: string; + }): Promise | null>; checkOnlineAgents(agentId?: string, isLivechatEnabledWhenIdle?: boolean, acceptChatsWithNoAgents?: boolean): Promise; findOnlineAgents( agentId?: IUser['_id'], diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts index e01b1b7040f7c..cccd374af1fae 100644 --- a/packages/models/src/models/Users.ts +++ b/packages/models/src/models/Users.ts @@ -1793,6 +1793,16 @@ export class UsersRaw extends BaseRaw> implements IU return this.findOne(query); } + findPersonalAccessTokenByHashedTokenAndUserId({ userId, hashedToken }: { userId: IUser['_id']; hashedToken: string }) { + return this.findOne( + { + '_id': userId, + 'services.resume.loginTokens': { $elemMatch: { hashedToken, type: 'personalAccessToken' } }, + }, + { projection: { _id: 1 } }, + ); + } + async checkOnlineAgents(agentId: IUser['_id'], isLivechatEnabledWhenAgentIdle?: boolean, acceptChatsWithNoAgents?: boolean) { // TODO:: Create class Agent const query = queryStatusAgentOnline(agentId && { _id: agentId }, isLivechatEnabledWhenAgentIdle, acceptChatsWithNoAgents); diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index a3ad511a1bf4d..8e300fe4bfc06 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -85,10 +85,12 @@ type ChatGetMessage = { const ChatGetMessageSchema = { type: 'object', + description: 'Fetch a single message by its `msgId`.', properties: { msgId: { type: 'string', minLength: 1, + description: 'The message id.', }, }, required: ['msgId'], @@ -782,6 +784,8 @@ const ChatPostMessageSchema = { oneOf: [ { type: 'object', + description: + 'Post a message to a room by its id. Provide `roomId`; optionally provide `text` or attachments, and `tmid` to reply in a thread.', properties: { roomId: { oneOf: [ @@ -793,22 +797,27 @@ const ChatPostMessageSchema = { }, }, ], + description: 'The room id (or array of room ids) to post to.', }, text: { type: 'string', nullable: true, + description: 'The text content of the message.', }, alias: { type: 'string', nullable: true, + description: "A name to display as the message author instead of the sender's username.", }, emoji: { type: 'string', nullable: true, + description: 'Emoji to display as the message avatar (e.g. ":smile:").', }, avatar: { type: 'string', nullable: true, + description: 'URL of an image to display as the message avatar.', }, attachments: { type: 'array', @@ -816,16 +825,20 @@ const ChatPostMessageSchema = { type: 'object', }, nullable: true, + description: 'Rich-content attachments for the message.', }, tmid: { type: 'string', + description: 'Thread parent message id. Only valid together with `roomId`.', }, customFields: { type: 'object', nullable: true, + description: 'Custom fields to store on the message.', }, parseUrls: { type: 'boolean', + description: 'Whether URLs in the message should be parsed for previews.', }, }, required: ['roomId'], @@ -833,6 +846,8 @@ const ChatPostMessageSchema = { }, { type: 'object', + description: + 'Post a message to a channel by its name (e.g. "#general"). Provide `channel`; optionally provide `text` or attachments.', properties: { channel: { oneOf: [ @@ -844,22 +859,27 @@ const ChatPostMessageSchema = { }, }, ], + description: 'The channel name (e.g. "general" or "#general") to post to.', }, text: { type: 'string', nullable: true, + description: 'The text content of the message.', }, alias: { type: 'string', nullable: true, + description: "A name to display as the message author instead of the sender's username.", }, emoji: { type: 'string', nullable: true, + description: 'Emoji to display as the message avatar (e.g. ":smile:").', }, avatar: { type: 'string', nullable: true, + description: 'URL of an image to display as the message avatar.', }, attachments: { type: 'array', @@ -867,13 +887,16 @@ const ChatPostMessageSchema = { type: 'object', }, nullable: true, + description: 'Rich-content attachments for the message.', }, customFields: { type: 'object', nullable: true, + description: 'Custom fields to store on the message.', }, parseUrls: { type: 'boolean', + description: 'Whether URLs in the message should be parsed for previews.', }, }, required: ['channel'], diff --git a/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts b/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts index b988d5240e10c..586a83984542b 100644 --- a/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts +++ b/packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts @@ -15,16 +15,20 @@ const UsersInfoParamsGetSchema = { anyOf: [ { type: 'object', + description: 'Get information about a user by their id.', properties: { userId: { type: 'string', + description: 'The user id.', }, includeUserRooms: { type: 'string', + description: 'Set to "true" to include the rooms the user belongs to in the response.', }, fields: { type: 'string', nullable: true, + description: 'JSON string describing which fields to include or exclude from the response.', }, }, required: ['userId'], @@ -32,16 +36,20 @@ const UsersInfoParamsGetSchema = { }, { type: 'object', + description: 'Get information about a user by their username.', properties: { username: { type: 'string', + description: 'The username.', }, includeUserRooms: { type: 'string', + description: 'Set to "true" to include the rooms the user belongs to in the response.', }, fields: { type: 'string', nullable: true, + description: 'JSON string describing which fields to include or exclude from the response.', }, }, required: ['username'], @@ -49,16 +57,20 @@ const UsersInfoParamsGetSchema = { }, { type: 'object', + description: 'Get information about a user by their import id.', properties: { importId: { type: 'string', + description: 'The import id.', }, includeUserRooms: { type: 'string', + description: 'Set to "true" to include the rooms the user belongs to in the response.', }, fields: { type: 'string', nullable: true, + description: 'JSON string describing which fields to include or exclude from the response.', }, }, required: ['importId'], @@ -66,16 +78,20 @@ const UsersInfoParamsGetSchema = { }, { type: 'object', + description: 'Get information about a user by their email address.', properties: { email: { type: 'string', + description: 'The user email address.', }, includeUserRooms: { type: 'string', + description: 'Set to "true" to include the rooms the user belongs to in the response.', }, fields: { type: 'string', nullable: true, + description: 'JSON string describing which fields to include or exclude from the response.', }, }, required: ['email'], @@ -83,16 +99,20 @@ const UsersInfoParamsGetSchema = { }, { type: 'object', + description: 'Get information about a user by their FreeSwitch extension.', properties: { freeSwitchExtension: { type: 'string', + description: 'The FreeSwitch extension.', }, includeUserRooms: { type: 'string', + description: 'Set to "true" to include the rooms the user belongs to in the response.', }, fields: { type: 'string', nullable: true, + description: 'JSON string describing which fields to include or exclude from the response.', }, }, required: ['freeSwitchExtension'], From 3a61c3afed2df5d1555282e496e9f854c9e69916 Mon Sep 17 00:00:00 2001 From: Julio Araujo Date: Tue, 18 Aug 2026 13:30:19 +0200 Subject: [PATCH 2/3] fix: forgot password email DDP method doesn't have strict rate limits (#41699) --- .changeset/fuzzy-ends-refuse.md | 5 +++++ .../meteor-methods/auth/sendForgotPasswordEmail.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 .changeset/fuzzy-ends-refuse.md diff --git a/.changeset/fuzzy-ends-refuse.md b/.changeset/fuzzy-ends-refuse.md new file mode 100644 index 0000000000000..8ddb46f9b3fc7 --- /dev/null +++ b/.changeset/fuzzy-ends-refuse.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Adds per-client rate limiting to the unauthenticated sendForgotPasswordEmail method, matching the REST users.forgotPassword endpoint diff --git a/apps/meteor/server/meteor-methods/auth/sendForgotPasswordEmail.ts b/apps/meteor/server/meteor-methods/auth/sendForgotPasswordEmail.ts index e49c1dce0da09..95fb48e595833 100644 --- a/apps/meteor/server/meteor-methods/auth/sendForgotPasswordEmail.ts +++ b/apps/meteor/server/meteor-methods/auth/sendForgotPasswordEmail.ts @@ -2,6 +2,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Users } from '@rocket.chat/models'; import { Accounts } from 'meteor/accounts-base'; import { check } from 'meteor/check'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; import { SystemLogger } from '../../lib/logger/system'; @@ -44,3 +45,15 @@ Meteor.methods({ return sendForgotPasswordEmail(to); }, }); + +DDPRateLimiter.addRule( + { + type: 'method', + name: 'sendForgotPasswordEmail', + clientAddress() { + return true; + }, + }, + 10, + 60000, +); From b2c16d5842cbe6b69b59bdf6fc5e5f1afcd1f0b0 Mon Sep 17 00:00:00 2001 From: Julio Araujo Date: Tue, 18 Aug 2026 13:31:17 +0200 Subject: [PATCH 3/3] fix: special characters not escaped in Omnichannel queue side panel message preview (#41595) --- .changeset/shy-actors-jump.md | 5 +++++ .../sidepanel/omnichannel/InquireSidePanelItem.tsx | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changeset/shy-actors-jump.md diff --git a/.changeset/shy-actors-jump.md b/.changeset/shy-actors-jump.md new file mode 100644 index 0000000000000..2d9967854e895 --- /dev/null +++ b/.changeset/shy-actors-jump.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes special characters not being escaped in the visitor name shown in the Omnichannel queue side panel's message preview diff --git a/apps/meteor/client/views/navigation/sidepanel/omnichannel/InquireSidePanelItem.tsx b/apps/meteor/client/views/navigation/sidepanel/omnichannel/InquireSidePanelItem.tsx index 8d8c4c1ef83cf..852cc7ce9112e 100644 --- a/apps/meteor/client/views/navigation/sidepanel/omnichannel/InquireSidePanelItem.tsx +++ b/apps/meteor/client/views/navigation/sidepanel/omnichannel/InquireSidePanelItem.tsx @@ -1,5 +1,6 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { SidebarV2ItemIcon as SidebarItemIcon } from '@rocket.chat/fuselage'; +import { escapeHTML } from '@rocket.chat/string-helpers'; import { RoomAvatar } from '@rocket.chat/ui-avatar'; import { useUserId } from '@rocket.chat/ui-contexts'; import { memo } from 'react'; @@ -29,7 +30,8 @@ const InquireSidePanelItem = ({ room, openedRoom, ...props }: InquireSidePanelIt const time = 'lastMessage' in room ? room.lastMessage?.ts : undefined; const message = - room.lastMessage && `${room.lastMessage.u.name || room.lastMessage.u.username}: ${normalizeMessagePreview(room.lastMessage, t)}`; + room.lastMessage && + `${escapeHTML(room.lastMessage.u.name || room.lastMessage.u.username)}: ${normalizeMessagePreview(room.lastMessage, t)}`; const title = roomCoordinator.getRoomName(room.t, room) || ''; const href = roomCoordinator.getRouteLink(room.t, room) || '';