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:
@@ -0,0 +1,3 @@
|
||||
export const MCP_PROGRESS_NOTIFICATION_METHOD = 'notifications/progress';
|
||||
|
||||
export const TOOL_CALL_PROGRESS_TOKEN_PREFIX = 'tool-call-';
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -261,6 +261,7 @@ describe('McpProtocolService', () => {
|
||||
),
|
||||
),
|
||||
mockRequest.params,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+58
@@ -1,4 +1,8 @@
|
||||
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
|
||||
import {
|
||||
MCP_PROGRESS_NOTIFICATION_METHOD,
|
||||
TOOL_CALL_PROGRESS_TOKEN_PREFIX,
|
||||
} from 'src/engine/api/mcp/constants/mcp-progress-notification.const';
|
||||
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
|
||||
|
||||
describe('McpToolExecutorService', () => {
|
||||
@@ -142,5 +146,59 @@ describe('McpToolExecutorService', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit progress notification via sseWriter before execution', async () => {
|
||||
const sseWriter = jest.fn();
|
||||
const toolSet = {
|
||||
my_tool: {
|
||||
execute: jest.fn().mockResolvedValue({ data: 'ok' }),
|
||||
description: 'My tool',
|
||||
inputSchema: { type: 'object' },
|
||||
},
|
||||
} as any;
|
||||
|
||||
await service.handleToolCall(
|
||||
'sse-1',
|
||||
toolSet,
|
||||
{ name: 'my_tool', arguments: {} },
|
||||
sseWriter,
|
||||
);
|
||||
|
||||
expect(sseWriter).toHaveBeenCalledTimes(1);
|
||||
expect(sseWriter).toHaveBeenCalledWith({
|
||||
jsonrpc: '2.0',
|
||||
method: MCP_PROGRESS_NOTIFICATION_METHOD,
|
||||
params: {
|
||||
progressToken: `${TOOL_CALL_PROGRESS_TOKEN_PREFIX}sse-1`,
|
||||
progress: 0,
|
||||
total: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit progress notification when sseWriter is undefined', async () => {
|
||||
const toolSet = {
|
||||
my_tool: {
|
||||
execute: jest.fn().mockResolvedValue({ data: 'ok' }),
|
||||
description: 'My tool',
|
||||
inputSchema: { type: 'object' },
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await service.handleToolCall('no-sse', toolSet, {
|
||||
name: 'my_tool',
|
||||
arguments: {},
|
||||
});
|
||||
|
||||
// Should still return normal result without errors
|
||||
expect(result).toEqual({
|
||||
id: 'no-sse',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
content: [{ type: 'text', text: '{"data":"ok"}' }],
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,6 +178,7 @@ export class McpProtocolService {
|
||||
userWorkspaceId?: string;
|
||||
apiKey: FlatApiKey | undefined;
|
||||
},
|
||||
sseWriter?: (data: Record<string, unknown>) => void,
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
// JSON-RPC notifications have no id and expect no response
|
||||
@@ -244,6 +245,7 @@ export class McpProtocolService {
|
||||
id,
|
||||
toolSet,
|
||||
params,
|
||||
sseWriter,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import { type ToolSet } from 'ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
|
||||
import {
|
||||
MCP_PROGRESS_NOTIFICATION_METHOD,
|
||||
TOOL_CALL_PROGRESS_TOKEN_PREFIX,
|
||||
} from 'src/engine/api/mcp/constants/mcp-progress-notification.const';
|
||||
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
|
||||
|
||||
@Injectable()
|
||||
@@ -12,6 +16,7 @@ export class McpToolExecutorService {
|
||||
id: string | number,
|
||||
toolSet: ToolSet,
|
||||
params: Record<string, unknown>,
|
||||
sseWriter?: (data: Record<string, unknown>) => void,
|
||||
) {
|
||||
const toolName = params.name as keyof typeof toolSet;
|
||||
const tool = toolSet[toolName];
|
||||
@@ -25,6 +30,18 @@ export class McpToolExecutorService {
|
||||
});
|
||||
}
|
||||
|
||||
if (isDefined(sseWriter)) {
|
||||
sseWriter({
|
||||
jsonrpc: '2.0',
|
||||
method: MCP_PROGRESS_NOTIFICATION_METHOD,
|
||||
params: {
|
||||
progressToken: `${TOOL_CALL_PROGRESS_TOKEN_PREFIX}${String(id)}`,
|
||||
progress: 0,
|
||||
total: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.execute(params.arguments, {
|
||||
toolCallId: '1',
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { writeSseEvent } from 'src/engine/api/mcp/utils/write-sse-event.util';
|
||||
|
||||
const createMockResponse = (headersSent = false) => ({
|
||||
write: jest.fn(),
|
||||
setHeader: jest.fn(),
|
||||
headersSent,
|
||||
});
|
||||
|
||||
describe('writeSseEvent', () => {
|
||||
it('should write correctly formatted SSE event', () => {
|
||||
const mockResponse = createMockResponse() as any;
|
||||
const data = { jsonrpc: '2.0', id: '123', result: { foo: 'bar' } };
|
||||
|
||||
writeSseEvent(mockResponse, data);
|
||||
|
||||
expect(mockResponse.write).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.write).toHaveBeenCalledWith(
|
||||
`event: message\ndata: ${JSON.stringify(data)}\n\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should produce wire format per HTML SSE spec', () => {
|
||||
const mockResponse = createMockResponse() as any;
|
||||
const data = { jsonrpc: '2.0', id: 1, result: {} };
|
||||
|
||||
writeSseEvent(mockResponse, data);
|
||||
|
||||
const written = mockResponse.write.mock.calls[0][0] as string;
|
||||
|
||||
// Must start with "event: message\n"
|
||||
expect(written.startsWith('event: message\n')).toBe(true);
|
||||
// Must contain "data: " followed by valid JSON
|
||||
const dataLine = written.split('\n')[1];
|
||||
|
||||
expect(dataLine).toBeDefined();
|
||||
expect(dataLine!.startsWith('data: ')).toBe(true);
|
||||
const parsed = JSON.parse(dataLine!.slice('data: '.length));
|
||||
|
||||
expect(parsed).toEqual(data);
|
||||
// Must end with double newline to terminate the event
|
||||
expect(written.endsWith('\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('should set content-type headers when headers have not been sent', () => {
|
||||
const mockResponse = createMockResponse(false) as any;
|
||||
|
||||
writeSseEvent(mockResponse, { jsonrpc: '2.0', id: '1', result: {} });
|
||||
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Type',
|
||||
'text/event-stream',
|
||||
);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'X-Content-Type-Options',
|
||||
'nosniff',
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip setting headers when headers have already been sent', () => {
|
||||
const mockResponse = createMockResponse(true) as any;
|
||||
|
||||
writeSseEvent(mockResponse, { jsonrpc: '2.0', id: '1', result: {} });
|
||||
|
||||
expect(mockResponse.setHeader).not.toHaveBeenCalled();
|
||||
expect(mockResponse.write).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type Response } from 'express';
|
||||
|
||||
export const writeSseEvent = (
|
||||
response: Response,
|
||||
data: Record<string, unknown>,
|
||||
): void => {
|
||||
// Ensure non-HTML content type so user data serialized as JSON cannot trigger XSS
|
||||
if (!response.headersSent) {
|
||||
response.setHeader('Content-Type', 'text/event-stream');
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
|
||||
response.write(`event: message\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
+112
-1
@@ -11,11 +11,16 @@ describe('MCP Controller (integration)', () => {
|
||||
const baseUrl = `http://localhost:${APP_PORT}`;
|
||||
const endpoint = '/mcp';
|
||||
|
||||
const postMcp = (body: any, bearer: string = API_KEY_ACCESS_TOKEN) => {
|
||||
const postMcp = (
|
||||
body: any,
|
||||
bearer: string = API_KEY_ACCESS_TOKEN,
|
||||
accept: string = 'application/json',
|
||||
) => {
|
||||
return request(baseUrl)
|
||||
.post(endpoint)
|
||||
.set('Authorization', `Bearer ${bearer}`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.set('Accept', accept)
|
||||
.send(JSON.stringify(body));
|
||||
};
|
||||
|
||||
@@ -191,4 +196,110 @@ describe('MCP Controller (integration)', () => {
|
||||
expect(Array.isArray(resources.body.result.resources)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSE Streaming', () => {
|
||||
const parseSseEvents = (
|
||||
rawBody: string,
|
||||
): Array<Record<string, unknown>> => {
|
||||
return rawBody
|
||||
.split('\n\n')
|
||||
.filter((block) => block.trim().length > 0)
|
||||
.map((block) => {
|
||||
const dataLine = block
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith('data: '));
|
||||
|
||||
if (!dataLine) {
|
||||
throw new Error(`No data line found in SSE block: ${block}`);
|
||||
}
|
||||
|
||||
return JSON.parse(dataLine.slice('data: '.length));
|
||||
});
|
||||
};
|
||||
|
||||
it('should respond with SSE when Accept includes text/event-stream', async () => {
|
||||
const res = await postMcp(
|
||||
{ jsonrpc: '2.0', method: 'ping', id: 'sse-ping-1' },
|
||||
API_KEY_ACCESS_TOKEN,
|
||||
'application/json, text/event-stream',
|
||||
).expect(200);
|
||||
|
||||
expect(res.headers['content-type']).toMatch(/text\/event-stream/);
|
||||
|
||||
const events = parseSseEvents(res.text);
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// The last event should be the JSON-RPC response
|
||||
const lastEvent = events[events.length - 1];
|
||||
|
||||
expect(lastEvent).toMatchObject({
|
||||
id: 'sse-ping-1',
|
||||
jsonrpc: '2.0',
|
||||
result: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should respond with JSON when Accept is application/json only', async () => {
|
||||
const res = await postMcp(
|
||||
{ jsonrpc: '2.0', method: 'ping', id: 'json-ping-1' },
|
||||
API_KEY_ACCESS_TOKEN,
|
||||
'application/json',
|
||||
).expect(200);
|
||||
|
||||
expect(res.headers['content-type']).toMatch(/application\/json/);
|
||||
expect(res.body).toMatchObject({
|
||||
id: 'json-ping-1',
|
||||
jsonrpc: '2.0',
|
||||
result: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include progress notification before tool call result in SSE', async () => {
|
||||
const res = await postMcp(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
id: 'sse-tool-1',
|
||||
params: { name: 'get_tool_catalog', arguments: {} },
|
||||
},
|
||||
API_KEY_ACCESS_TOKEN,
|
||||
'application/json, text/event-stream',
|
||||
).expect(200);
|
||||
|
||||
expect(res.headers['content-type']).toMatch(/text\/event-stream/);
|
||||
|
||||
const events = parseSseEvents(res.text);
|
||||
|
||||
// Should have at least a progress notification and the final response
|
||||
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// First event should be a progress notification
|
||||
const progressEvent = events[0];
|
||||
|
||||
expect(progressEvent).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/progress',
|
||||
params: {
|
||||
progressToken: 'tool-call-sse-tool-1',
|
||||
progress: 0,
|
||||
total: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Last event should be the final JSON-RPC response
|
||||
const lastEvent = events[events.length - 1];
|
||||
|
||||
expect(lastEvent).toMatchObject({
|
||||
id: 'sse-tool-1',
|
||||
jsonrpc: '2.0',
|
||||
});
|
||||
|
||||
// Should have either result.content or error
|
||||
const hasResultContent = !!(lastEvent as any)?.result?.content;
|
||||
const hasError = !!(lastEvent as any)?.error;
|
||||
|
||||
expect(hasResultContent || hasError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user