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

175 lines
6.2 KiB
TypeScript

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