feat(MCP Server Trigger Node): Terminate sessions on DELETE request (#16550)

This commit is contained in:
Eugene
2025-06-23 08:59:40 +02:00
committed by GitHub
parent 351db434a8
commit 3969425925
4 changed files with 167 additions and 11 deletions

View File

@@ -219,6 +219,30 @@ export class McpServerManager {
return wasToolCall(req.rawBody.toString());
}
async handleDeleteRequest(req: express.Request, resp: CompressionResponse) {
const sessionId = this.getSessionId(req);
if (!sessionId) {
resp.status(400).send('No sessionId provided');
return;
}
const transport = this.getTransport(sessionId);
if (transport) {
if (transport instanceof FlushingStreamableHTTPTransport) {
await transport.handleRequest(req, resp);
return;
} else {
// For SSE transport, we don't support DELETE requests
resp.status(405).send('Method Not Allowed');
return;
}
}
resp.status(404).send('Session not found');
}
setUpHandlers(server: Server) {
server.setRequestHandler(
ListToolsRequestSchema,

View File

@@ -125,6 +125,16 @@ export class McpTrigger extends Node {
ndvHideMethod: true,
ndvHideUrl: true,
},
{
name: 'default',
httpMethod: 'DELETE',
responseMode: 'onReceived',
isFullPath: true,
path: '={{$parameter["path"]}}',
nodeType: 'mcp',
ndvHideMethod: true,
ndvHideUrl: true,
},
],
};
@@ -162,22 +172,30 @@ export class McpTrigger extends Node {
return { noWebhookResponse: true };
} else if (webhookName === 'default') {
// Here we handle POST requests. These can be either
// Here we handle POST and DELETE requests.
// POST can be either:
// 1) Client calls in an established session using the SSE transport, or
// 2) Client calls in an established session using the StreamableHTTPServerTransport
// 3) Session setup requests using the StreamableHTTPServerTransport
// DELETE is used to terminate the session using the StreamableHTTPServerTransport
// Check if there is a session and a transport is already established
const sessionId = mcpServerManager.getSessionId(req);
if (sessionId && mcpServerManager.getTransport(sessionId)) {
const connectedTools = await getConnectedTools(context, true);
const wasToolCall = await mcpServerManager.handlePostMessage(req, resp, connectedTools);
if (wasToolCall) return { noWebhookResponse: true, workflowData: [[{ json: {} }]] };
if (req.method === 'DELETE') {
await mcpServerManager.handleDeleteRequest(req, resp);
} else {
// If no session is established, this is a setup request
// for the StreamableHTTPServerTransport, so we create a new transport
await mcpServerManager.createServerWithStreamableHTTPTransport(serverName, resp, req);
// Check if there is a session and a transport is already established
const sessionId = mcpServerManager.getSessionId(req);
if (sessionId && mcpServerManager.getTransport(sessionId)) {
const connectedTools = await getConnectedTools(context, true);
const wasToolCall = await mcpServerManager.handlePostMessage(req, resp, connectedTools);
if (wasToolCall) return { noWebhookResponse: true, workflowData: [[{ json: {} }]] };
} else {
// If no session is established, this is a setup request
// for the StreamableHTTPServerTransport, so we create a new transport
await mcpServerManager.createServerWithStreamableHTTPTransport(serverName, resp, req);
}
}
return { noWebhookResponse: true };
}

View File

@@ -445,4 +445,88 @@ describe('McpServer', () => {
expect(result2).toBe(mockTransport2);
});
});
describe('handleDeleteRequest', () => {
beforeEach(() => {
// Clear transports and servers before each test
mcpServerManager.transports = {};
mcpServerManager.servers = {};
});
it('should handle DELETE request for StreamableHTTP transport', async () => {
const deleteSessionId = 'delete-session-id';
const mockDeleteRequest = mock<Request>({
headers: { 'mcp-session-id': deleteSessionId },
});
const mockDeleteResponse = mock<CompressionResponse>();
mockDeleteResponse.status.mockReturnThis();
// Create a mock transport that passes instanceof check
const mockHttpTransport = Object.create(FlushingStreamableHTTPTransport.prototype);
mockHttpTransport.handleRequest = jest.fn();
// Set up the transport
mcpServerManager.transports[deleteSessionId] = mockHttpTransport;
// Call handleDeleteRequest
await mcpServerManager.handleDeleteRequest(mockDeleteRequest, mockDeleteResponse);
// Verify transport.handleRequest was called
expect(mockHttpTransport.handleRequest).toHaveBeenCalledWith(
mockDeleteRequest,
mockDeleteResponse,
);
});
it('should return 400 when no sessionId provided', async () => {
const mockDeleteRequest = mock<Request>({
query: {},
headers: {},
});
const mockDeleteResponse = mock<CompressionResponse>();
mockDeleteResponse.status.mockReturnThis();
// Mock getSessionId to return undefined
jest.spyOn(mcpServerManager, 'getSessionId').mockReturnValueOnce(undefined);
// Call handleDeleteRequest without sessionId
await mcpServerManager.handleDeleteRequest(mockDeleteRequest, mockDeleteResponse);
// Verify 400 response
expect(mockDeleteResponse.status).toHaveBeenCalledWith(400);
});
it('should return 404 for non-existent session', async () => {
const mockDeleteRequest = mock<Request>({
headers: { 'mcp-session-id': 'non-existent-session' },
});
const mockDeleteResponse = mock<CompressionResponse>();
mockDeleteResponse.status.mockReturnThis();
// Call handleDeleteRequest with non-existent sessionId
await mcpServerManager.handleDeleteRequest(mockDeleteRequest, mockDeleteResponse);
// Verify 404 response (session not found)
expect(mockDeleteResponse.status).toHaveBeenCalledWith(404);
});
it('should return 405 for SSE transport session', async () => {
const sseSessionId = 'sse-session-id';
const mockDeleteRequest = mock<Request>({
query: { sessionId: sseSessionId },
});
const mockDeleteResponse = mock<CompressionResponse>();
mockDeleteResponse.status.mockReturnThis();
const mockSSETransport = mock<FlushingSSEServerTransport>();
// Set up SSE transport
mcpServerManager.transports[sseSessionId] = mockSSETransport;
// Call handleDeleteRequest
await mcpServerManager.handleDeleteRequest(mockDeleteRequest, mockDeleteResponse);
// Verify 405 response (DELETE not supported for SSE)
expect(mockDeleteResponse.status).toHaveBeenCalledWith(405);
});
});
});

