feat(mcp): add rate limiting and auth guard tests for MCP transport controller

MCP endpoints already had JwtAuthGuard applied but lacked per-route rate
limiting and test coverage for security behavior. Add @Throttle decorators
with appropriate limits (5 req/min for SSE connections, 30 req/min for
server list and messages), unit tests verifying guard/throttle metadata,
and E2E tests confirming 401 rejection for unauthenticated requests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 18:12:19 +07:00
parent 2432a20b45
commit 3418ab30b0
3 changed files with 111 additions and 0 deletions

View File

@@ -28,6 +28,41 @@ describe('McpTransportController', () => {
controller = new McpTransportController(mockRegistry as any);
});
describe('security decorators', () => {
it('has JwtAuthGuard applied at controller level', () => {
const guards = Reflect.getMetadata('__guards__', McpTransportController);
expect(guards).toBeDefined();
expect(guards).toHaveLength(1);
// The guard is applied via @UseGuards(JwtAuthGuard)
const guardNames = guards.map((g: any) => g.name || g.constructor?.name);
expect(guardNames).toContain('JwtAuthGuard');
});
it('has Throttle metadata on listServers endpoint', () => {
const limit = Reflect.getMetadata(
'THROTTLER:LIMITdefault',
McpTransportController.prototype.listServers,
);
expect(limit).toBe(30);
});
it('has Throttle metadata on handleSse endpoint with low limit', () => {
const limit = Reflect.getMetadata(
'THROTTLER:LIMITdefault',
McpTransportController.prototype.handleSse,
);
expect(limit).toBe(5);
});
it('has Throttle metadata on handleMessage endpoint', () => {
const limit = Reflect.getMetadata(
'THROTTLER:LIMITdefault',
McpTransportController.prototype.handleMessage,
);
expect(limit).toBe(30);
});
});
describe('listServers', () => {
it('returns list of server names from registry', () => {
mockRegistry.getServerNames.mockReturnValue(['search', 'listings']);
@@ -82,6 +117,24 @@ describe('McpTransportController', () => {
expect(mockServer.connect).toHaveBeenCalledOnce();
expect(mockReq.on).toHaveBeenCalledWith('close', expect.any(Function));
});
it('cleans up transport on connection close', async () => {
const mockServer = { connect: vi.fn().mockResolvedValue(undefined) };
mockRegistry.getServer.mockReturnValue(mockServer);
await controller.handleSse('search', mockUser as any, mockReq as any, mockRes as any);
// Simulate connection close
const closeHandler = mockReq.on.mock.calls[0]![1] as () => void;
closeHandler();
// Transport should be removed — subsequent message calls should fail
const msgReq = { query: { sessionId: 'mock-session-id' } } as any;
const msgRes = {} as any;
await expect(
controller.handleMessage('search', mockUser as any, msgReq, msgRes),
).rejects.toThrow(HttpException);
});
});
describe('handleMessage', () => {

View File

@@ -11,6 +11,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { JwtAuthGuard, CurrentUser, type JwtPayload } from '@modules/auth';
@@ -24,6 +25,7 @@ export class McpTransportController {
constructor(private readonly registry: McpRegistryService) {}
@Get('servers')
@Throttle({ default: { ttl: 60_000, limit: 30 } })
@ApiOperation({ summary: 'List available MCP servers' })
@ApiResponse({ status: 200, description: 'List of registered MCP server names' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@@ -32,6 +34,7 @@ export class McpTransportController {
}
@Get(':serverName/sse')
@Throttle({ default: { ttl: 60_000, limit: 5 } })
@ApiOperation({ summary: 'Open SSE connection to an MCP server' })
@ApiParam({ name: 'serverName', description: 'Name of the MCP server to connect to' })
@ApiResponse({ status: 200, description: 'SSE stream established' })
@@ -65,6 +68,7 @@ export class McpTransportController {
}
@Post(':serverName/messages')
@Throttle({ default: { ttl: 60_000, limit: 30 } })
@ApiOperation({ summary: 'Send a message to an MCP server session' })
@ApiParam({ name: 'serverName', description: 'Name of the MCP server' })
@ApiResponse({ status: 200, description: 'Message processed' })