Files
upstream-rocketchat/apps/meteor/ee/server/api/mcp/index.spec.ts
upstream-mirror 30810eecd7
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
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

378 lines
14 KiB
TypeScript

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