View File

@@ -5,7 +5,10 @@ import type { INode, IWebhookFunctions } from 'n8n-workflow';
import * as helpers from '@utils/helpers';
import type { FlushingSSEServerTransport } from '../FlushingTransport';
import type {
FlushingSSEServerTransport,
FlushingStreamableHTTPTransport,
} from '../FlushingTransport';
import type { McpServerManager } from '../McpServer';
import { McpTrigger } from '../McpTrigger.node';
@@ -137,5 +140,32 @@ describe('McpTrigger Node', () => {
mockResponse,
);
});
it('should handle DELETE webhook for StreamableHTTP session termination', async () => {
// Configure the context for DELETE webhook
mockContext.getWebhookName.mockReturnValue('default');
const mockDeleteRequest = mock<Request>({
method: 'DELETE',
headers: { 'mcp-session-id': sessionId },
path: '/custom-path',
});
mockContext.getRequestObject.mockReturnValueOnce(mockDeleteRequest);
// Mock existing StreamableHTTP transport
mockServerManager.getSessionId.mockReturnValue(sessionId);
mockServerManager.getTransport.mockReturnValue(mock<FlushingStreamableHTTPTransport>({}));
// Call the webhook method
const result = await mcpTrigger.webhook(mockContext);
// Verify that handleDeleteRequest was called
expect(mockServerManager.handleDeleteRequest).toHaveBeenCalledWith(
mockDeleteRequest,
mockResponse,
);
// Verify the returned result
expect(result).toEqual({ noWebhookResponse: true });
});
});
});