mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(MCP Server Trigger Node): Terminate sessions on DELETE request (#16550)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user