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
175 lines
6.2 KiB
TypeScript
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();
|
|
});
|
|
});
|