mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-08 22:33:08 +00:00
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:
committed by
GitHub
parent
6814880410
commit
1f45cc6dcc
1911
ui-apps/package-lock.json
generated
Normal file
1911
ui-apps/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
ui-apps/package.json
Normal file
25
ui-apps/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "n8n-mcp-ui-apps",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "APP_NAME=operation-result vite build && APP_NAME=validation-summary vite build",
|
||||
"build:operation-result": "APP_NAME=operation-result vite build",
|
||||
"build:validation-summary": "APP_NAME=validation-summary vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-singlefile": "^2.0.0"
|
||||
}
|
||||
}
|
||||
84
ui-apps/src/apps/operation-result/App.tsx
Normal file
84
ui-apps/src/apps/operation-result/App.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import '@shared/styles/theme.css';
|
||||
import { Card, Badge, Expandable } from '@shared/components';
|
||||
import { useToolData } from '@shared/hooks/useToolData';
|
||||
import type { OperationResultData } from '@shared/types';
|
||||
|
||||
export default function App() {
|
||||
const data = useToolData<OperationResultData>();
|
||||
|
||||
if (!data) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Loading...</div>;
|
||||
}
|
||||
|
||||
const isSuccess = data.status === 'success';
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<Badge variant={isSuccess ? 'success' : 'error'}>
|
||||
{isSuccess ? 'Success' : 'Error'}
|
||||
</Badge>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 600 }}>{data.operation}</h2>
|
||||
</div>
|
||||
|
||||
<Card title="Workflow">
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
{data.workflowName && <div><strong>Name:</strong> {data.workflowName}</div>}
|
||||
{data.workflowId && <div><strong>ID:</strong> {data.workflowId}</div>}
|
||||
{data.timestamp && (
|
||||
<div style={{ color: 'var(--n8n-text-muted)', fontSize: '12px', marginTop: '4px' }}>
|
||||
{data.timestamp}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{data.message && (
|
||||
<Card>
|
||||
<div style={{ fontSize: '13px' }}>{data.message}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data.changes && (
|
||||
<>
|
||||
{data.changes.nodesAdded && data.changes.nodesAdded.length > 0 && (
|
||||
<Expandable title="Nodes Added" count={data.changes.nodesAdded.length} defaultOpen>
|
||||
<ul style={{ listStyle: 'none', fontSize: '13px' }}>
|
||||
{data.changes.nodesAdded.map((node, i) => (
|
||||
<li key={i} style={{ padding: '4px 0', borderBottom: '1px solid var(--n8n-border)' }}>
|
||||
<span style={{ color: 'var(--n8n-success)' }}>+</span> {node}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Expandable>
|
||||
)}
|
||||
|
||||
{data.changes.nodesModified && data.changes.nodesModified.length > 0 && (
|
||||
<Expandable title="Nodes Modified" count={data.changes.nodesModified.length}>
|
||||
<ul style={{ listStyle: 'none', fontSize: '13px' }}>
|
||||
{data.changes.nodesModified.map((node, i) => (
|
||||
<li key={i} style={{ padding: '4px 0', borderBottom: '1px solid var(--n8n-border)' }}>
|
||||
<span style={{ color: 'var(--n8n-warning)' }}>~</span> {node}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Expandable>
|
||||
)}
|
||||
|
||||
{data.changes.nodesRemoved && data.changes.nodesRemoved.length > 0 && (
|
||||
<Expandable title="Nodes Removed" count={data.changes.nodesRemoved.length}>
|
||||
<ul style={{ listStyle: 'none', fontSize: '13px' }}>
|
||||
{data.changes.nodesRemoved.map((node, i) => (
|
||||
<li key={i} style={{ padding: '4px 0', borderBottom: '1px solid var(--n8n-border)' }}>
|
||||
<span style={{ color: 'var(--n8n-error)' }}>-</span> {node}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Expandable>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
ui-apps/src/apps/operation-result/index.html
Normal file
12
ui-apps/src/apps/operation-result/index.html
Normal 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>Operation Result</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
ui-apps/src/apps/operation-result/main.tsx
Normal file
8
ui-apps/src/apps/operation-result/main.tsx
Normal 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 />);
|
||||
}
|
||||
88
ui-apps/src/apps/validation-summary/App.tsx
Normal file
88
ui-apps/src/apps/validation-summary/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
ui-apps/src/apps/validation-summary/index.html
Normal file
12
ui-apps/src/apps/validation-summary/index.html
Normal 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>
|
||||
8
ui-apps/src/apps/validation-summary/main.tsx
Normal file
8
ui-apps/src/apps/validation-summary/main.tsx
Normal 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 />);
|
||||
}
|
||||
32
ui-apps/src/shared/components/Badge.tsx
Normal file
32
ui-apps/src/shared/components/Badge.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
type BadgeVariant = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
interface BadgeProps {
|
||||
variant: BadgeVariant;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, { bg: string; color: string }> = {
|
||||
success: { bg: 'var(--n8n-success-light)', color: 'var(--n8n-success)' },
|
||||
warning: { bg: 'var(--n8n-warning-light)', color: 'var(--n8n-warning)' },
|
||||
error: { bg: 'var(--n8n-error-light)', color: 'var(--n8n-error)' },
|
||||
info: { bg: 'var(--n8n-info-light)', color: 'var(--n8n-info)' },
|
||||
};
|
||||
|
||||
export function Badge({ variant, children }: BadgeProps) {
|
||||
const style = variantStyles[variant];
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 10px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
background: style.bg,
|
||||
color: style.color,
|
||||
}}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
25
ui-apps/src/shared/components/Card.tsx
Normal file
25
ui-apps/src/shared/components/Card.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Card({ title, children }: CardProps) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--n8n-bg-card)',
|
||||
border: '1px solid var(--n8n-border)',
|
||||
borderRadius: 'var(--n8n-radius)',
|
||||
padding: '16px',
|
||||
marginBottom: '12px',
|
||||
}}>
|
||||
{title && (
|
||||
<h3 style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--n8n-text-muted)' }}>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
ui-apps/src/shared/components/Expandable.tsx
Normal file
36
ui-apps/src/shared/components/Expandable.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ExpandableProps {
|
||||
title: string;
|
||||
count?: number;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Expandable({ title, count, defaultOpen = false, children }: ExpandableProps) {
|
||||
return (
|
||||
<details open={defaultOpen} style={{
|
||||
marginBottom: '8px',
|
||||
border: '1px solid var(--n8n-border)',
|
||||
borderRadius: 'var(--n8n-radius)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<summary style={{
|
||||
padding: '10px 14px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
background: 'var(--n8n-bg-card)',
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
{title}
|
||||
{count !== undefined && (
|
||||
<span style={{ marginLeft: '8px', color: 'var(--n8n-text-muted)' }}>({count})</span>
|
||||
)}
|
||||
</summary>
|
||||
<div style={{ padding: '12px 14px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
3
ui-apps/src/shared/components/index.ts
Normal file
3
ui-apps/src/shared/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Card } from './Card';
|
||||
export { Badge } from './Badge';
|
||||
export { Expandable } from './Expandable';
|
||||
31
ui-apps/src/shared/hooks/useToolData.ts
Normal file
31
ui-apps/src/shared/hooks/useToolData.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__MCP_DATA__?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export function useToolData<T>(): T | null {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Try window.__MCP_DATA__ first (injected by host)
|
||||
if (window.__MCP_DATA__) {
|
||||
setData(window.__MCP_DATA__ as T);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try embedded script tag
|
||||
const scriptEl = document.getElementById('mcp-data');
|
||||
if (scriptEl?.textContent) {
|
||||
try {
|
||||
setData(JSON.parse(scriptEl.textContent) as T);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return data;
|
||||
}
|
||||
3
ui-apps/src/shared/index.ts
Normal file
3
ui-apps/src/shared/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Card, Badge, Expandable } from './components';
|
||||
export { useToolData } from './hooks/useToolData';
|
||||
export type { OperationResultData, ValidationSummaryData, ValidationError, ValidationWarning } from './types';
|
||||
32
ui-apps/src/shared/styles/theme.css
Normal file
32
ui-apps/src/shared/styles/theme.css
Normal file
@@ -0,0 +1,32 @@
|
||||
:root {
|
||||
--n8n-primary: #ff6d5a;
|
||||
--n8n-primary-light: #ff8a7a;
|
||||
--n8n-success: #17bf79;
|
||||
--n8n-success-light: #e8f9f0;
|
||||
--n8n-warning: #f59e0b;
|
||||
--n8n-warning-light: #fef3cd;
|
||||
--n8n-error: #ef4444;
|
||||
--n8n-error-light: #fee2e2;
|
||||
--n8n-info: #3b82f6;
|
||||
--n8n-info-light: #dbeafe;
|
||||
--n8n-bg: #1a1a2e;
|
||||
--n8n-bg-card: #252540;
|
||||
--n8n-text: #e0e0e0;
|
||||
--n8n-text-muted: #9ca3af;
|
||||
--n8n-border: #374151;
|
||||
--n8n-radius: 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--n8n-bg);
|
||||
color: var(--n8n-text);
|
||||
line-height: 1.5;
|
||||
padding: 16px;
|
||||
}
|
||||
38
ui-apps/src/shared/types.ts
Normal file
38
ui-apps/src/shared/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface OperationResultData {
|
||||
status: 'success' | 'error';
|
||||
operation: string;
|
||||
workflowName?: string;
|
||||
workflowId?: string;
|
||||
timestamp?: string;
|
||||
message?: string;
|
||||
changes?: {
|
||||
nodesAdded?: string[];
|
||||
nodesModified?: string[];
|
||||
nodesRemoved?: string[];
|
||||
};
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
type: string;
|
||||
property?: string;
|
||||
message: string;
|
||||
fix?: string;
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
type: string;
|
||||
property?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationSummaryData {
|
||||
valid: boolean;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
suggestions?: string[];
|
||||
nodeType?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
21
ui-apps/tsconfig.json
Normal file
21
ui-apps/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@shared/*": ["src/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
21
ui-apps/vite.config.ts
Normal file
21
ui-apps/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile';
|
||||
import path from 'path';
|
||||
|
||||
// App name is passed via environment variable for per-app builds
|
||||
const appName = process.env.APP_NAME || 'operation-result';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), viteSingleFile()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': path.resolve(__dirname, 'src/shared'),
|
||||
},
|
||||
},
|
||||
root: path.resolve(__dirname, 'src/apps', appName),
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, 'dist', appName),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user