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:
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 })));
|
||||
Reference in New Issue
Block a user