diff --git a/apps/api/src/modules/mcp/presentation/__tests__/mcp-transport.controller.spec.ts b/apps/api/src/modules/mcp/presentation/__tests__/mcp-transport.controller.spec.ts index e6ca2ec..63b3b11 100644 --- a/apps/api/src/modules/mcp/presentation/__tests__/mcp-transport.controller.spec.ts +++ b/apps/api/src/modules/mcp/presentation/__tests__/mcp-transport.controller.spec.ts @@ -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', () => { diff --git a/apps/api/src/modules/mcp/presentation/mcp-transport.controller.ts b/apps/api/src/modules/mcp/presentation/mcp-transport.controller.ts index a0e6214..a1feaa3 100644 --- a/apps/api/src/modules/mcp/presentation/mcp-transport.controller.ts +++ b/apps/api/src/modules/mcp/presentation/mcp-transport.controller.ts @@ -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' }) diff --git a/e2e/api/mcp.spec.ts b/e2e/api/mcp.spec.ts new file mode 100644 index 0000000..c99b076 --- /dev/null +++ b/e2e/api/mcp.spec.ts @@ -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); + }); + }); +});