mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-09 06:43: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
@@ -1,13 +1,16 @@
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
InitializeRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { existsSync, promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { n8nDocumentationToolsFinal } from './tools';
|
||||
import { UIAppRegistry } from './ui';
|
||||
import { n8nManagementTools } from './tools-n8n-manager';
|
||||
import { makeToolsN8nFriendly } from './tools-n8n-friendly';
|
||||
import { getWorkflowExampleString } from './workflow-examples';
|
||||
@@ -235,10 +238,12 @@ export class N8NDocumentationMCPServer {
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
UIAppRegistry.load();
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
@@ -563,6 +568,7 @@ export class N8NDocumentationMCPServer {
|
||||
protocolVersion: negotiationResult.version,
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'n8n-documentation-mcp',
|
||||
@@ -774,7 +780,13 @@ export class N8NDocumentationMCPServer {
|
||||
if (name.startsWith('validate_') && structuredContent !== null) {
|
||||
mcpResponse.structuredContent = structuredContent;
|
||||
}
|
||||
|
||||
|
||||
// Inject UI app metadata if available
|
||||
const uiApp = UIAppRegistry.getAppForTool(name);
|
||||
if (uiApp && uiApp.html) {
|
||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
||||
}
|
||||
|
||||
return mcpResponse;
|
||||
} catch (error) {
|
||||
logger.error(`Error executing tool ${name}`, error);
|
||||
@@ -826,6 +838,46 @@ export class N8NDocumentationMCPServer {
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Handle ListResources for UI apps
|
||||
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||
const apps = UIAppRegistry.getAllApps();
|
||||
return {
|
||||
resources: apps
|
||||
.filter(app => app.html !== null)
|
||||
.map(app => ({
|
||||
uri: app.config.uri,
|
||||
name: app.config.displayName,
|
||||
description: app.config.description,
|
||||
mimeType: app.config.mimeType,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Handle ReadResource for UI apps
|
||||
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||
const uri = request.params.uri;
|
||||
// Parse n8n-mcp://ui/{id} pattern
|
||||
const match = uri.match(/^n8n-mcp:\/\/ui\/(.+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Unknown resource URI: ${uri}`);
|
||||
}
|
||||
|
||||
const app = UIAppRegistry.getAppById(match[1]);
|
||||
if (!app || !app.html) {
|
||||
throw new Error(`UI app not found or not built: ${match[1]}`);
|
||||
}
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: app.config.uri,
|
||||
mimeType: app.config.mimeType,
|
||||
text: app.html,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
32
src/mcp/ui/app-configs.ts
Normal file
32
src/mcp/ui/app-configs.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { UIAppConfig } from './types';
|
||||
|
||||
export const UI_APP_CONFIGS: UIAppConfig[] = [
|
||||
{
|
||||
id: 'operation-result',
|
||||
displayName: 'Operation Result',
|
||||
description: 'Visual summary of workflow operations (create, update, delete, test)',
|
||||
uri: 'n8n-mcp://ui/operation-result',
|
||||
mimeType: 'text/html',
|
||||
toolPatterns: [
|
||||
'n8n_create_workflow',
|
||||
'n8n_update_full_workflow',
|
||||
'n8n_update_partial_workflow',
|
||||
'n8n_delete_workflow',
|
||||
'n8n_test_workflow',
|
||||
'n8n_autofix_workflow',
|
||||
'n8n_deploy_template',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'validation-summary',
|
||||
displayName: 'Validation Summary',
|
||||
description: 'Visual summary of node and workflow validation results',
|
||||
uri: 'n8n-mcp://ui/validation-summary',
|
||||
mimeType: 'text/html',
|
||||
toolPatterns: [
|
||||
'validate_node',
|
||||
'validate_workflow',
|
||||
'n8n_validate_workflow',
|
||||
],
|
||||
},
|
||||
];
|
||||
3
src/mcp/ui/index.ts
Normal file
3
src/mcp/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { UIAppConfig, UIMetadata, UIAppEntry } from './types';
|
||||
export { UI_APP_CONFIGS } from './app-configs';
|
||||
export { UIAppRegistry } from './registry';
|
||||
69
src/mcp/ui/registry.ts
Normal file
69
src/mcp/ui/registry.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type { UIAppConfig, UIAppEntry } from './types';
|
||||
import { UI_APP_CONFIGS } from './app-configs';
|
||||
|
||||
export class UIAppRegistry {
|
||||
private static entries: Map<string, UIAppEntry> = new Map();
|
||||
private static toolIndex: Map<string, UIAppEntry> = new Map();
|
||||
private static loaded = false;
|
||||
|
||||
static load(): void {
|
||||
// Resolve dist directory relative to package root
|
||||
// In production: package-root/ui-apps/dist/
|
||||
// __dirname will be src/mcp/ui or dist/mcp/ui
|
||||
const packageRoot = path.resolve(__dirname, '..', '..', '..');
|
||||
const distDir = path.join(packageRoot, 'ui-apps', 'dist');
|
||||
|
||||
this.entries.clear();
|
||||
this.toolIndex.clear();
|
||||
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
let html: string | null = null;
|
||||
const htmlPath = path.join(distDir, config.id, 'index.html');
|
||||
|
||||
if (existsSync(htmlPath)) {
|
||||
try {
|
||||
html = readFileSync(htmlPath, 'utf-8');
|
||||
logger.info(`Loaded UI app: ${config.id}`);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to read UI app HTML: ${config.id}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const entry: UIAppEntry = { config, html };
|
||||
this.entries.set(config.id, entry);
|
||||
|
||||
// Build tool -> entry index
|
||||
for (const pattern of config.toolPatterns) {
|
||||
this.toolIndex.set(pattern, entry);
|
||||
}
|
||||
}
|
||||
|
||||
this.loaded = true;
|
||||
logger.info(`UI App Registry loaded: ${this.entries.size} apps, ${this.toolIndex.size} tool mappings`);
|
||||
}
|
||||
|
||||
static getAppForTool(toolName: string): UIAppEntry | null {
|
||||
if (!this.loaded) return null;
|
||||
return this.toolIndex.get(toolName) ?? null;
|
||||
}
|
||||
|
||||
static getAppById(id: string): UIAppEntry | null {
|
||||
if (!this.loaded) return null;
|
||||
return this.entries.get(id) ?? null;
|
||||
}
|
||||
|
||||
static getAllApps(): UIAppEntry[] {
|
||||
if (!this.loaded) return [];
|
||||
return Array.from(this.entries.values());
|
||||
}
|
||||
|
||||
/** Reset registry state. Intended for testing only. */
|
||||
static reset(): void {
|
||||
this.entries.clear();
|
||||
this.toolIndex.clear();
|
||||
this.loaded = false;
|
||||
}
|
||||
}
|
||||
23
src/mcp/ui/types.ts
Normal file
23
src/mcp/ui/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* MCP Apps UI type definitions
|
||||
*/
|
||||
|
||||
export interface UIAppConfig {
|
||||
id: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
toolPatterns: string[];
|
||||
}
|
||||
|
||||
export interface UIMetadata {
|
||||
ui: {
|
||||
app: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UIAppEntry {
|
||||
config: UIAppConfig;
|
||||
html: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user