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());
|
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) {
|
setUpHandlers(server: Server) {
|
||||||
server.setRequestHandler(
|
server.setRequestHandler(
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
|
|||||||
@@ -125,6 +125,16 @@ export class McpTrigger extends Node {
|
|||||||
ndvHideMethod: true,
|
ndvHideMethod: true,
|
||||||
ndvHideUrl: 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 };
|
return { noWebhookResponse: true };
|
||||||
} else if (webhookName === 'default') {
|
} 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
|
// 1) Client calls in an established session using the SSE transport, or
|
||||||
// 2) Client calls in an established session using the StreamableHTTPServerTransport
|
// 2) Client calls in an established session using the StreamableHTTPServerTransport
|
||||||
// 3) Session setup requests 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
|
if (req.method === 'DELETE') {
|
||||||
const sessionId = mcpServerManager.getSessionId(req);
|
await mcpServerManager.handleDeleteRequest(req, resp);
|
||||||
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 {
|
} else {
|
||||||
// If no session is established, this is a setup request
|
// Check if there is a session and a transport is already established
|
||||||
// for the StreamableHTTPServerTransport, so we create a new transport
|
const sessionId = mcpServerManager.getSessionId(req);
|
||||||
await mcpServerManager.createServerWithStreamableHTTPTransport(serverName, resp, 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 };
|
return { noWebhookResponse: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -445,4 +445,88 @@ describe('McpServer', () => {
|
|||||||
expect(result2).toBe(mockTransport2);
|
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 * as helpers from '@utils/helpers';
|
||||||
|
|
||||||
import type { FlushingSSEServerTransport } from '../FlushingTransport';
|
import type {
|
||||||
|
FlushingSSEServerTransport,
|
||||||
|
FlushingStreamableHTTPTransport,
|
||||||
|
} from '../FlushingTransport';
|
||||||
import type { McpServerManager } from '../McpServer';
|
import type { McpServerManager } from '../McpServer';
|
||||||
import { McpTrigger } from '../McpTrigger.node';
|
import { McpTrigger } from '../McpTrigger.node';
|
||||||
|
|
||||||
@@ -137,5 +140,32 @@ describe('McpTrigger Node', () => {
|
|||||||
mockResponse,
|
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