Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
688 lines
25 KiB
TypeScript
688 lines
25 KiB
TypeScript
import { nanoid } from 'nanoid';
|
|
|
|
import { test, expect } from '../../../fixtures/base';
|
|
import type { McpSession } from '../../../services/mcp-api-helper';
|
|
|
|
/**
|
|
* E2E tests for the MCP Server Trigger node.
|
|
*
|
|
* Tests cover:
|
|
* - SSE and Streamable HTTP transports
|
|
* - Authentication (none, bearer, header)
|
|
* - Tool listing and execution
|
|
* - Session management
|
|
* - Error handling
|
|
*/
|
|
|
|
test.describe('MCP Trigger Node', {
|
|
annotation: [
|
|
{ type: 'owner', description: 'AI' },
|
|
],
|
|
}, () => {
|
|
test.describe('Streamable HTTP Transport', () => {
|
|
test('should initialize session and return mcp-session-id', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
// Get the MCP path from the workflow
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
|
|
|
expect(session.sessionId).toBeTruthy();
|
|
expect(session.transport).toBe('streamableHttp');
|
|
});
|
|
|
|
test('should list tools via Streamable HTTP', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
|
const tools = await api.mcp.listTools(session, mcpPath);
|
|
|
|
expect(tools).toHaveLength(1);
|
|
expect(tools[0].name).toBe('echo');
|
|
expect(tools[0].description).toContain('Echoes');
|
|
});
|
|
|
|
test('should call tool via Streamable HTTP', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
|
const result = await api.mcp.callTool(session, mcpPath, 'echo', {
|
|
message: 'Hello from E2E test!',
|
|
});
|
|
|
|
expect(result.content).toBeDefined();
|
|
expect(result.content.length).toBeGreaterThan(0);
|
|
expect(result.content[0].text).toContain('Hello from E2E test!');
|
|
});
|
|
|
|
test('should close session via DELETE', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
|
const deleteResponse = await api.mcp.streamableHttpDelete(session, mcpPath);
|
|
|
|
// DELETE should return success (200 or 202)
|
|
expect(deleteResponse.status()).toBeLessThan(300);
|
|
});
|
|
});
|
|
|
|
test.describe('SSE Transport', () => {
|
|
test('should establish SSE connection and return session', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
const session = await api.mcp.sseSetup(mcpPath);
|
|
|
|
try {
|
|
expect(session.sessionId).toBeTruthy();
|
|
expect(session.transport).toBe('sse');
|
|
expect(session.postUrl).toBeTruthy();
|
|
} finally {
|
|
api.mcp.sseClose(session);
|
|
}
|
|
});
|
|
|
|
test('should list connected tools via SSE', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
const session = await api.mcp.sseSetup(mcpPath);
|
|
|
|
try {
|
|
const tools = await api.mcp.listTools(session, mcpPath);
|
|
|
|
expect(tools).toHaveLength(1);
|
|
expect(tools[0].name).toBe('echo');
|
|
} finally {
|
|
api.mcp.sseClose(session);
|
|
}
|
|
});
|
|
|
|
test('should call tool and receive response via SSE', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
const session = await api.mcp.sseSetup(mcpPath);
|
|
|
|
try {
|
|
const result = await api.mcp.callTool(session, mcpPath, 'echo', {
|
|
message: 'SSE test message',
|
|
});
|
|
|
|
expect(result.content).toBeDefined();
|
|
expect(result.content[0].text).toContain('SSE test message');
|
|
} finally {
|
|
api.mcp.sseClose(session);
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Authentication', () => {
|
|
test('should reject unauthenticated request with bearerAuth', async ({ api }) => {
|
|
const token = `secret-token-${nanoid()}`;
|
|
const credential = await api.credentials.createCredential({
|
|
type: 'httpBearerAuth',
|
|
name: `mcp-bearer-${nanoid()}`,
|
|
data: { token },
|
|
});
|
|
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-bearer-auth.json',
|
|
{
|
|
transform: (wf) => {
|
|
const mcpNode = wf.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
if (mcpNode) {
|
|
mcpNode.credentials = {
|
|
httpBearerAuth: { id: credential.id, name: credential.name },
|
|
};
|
|
}
|
|
return wf;
|
|
},
|
|
},
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
// Try without auth - should fail
|
|
const noAuthResponse = await api.webhooks.trigger(mcpPath, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
data: api.mcp.createMessage('initialize', {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: { name: 'test', version: '1.0.0' },
|
|
}),
|
|
});
|
|
|
|
expect(noAuthResponse.status()).toBe(403);
|
|
});
|
|
|
|
test('should accept valid bearer token', async ({ api }) => {
|
|
const token = `secret-token-${nanoid()}`;
|
|
const credential = await api.credentials.createCredential({
|
|
type: 'httpBearerAuth',
|
|
name: `mcp-bearer-${nanoid()}`,
|
|
data: { token },
|
|
});
|
|
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-bearer-auth.json',
|
|
{
|
|
transform: (wf) => {
|
|
const mcpNode = wf.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
if (mcpNode) {
|
|
mcpNode.credentials = {
|
|
httpBearerAuth: { id: credential.id, name: credential.name },
|
|
};
|
|
}
|
|
return wf;
|
|
},
|
|
},
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
// Try with valid auth - should succeed
|
|
const session = await api.mcp.streamableHttpInitialize(mcpPath, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
|
|
expect(session.sessionId).toBeTruthy();
|
|
});
|
|
|
|
test('should accept valid header auth', async ({ api }) => {
|
|
const headerName = `X-Auth-${nanoid(8)}`;
|
|
const headerValue = `secret-value-${nanoid()}`;
|
|
const credential = await api.credentials.createCredential({
|
|
type: 'httpHeaderAuth',
|
|
name: `mcp-header-${nanoid()}`,
|
|
data: { name: headerName, value: headerValue },
|
|
});
|
|
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-header-auth.json',
|
|
{
|
|
transform: (wf) => {
|
|
const mcpNode = wf.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
if (mcpNode) {
|
|
mcpNode.credentials = {
|
|
httpHeaderAuth: { id: credential.id, name: credential.name },
|
|
};
|
|
}
|
|
return wf;
|
|
},
|
|
},
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
// Try without auth - should fail
|
|
const noAuthResponse = await api.webhooks.trigger(mcpPath, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
data: api.mcp.createMessage('initialize', {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {},
|
|
clientInfo: { name: 'test', version: '1.0.0' },
|
|
}),
|
|
});
|
|
expect(noAuthResponse.status()).toBe(403);
|
|
|
|
// Try with valid auth - should succeed
|
|
const session = await api.mcp.streamableHttpInitialize(mcpPath, {
|
|
headers: { [headerName]: headerValue },
|
|
});
|
|
|
|
expect(session.sessionId).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test.describe('Tool Operations', () => {
|
|
test('should return all connected tools in tools/list', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-multi-tool.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
|
const tools = await api.mcp.listTools(session, mcpPath);
|
|
|
|
expect(tools).toHaveLength(3);
|
|
|
|
const toolNames = tools.map((t) => t.name).sort();
|
|
expect(toolNames).toEqual(['add', 'echo', 'multiply']);
|
|
});
|
|
|
|
test('should execute tool with arguments', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-multi-tool.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
|
|
|
// Test echo tool
|
|
const echoResult = await api.mcp.callTool(session, mcpPath, 'echo', {
|
|
message: 'Multi-tool test',
|
|
});
|
|
expect(echoResult.content[0].text).toContain('Multi-tool test');
|
|
|
|
// Test add tool
|
|
const addResult = await api.mcp.callTool(session, mcpPath, 'add', { a: 5, b: 3 });
|
|
expect(addResult.content[0].text).toContain('8');
|
|
|
|
// Test multiply tool
|
|
const multiplyResult = await api.mcp.callTool(session, mcpPath, 'multiply', { a: 4, b: 7 });
|
|
expect(multiplyResult.content[0].text).toContain('28');
|
|
});
|
|
|
|
test('should return error for unknown tool', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
|
|
|
// Try to call a non-existent tool
|
|
const message = api.mcp.createMessage('tools/call', {
|
|
name: 'nonexistent_tool',
|
|
arguments: {},
|
|
});
|
|
|
|
const response = await api.mcp.streamableHttpSendMessage(session, mcpPath, message);
|
|
const body = await response.text();
|
|
|
|
// Should get an error response
|
|
expect(body).toContain('error');
|
|
});
|
|
});
|
|
|
|
test.describe('Session Management', () => {
|
|
test('should reject requests with invalid session ID', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
// Create a fake session with an invalid session ID
|
|
const fakeSession: McpSession = {
|
|
sessionId: 'invalid-session-id-12345',
|
|
transport: 'streamableHttp',
|
|
};
|
|
|
|
const message = api.mcp.createMessage('tools/list');
|
|
const response = await api.mcp.streamableHttpSendMessage(fakeSession, mcpPath, message);
|
|
|
|
// Should return an error status (404 or 401)
|
|
expect(response.status()).toBeGreaterThanOrEqual(400);
|
|
});
|
|
|
|
test('should cleanup session on DELETE request', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
// Initialize and then delete session
|
|
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
|
await api.mcp.streamableHttpDelete(session, mcpPath);
|
|
|
|
// Try to use the deleted session - should fail
|
|
const message = api.mcp.createMessage('tools/list');
|
|
const response = await api.mcp.streamableHttpSendMessage(session, mcpPath, message);
|
|
|
|
expect(response.status()).toBeGreaterThanOrEqual(400);
|
|
});
|
|
});
|
|
|
|
test.describe('Error Handling', () => {
|
|
test('should return 404 for non-existent endpoint', async ({ api }) => {
|
|
const response = await api.webhooks.trigger('webhook/non-existent-mcp-endpoint-12345', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
data: api.mcp.createMessage('initialize'),
|
|
});
|
|
|
|
expect(response.status()).toBe(404);
|
|
});
|
|
|
|
test('should handle malformed JSON-RPC messages', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
// First establish a valid session
|
|
const session = await api.mcp.streamableHttpInitialize(mcpPath);
|
|
|
|
// Send malformed message (missing required fields)
|
|
const malformedMessage = {
|
|
// Missing jsonrpc version
|
|
id: nanoid(),
|
|
method: 'tools/list',
|
|
};
|
|
|
|
const response = await api.mcp.streamableHttpSendMessage(session, mcpPath, malformedMessage);
|
|
|
|
// Server should handle gracefully (either error response or parse error)
|
|
// The exact behavior depends on implementation
|
|
const body = await response.text();
|
|
expect(body).toBeTruthy(); // Should return some response
|
|
});
|
|
});
|
|
});
|
|
|
|
// Queue mode tests - tagged with @mode:queue to run only in queue infrastructure
|
|
test.describe('MCP Trigger - Queue Mode', () => {
|
|
test('@mode:queue should return 202 Accepted for tool call in queue mode', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
// In SSE mode with queue mode enabled, tool calls should return 202 Accepted
|
|
// because the execution is queued and response comes via Redis pub/sub
|
|
const session = await api.mcp.sseSetup(mcpPath);
|
|
|
|
try {
|
|
const message = api.mcp.createMessage('tools/call', {
|
|
name: 'echo',
|
|
arguments: { message: 'Queue mode test' },
|
|
});
|
|
|
|
const response = await api.mcp.sseSendMessage(session, message);
|
|
|
|
// In queue mode, SSE tool calls may return 202 Accepted
|
|
// The actual result would come back asynchronously
|
|
expect([200, 202]).toContain(response.status());
|
|
} finally {
|
|
api.mcp.sseClose(session);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Multi-main tests - tagged with @mode:multi-main to run only in multi-main infrastructure
|
|
test.describe('MCP Trigger - Multi-Main', () => {
|
|
test.describe('Streamable HTTP Transport', () => {
|
|
test('@mode:multi-main should handle tool call on different main than session creator', async ({
|
|
api,
|
|
mainUrls,
|
|
createApiForMain,
|
|
}) => {
|
|
// This test verifies that MCP sessions work correctly in multi-main setups
|
|
// where the session might be created on one main but tool calls routed to another
|
|
|
|
// Skip if not in multi-main mode (need at least 2 mains)
|
|
test.skip(mainUrls.length < 2, 'Requires at least 2 mains for multi-main testing');
|
|
|
|
// Create workflow via load balancer (normal flow)
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
// Initialize session on main-1 (direct access, bypassing load balancer)
|
|
const main1Api = await createApiForMain(0);
|
|
const session = await main1Api.mcp.streamableHttpInitialize(mcpPath);
|
|
|
|
expect(session.sessionId).toBeTruthy();
|
|
|
|
// Send tool call to main-2 (different main than where session was created)
|
|
// This tests that session state is properly shared across mains via Redis
|
|
const main2Api = await createApiForMain(1);
|
|
const result = await main2Api.mcp.callTool(session, mcpPath, 'echo', {
|
|
message: 'Cross-main test',
|
|
});
|
|
|
|
expect(result.content).toBeDefined();
|
|
expect(result.content[0].text).toContain('Cross-main test');
|
|
});
|
|
|
|
test('@mode:multi-main should handle multiple tool calls across different mains', async ({
|
|
api,
|
|
mainUrls,
|
|
createApiForMain,
|
|
}) => {
|
|
// Test that multiple tool calls can be distributed across mains
|
|
test.skip(mainUrls.length < 2, 'Requires at least 2 mains for multi-main testing');
|
|
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-multi-tool.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
// Initialize session on main-1
|
|
const main1Api = await createApiForMain(0);
|
|
const session = await main1Api.mcp.streamableHttpInitialize(mcpPath);
|
|
|
|
// Alternate tool calls between mains to simulate load balancer behavior
|
|
const main2Api = await createApiForMain(1);
|
|
|
|
// Call 1: main-1
|
|
const echoResult = await main1Api.mcp.callTool(session, mcpPath, 'echo', {
|
|
message: 'From main 1',
|
|
});
|
|
expect(echoResult.content[0].text).toContain('From main 1');
|
|
|
|
// Call 2: main-2
|
|
const addResult = await main2Api.mcp.callTool(session, mcpPath, 'add', { a: 10, b: 20 });
|
|
expect(addResult.content[0].text).toContain('30');
|
|
|
|
// Call 3: main-1 again
|
|
const multiplyResult = await main1Api.mcp.callTool(session, mcpPath, 'multiply', {
|
|
a: 5,
|
|
b: 6,
|
|
});
|
|
expect(multiplyResult.content[0].text).toContain('30');
|
|
|
|
// Call 4: main-2 again
|
|
const echoResult2 = await main2Api.mcp.callTool(session, mcpPath, 'echo', {
|
|
message: 'From main 2',
|
|
});
|
|
expect(echoResult2.content[0].text).toContain('From main 2');
|
|
});
|
|
});
|
|
|
|
test.describe('SSE Transport', () => {
|
|
test('@mode:multi-main should handle SSE tool call on different main than session creator', async ({
|
|
api,
|
|
mainUrls,
|
|
createApiForMain,
|
|
}) => {
|
|
// This test verifies that SSE-based MCP sessions work correctly in multi-main setups
|
|
test.skip(mainUrls.length < 2, 'Requires at least 2 mains for multi-main testing');
|
|
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
// Initialize SSE session on main-1
|
|
const main1Api = await createApiForMain(0);
|
|
const session = await main1Api.mcp.sseSetup(mcpPath);
|
|
|
|
try {
|
|
expect(session.sessionId).toBeTruthy();
|
|
expect(session.transport).toBe('sse');
|
|
|
|
// Send tool call to main-2 (different main than where SSE session was created)
|
|
// This tests that SSE session state is properly shared across mains via Redis
|
|
// Use callToolCrossMain to POST to main2's URL while receiving on main1's SSE stream
|
|
const main2McpPath = `${mainUrls[1]}/${mcpPath}`;
|
|
const result = await main1Api.mcp.callToolCrossMain(session, main2McpPath, 'echo', {
|
|
message: 'SSE cross-main test',
|
|
});
|
|
|
|
expect(result.content).toBeDefined();
|
|
expect(result.content[0].text).toContain('SSE cross-main test');
|
|
} finally {
|
|
main1Api.mcp.sseClose(session);
|
|
}
|
|
});
|
|
|
|
test('@mode:multi-main should handle multiple SSE tool calls across different mains', async ({
|
|
api,
|
|
mainUrls,
|
|
createApiForMain,
|
|
}) => {
|
|
// Test that multiple SSE tool calls can be distributed across mains
|
|
test.skip(mainUrls.length < 2, 'Requires at least 2 mains for multi-main testing');
|
|
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-multi-tool.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
// Initialize SSE session on main-1
|
|
const main1Api = await createApiForMain(0);
|
|
const session = await main1Api.mcp.sseSetup(mcpPath);
|
|
|
|
// Construct full URL paths for cross-main calls
|
|
const main2McpPath = `${mainUrls[1]}/${mcpPath}`;
|
|
|
|
try {
|
|
// Call 1: main-1 (where SSE connection was established)
|
|
const echoResult = await main1Api.mcp.callTool(session, mcpPath, 'echo', {
|
|
message: 'SSE from main 1',
|
|
});
|
|
expect(echoResult.content[0].text).toContain('SSE from main 1');
|
|
|
|
// Call 2: main-2 (different main, tests Redis pub/sub for response routing)
|
|
// Use callToolCrossMain to POST to main2 but receive response on main1's SSE stream
|
|
const addResult = await main1Api.mcp.callToolCrossMain(session, main2McpPath, 'add', {
|
|
a: 15,
|
|
b: 25,
|
|
});
|
|
expect(addResult.content[0].text).toContain('40');
|
|
|
|
// Call 3: main-1 again
|
|
const multiplyResult = await main1Api.mcp.callTool(session, mcpPath, 'multiply', {
|
|
a: 7,
|
|
b: 8,
|
|
});
|
|
expect(multiplyResult.content[0].text).toContain('56');
|
|
|
|
// Call 4: main-2 again
|
|
const echoResult2 = await main1Api.mcp.callToolCrossMain(session, main2McpPath, 'echo', {
|
|
message: 'SSE from main 2',
|
|
});
|
|
expect(echoResult2.content[0].text).toContain('SSE from main 2');
|
|
} finally {
|
|
main1Api.mcp.sseClose(session);
|
|
}
|
|
});
|
|
|
|
test('@mode:multi-main should list tools via SSE from different main', async ({
|
|
api,
|
|
mainUrls,
|
|
createApiForMain,
|
|
}) => {
|
|
// Test that tools/list works across mains with SSE transport
|
|
test.skip(mainUrls.length < 2, 'Requires at least 2 mains for multi-main testing');
|
|
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-trigger/mcp-trigger-multi-tool.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const mcpNode = createdWorkflow.nodes?.find((n) => n.type.includes('mcpTrigger'));
|
|
const mcpPath = `webhook/${mcpNode?.parameters.path as string}`;
|
|
|
|
// Initialize SSE session on main-1
|
|
const main1Api = await createApiForMain(0);
|
|
const session = await main1Api.mcp.sseSetup(mcpPath);
|
|
|
|
try {
|
|
// List tools via main-2 (different main)
|
|
// Use listToolsCrossMain to POST to main2 but receive response on main1's SSE stream
|
|
const main2McpPath = `${mainUrls[1]}/${mcpPath}`;
|
|
const tools = await main1Api.mcp.listToolsCrossMain(session, main2McpPath);
|
|
|
|
expect(tools).toHaveLength(3);
|
|
const toolNames = tools.map((t) => t.name).sort();
|
|
expect(toolNames).toEqual(['add', 'echo', 'multiply']);
|
|
} finally {
|
|
main1Api.mcp.sseClose(session);
|
|
}
|
|
});
|
|
});
|
|
});
|