feat: MCP Apps - rich HTML UIs for tool results (#573)

* 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>
This commit is contained in:
Romuald Członkowski
2026-02-07 04:11:21 +01:00
committed by GitHub
parent 6814880410
commit 1f45cc6dcc
37 changed files with 3306 additions and 9 deletions

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { UI_APP_CONFIGS } from '@/mcp/ui/app-configs';
describe('UI_APP_CONFIGS', () => {
it('should have all required fields for every config', () => {
for (const config of UI_APP_CONFIGS) {
expect(config.id).toBeDefined();
expect(typeof config.id).toBe('string');
expect(config.id.length).toBeGreaterThan(0);
expect(config.displayName).toBeDefined();
expect(typeof config.displayName).toBe('string');
expect(config.displayName.length).toBeGreaterThan(0);
expect(config.description).toBeDefined();
expect(typeof config.description).toBe('string');
expect(config.description.length).toBeGreaterThan(0);
expect(config.uri).toBeDefined();
expect(typeof config.uri).toBe('string');
expect(config.mimeType).toBeDefined();
expect(typeof config.mimeType).toBe('string');
expect(config.toolPatterns).toBeDefined();
expect(Array.isArray(config.toolPatterns)).toBe(true);
}
});
it('should have URIs following n8n-mcp://ui/{id} pattern', () => {
for (const config of UI_APP_CONFIGS) {
expect(config.uri).toBe(`n8n-mcp://ui/${config.id}`);
}
});
it('should have unique IDs', () => {
const ids = UI_APP_CONFIGS.map(c => c.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it('should have non-empty toolPatterns arrays', () => {
for (const config of UI_APP_CONFIGS) {
expect(config.toolPatterns.length).toBeGreaterThan(0);
for (const pattern of config.toolPatterns) {
expect(typeof pattern).toBe('string');
expect(pattern.length).toBeGreaterThan(0);
}
}
});
it('should not have duplicate tool patterns across configs', () => {
const allPatterns: string[] = [];
for (const config of UI_APP_CONFIGS) {
allPatterns.push(...config.toolPatterns);
}
const uniquePatterns = new Set(allPatterns);
expect(uniquePatterns.size).toBe(allPatterns.length);
});
it('should not have duplicate tool patterns within a single config', () => {
for (const config of UI_APP_CONFIGS) {
const unique = new Set(config.toolPatterns);
expect(unique.size).toBe(config.toolPatterns.length);
}
});
it('should have consistent mimeType of text/html', () => {
for (const config of UI_APP_CONFIGS) {
expect(config.mimeType).toBe('text/html');
}
});
it('should have URIs that start with the n8n-mcp://ui/ scheme', () => {
for (const config of UI_APP_CONFIGS) {
expect(config.uri).toMatch(/^n8n-mcp:\/\/ui\//);
}
});
// Regression: verify expected configs are present
it('should contain the operation-result config', () => {
const config = UI_APP_CONFIGS.find(c => c.id === 'operation-result');
expect(config).toBeDefined();
expect(config!.displayName).toBe('Operation Result');
expect(config!.toolPatterns).toContain('n8n_create_workflow');
expect(config!.toolPatterns).toContain('n8n_update_full_workflow');
expect(config!.toolPatterns).toContain('n8n_delete_workflow');
expect(config!.toolPatterns).toContain('n8n_test_workflow');
expect(config!.toolPatterns).toContain('n8n_deploy_template');
});
it('should contain the validation-summary config', () => {
const config = UI_APP_CONFIGS.find(c => c.id === 'validation-summary');
expect(config).toBeDefined();
expect(config!.displayName).toBe('Validation Summary');
expect(config!.toolPatterns).toContain('validate_node');
expect(config!.toolPatterns).toContain('validate_workflow');
expect(config!.toolPatterns).toContain('n8n_validate_workflow');
});
it('should have exactly 2 configs', () => {
expect(UI_APP_CONFIGS.length).toBe(2);
});
it('should have IDs that are valid URI path segments (no spaces or special chars)', () => {
for (const config of UI_APP_CONFIGS) {
expect(config.id).toMatch(/^[a-z0-9-]+$/);
}
});
});

View File

@@ -0,0 +1,214 @@
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');
});
});
});

