snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
235
apps/meteor/ee/server/api/mcp/catalog.spec.ts
Normal file
235
apps/meteor/ee/server/api/mcp/catalog.spec.ts
Normal file
@@ -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<string, unknown>;
|
||||
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());
|
||||
});
|
||||
});
|
||||
420
apps/meteor/ee/server/api/mcp/catalog.ts
Normal file
420
apps/meteor/ee/server/api/mcp/catalog.ts
Normal file
@@ -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<string, unknown>;
|
||||
/** 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_<discriminator>` 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<string>([
|
||||
// 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<string, unknown> = { 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<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
||||
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<string, unknown>;
|
||||
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<string, unknown> => {
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
||||
return FALLBACK_SCHEMA;
|
||||
}
|
||||
const obj = mcpSafeSchema(schema) as Record<string, unknown>;
|
||||
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<string, unknown>,
|
||||
variantKey: (typeof VARIANT_KEYS)[number],
|
||||
variant: unknown,
|
||||
): Record<string, unknown> => {
|
||||
const { [variantKey]: _variants, ...baseSchema } = root;
|
||||
if (!variant || typeof variant !== 'object' || Array.isArray(variant)) {
|
||||
return ensureObjectSchema(baseSchema);
|
||||
}
|
||||
|
||||
const branch = variant as Record<string, unknown>;
|
||||
const baseProperties =
|
||||
baseSchema.properties && typeof baseSchema.properties === 'object' && !Array.isArray(baseSchema.properties)
|
||||
? (baseSchema.properties as Record<string, unknown>)
|
||||
: undefined;
|
||||
const branchProperties =
|
||||
branch.properties && typeof branch.properties === 'object' && !Array.isArray(branch.properties)
|
||||
? (branch.properties as Record<string, unknown>)
|
||||
: 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, unknown>): 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<string, unknown> };
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
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<string>();
|
||||
|
||||
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;
|
||||
};
|
||||
156
apps/meteor/ee/server/api/mcp/dispatch.spec.ts
Normal file
156
apps/meteor/ee/server/api/mcp/dispatch.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
147
apps/meteor/ee/server/api/mcp/dispatch.ts
Normal file
147
apps/meteor/ee/server/api/mcp/dispatch.ts
Normal file
@@ -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<string> => {
|
||||
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<string, unknown>,
|
||||
auth: McpAuth,
|
||||
clientIp?: string,
|
||||
responseBudget?: McpResponseBudget,
|
||||
): Promise<DispatchResult> => {
|
||||
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<string, string> = {
|
||||
'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 };
|
||||
};
|
||||
377
apps/meteor/ee/server/api/mcp/index.spec.ts
Normal file
377
apps/meteor/ee/server/api/mcp/index.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
256
apps/meteor/ee/server/api/mcp/index.ts
Normal file
256
apps/meteor/ee/server/api/mcp/index.ts
Normal file
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
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<McpActionContext, 'userId' | 'token'>): Promise<boolean> =>
|
||||
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<JsonRpcResponse | null>(messages.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
const processNext = async (): Promise<void> => {
|
||||
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<McpHttpResponse> => {
|
||||
if (!settings.get<boolean>('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<McpActionContext, 'request'>): McpHttpResponse => {
|
||||
if (!settings.get<boolean>('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 })));
|
||||
174
apps/meteor/ee/server/api/mcp/server.spec.ts
Normal file
174
apps/meteor/ee/server/api/mcp/server.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
150
apps/meteor/ee/server/api/mcp/server.ts
Normal file
150
apps/meteor/ee/server/api/mcp/server.ts
Normal file
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
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<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const isInitializeParams = (params: unknown): params is Record<string, unknown> => {
|
||||
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<boolean>('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<JsonRpcResponse | null> => {
|
||||
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}`));
|
||||
}
|
||||
};
|
||||
63
apps/meteor/ee/server/api/mcp/transport.spec.ts
Normal file
63
apps/meteor/ee/server/api/mcp/transport.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
52
apps/meteor/ee/server/api/mcp/transport.ts
Normal file
52
apps/meteor/ee/server/api/mcp/transport.ts
Normal file
@@ -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<boolean>('API_Enable_CORS')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const configuredOrigins = settings.get<string>('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';
|
||||
Reference in New Issue
Block a user