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,88 @@
import React from 'react';
import '@shared/styles/theme.css';
import { Card, Badge, Expandable } from '@shared/components';
import { useToolData } from '@shared/hooks/useToolData';
import type { ValidationSummaryData } from '@shared/types';
export default function App() {
const data = useToolData<ValidationSummaryData>();
if (!data) {
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Loading...</div>;
}
return (
<div style={{ maxWidth: '480px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
<Badge variant={data.valid ? 'success' : 'error'}>
{data.valid ? 'Valid' : 'Invalid'}
</Badge>
{data.displayName && (
<span style={{ fontSize: '14px', color: 'var(--n8n-text-muted)' }}>{data.displayName}</span>
)}
</div>
<Card>
<div style={{ display: 'flex', gap: '16px', fontSize: '13px' }}>
<div>
<span style={{ color: 'var(--n8n-error)' }}>{data.errorCount}</span> errors
</div>
<div>
<span style={{ color: 'var(--n8n-warning)' }}>{data.warningCount}</span> warnings
</div>
</div>
</Card>
{data.errors.length > 0 && (
<Expandable title="Errors" count={data.errors.length} defaultOpen>
{data.errors.map((err, i) => (
<div key={i} style={{
padding: '8px',
marginBottom: '6px',
background: 'var(--n8n-error-light)',
borderRadius: '4px',
fontSize: '12px',
color: 'var(--n8n-error)',
}}>
<div style={{ fontWeight: 600 }}>{err.type}</div>
{err.property && <div style={{ opacity: 0.8 }}>Property: {err.property}</div>}
<div>{err.message}</div>
{err.fix && (
<div style={{ marginTop: '4px', fontStyle: 'italic', opacity: 0.9 }}>Fix: {err.fix}</div>
)}
</div>
))}
</Expandable>
)}
{data.warnings.length > 0 && (
<Expandable title="Warnings" count={data.warnings.length}>
{data.warnings.map((warn, i) => (
<div key={i} style={{
padding: '8px',
marginBottom: '6px',
background: 'var(--n8n-warning-light)',
borderRadius: '4px',
fontSize: '12px',
color: 'var(--n8n-warning)',
}}>
<div style={{ fontWeight: 600 }}>{warn.type}</div>
{warn.property && <div style={{ opacity: 0.8 }}>Property: {warn.property}</div>}
<div>{warn.message}</div>
</div>
))}
</Expandable>
)}
{data.suggestions && data.suggestions.length > 0 && (
<Expandable title="Suggestions" count={data.suggestions.length}>
<ul style={{ paddingLeft: '16px', fontSize: '12px' }}>
{data.suggestions.map((suggestion, i) => (
<li key={i} style={{ padding: '2px 0', color: 'var(--n8n-info)' }}>{suggestion}</li>
))}
</ul>
</Expandable>
)}
</div>
);
}

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Validation Summary</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
const root = document.getElementById('root');
if (root) {
createRoot(root).render(<App />);
}