mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-08 06:13:07 +00:00
* feat: add MCP Apps with rich HTML UIs for tool results Add MCP Apps infrastructure that allows MCP hosts like Claude Desktop to render rich HTML UIs alongside tool results via `_meta.ui` and the MCP resources protocol. - Server-side UI module (src/mcp/ui/) with UIAppRegistry, tool-to-UI mapping, and _meta.ui injection into tool responses - React + Vite build pipeline (ui-apps/) producing self-contained HTML per app using vite-plugin-singlefile - Operation Result UI for workflow CRUD tools (create, update, delete, test, autofix, deploy) - Validation Summary UI for validation tools (validate_node, validate_workflow, n8n_validate_workflow) - Shared component library (Card, Badge, Expandable) with n8n dark theme - MCP resources protocol support (ListResources, ReadResource handlers) - Graceful degradation when ui-apps/dist/ is not built - 22 unit tests across 3 test files Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: improve MCP Apps test coverage and add security hardening - Expand test suite from 22 to 57 tests across 3 test files - Add UIAppRegistry.reset() for proper test isolation between tests - Replace some fs mocks with real temp directory tests in registry - Add edge case coverage: empty strings, pre-load state, double load, malformed URIs, duplicate tool patterns, empty HTML files - Add regression tests for specific tool-to-UI mappings - Add URI format consistency validation across all configs - Improve _meta.ui injection tests with structuredContent coexistence - Coverage: statements 79.4% -> 80%, lines 79.4% -> 80% Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
215 lines
6.7 KiB
TypeScript
215 lines
6.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { UIAppRegistry } from '@/mcp/ui/registry';
|
|
|
|
vi.mock('fs', () => ({
|
|
existsSync: vi.fn(),
|
|
readFileSync: vi.fn(),
|
|
}));
|
|
|
|
import { existsSync, readFileSync } from 'fs';
|
|
|
|
const mockExistsSync = vi.mocked(existsSync);
|
|
const mockReadFileSync = vi.mocked(readFileSync);
|
|
|
|
describe('UI Meta Injection Logic', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
UIAppRegistry.reset();
|
|
});
|
|
|
|
describe('when HTML is loaded', () => {
|
|
beforeEach(() => {
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockReadFileSync.mockReturnValue('<html>ui content</html>');
|
|
UIAppRegistry.load();
|
|
});
|
|
|
|
it('should add _meta.ui for matching tools', () => {
|
|
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
|
expect(uiApp).not.toBeNull();
|
|
expect(uiApp!.html).not.toBeNull();
|
|
|
|
// Simulate the injection logic from server.ts
|
|
const mcpResponse: any = {
|
|
content: [{ type: 'text', text: 'result' }],
|
|
};
|
|
|
|
if (uiApp && uiApp.html) {
|
|
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
|
}
|
|
|
|
expect(mcpResponse._meta).toBeDefined();
|
|
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/operation-result');
|
|
});
|
|
|
|
it('should add _meta.ui for validation tools', () => {
|
|
const uiApp = UIAppRegistry.getAppForTool('validate_workflow');
|
|
expect(uiApp).not.toBeNull();
|
|
|
|
const mcpResponse: any = {
|
|
content: [{ type: 'text', text: 'validation result' }],
|
|
};
|
|
|
|
if (uiApp && uiApp.html) {
|
|
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
|
}
|
|
|
|
expect(mcpResponse._meta).toBeDefined();
|
|
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/validation-summary');
|
|
});
|
|
|
|
it('should NOT add _meta.ui for non-matching tools', () => {
|
|
const uiApp = UIAppRegistry.getAppForTool('get_node_info');
|
|
expect(uiApp).toBeNull();
|
|
|
|
const mcpResponse: any = {
|
|
content: [{ type: 'text', text: 'node info' }],
|
|
};
|
|
|
|
if (uiApp && uiApp.html) {
|
|
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
|
}
|
|
|
|
expect(mcpResponse._meta).toBeUndefined();
|
|
});
|
|
|
|
it('should produce _meta with exact shape { ui: { app: string } }', () => {
|
|
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow')!;
|
|
const meta = { ui: { app: uiApp.config.uri } };
|
|
|
|
expect(meta).toEqual({
|
|
ui: {
|
|
app: 'n8n-mcp://ui/operation-result',
|
|
},
|
|
});
|
|
expect(Object.keys(meta)).toEqual(['ui']);
|
|
expect(Object.keys(meta.ui)).toEqual(['app']);
|
|
expect(typeof meta.ui.app).toBe('string');
|
|
});
|
|
|
|
it('should produce _meta.ui.app that matches the config uri', () => {
|
|
const uiApp = UIAppRegistry.getAppForTool('validate_node')!;
|
|
const meta = { ui: { app: uiApp.config.uri } };
|
|
expect(meta.ui.app).toBe(uiApp.config.uri);
|
|
expect(meta.ui.app).toBe('n8n-mcp://ui/validation-summary');
|
|
});
|
|
});
|
|
|
|
describe('when HTML is not loaded', () => {
|
|
beforeEach(() => {
|
|
mockExistsSync.mockReturnValue(false);
|
|
UIAppRegistry.load();
|
|
});
|
|
|
|
it('should NOT add _meta.ui even for matching tools', () => {
|
|
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
|
expect(uiApp).not.toBeNull();
|
|
expect(uiApp!.html).toBeNull();
|
|
|
|
const mcpResponse: any = {
|
|
content: [{ type: 'text', text: 'result' }],
|
|
};
|
|
|
|
if (uiApp && uiApp.html) {
|
|
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
|
}
|
|
|
|
expect(mcpResponse._meta).toBeUndefined();
|
|
});
|
|
|
|
it('should NOT add _meta.ui for validation tools without HTML', () => {
|
|
const uiApp = UIAppRegistry.getAppForTool('validate_node');
|
|
expect(uiApp).not.toBeNull();
|
|
expect(uiApp!.html).toBeNull();
|
|
|
|
const mcpResponse: any = {
|
|
content: [{ type: 'text', text: 'result' }],
|
|
};
|
|
|
|
if (uiApp && uiApp.html) {
|
|
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
|
}
|
|
|
|
expect(mcpResponse._meta).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('when registry has not been loaded at all', () => {
|
|
it('should NOT add _meta because getAppForTool returns null', () => {
|
|
// Registry never loaded - reset() was called in beforeEach
|
|
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
|
expect(uiApp).toBeNull();
|
|
|
|
const mcpResponse: any = {
|
|
content: [{ type: 'text', text: 'result' }],
|
|
};
|
|
|
|
if (uiApp && uiApp.html) {
|
|
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
|
}
|
|
|
|
expect(mcpResponse._meta).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('coexistence with structuredContent', () => {
|
|
beforeEach(() => {
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockReadFileSync.mockReturnValue('<html>ui</html>');
|
|
UIAppRegistry.load();
|
|
});
|
|
|
|
it('should coexist with structuredContent on the response', () => {
|
|
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
|
|
|
const mcpResponse: any = {
|
|
content: [{ type: 'text', text: 'result' }],
|
|
structuredContent: { workflowId: '123', status: 'created' },
|
|
};
|
|
|
|
if (uiApp && uiApp.html) {
|
|
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
|
}
|
|
|
|
expect(mcpResponse.structuredContent).toBeDefined();
|
|
expect(mcpResponse.structuredContent.workflowId).toBe('123');
|
|
expect(mcpResponse._meta).toBeDefined();
|
|
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/operation-result');
|
|
});
|
|
|
|
it('should not overwrite existing _meta properties when merging', () => {
|
|
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
|
|
|
const mcpResponse: any = {
|
|
content: [{ type: 'text', text: 'result' }],
|
|
_meta: { existingProp: 'value' },
|
|
};
|
|
|
|
if (uiApp && uiApp.html) {
|
|
mcpResponse._meta = { ...mcpResponse._meta, ui: { app: uiApp.config.uri } };
|
|
}
|
|
|
|
expect(mcpResponse._meta.existingProp).toBe('value');
|
|
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/operation-result');
|
|
});
|
|
|
|
it('should work with responses that have both structuredContent and existing _meta', () => {
|
|
const uiApp = UIAppRegistry.getAppForTool('validate_workflow');
|
|
|
|
const mcpResponse: any = {
|
|
content: [{ type: 'text', text: 'validation ok' }],
|
|
structuredContent: { valid: true, errors: [] },
|
|
_meta: { timing: 42 },
|
|
};
|
|
|
|
if (uiApp && uiApp.html) {
|
|
mcpResponse._meta = { ...mcpResponse._meta, ui: { app: uiApp.config.uri } };
|
|
}
|
|
|
|
expect(mcpResponse.structuredContent.valid).toBe(true);
|
|
expect(mcpResponse._meta.timing).toBe(42);
|
|
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/validation-summary');
|
|
});
|
|
});
|
|
});
|