Add SSE streaming support on POST /mcp (Phase 2) (#19528)
## Summary - Add SSE (`text/event-stream`) as an alternative response format on `POST /mcp` per the MCP streamable-http spec - When clients send `Accept: text/event-stream`, the server responds with SSE wire format; otherwise returns JSON as before (fully backwards compatible) - Emit a `notifications/progress` SSE event before tool execution to signal long-running operations - No sessions, no GET SSE, no protocol version bump — this is transport-level only (Phase 2) ## Changes - **New**: `write-sse-event.util.ts` — writes correctly formatted SSE events to Express Response - **New**: `mcp-progress-notification.const.ts` — constants for progress notification method and token prefix - **Modified**: `mcp-core.controller.ts` — checks `Accept` header, branches into SSE vs JSON response path - **Modified**: `mcp-protocol.service.ts` — passes optional `sseWriter` callback to tool executor - **Modified**: `mcp-tool-executor.service.ts` — emits progress notification via `sseWriter` before tool execution - **Tests**: Unit tests for SSE utility, controller SSE/JSON paths, tool executor progress notifications, and integration tests for SSE streaming ## Test plan - [x] Unit tests: `writeSseEvent` utility produces correct SSE wire format - [x] Unit tests: Controller returns SSE headers and writes events when `Accept: text/event-stream` - [x] Unit tests: Controller returns JSON when `Accept: application/json` only - [x] Unit tests: Notifications (no `id`) return 202 regardless of Accept header - [x] Unit tests: Tool executor emits progress notification via sseWriter - [x] Unit tests: Tool executor works without sseWriter (backwards compatible) - [x] Integration tests: SSE response for ping, JSON fallback, progress notification before tool call - [x] All 503 test suites pass, typecheck clean https://claude.ai/code/session_01QrqjBUXePJkPMd6gBAoWaR --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+130
@@ -75,10 +75,16 @@ describe('McpCoreController', () => {
|
||||
const mockApiKey = { id: 'api-key-1' } as FlatApiKey;
|
||||
const mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn(),
|
||||
write: jest.fn(),
|
||||
end: jest.fn(),
|
||||
} as unknown as import('express').Response;
|
||||
|
||||
beforeEach(() => {
|
||||
(mockRes.status as jest.Mock).mockClear();
|
||||
(mockRes.setHeader as jest.Mock).mockClear();
|
||||
(mockRes.write as jest.Mock).mockClear();
|
||||
(mockRes.end as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
it('should call mcpProtocolService.handleMCPCoreQuery with correct parameters', async () => {
|
||||
@@ -106,6 +112,7 @@ describe('McpCoreController', () => {
|
||||
mockApiKey,
|
||||
mockUser,
|
||||
mockUserWorkspaceId,
|
||||
undefined,
|
||||
mockRes,
|
||||
);
|
||||
|
||||
@@ -151,6 +158,7 @@ describe('McpCoreController', () => {
|
||||
mockApiKey,
|
||||
mockUser,
|
||||
mockUserWorkspaceId,
|
||||
undefined,
|
||||
mockRes,
|
||||
);
|
||||
|
||||
@@ -195,6 +203,7 @@ describe('McpCoreController', () => {
|
||||
mockApiKey,
|
||||
mockUser,
|
||||
mockUserWorkspaceId,
|
||||
undefined,
|
||||
mockRes,
|
||||
);
|
||||
|
||||
@@ -224,6 +233,7 @@ describe('McpCoreController', () => {
|
||||
mockApiKey,
|
||||
mockUser,
|
||||
mockUserWorkspaceId,
|
||||
undefined,
|
||||
mockRes,
|
||||
);
|
||||
|
||||
@@ -256,6 +266,7 @@ describe('McpCoreController', () => {
|
||||
mockApiKey,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
mockRes,
|
||||
);
|
||||
|
||||
@@ -270,5 +281,124 @@ describe('McpCoreController', () => {
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should respond with SSE when client accepts text/event-stream', async () => {
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: { name: 'testTool', arguments: {} },
|
||||
id: '789',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: '789',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
content: [{ type: 'text', text: '{"ok":true}' }],
|
||||
isError: false,
|
||||
},
|
||||
};
|
||||
|
||||
mcpProtocolService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.handleMcpCore(
|
||||
mockRequest,
|
||||
mockWorkspace,
|
||||
mockApiKey,
|
||||
mockUser,
|
||||
mockUserWorkspaceId,
|
||||
'application/json, text/event-stream',
|
||||
mockRes,
|
||||
);
|
||||
|
||||
// SSE path returns nothing — response is written directly
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockRes.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Type',
|
||||
'text/event-stream',
|
||||
);
|
||||
expect(mockRes.setHeader).toHaveBeenCalledWith(
|
||||
'Cache-Control',
|
||||
'no-cache',
|
||||
);
|
||||
expect(mockRes.setHeader).toHaveBeenCalledWith(
|
||||
'Connection',
|
||||
'keep-alive',
|
||||
);
|
||||
expect(mockRes.setHeader).toHaveBeenCalledWith(
|
||||
'X-Content-Type-Options',
|
||||
'nosniff',
|
||||
);
|
||||
expect(mockRes.write).toHaveBeenCalledWith(
|
||||
`event: message\ndata: ${JSON.stringify(mockResponse)}\n\n`,
|
||||
);
|
||||
expect(mockRes.end).toHaveBeenCalled();
|
||||
// sseWriter callback should be passed to protocol service
|
||||
expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
|
||||
mockRequest,
|
||||
{
|
||||
workspace: mockWorkspace,
|
||||
userId: mockUser.id,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
apiKey: mockApiKey,
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return JSON when client only accepts application/json', async () => {
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'ping',
|
||||
id: '101',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: '101',
|
||||
jsonrpc: '2.0',
|
||||
result: {},
|
||||
};
|
||||
|
||||
mcpProtocolService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.handleMcpCore(
|
||||
mockRequest,
|
||||
mockWorkspace,
|
||||
mockApiKey,
|
||||
mockUser,
|
||||
mockUserWorkspaceId,
|
||||
'application/json',
|
||||
mockRes,
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(mockRes.setHeader).not.toHaveBeenCalled();
|
||||
expect(mockRes.write).not.toHaveBeenCalled();
|
||||
expect(mockRes.end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 202 for notifications even when client accepts SSE', async () => {
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
};
|
||||
|
||||
mcpProtocolService.handleMCPCoreQuery.mockResolvedValue(null);
|
||||
|
||||
const result = await controller.handleMcpCore(
|
||||
mockRequest,
|
||||
mockWorkspace,
|
||||
mockApiKey,
|
||||
mockUser,
|
||||
mockUserWorkspaceId,
|
||||
'application/json, text/event-stream',
|
||||
mockRes,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockRes.status).toHaveBeenCalledWith(202);
|
||||
expect(mockRes.setHeader).not.toHaveBeenCalled();
|
||||
expect(mockRes.end).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Headers,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
@@ -17,6 +18,7 @@ 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';
|
||||
import { writeSseEvent } from 'src/engine/api/mcp/utils/write-sse-event.util';
|
||||
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
|
||||
import { FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type';
|
||||
import { FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
@@ -50,22 +52,62 @@ export class McpCoreController {
|
||||
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
@Headers('accept') acceptHeader: string | undefined,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const result = await this.mcpProtocolService.handleMCPCoreQuery(body, {
|
||||
const authContext = {
|
||||
workspace,
|
||||
userId: user?.id,
|
||||
userWorkspaceId,
|
||||
apiKey,
|
||||
});
|
||||
};
|
||||
|
||||
// JSON-RPC notifications (no id) expect no response body regardless of Accept
|
||||
if (!isDefined(body.id)) {
|
||||
await this.mcpProtocolService.handleMCPCoreQuery(body, authContext);
|
||||
|
||||
// JSON-RPC notifications (no id) expect no response body
|
||||
if (!isDefined(result)) {
|
||||
res.status(HttpStatus.ACCEPTED);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const clientAcceptsSse =
|
||||
isDefined(acceptHeader) &&
|
||||
acceptHeader
|
||||
.split(',')
|
||||
.some((type) => type.trim().startsWith('text/event-stream'));
|
||||
|
||||
if (clientAcceptsSse) {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
// Prevent browsers from MIME-sniffing the SSE stream as HTML
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
const sseWriter = (data: Record<string, unknown>) => {
|
||||
writeSseEvent(res, data);
|
||||
};
|
||||
|
||||
const result = await this.mcpProtocolService.handleMCPCoreQuery(
|
||||
body,
|
||||
authContext,
|
||||
sseWriter,
|
||||
);
|
||||
|
||||
if (isDefined(result)) {
|
||||
writeSseEvent(res, result);
|
||||
}
|
||||
|
||||
res.end();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.mcpProtocolService.handleMCPCoreQuery(
|
||||
body,
|
||||
authContext,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user