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 2s
378 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|