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
400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
import { nanoid } from 'nanoid';
|
|
|
|
import { test, expect } from '../../../fixtures/base';
|
|
|
|
/**
|
|
* E2E tests for the Internal MCP Service (/mcp-server/http).
|
|
*
|
|
* This tests the built-in MCP server that exposes n8n workflows to external
|
|
* MCP clients (like Claude AI). It provides 3 tools:
|
|
* - search_workflows: Search for workflows available in MCP
|
|
* - get_workflow_details: Get detailed information about a workflow
|
|
* - execute_workflow: Execute a workflow and get results
|
|
*
|
|
* Authentication is via Bearer token (MCP API key).
|
|
*
|
|
* NOTE: Tests run serially because n8n only supports ONE MCP API key at a time.
|
|
* Each test uses rotateMcpApiKey() to get a usable key (since getMcpApiKey()
|
|
* returns REDACTED after the first call), and rotation invalidates the previous
|
|
* key. Running in parallel would cause race conditions where tests invalidate
|
|
* each other's keys.
|
|
*/
|
|
|
|
test.describe(
|
|
'MCP Service',
|
|
{
|
|
annotation: [{ type: 'owner', description: 'AI' }],
|
|
},
|
|
() => {
|
|
// Run tests serially - n8n only supports one MCP API key at a time,
|
|
// and rotation invalidates the previous key
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
// Enable MCP access before each test
|
|
test.beforeEach(async ({ api }) => {
|
|
await api.setMcpAccess(true);
|
|
});
|
|
|
|
test.describe('Authentication', () => {
|
|
test('should reject requests without bearer token', async ({ api }) => {
|
|
const message = api.mcp.createMessage('tools/list');
|
|
const response = await api.mcp.internalMcpSendMessageNoAuth(message);
|
|
|
|
expect(response.status()).toBe(401);
|
|
});
|
|
|
|
test('should reject requests with invalid bearer token', async ({ api }) => {
|
|
const message = api.mcp.createMessage('tools/list');
|
|
const response = await api.mcp.internalMcpSendMessageNoAuth(message, {
|
|
Authorization: 'Bearer invalid-token-12345',
|
|
});
|
|
|
|
expect(response.status()).toBe(401);
|
|
});
|
|
|
|
test('should accept valid API key', async ({ api }) => {
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
|
|
const message = api.mcp.createMessage('tools/list');
|
|
const response = await api.mcp.internalMcpSendMessage(apiKey, message);
|
|
|
|
expect(response.status()).toBeLessThan(300);
|
|
});
|
|
|
|
test('should reject requests after key rotation with old key', async ({ api }) => {
|
|
const { apiKey: oldKey } = await api.rotateMcpApiKey();
|
|
const { apiKey: newKey } = await api.rotateMcpApiKey();
|
|
|
|
const message = api.mcp.createMessage('tools/list');
|
|
const responseWithOldKey = await api.mcp.internalMcpSendMessageNoAuth(message, {
|
|
Authorization: `Bearer ${oldKey}`,
|
|
});
|
|
expect(responseWithOldKey.status()).toBe(401);
|
|
|
|
const responseWithNewKey = await api.mcp.internalMcpSendMessage(newKey, message);
|
|
expect(responseWithNewKey.status()).toBeLessThan(300);
|
|
});
|
|
});
|
|
|
|
test.describe('MCP Settings', () => {
|
|
test('should reject when MCP access is disabled', async ({ api }) => {
|
|
await api.setMcpAccess(false);
|
|
|
|
try {
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
const message = api.mcp.createMessage('tools/list');
|
|
const response = await api.mcp.internalMcpSendMessage(apiKey, message);
|
|
|
|
expect(response.status()).toBe(403);
|
|
const body = await response.json();
|
|
expect(body.message).toContain('MCP access is disabled');
|
|
} finally {
|
|
await api.setMcpAccess(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('tools/list', () => {
|
|
test('should return all 3 built-in tools', async ({ api }) => {
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
const tools = await api.mcp.internalMcpListTools(apiKey);
|
|
|
|
expect(tools).toHaveLength(3);
|
|
|
|
const toolNames = tools.map((t) => t.name).sort();
|
|
expect(toolNames).toEqual(['execute_workflow', 'get_workflow_details', 'search_workflows']);
|
|
});
|
|
|
|
test('should include proper tool descriptions and schemas', async ({ api }) => {
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
const tools = await api.mcp.internalMcpListTools(apiKey);
|
|
|
|
const searchTool = tools.find((t) => t.name === 'search_workflows');
|
|
expect(searchTool).toBeDefined();
|
|
expect(searchTool!.description).toContain('Search');
|
|
expect(searchTool!.inputSchema).toBeDefined();
|
|
|
|
const detailsTool = tools.find((t) => t.name === 'get_workflow_details');
|
|
expect(detailsTool).toBeDefined();
|
|
expect(detailsTool!.description).toContain('workflow');
|
|
expect(detailsTool!.inputSchema).toBeDefined();
|
|
|
|
const executeTool = tools.find((t) => t.name === 'execute_workflow');
|
|
expect(executeTool).toBeDefined();
|
|
expect(executeTool!.description).toContain('Execute');
|
|
expect(executeTool!.inputSchema).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test.describe('search_workflows', () => {
|
|
test('should return workflows marked as available in MCP', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-service/mcp-available-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
const result = await api.mcp.internalMcpSearchWorkflows(apiKey);
|
|
|
|
expect(result.count).toBeGreaterThanOrEqual(1);
|
|
expect(result.data.length).toBeGreaterThanOrEqual(1);
|
|
|
|
const foundWorkflow = result.data.find((w) => w.id === workflowId);
|
|
expect(foundWorkflow).toBeDefined();
|
|
expect(foundWorkflow!.active).toBe(true);
|
|
expect(foundWorkflow!.scopes).toBeDefined();
|
|
expect(foundWorkflow!.availableInMCP).toBe(true);
|
|
});
|
|
|
|
test('should return workflows not marked as available in MCP with availableInMCP: false', async ({
|
|
api,
|
|
}) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-service/mcp-unavailable.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
const result = await api.mcp.internalMcpSearchWorkflows(apiKey);
|
|
|
|
const foundWorkflow = result.data.find((w) => w.id === workflowId);
|
|
expect(foundWorkflow).toBeDefined();
|
|
expect(foundWorkflow!.availableInMCP).toBe(false);
|
|
});
|
|
|
|
test('should support limit parameter', async ({ api }) => {
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
|
|
const result = await api.mcp.internalMcpSearchWorkflows(apiKey, { limit: 1 });
|
|
|
|
expect(result.data.length).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test('should support query filter for name search', async ({ api }) => {
|
|
const uniqueName = `Searchable-${nanoid(8)}`;
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-service/mcp-available-basic.json',
|
|
{
|
|
transform: (wf) => {
|
|
wf.name = uniqueName;
|
|
return wf;
|
|
},
|
|
},
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
|
|
const result = await api.mcp.internalMcpSearchWorkflows(apiKey, { query: uniqueName });
|
|
|
|
expect(result.data.length).toBe(1);
|
|
expect(result.data[0].id).toBe(workflowId);
|
|
});
|
|
|
|
test('should return workflow metadata (id, name, scopes)', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-service/mcp-available-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
const result = await api.mcp.internalMcpSearchWorkflows(apiKey);
|
|
|
|
const foundWorkflow = result.data.find((w) => w.id === workflowId);
|
|
expect(foundWorkflow).toBeDefined();
|
|
|
|
expect(foundWorkflow!.id).toBe(workflowId);
|
|
expect(foundWorkflow!.name).toBeTruthy();
|
|
expect(foundWorkflow!.scopes).toBeInstanceOf(Array);
|
|
expect(typeof foundWorkflow!.canExecute).toBe('boolean');
|
|
expect(typeof foundWorkflow!.availableInMCP).toBe('boolean');
|
|
});
|
|
});
|
|
|
|
test.describe('get_workflow_details', () => {
|
|
test('should return detailed info for accessible workflow', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-service/mcp-available-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
const result = await api.mcp.internalMcpGetWorkflowDetails(apiKey, workflowId);
|
|
|
|
expect(result.workflow).toBeDefined();
|
|
expect(result.workflow.id).toBe(workflowId);
|
|
expect(result.workflow.nodes).toBeDefined();
|
|
expect(result.workflow.connections).toBeDefined();
|
|
expect(result.workflow.settings).toBeDefined();
|
|
expect(result.workflow.scopes).toBeDefined();
|
|
expect(typeof result.workflow.canExecute).toBe('boolean');
|
|
});
|
|
|
|
test('should return error for non-existent workflow', async ({ api }) => {
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
|
|
const fakeWorkflowId = 'nonexistent-workflow-id-12345';
|
|
|
|
await expect(
|
|
api.mcp.internalMcpGetWorkflowDetails(apiKey, fakeWorkflowId),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
test('should return error for workflow not available in MCP', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-service/mcp-unavailable.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
|
|
await expect(api.mcp.internalMcpGetWorkflowDetails(apiKey, workflowId)).rejects.toThrow();
|
|
});
|
|
|
|
test('should include trigger info in response', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-service/mcp-available-webhook.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
const result = await api.mcp.internalMcpGetWorkflowDetails(apiKey, workflowId);
|
|
|
|
expect(result.triggerInfo).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test.describe('execute_workflow', () => {
|
|
test('should execute workflow successfully', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-service/mcp-available-basic.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
const result = await api.mcp.internalMcpExecuteWorkflow(apiKey, workflowId);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.executionId).toBeTruthy();
|
|
expect(result.result).toBeDefined();
|
|
});
|
|
|
|
test('should return error for non-existent workflow', async ({ api }) => {
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
const fakeWorkflowId = 'nonexistent-workflow-id-12345';
|
|
|
|
const result = await api.mcp.internalMcpExecuteWorkflow(apiKey, fakeWorkflowId);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
test('should return error for workflow not available in MCP', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-service/mcp-unavailable.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
const result = await api.mcp.internalMcpExecuteWorkflow(apiKey, workflowId);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBeTruthy();
|
|
});
|
|
|
|
test('should execute webhook workflow with inputs', async ({ api }) => {
|
|
const { workflowId, createdWorkflow } = await api.workflows.importWorkflowFromFile(
|
|
'mcp-service/mcp-available-webhook.json',
|
|
);
|
|
await api.workflows.activate(workflowId, createdWorkflow.versionId!);
|
|
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
const result = await api.mcp.internalMcpExecuteWorkflow(apiKey, workflowId, {
|
|
type: 'webhook',
|
|
webhookData: {
|
|
method: 'POST',
|
|
body: { message: 'Hello from MCP test' },
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.executionId).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test.describe('Error Handling', () => {
|
|
test('should handle malformed JSON-RPC messages', async ({ api }) => {
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
|
|
// Missing required 'jsonrpc: "2.0"' field
|
|
const malformedMessage = {
|
|
id: nanoid(),
|
|
method: 'tools/list',
|
|
};
|
|
|
|
const response = await api.mcp.internalMcpSendMessage(apiKey, malformedMessage);
|
|
|
|
// Server returns 400 Bad Request for malformed JSON-RPC
|
|
expect(response.status()).toBe(400);
|
|
|
|
const body = await response.json();
|
|
expect(body.error).toBeDefined();
|
|
expect(body.error.code).toBe(-32700); // Parse error
|
|
expect(body.error.message).toBeTruthy();
|
|
});
|
|
|
|
test('should handle unknown methods', async ({ api }) => {
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
|
|
const message = api.mcp.createMessage('unknown/method');
|
|
const response = await api.mcp.internalMcpSendMessage(apiKey, message);
|
|
|
|
// Server returns 200 OK with SSE response containing error
|
|
expect(response.status()).toBe(200);
|
|
expect(response.headers()['content-type']).toContain('text/event-stream');
|
|
|
|
// Parse SSE format: extract JSON from "data: {...}" line
|
|
const text = await response.text();
|
|
const dataLine = text.split('\n').find((line) => line.startsWith('data:'))!;
|
|
const body = JSON.parse(dataLine.slice(5).trim()) as {
|
|
error: { code: number; message: string };
|
|
};
|
|
|
|
expect(body.error).toBeDefined();
|
|
expect(body.error.code).toBe(-32601); // Method not found
|
|
expect(body.error.message).toBeTruthy();
|
|
});
|
|
|
|
test('should handle invalid tool parameters', async ({ api }) => {
|
|
const { apiKey } = await api.rotateMcpApiKey();
|
|
|
|
const message = api.mcp.createMessage('tools/call', {
|
|
name: 'search_workflows',
|
|
arguments: {
|
|
limit: 'not-a-number',
|
|
},
|
|
});
|
|
|
|
const response = await api.mcp.internalMcpSendMessage(apiKey, message);
|
|
|
|
// Server returns 200 OK with SSE response
|
|
expect(response.ok()).toBe(true);
|
|
expect(response.headers()['content-type']).toContain('text/event-stream');
|
|
|
|
// Parse SSE format: extract JSON from "data: {...}" line
|
|
const text = await response.text();
|
|
const dataLine = text.split('\n').find((line) => line.startsWith('data:'))!;
|
|
const body = JSON.parse(dataLine.slice(5).trim()) as {
|
|
error?: unknown;
|
|
result?: unknown;
|
|
jsonrpc: string;
|
|
};
|
|
|
|
expect(body.jsonrpc).toBe('2.0');
|
|
// Should return either a JSON-RPC error or a successful result
|
|
expect(body.error !== undefined || body.result !== undefined).toBe(true);
|
|
});
|
|
});
|
|
},
|
|
);
|