Compare commits

...

5 Commits

Author SHA1 Message Date
Félix Malfait 9decbf41d3 fix: handle JSON-RPC notifications correctly and distinguish auth errors
- Return null (HTTP 202) for JSON-RPC notifications (no id) instead of
  sending a malformed response with id: undefined
- Make JsonRpc DTO `id` field properly optional in TypeScript
- Distinguish HttpException (auth/FORBIDDEN → SERVER_ERROR -32000) from
  unexpected errors (INTERNAL_ERROR -32603) in the catch block
- Add SERVER_ERROR (-32000) to JSON-RPC error code constants
- Update controller to return 202 Accepted for notifications via
  @Res({ passthrough: true })
- Update unit and integration tests for new notification and error semantics

Made-with: Cursor
2026-03-16 12:29:42 +01:00
Félix Malfait 8d1da76d2c Small improvements 2026-03-16 12:05:16 +01:00
channi23 0fe2e260d3 test: update MCP integration expectations 2026-03-16 15:33:58 +05:30
channi23 e3e9d8d598 test: align MCP controller spec with tools/list response 2026-03-16 15:13:25 +05:30
channi23 16e66c8df0 fix: return method-specific MCP responses 2026-03-16 15:06:59 +05:30
14 changed files with 468 additions and 148 deletions
@@ -0,0 +1,9 @@
export const JSON_RPC_ERROR_CODE = {
PARSE_ERROR: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL_ERROR: -32603,
// -32000 to -32099: reserved for implementation-defined server errors
SERVER_ERROR: -32000,
} as const;
@@ -0,0 +1 @@
export const MCP_PROTOCOL_VERSION = '2024-11-05';
@@ -0,0 +1,4 @@
export const MCP_SERVER_INFO = {
name: 'Twenty MCP Server',
version: '0.1.0',
};
@@ -0,0 +1,2 @@
export const MCP_SERVER_INSTRUCTIONS =
'Twenty CRM MCP Server. Follow this workflow: (1) get_tool_catalog to discover tools, (2) learn_tools to get input schemas, (3) execute_tool to run them. Never guess tool names — always start with get_tool_catalog. Use load_skills for guidance on complex tasks like workflow or dashboard building.';
@@ -1,10 +0,0 @@
export const MCP_SERVER_METADATA = {
metadata: {
info: 'Twenty CRM MCP Server. Follow this workflow: (1) get_tool_catalog to discover tools, (2) learn_tools to get input schemas, (3) execute_tool to run them. Never guess tool names — always start with get_tool_catalog. Use load_skills for guidance on complex tasks like workflow or dashboard building.',
},
protocolVersion: '2024-11-05',
serverInfo: {
name: 'Twenty MCP Server',
version: '0.1.0',
},
};
@@ -2,7 +2,9 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function';
import { MCP_SERVER_METADATA } from 'src/engine/api/mcp/constants/mcp.const';
import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const';
import { MCP_SERVER_INFO } from 'src/engine/api/mcp/constants/mcp-server-info.const';
import { MCP_SERVER_INSTRUCTIONS } from 'src/engine/api/mcp/constants/mcp-server-instructions.const';
import { McpCoreController } from 'src/engine/api/mcp/controllers/mcp-core.controller';
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpAuthGuard } from 'src/engine/api/mcp/guards/mcp-auth.guard';
@@ -71,6 +73,13 @@ describe('McpCoreController', () => {
const mockUser = { id: 'user-1' } as UserEntity;
const mockUserWorkspaceId = 'user-workspace-1';
const mockApiKey = { id: 'api-key-1' } as ApiKeyEntity;
const mockRes = {
status: jest.fn().mockReturnThis(),
} as unknown as import('express').Response;
beforeEach(() => {
(mockRes.status as jest.Mock).mockClear();
});
it('should call mcpProtocolService.handleMCPCoreQuery with correct parameters', async () => {
const mockRequest: JsonRpc = {
@@ -97,6 +106,7 @@ describe('McpCoreController', () => {
mockApiKey,
mockUser,
mockUserWorkspaceId,
mockRes,
);
expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
@@ -122,12 +132,14 @@ describe('McpCoreController', () => {
id: '123',
jsonrpc: '2.0',
result: {
...MCP_SERVER_METADATA,
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {
tools: { listChanged: false },
resources: { listChanged: false },
prompts: { listChanged: false },
},
serverInfo: MCP_SERVER_INFO,
instructions: MCP_SERVER_INSTRUCTIONS,
},
};
@@ -139,6 +151,7 @@ describe('McpCoreController', () => {
mockApiKey,
mockUser,
mockUserWorkspaceId,
mockRes,
);
expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
@@ -164,10 +177,6 @@ describe('McpCoreController', () => {
id: '123',
jsonrpc: '2.0',
result: {
...MCP_SERVER_METADATA,
capabilities: {
tools: { listChanged: false },
},
tools: [
{
name: 'testTool',
@@ -186,6 +195,7 @@ describe('McpCoreController', () => {
mockApiKey,
mockUser,
mockUserWorkspaceId,
mockRes,
);
expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
@@ -200,6 +210,27 @@ describe('McpCoreController', () => {
expect(result).toEqual(mockResponse);
});
it('should return 202 with no body for notifications', async () => {
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'notifications/initialized',
};
mcpProtocolService.handleMCPCoreQuery.mockResolvedValue(null);
const result = await controller.handleMcpCore(
mockRequest,
mockWorkspace,
mockApiKey,
mockUser,
mockUserWorkspaceId,
mockRes,
);
expect(result).toBeUndefined();
expect(mockRes.status).toHaveBeenCalledWith(202);
});
it('should handle API key auth without user', async () => {
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
@@ -225,6 +256,7 @@ describe('McpCoreController', () => {
mockApiKey,
undefined,
undefined,
mockRes,
);
expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
@@ -1,13 +1,19 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Res,
UseFilters,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { type Response } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpAuthGuard } from 'src/engine/api/mcp/guards/mcp-auth.guard';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
@@ -16,8 +22,8 @@ import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@@ -29,6 +35,7 @@ export class McpCoreController {
constructor(private readonly mcpProtocolService: McpProtocolService) {}
@Post()
@HttpCode(HttpStatus.OK)
@UsePipes(
new ValidationPipe({
transform: true,
@@ -42,12 +49,22 @@ export class McpCoreController {
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
return await this.mcpProtocolService.handleMCPCoreQuery(body, {
const result = await this.mcpProtocolService.handleMCPCoreQuery(body, {
workspace,
userId: user?.id,
userWorkspaceId,
apiKey,
});
// JSON-RPC notifications (no id) expect no response body
if (!isDefined(result)) {
res.status(HttpStatus.ACCEPTED);
return;
}
return result;
}
}
@@ -29,5 +29,5 @@ export class JsonRpc {
@IsOptional()
@Validate(IsNumberOrString)
id: string | number;
id?: string | number;
}
@@ -1,7 +1,10 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { MCP_SERVER_METADATA } from 'src/engine/api/mcp/constants/mcp.const';
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const';
import { MCP_SERVER_INFO } from 'src/engine/api/mcp/constants/mcp-server-info.const';
import { MCP_SERVER_INSTRUCTIONS } from 'src/engine/api/mcp/constants/mcp-server-instructions.const';
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
@@ -97,21 +100,23 @@ describe('McpProtocolService', () => {
});
describe('handleInitialize', () => {
it('should return correct initialization response', () => {
it('should return spec-compliant initialization response', () => {
const requestId = '123';
const result = service.handleInitialize(requestId);
expect(result).toMatchObject({
expect(result).toEqual({
id: requestId,
jsonrpc: '2.0',
result: expect.objectContaining({
...MCP_SERVER_METADATA,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {
tools: { listChanged: false },
resources: { listChanged: false },
prompts: { listChanged: false },
},
}),
serverInfo: MCP_SERVER_INFO,
instructions: MCP_SERVER_INSTRUCTIONS,
},
});
});
});
@@ -176,20 +181,37 @@ describe('McpProtocolService', () => {
apiKey: undefined,
});
expect(result).toMatchObject({
expect(result).toEqual({
id: '123',
jsonrpc: '2.0',
result: expect.objectContaining({
...MCP_SERVER_METADATA,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {
tools: { listChanged: false },
resources: { listChanged: false },
prompts: { listChanged: false },
},
}),
serverInfo: MCP_SERVER_INFO,
instructions: MCP_SERVER_INSTRUCTIONS,
},
});
});
it('should return null for notifications (no id)', async () => {
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'notifications/initialized',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: undefined,
});
expect(result).toBeNull();
});
it('should build a ToolSet with exactly 5 tools and pass it to executor for tools/call', async () => {
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
@@ -197,7 +219,6 @@ describe('McpProtocolService', () => {
id: '123',
jsonrpc: '2.0',
result: {
...MCP_SERVER_METADATA,
content: [{ type: 'text', text: '{}' }],
isError: false,
},
@@ -276,6 +297,71 @@ describe('McpProtocolService', () => {
);
});
it('should return prompts list without role resolution', async () => {
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'prompts/list',
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: undefined,
});
expect(result).toEqual({
id: '123',
jsonrpc: '2.0',
result: { prompts: [] },
});
expect(userRoleService.getRoleIdForUserWorkspace).not.toHaveBeenCalled();
});
it('should return resources list without role resolution', async () => {
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'resources/list',
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: undefined,
});
expect(result).toEqual({
id: '123',
jsonrpc: '2.0',
result: { resources: [] },
});
expect(userRoleService.getRoleIdForUserWorkspace).not.toHaveBeenCalled();
});
it('should return method not found for unknown methods', async () => {
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'unknown/method',
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: undefined,
});
expect(result).toEqual({
id: '123',
jsonrpc: '2.0',
error: {
code: JSON_RPC_ERROR_CODE.METHOD_NOT_FOUND,
message: "Method 'unknown/method' not found",
},
});
});
it('should handle tools/call with apiKey authentication', async () => {
const mockToolCallResponse = {
id: '123',
@@ -306,20 +392,17 @@ describe('McpProtocolService', () => {
);
});
it('should handle error when tool execution fails', async () => {
it('should wrap unexpected errors with INTERNAL_ERROR code', async () => {
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
mcpToolExecutorService.handleToolCall.mockRejectedValue(
new HttpException(
"Tool 'nonExistentTool' not found",
HttpStatus.NOT_FOUND,
),
new Error('Something went wrong'),
);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
params: { name: 'nonExistentTool', arguments: {} },
params: { name: 'execute_tool', arguments: {} },
id: '123',
};
@@ -333,9 +416,36 @@ describe('McpProtocolService', () => {
id: '123',
jsonrpc: '2.0',
error: {
...MCP_SERVER_METADATA,
code: HttpStatus.NOT_FOUND,
message: "Tool 'nonExistentTool' not found",
code: JSON_RPC_ERROR_CODE.INTERNAL_ERROR,
message: 'Something went wrong',
},
});
});
it('should wrap HttpException errors with SERVER_ERROR code', async () => {
userRoleService.getRoleIdForUserWorkspace.mockRejectedValue(
new HttpException('Role ID missing', HttpStatus.FORBIDDEN),
);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
params: { name: 'execute_tool', arguments: {} },
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: undefined,
});
expect(result).toEqual({
id: '123',
jsonrpc: '2.0',
error: {
code: JSON_RPC_ERROR_CODE.SERVER_ERROR,
message: 'Role ID missing',
},
});
});
@@ -0,0 +1,146 @@
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
describe('McpToolExecutorService', () => {
let service: McpToolExecutorService;
beforeEach(() => {
service = new McpToolExecutorService();
});
describe('handleToolsListing', () => {
it('should return only tools array in result', () => {
const toolSet = {
test_tool: {
description: 'A test tool',
inputSchema: {
jsonSchema: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
},
},
} as any;
const result = service.handleToolsListing('123', toolSet);
expect(result).toEqual({
id: '123',
jsonrpc: '2.0',
result: {
tools: [
{
name: 'test_tool',
description: 'A test tool',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
},
],
},
});
});
it('should keep inputSchema unchanged when it is already a plain schema', () => {
const toolSet = {
plain_tool: {
description: 'A plain schema tool',
inputSchema: {
type: 'object',
properties: { name: { type: 'string' } },
},
},
} as any;
const result = service.handleToolsListing('456', toolSet);
expect(result).toEqual({
id: '456',
jsonrpc: '2.0',
result: {
tools: [
{
name: 'plain_tool',
description: 'A plain schema tool',
inputSchema: {
type: 'object',
properties: { name: { type: 'string' } },
},
},
],
},
});
});
});
describe('handleToolCall', () => {
it('should return JSON-RPC error with INVALID_PARAMS for unknown tools', async () => {
const toolSet = {} as any;
const result = await service.handleToolCall('123', toolSet, {
name: 'nonexistent_tool',
arguments: {},
});
expect(result).toEqual({
id: '123',
jsonrpc: '2.0',
error: {
code: JSON_RPC_ERROR_CODE.INVALID_PARAMS,
message: 'Unknown tool: nonexistent_tool',
},
});
});
it('should return result with isError: false on success', async () => {
const toolSet = {
my_tool: {
execute: jest.fn().mockResolvedValue({ data: 'ok' }),
description: 'My tool',
inputSchema: { type: 'object' },
},
} as any;
const result = await service.handleToolCall('123', toolSet, {
name: 'my_tool',
arguments: {},
});
expect(result).toEqual({
id: '123',
jsonrpc: '2.0',
result: {
content: [{ type: 'text', text: '{"data":"ok"}' }],
isError: false,
},
});
});
it('should return result with isError: true when tool execution throws', async () => {
const toolSet = {
failing_tool: {
execute: jest.fn().mockRejectedValue(new Error('API rate limited')),
description: 'A tool that fails',
inputSchema: { type: 'object' },
},
} as any;
const result = await service.handleToolCall('123', toolSet, {
name: 'failing_tool',
arguments: {},
});
expect(result).toEqual({
id: '123',
jsonrpc: '2.0',
result: {
content: [{ type: 'text', text: 'API rate limited' }],
isError: true,
},
});
});
});
});
@@ -3,6 +3,10 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { type ToolSet, zodSchema } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const';
import { MCP_SERVER_INFO } from 'src/engine/api/mcp/constants/mcp-server-info.const';
import { MCP_SERVER_INSTRUCTIONS } from 'src/engine/api/mcp/constants/mcp-server-instructions.const';
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
@@ -51,14 +55,14 @@ export class McpProtocolService {
handleInitialize(requestId: string | number) {
return wrapJsonRpcResponse(requestId, {
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {
tools: { listChanged: false },
resources: { listChanged: false },
prompts: { listChanged: false },
},
tools: [],
resources: [],
prompts: [],
serverInfo: MCP_SERVER_INFO,
instructions: MCP_SERVER_INSTRUCTIONS,
},
});
}
@@ -152,6 +156,7 @@ export class McpProtocolService {
};
}
// Returns null for JSON-RPC notifications (no id), which require no response body
async handleMCPCoreQuery(
{ id, method, params }: JsonRpc,
{
@@ -165,20 +170,40 @@ export class McpProtocolService {
userWorkspaceId?: string;
apiKey: ApiKeyEntity | undefined;
},
): Promise<Record<string, unknown>> {
): Promise<Record<string, unknown> | null> {
try {
// JSON-RPC notifications have no id and expect no response
if (!isDefined(id)) {
return null;
}
if (method === 'initialize') {
return this.handleInitialize(id);
}
if (method === 'ping') {
return wrapJsonRpcResponse(
id,
{
result: {},
return wrapJsonRpcResponse(id, { result: {} });
}
if (method === 'prompts/list') {
return wrapJsonRpcResponse(id, {
result: { prompts: [] },
});
}
if (method === 'resources/list') {
return wrapJsonRpcResponse(id, {
result: { resources: [] },
});
}
if (method !== 'tools/list' && method !== 'tools/call') {
return wrapJsonRpcResponse(id, {
error: {
code: JSON_RPC_ERROR_CODE.METHOD_NOT_FOUND,
message: `Method '${method}' not found`,
},
true,
);
});
}
const roleId = await this.getRoleId(
@@ -197,7 +222,16 @@ export class McpProtocolService {
userWorkspaceId,
});
if (method === 'tools/call' && params) {
if (method === 'tools/call') {
if (!params) {
return wrapJsonRpcResponse(id, {
error: {
code: JSON_RPC_ERROR_CODE.INVALID_PARAMS,
message: 'tools/call requires params with name and arguments',
},
});
}
return await this.mcpToolExecutorService.handleToolCall(
id,
toolSet,
@@ -205,40 +239,22 @@ export class McpProtocolService {
);
}
if (method === 'tools/list') {
return this.mcpToolExecutorService.handleToolsListing(id, toolSet);
}
if (method === 'prompts/list') {
return wrapJsonRpcResponse(id, {
result: {
capabilities: {
prompts: { listChanged: false },
},
prompts: [],
},
});
}
if (method === 'resources/list') {
return wrapJsonRpcResponse(id, {
result: {
capabilities: {
resources: { listChanged: false },
},
resources: [],
},
});
}
return wrapJsonRpcResponse(id, {
result: {},
});
return this.mcpToolExecutorService.handleToolsListing(id, toolSet);
} catch (error) {
return wrapJsonRpcResponse(id, {
if (error instanceof HttpException) {
return wrapJsonRpcResponse(id ?? 0, {
error: {
code: JSON_RPC_ERROR_CODE.SERVER_ERROR,
message: error.message || 'Request failed',
},
});
}
return wrapJsonRpcResponse(id ?? 0, {
error: {
code: error.status || HttpStatus.INTERNAL_SERVER_ERROR,
message: error.message || 'Failed to execute tool',
code: JSON_RPC_ERROR_CODE.INTERNAL_ERROR,
message:
error instanceof Error ? error.message : 'Internal server error',
},
});
}
@@ -1,8 +1,9 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
@Injectable()
@@ -15,29 +16,43 @@ export class McpToolExecutorService {
const toolName = params.name as keyof typeof toolSet;
const tool = toolSet[toolName];
if (isDefined(tool) && isDefined(tool.execute)) {
if (!isDefined(tool) || !isDefined(tool.execute)) {
return wrapJsonRpcResponse(id, {
error: {
code: JSON_RPC_ERROR_CODE.INVALID_PARAMS,
message: `Unknown tool: ${String(params.name)}`,
},
});
}
try {
const result = await tool.execute(params.arguments, {
toolCallId: '1',
messages: [],
});
return wrapJsonRpcResponse(id, {
result: {
content: [{ type: 'text', text: JSON.stringify(result) }],
isError: false,
},
});
} catch (executionError) {
return wrapJsonRpcResponse(id, {
result: {
content: [
{
type: 'text',
text: JSON.stringify(
await tool.execute(params.arguments, {
toolCallId: '1',
messages: [],
}),
),
text:
executionError instanceof Error
? executionError.message
: 'Tool execution failed',
},
],
isError: false,
isError: true,
},
});
}
throw new HttpException(
`Tool '${params.name}' not found`,
HttpStatus.NOT_FOUND,
);
}
handleToolsListing(id: string | number, toolSet: ToolSet) {
@@ -63,12 +78,7 @@ export class McpToolExecutorService {
return wrapJsonRpcResponse(id, {
result: {
capabilities: {
tools: { listChanged: false },
},
tools: toolsArray,
resources: [],
prompts: [],
},
});
}
@@ -1,28 +1,12 @@
import { MCP_SERVER_METADATA } from 'src/engine/api/mcp/constants/mcp.const';
export const wrapJsonRpcResponse = (
id: string | number,
payload:
| Record<'result', Record<string, unknown>>
| Record<'error', Record<string, unknown>>,
omitMetadata = false,
) => {
const body =
'result' in payload
? {
result: omitMetadata
? payload.result
: { ...payload.result, ...MCP_SERVER_METADATA },
}
: {
error: omitMetadata
? payload.error
: { ...payload.error, ...MCP_SERVER_METADATA },
};
return {
id,
jsonrpc: '2.0',
...body,
...payload,
};
};
@@ -21,7 +21,7 @@ describe('MCP Controller (integration)', () => {
it('should respond to ping with a JSON-RPC result envelope', async () => {
await postMcp({ jsonrpc: '2.0', method: 'ping', id: '1' })
.expect(201)
.expect(200)
.expect((res) => {
expect(res.body).toMatchObject({
id: '1',
@@ -32,18 +32,26 @@ describe('MCP Controller (integration)', () => {
});
});
it('should respond to initialize with server metadata and capabilities', async () => {
it('should respond to initialize with spec-compliant InitializeResult', async () => {
await postMcp({ jsonrpc: '2.0', method: 'initialize', id: 123 })
.expect(201)
.expect(200)
.expect((res) => {
expect(res.body.id).toBe(123);
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.result).toBeDefined();
// Should include capabilities and server metadata fields merged by wrapJsonRpcResponse
expect(res.body.result.protocolVersion).toBe('2024-11-05');
expect(res.body.result.capabilities).toBeDefined();
expect(Array.isArray(res.body.result.tools)).toBe(true);
expect(Array.isArray(res.body.result.resources)).toBe(true);
expect(Array.isArray(res.body.result.prompts)).toBe(true);
expect(res.body.result.serverInfo).toBeDefined();
expect(res.body.result.serverInfo.name).toBe('Twenty MCP Server');
expect(typeof res.body.result.instructions).toBe('string');
});
});
it('should return 202 with no body for notifications/initialized (no id)', async () => {
await postMcp({ jsonrpc: '2.0', method: 'notifications/initialized' })
.expect(202)
.expect((res) => {
expect(res.body).toEqual({});
});
});
@@ -61,12 +69,11 @@ describe('MCP Controller (integration)', () => {
jsonrpc: '2.0',
method: 'tools/list',
id: 'tools-list-1',
}).expect(201);
}).expect(200);
expect(res.body.id).toBe('tools-list-1');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.result).toBeDefined();
expect(res.body.result.capabilities?.tools?.listChanged).toBe(false);
expect(Array.isArray(res.body.result.tools)).toBe(true);
// In a seeded workspace, there should be at least one tool
expect(res.body.result.tools.length).toBeGreaterThanOrEqual(0);
@@ -80,18 +87,17 @@ describe('MCP Controller (integration)', () => {
}
});
it('should return empty result for tools/call without params', async () => {
it('should return invalid params error for tools/call without params', async () => {
const res = await postMcp({
jsonrpc: '2.0',
method: 'tools/call',
id: 'tools-call-empty',
}).expect(201);
}).expect(200);
expect(res.body).toMatchObject({
id: 'tools-call-empty',
jsonrpc: '2.0',
result: {},
});
expect(res.body.id).toBe('tools-call-empty');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeDefined();
expect(res.body.error.code).toBe(-32602);
});
it('should return error when calling a non-existent tool', async () => {
@@ -100,13 +106,12 @@ describe('MCP Controller (integration)', () => {
method: 'tools/call',
id: 'tools-call-missing',
params: { name: 'non_existent_tool', arguments: {} },
}).expect(201);
}).expect(200);
expect(res.body.id).toBe('tools-call-missing');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeDefined();
// From McpService error wrapper: code = HttpStatus.NOT_FOUND (404) and message containing tool name
expect(res.body.error.code).toBe(404);
expect(res.body.error.code).toBe(-32602);
expect(String(res.body.error.message)).toMatch(/non_existent_tool/);
});
@@ -115,7 +120,7 @@ describe('MCP Controller (integration)', () => {
jsonrpc: '2.0',
method: 'tools/list',
id: 'tools-list-for-calls',
}).expect(201);
}).expect(200);
const tools: Array<{ name: string }> = list.body.result.tools || [];
@@ -130,7 +135,7 @@ describe('MCP Controller (integration)', () => {
method: 'tools/call',
id,
params: { name: tool.name, arguments: {} },
}).expect(201);
}).expect(200);
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.id).toBe(id);
@@ -142,27 +147,21 @@ describe('MCP Controller (integration)', () => {
}
});
it('should list prompts and resources as empty arrays with listChanged=false', async () => {
it('should list prompts and resources as empty arrays', async () => {
const prompts = await postMcp({
jsonrpc: '2.0',
method: 'prompts/list',
id: 'prompts-1',
}).expect(201);
}).expect(200);
expect(prompts.body.result.capabilities?.prompts?.listChanged).toBe(
false,
);
expect(Array.isArray(prompts.body.result.prompts)).toBe(true);
const resources = await postMcp({
jsonrpc: '2.0',
method: 'resources/list',
id: 'resources-1',
}).expect(201);
}).expect(200);
expect(resources.body.result.capabilities?.resources?.listChanged).toBe(
false,
);
expect(Array.isArray(resources.body.result.resources)).toBe(true);
});
});