Compare commits

...

6 Commits

Author SHA1 Message Date
Claude 318bbf415f refactor: use middleware for MCP 405 responses instead of controller handlers
Replace controller-level GET/DELETE handlers with a NestJS middleware that
intercepts non-POST requests on /mcp and returns proper 405 Method Not
Allowed with a JSON-RPC error body. This keeps the controller focused on
business logic, preserves class-level auth guards, and runs before the
guard pipeline so rejected methods incur no authentication overhead.

Also reverts the protocol version bump to 2024-11-05 — claiming 2025-03-26
without supporting its features (SSE streaming, sessions, batching) would
mislead strict clients.

https://claude.ai/code/session_011kTnDq6MymZbxfVCBRbbu3
2026-04-09 20:20:11 +00:00
Sri Hari Haran Sharma 597f623064 Merge branch 'main' into mcp-streamable-http-405 2026-04-09 18:42:52 +05:30
Sri Hari Haran Sharma dea077fa15 Merge branch 'main' into mcp-streamable-http-405 2026-04-09 18:33:10 +05:30
Sri Hari Haran Sharma 9f1fad8ea1 Merge branch 'main' into mcp-streamable-http-405 2026-04-09 18:21:55 +05:30
Sri Hari Haran Sharma 671fa401dc Merge branch 'main' into mcp-streamable-http-405 2026-04-09 18:06:11 +05:30
channi23 9877d50422 fix mcp streamable-http method handling 2026-04-09 18:03:34 +05:30
4 changed files with 137 additions and 0 deletions
+5
View File
@@ -17,6 +17,7 @@ import { CoreGraphQLApiModule } from 'src/engine/api/graphql/core-graphql-api.mo
import { GraphQLConfigModule } from 'src/engine/api/graphql/graphql-config/graphql-config.module';
import { GraphQLConfigService } from 'src/engine/api/graphql/graphql-config/graphql-config.service';
import { MetadataGraphQLApiModule } from 'src/engine/api/graphql/metadata-graphql-api.module';
import { McpMethodGuardMiddleware } from 'src/engine/api/mcp/middlewares/mcp-method-guard.middleware';
import { McpModule } from 'src/engine/api/mcp/mcp.module';
import { RestApiModule } from 'src/engine/api/rest/rest-api.module';
import { WorkspaceAuthContextMiddleware } from 'src/engine/core-modules/auth/middlewares/workspace-auth-context.middleware';
@@ -125,6 +126,10 @@ export class AppModule {
)
.forRoutes({ path: 'metadata', method: RequestMethod.ALL });
consumer
.apply(McpMethodGuardMiddleware)
.forRoutes({ path: 'mcp', method: RequestMethod.ALL });
for (const method of MIGRATED_REST_METHODS) {
consumer
.apply(RestCoreMiddleware, WorkspaceAuthContextMiddleware)
@@ -0,0 +1,76 @@
import { type Request, type Response } from 'express';
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
import { McpMethodGuardMiddleware } from 'src/engine/api/mcp/middlewares/mcp-method-guard.middleware';
describe('McpMethodGuardMiddleware', () => {
let middleware: McpMethodGuardMiddleware;
let mockRes: Partial<Response>;
let next: jest.Mock;
beforeEach(() => {
middleware = new McpMethodGuardMiddleware();
next = jest.fn();
mockRes = {
setHeader: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
});
it('should call next() for POST requests', () => {
const req = { method: 'POST' } as Request;
middleware.use(req, mockRes as Response, next);
expect(next).toHaveBeenCalled();
expect(mockRes.status).not.toHaveBeenCalled();
});
it('should return 405 with Allow header for GET requests', () => {
const req = { method: 'GET' } as Request;
middleware.use(req, mockRes as Response, next);
expect(next).not.toHaveBeenCalled();
expect(mockRes.setHeader).toHaveBeenCalledWith('Allow', 'POST');
expect(mockRes.status).toHaveBeenCalledWith(405);
expect(mockRes.json).toHaveBeenCalledWith({
jsonrpc: '2.0',
error: {
code: JSON_RPC_ERROR_CODE.INVALID_REQUEST,
message:
'HTTP method GET is not allowed. This MCP endpoint only accepts POST requests.',
},
id: null,
});
});
it('should return 405 with Allow header for DELETE requests', () => {
const req = { method: 'DELETE' } as Request;
middleware.use(req, mockRes as Response, next);
expect(next).not.toHaveBeenCalled();
expect(mockRes.setHeader).toHaveBeenCalledWith('Allow', 'POST');
expect(mockRes.status).toHaveBeenCalledWith(405);
expect(mockRes.json).toHaveBeenCalledWith({
jsonrpc: '2.0',
error: {
code: JSON_RPC_ERROR_CODE.INVALID_REQUEST,
message:
'HTTP method DELETE is not allowed. This MCP endpoint only accepts POST requests.',
},
id: null,
});
});
it('should return 405 for PUT requests', () => {
const req = { method: 'PUT' } as Request;
middleware.use(req, mockRes as Response, next);
expect(next).not.toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(405);
});
});
@@ -0,0 +1,30 @@
import { Injectable, type NestMiddleware } from '@nestjs/common';
import { type NextFunction, type Request, type Response } from 'express';
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
// MCP streamable-http spec (2025-03-26) requires that servers respond with
// 405 Method Not Allowed (plus an Allow header) for HTTP methods they do not
// support. This middleware runs before guards and controllers so non-POST
// requests are rejected without any authentication overhead.
@Injectable()
export class McpMethodGuardMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
if (req.method === 'POST') {
next();
return;
}
res.setHeader('Allow', 'POST');
res.status(405).json({
jsonrpc: '2.0',
error: {
code: JSON_RPC_ERROR_CODE.INVALID_REQUEST,
message: `HTTP method ${req.method} is not allowed. This MCP endpoint only accepts POST requests.`,
},
id: null,
});
}
}
@@ -19,6 +19,32 @@ describe('MCP Controller (integration)', () => {
.send(JSON.stringify(body));
};
it('should return 405 with JSON-RPC error for GET /mcp', async () => {
await request(baseUrl)
.get(endpoint)
.expect(405)
.expect((res) => {
expect(res.headers.allow).toBe('POST');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeDefined();
expect(res.body.error.code).toBe(-32600);
expect(res.body.id).toBeNull();
});
});
it('should return 405 with JSON-RPC error for DELETE /mcp', async () => {
await request(baseUrl)
.delete(endpoint)
.expect(405)
.expect((res) => {
expect(res.headers.allow).toBe('POST');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeDefined();
expect(res.body.error.code).toBe(-32600);
expect(res.body.id).toBeNull();
});
});
it('should respond to ping with a JSON-RPC result envelope', async () => {
await postMcp({ jsonrpc: '2.0', method: 'ping', id: '1' })
.expect(200)