fix: align MCP Apps with official ext-apps spec (#574)

* 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>

* fix: align MCP Apps with official ext-apps spec

Update URI scheme from n8n-mcp://ui/ to ui://n8n-mcp/ per MCP spec.
Move _meta.ui.resourceUri to tool definitions (tools/list) instead of
tool call responses. Rewrite UI apps hook to use @modelcontextprotocol/ext-apps
App class instead of window.__MCP_DATA__.

Conceived by Romuald Czlonkowski - 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 05:16:15 +01:00
committed by GitHub
parent 1f45cc6dcc
commit 23b90d01a6
12 changed files with 210 additions and 175 deletions

View File

@@ -287,6 +287,75 @@ describe('UIAppRegistry', () => {
});
});
describe('injectToolMeta()', () => {
it('should not modify tools before load() is called', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeUndefined();
});
describe('after loading with HTML', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>loaded</html>');
UIAppRegistry.load();
});
it('should set _meta.ui.resourceUri on matching operation tools', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toEqual({ ui: { resourceUri: 'ui://n8n-mcp/operation-result' } });
});
it('should set _meta.ui.resourceUri on matching validation tools', () => {
const tools: any[] = [
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toEqual({ ui: { resourceUri: 'ui://n8n-mcp/validation-summary' } });
});
it('should not set _meta on tools without a matching UI app', () => {
const tools: any[] = [
{ name: 'search_nodes', description: 'Search', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeUndefined();
});
it('should handle a mix of matching and non-matching tools', () => {
const tools: any[] = [
{ name: 'n8n_delete_workflow', description: 'Delete', inputSchema: { type: 'object', properties: {} } },
{ name: 'get_node_essentials', description: 'Essentials', inputSchema: { type: 'object', properties: {} } },
{ name: 'validate_workflow', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta?.ui?.resourceUri).toBe('ui://n8n-mcp/operation-result');
expect(tools[1]._meta).toBeUndefined();
expect(tools[2]._meta?.ui?.resourceUri).toBe('ui://n8n-mcp/validation-summary');
});
});
describe('after loading without HTML', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
});
it('should not set _meta when HTML is not available', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeUndefined();
});
});
});
describe('reset()', () => {
it('should clear loaded state so getters return defaults', () => {
mockExistsSync.mockReturnValue(true);