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:
@@ -28,6 +28,41 @@ describe('McpTransportController', () => {
|
|||||||
controller = new McpTransportController(mockRegistry as any);
|
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', () => {
|
describe('listServers', () => {
|
||||||
it('returns list of server names from registry', () => {
|
it('returns list of server names from registry', () => {
|
||||||
mockRegistry.getServerNames.mockReturnValue(['search', 'listings']);
|
mockRegistry.getServerNames.mockReturnValue(['search', 'listings']);
|
||||||
@@ -82,6 +117,24 @@ describe('McpTransportController', () => {
|
|||||||
expect(mockServer.connect).toHaveBeenCalledOnce();
|
expect(mockServer.connect).toHaveBeenCalledOnce();
|
||||||
expect(mockReq.on).toHaveBeenCalledWith('close', expect.any(Function));
|
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', () => {
|
describe('handleMessage', () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { JwtAuthGuard, CurrentUser, type JwtPayload } from '@modules/auth';
|
import { JwtAuthGuard, CurrentUser, type JwtPayload } from '@modules/auth';
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export class McpTransportController {
|
|||||||
constructor(private readonly registry: McpRegistryService) {}
|
constructor(private readonly registry: McpRegistryService) {}
|
||||||
|
|
||||||
@Get('servers')
|
@Get('servers')
|
||||||
|
@Throttle({ default: { ttl: 60_000, limit: 30 } })
|
||||||
@ApiOperation({ summary: 'List available MCP servers' })
|
@ApiOperation({ summary: 'List available MCP servers' })
|
||||||
@ApiResponse({ status: 200, description: 'List of registered MCP server names' })
|
@ApiResponse({ status: 200, description: 'List of registered MCP server names' })
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
@@ -32,6 +34,7 @@ export class McpTransportController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':serverName/sse')
|
@Get(':serverName/sse')
|
||||||
|
@Throttle({ default: { ttl: 60_000, limit: 5 } })
|
||||||
@ApiOperation({ summary: 'Open SSE connection to an MCP server' })
|
@ApiOperation({ summary: 'Open SSE connection to an MCP server' })
|
||||||
@ApiParam({ name: 'serverName', description: 'Name of the MCP server to connect to' })
|
@ApiParam({ name: 'serverName', description: 'Name of the MCP server to connect to' })
|
||||||
@ApiResponse({ status: 200, description: 'SSE stream established' })
|
@ApiResponse({ status: 200, description: 'SSE stream established' })
|
||||||
@@ -65,6 +68,7 @@ export class McpTransportController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':serverName/messages')
|
@Post(':serverName/messages')
|
||||||
|
@Throttle({ default: { ttl: 60_000, limit: 30 } })
|
||||||
@ApiOperation({ summary: 'Send a message to an MCP server session' })
|
@ApiOperation({ summary: 'Send a message to an MCP server session' })
|
||||||
@ApiParam({ name: 'serverName', description: 'Name of the MCP server' })
|
@ApiParam({ name: 'serverName', description: 'Name of the MCP server' })
|
||||||
@ApiResponse({ status: 200, description: 'Message processed' })
|
@ApiResponse({ status: 200, description: 'Message processed' })
|
||||||
|
|||||||
54
e2e/api/mcp.spec.ts
Normal file
54
e2e/api/mcp.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { test, expect, registerUser } from '../fixtures';
|
||||||
|
|
||||||
|
test.describe('MCP API — Auth Guards', () => {
|
||||||
|
test.describe('GET /mcp/servers', () => {
|
||||||
|
test('rejects unauthenticated request with 401', async ({ request }) => {
|
||||||
|
const res = await request.get('/mcp/servers');
|
||||||
|
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns server list for authenticated user', async ({ request }) => {
|
||||||
|
const { accessToken } = await registerUser(request);
|
||||||
|
|
||||||
|
const res = await request.get('/mcp/servers', {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveProperty('servers');
|
||||||
|
expect(Array.isArray(body.servers)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('GET /mcp/:serverName/sse', () => {
|
||||||
|
test('rejects unauthenticated SSE connection with 401', async ({ request }) => {
|
||||||
|
const res = await request.get('/mcp/search/sse');
|
||||||
|
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('POST /mcp/:serverName/messages', () => {
|
||||||
|
test('rejects unauthenticated message with 401', async ({ request }) => {
|
||||||
|
const res = await request.post('/mcp/search/messages', {
|
||||||
|
params: { sessionId: 'fake-session' },
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status()).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 when sessionId is missing for authenticated user', async ({ request }) => {
|
||||||
|
const { accessToken } = await registerUser(request);
|
||||||
|
|
||||||
|
const res = await request.post('/mcp/search/messages', {
|
||||||
|
data: {},
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status()).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user