View File

@@ -0,0 +1,305 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UIAppRegistry } from '@/mcp/ui/registry';
import { UI_APP_CONFIGS } from '@/mcp/ui/app-configs';
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('UIAppRegistry', () => {
beforeEach(() => {
vi.clearAllMocks();
UIAppRegistry.reset();
});
describe('load()', () => {
it('should load HTML files when dist directory exists', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>test</html>');
UIAppRegistry.load();
const apps = UIAppRegistry.getAllApps();
expect(apps.length).toBe(UI_APP_CONFIGS.length);
for (const app of apps) {
expect(app.html).toBe('<html>test</html>');
}
});
it('should handle missing dist directory gracefully', () => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
const apps = UIAppRegistry.getAllApps();
expect(apps.length).toBe(UI_APP_CONFIGS.length);
for (const app of apps) {
expect(app.html).toBeNull();
}
});
it('should handle read errors gracefully', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockImplementation(() => {
throw new Error('Permission denied');
});
UIAppRegistry.load();
const apps = UIAppRegistry.getAllApps();
expect(apps.length).toBe(UI_APP_CONFIGS.length);
for (const app of apps) {
expect(app.html).toBeNull();
}
});
it('should set loaded flag so getters work', () => {
expect(UIAppRegistry.getAllApps()).toEqual([]);
expect(UIAppRegistry.getAppById('operation-result')).toBeNull();
expect(UIAppRegistry.getAppForTool('n8n_create_workflow')).toBeNull();
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
expect(UIAppRegistry.getAllApps().length).toBeGreaterThan(0);
});
it('should replace previous entries when called twice', () => {
// First load: files exist
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>first</html>');
UIAppRegistry.load();
expect(UIAppRegistry.getAppById('operation-result')!.html).toBe('<html>first</html>');
// Second load: files missing
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
expect(UIAppRegistry.getAppById('operation-result')!.html).toBeNull();
});
it('should handle empty HTML file content', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('');
UIAppRegistry.load();
const app = UIAppRegistry.getAppById('operation-result');
expect(app).not.toBeNull();
// Empty string is still a string, not null
expect(app!.html).toBe('');
});
it('should build the correct number of tool index entries', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>app</html>');
UIAppRegistry.load();
// Every tool pattern from every config should be resolvable
for (const config of UI_APP_CONFIGS) {
for (const pattern of config.toolPatterns) {
const entry = UIAppRegistry.getAppForTool(pattern);
expect(entry).not.toBeNull();
expect(entry!.config.id).toBe(config.id);
}
}
});
it('should call existsSync for each config', () => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
expect(mockExistsSync).toHaveBeenCalledTimes(UI_APP_CONFIGS.length);
});
it('should only call readFileSync when existsSync returns true', () => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
expect(mockReadFileSync).not.toHaveBeenCalled();
});
});
describe('getAppForTool()', () => {
it('should return null before load() is called', () => {
const entry = UIAppRegistry.getAppForTool('n8n_create_workflow');
expect(entry).toBeNull();
});
describe('after loading', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>loaded</html>');
UIAppRegistry.load();
});
it('should return correct entry for known tool patterns', () => {
const entry = UIAppRegistry.getAppForTool('n8n_create_workflow');
expect(entry).not.toBeNull();
expect(entry!.config.id).toBe('operation-result');
});
it('should return correct entry for validation tools', () => {
const entry = UIAppRegistry.getAppForTool('validate_node');
expect(entry).not.toBeNull();
expect(entry!.config.id).toBe('validation-summary');
});
it('should return null for unknown tools', () => {
const entry = UIAppRegistry.getAppForTool('unknown_tool');
expect(entry).toBeNull();
});
it('should return null for empty string tool name', () => {
const entry = UIAppRegistry.getAppForTool('');
expect(entry).toBeNull();
});
// Regression: verify specific tools ARE mapped so config changes break the test
it('should map n8n_create_workflow to operation-result', () => {
expect(UIAppRegistry.getAppForTool('n8n_create_workflow')!.config.id).toBe('operation-result');
});
it('should map n8n_update_full_workflow to operation-result', () => {
expect(UIAppRegistry.getAppForTool('n8n_update_full_workflow')!.config.id).toBe('operation-result');
});
it('should map n8n_update_partial_workflow to operation-result', () => {
expect(UIAppRegistry.getAppForTool('n8n_update_partial_workflow')!.config.id).toBe('operation-result');
});
it('should map n8n_delete_workflow to operation-result', () => {
expect(UIAppRegistry.getAppForTool('n8n_delete_workflow')!.config.id).toBe('operation-result');
});
it('should map n8n_test_workflow to operation-result', () => {
expect(UIAppRegistry.getAppForTool('n8n_test_workflow')!.config.id).toBe('operation-result');
});
it('should map n8n_autofix_workflow to operation-result', () => {
expect(UIAppRegistry.getAppForTool('n8n_autofix_workflow')!.config.id).toBe('operation-result');
});
it('should map n8n_deploy_template to operation-result', () => {
expect(UIAppRegistry.getAppForTool('n8n_deploy_template')!.config.id).toBe('operation-result');
});
it('should map validate_node to validation-summary', () => {
expect(UIAppRegistry.getAppForTool('validate_node')!.config.id).toBe('validation-summary');
});
it('should map validate_workflow to validation-summary', () => {
expect(UIAppRegistry.getAppForTool('validate_workflow')!.config.id).toBe('validation-summary');
});
it('should map n8n_validate_workflow to validation-summary', () => {
expect(UIAppRegistry.getAppForTool('n8n_validate_workflow')!.config.id).toBe('validation-summary');
});
});
});
describe('getAppById()', () => {
it('should return null before load() is called', () => {
const entry = UIAppRegistry.getAppById('operation-result');
expect(entry).toBeNull();
});
describe('after loading', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>app</html>');
UIAppRegistry.load();
});
it('should return correct entry for operation-result', () => {
const entry = UIAppRegistry.getAppById('operation-result');
expect(entry).not.toBeNull();
expect(entry!.config.displayName).toBe('Operation Result');
expect(entry!.html).toBe('<html>app</html>');
});
it('should return correct entry for validation-summary', () => {
const entry = UIAppRegistry.getAppById('validation-summary');
expect(entry).not.toBeNull();
expect(entry!.config.displayName).toBe('Validation Summary');
});
it('should return null for unknown id', () => {
const entry = UIAppRegistry.getAppById('nonexistent');
expect(entry).toBeNull();
});
it('should return null for empty string id', () => {
const entry = UIAppRegistry.getAppById('');
expect(entry).toBeNull();
});
});
});
describe('getAllApps()', () => {
it('should return empty array before load() is called', () => {
const apps = UIAppRegistry.getAllApps();
expect(apps).toEqual([]);
});
it('should return all entries after load', () => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
const apps = UIAppRegistry.getAllApps();
expect(apps.length).toBe(UI_APP_CONFIGS.length);
expect(apps.map(a => a.config.id)).toContain('operation-result');
expect(apps.map(a => a.config.id)).toContain('validation-summary');
});
it('should include entries with null html when dist is missing', () => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
const apps = UIAppRegistry.getAllApps();
for (const app of apps) {
expect(app.html).toBeNull();
}
// Entries are still present even with null html
expect(apps.length).toBe(UI_APP_CONFIGS.length);
});
it('should return entries with full config objects', () => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
for (const app of UIAppRegistry.getAllApps()) {
expect(app.config).toBeDefined();
expect(app.config.id).toBeDefined();
expect(app.config.displayName).toBeDefined();
expect(app.config.uri).toBeDefined();
expect(app.config.mimeType).toBeDefined();
expect(app.config.toolPatterns).toBeDefined();
expect(app.config.description).toBeDefined();
}
});
});
describe('reset()', () => {
it('should clear loaded state so getters return defaults', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>x</html>');
UIAppRegistry.load();
expect(UIAppRegistry.getAllApps().length).toBeGreaterThan(0);
UIAppRegistry.reset();
expect(UIAppRegistry.getAllApps()).toEqual([]);
expect(UIAppRegistry.getAppById('operation-result')).toBeNull();
expect(UIAppRegistry.getAppForTool('n8n_create_workflow')).toBeNull();
});
});
});