Compare commits

...

6 Commits

Author SHA1 Message Date
Romuald Członkowski
c601581714 Fix/mcp app blank UI (#580)
* fix: use official ext-apps useApp hook to fix blank MCP App rendering

The custom useToolData hook had lifecycle issues that prevented the UI
from rendering in Claude Desktop/web: no appInfo in App constructor,
unhandled connect() Promise, app.close() on unmount conflicting with
React Strict Mode. Switched to the official useApp hook from
@modelcontextprotocol/ext-apps/react which handles initialization
handshake, handler registration, and cleanup correctly.

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: align MCP App UI types with actual server response format

- useToolData hook now uses official useApp from ext-apps/react
- OperationResultData uses success:boolean + data.id/name (matching
  McpToolResponse from handlers-n8n-manager.ts)
- ValidationSummaryData handles both direct results (validate_node,
  validate_workflow) and wrapped results (n8n_validate_workflow)
- Added visible error/connection states for debugging

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: bump version to 2.34.5 for npm publish

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 03:23:16 +01:00
Romuald Członkowski
020bc3d43d fix: MCP App UI - use official ext-apps hook + align types with server responses (#579)
* fix: use official ext-apps useApp hook to fix blank MCP App rendering

The custom useToolData hook had lifecycle issues that prevented the UI
from rendering in Claude Desktop/web: no appInfo in App constructor,
unhandled connect() Promise, app.close() on unmount conflicting with
React Strict Mode. Switched to the official useApp hook from
@modelcontextprotocol/ext-apps/react which handles initialization
handshake, handler registration, and cleanup correctly.

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: align MCP App UI types with actual server response format

- useToolData hook now uses official useApp from ext-apps/react
- OperationResultData uses success:boolean + data.id/name (matching
  McpToolResponse from handlers-n8n-manager.ts)
- ValidationSummaryData handles both direct results (validate_node,
  validate_workflow) and wrapped results (n8n_validate_workflow)
- Added visible error/connection states for debugging

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>
2026-02-08 02:08:50 +01:00
Romuald Członkowski
a57b400bd0 fix: use official ext-apps useApp hook to fix blank MCP App rendering (#578)
The custom useToolData hook had lifecycle issues that prevented the UI
from rendering in Claude Desktop/web: no appInfo in App constructor,
unhandled connect() Promise, app.close() on unmount conflicting with
React Strict Mode. Switched to the official useApp hook from
@modelcontextprotocol/ext-apps/react which handles initialization
handshake, handler registration, and cleanup correctly.

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 16:25:27 +01:00
Romuald Członkowski
38aa70261a fix: use text/html;profile=mcp-app MIME type for MCP Apps resources (#577)
The ext-apps spec requires RESOURCE_MIME_TYPE (text/html;profile=mcp-app)
for hosts to recognize resources as MCP Apps. Without the profile parameter,
Claude Desktop/web fails with "Failed to load MCP App: the resource may
exceed the 5 MB size limit."

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 15:18:50 +01:00
Romuald Członkowski
1b328d8168 fix: include UI apps build in CI release pipeline (#575)
The release workflow only ran `npm run build` (TypeScript), skipping the
UI apps build. This meant ui-apps/dist/ was missing from npm packages.

- Change `npm run build` to `npm run build:all` in build-and-verify and
  publish-npm jobs
- Copy ui-apps/dist into the npm publish directory
- Add ui-apps/dist/**/* to the published package files list
- Bump version to 2.34.2

Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 05:40:21 +01:00
Romuald Członkowski
23b90d01a6 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>
2026-02-07 05:16:15 +01:00
16 changed files with 385 additions and 281 deletions

View File

@@ -283,8 +283,8 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
- name: Build project (server + UI apps)
run: npm run build:all
# Database is already built and committed during development
# Rebuilding here causes segfault due to memory pressure (exit code 139)
@@ -322,8 +322,8 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build project
run: npm run build
- name: Build project (server + UI apps)
run: npm run build:all
# Database is already built and committed during development
- name: Verify database exists
@@ -347,6 +347,8 @@ jobs:
# Copy necessary files
cp -r dist $PUBLISH_DIR/
cp -r data $PUBLISH_DIR/
mkdir -p $PUBLISH_DIR/ui-apps
cp -r ui-apps/dist $PUBLISH_DIR/ui-apps/
cp README.md $PUBLISH_DIR/
cp LICENSE $PUBLISH_DIR/
cp .env.example $PUBLISH_DIR/
@@ -377,7 +379,7 @@ jobs:
pkg.license = 'MIT';
pkg.bugs = { url: 'https://github.com/czlonkowski/n8n-mcp/issues' };
pkg.homepage = 'https://github.com/czlonkowski/n8n-mcp#readme';
pkg.files = ['dist/**/*', 'data/nodes.db', '.env.example', 'README.md', 'LICENSE'];
pkg.files = ['dist/**/*', 'ui-apps/dist/**/*', 'data/nodes.db', '.env.example', 'README.md', 'LICENSE'];
delete pkg.private;
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));
"

View File

@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.34.5] - 2026-02-08
### Fixed
- **MCP Apps: Fix blank UI and wrong status badge in Claude**: Rewrote `useToolData` hook to use the official `useApp` hook from `@modelcontextprotocol/ext-apps/react` for proper lifecycle management. Updated UI types and components to match actual server response format (`success: boolean` instead of `status: string`, nested `data` object for workflow details). Validation summary now handles both direct and wrapped (`n8n_validate_workflow`) response shapes.
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
## [2.34.3] - 2026-02-07
### Fixed
- **MCP Apps: Use correct MIME type for ext-apps spec**: Changed resource MIME type from `text/html` to `text/html;profile=mcp-app` (the `RESOURCE_MIME_TYPE` constant from `@modelcontextprotocol/ext-apps`). Without this profile parameter, Claude Desktop/web fails to recognize resources as MCP Apps and shows "Failed to load MCP App: the resource may exceed the 5 MB size limit."
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
## [2.34.2] - 2026-02-07
### Fixed
- **CI: UI apps missing from npm package**: Release pipeline only ran `npm run build` (TypeScript), so `ui-apps/dist/` was never built and excluded from published packages
- Changed build step to `npm run build:all` in `build-and-verify` and `publish-npm` jobs
- Added `ui-apps/dist/` to npm publish staging directory
- Added `ui-apps/dist/**/*` to published package files list
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
## [2.34.1] - 2026-02-07
### Changed
- **MCP Apps: Align with official ext-apps spec** for Claude Desktop/web compatibility
- URI scheme changed from `n8n-mcp://ui/{id}` to `ui://n8n-mcp/{id}` per MCP ext-apps spec
- `_meta.ui.resourceUri` now set on tool definitions (`tools/list`) instead of tool call responses
- `UIMetadata.ui.app` renamed to `UIMetadata.ui.resourceUri`
- Added `_meta` field to `ToolDefinition` type
- Added `UIAppRegistry.injectToolMeta()` method for enriching tool definitions
- UI apps now use `@modelcontextprotocol/ext-apps` `App` class instead of `window.__MCP_DATA__`
- Updated `ReadResource` URI parser to match new `ui://` scheme
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
## [2.34.0] - 2026-02-07
### Added

View File

@@ -1,6 +1,6 @@
{
"name": "n8n-mcp",
"version": "2.34.0",
"version": "2.34.5",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -651,6 +651,7 @@ export class N8NDocumentationMCPServer {
});
});
UIAppRegistry.injectToolMeta(tools);
return { tools };
});
@@ -781,12 +782,6 @@ export class N8NDocumentationMCPServer {
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);
@@ -857,8 +852,8 @@ export class N8NDocumentationMCPServer {
// 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\/(.+)$/);
// Parse ui://n8n-mcp/{id} pattern
const match = uri.match(/^ui:\/\/n8n-mcp\/(.+)$/);
if (!match) {
throw new Error(`Unknown resource URI: ${uri}`);
}

View File

@@ -5,8 +5,8 @@ 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',
uri: 'ui://n8n-mcp/operation-result',
mimeType: 'text/html;profile=mcp-app',
toolPatterns: [
'n8n_create_workflow',
'n8n_update_full_workflow',
@@ -21,8 +21,8 @@ export const UI_APP_CONFIGS: UIAppConfig[] = [
id: 'validation-summary',
displayName: 'Validation Summary',
description: 'Visual summary of node and workflow validation results',
uri: 'n8n-mcp://ui/validation-summary',
mimeType: 'text/html',
uri: 'ui://n8n-mcp/validation-summary',
mimeType: 'text/html;profile=mcp-app',
toolPatterns: [
'validate_node',
'validate_workflow',

View File

@@ -60,6 +60,21 @@ export class UIAppRegistry {
return Array.from(this.entries.values());
}
/**
* Enrich tool definitions with _meta.ui.resourceUri for tools that have
* a matching UI app. Per MCP ext-apps spec, this goes on the tool
* definition (tools/list), not the tool call response.
*/
static injectToolMeta(tools: Array<{ name: string; [key: string]: any }>): void {
if (!this.loaded) return;
for (const tool of tools) {
const entry = this.toolIndex.get(tool.name);
if (entry && entry.html) {
tool._meta = { ui: { resourceUri: entry.config.uri } };
}
}
}
/** Reset registry state. Intended for testing only. */
static reset(): void {
this.entries.clear();

View File

@@ -13,7 +13,7 @@ export interface UIAppConfig {
export interface UIMetadata {
ui: {
app: string;
resourceUri: string;
};
}

View File

@@ -44,6 +44,11 @@ export interface ToolDefinition {
};
/** Tool behavior hints for AI assistants */
annotations?: ToolAnnotations;
_meta?: {
ui?: {
resourceUri?: string;
};
};
}
export interface ResourceDefinition {

View File

@@ -27,9 +27,9 @@ describe('UI_APP_CONFIGS', () => {
}
});
it('should have URIs following n8n-mcp://ui/{id} pattern', () => {
it('should have URIs following ui://n8n-mcp/{id} pattern', () => {
for (const config of UI_APP_CONFIGS) {
expect(config.uri).toBe(`n8n-mcp://ui/${config.id}`);
expect(config.uri).toBe(`ui://n8n-mcp/${config.id}`);
}
});
@@ -65,15 +65,15 @@ describe('UI_APP_CONFIGS', () => {
}
});
it('should have consistent mimeType of text/html', () => {
it('should have consistent mimeType of text/html;profile=mcp-app', () => {
for (const config of UI_APP_CONFIGS) {
expect(config.mimeType).toBe('text/html');
expect(config.mimeType).toBe('text/html;profile=mcp-app');
}
});
it('should have URIs that start with the n8n-mcp://ui/ scheme', () => {
it('should have URIs that start with the ui://n8n-mcp/ scheme', () => {
for (const config of UI_APP_CONFIGS) {
expect(config.uri).toMatch(/^n8n-mcp:\/\/ui\//);
expect(config.uri).toMatch(/^ui:\/\/n8n-mcp\//);
}
});

View File

@@ -11,7 +11,7 @@ import { existsSync, readFileSync } from 'fs';
const mockExistsSync = vi.mocked(existsSync);
const mockReadFileSync = vi.mocked(readFileSync);
describe('UI Meta Injection Logic', () => {
describe('UI Meta Injection on Tool Definitions', () => {
beforeEach(() => {
vi.clearAllMocks();
UIAppRegistry.reset();
@@ -24,74 +24,69 @@ describe('UI Meta Injection Logic', () => {
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();
it('should add _meta.ui.resourceUri to matching tool definitions', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create workflow', inputSchema: { type: 'object', properties: {} } },
];
// Simulate the injection logic from server.ts
const mcpResponse: any = {
content: [{ type: 'text', text: 'result' }],
};
UIAppRegistry.injectToolMeta(tools);
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');
expect(tools[0]._meta).toBeDefined();
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
});
it('should add _meta.ui for validation tools', () => {
const uiApp = UIAppRegistry.getAppForTool('validate_workflow');
expect(uiApp).not.toBeNull();
it('should add _meta.ui.resourceUri to validation tool definitions', () => {
const tools: any[] = [
{ name: 'validate_workflow', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
];
const mcpResponse: any = {
content: [{ type: 'text', text: 'validation result' }],
};
UIAppRegistry.injectToolMeta(tools);
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');
expect(tools[0]._meta).toBeDefined();
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary');
});
it('should NOT add _meta.ui for non-matching tools', () => {
const uiApp = UIAppRegistry.getAppForTool('get_node_info');
expect(uiApp).toBeNull();
it('should NOT add _meta to non-matching tool definitions', () => {
const tools: any[] = [
{ name: 'get_node_info', description: 'Get info', inputSchema: { type: 'object', properties: {} } },
];
const mcpResponse: any = {
content: [{ type: 'text', text: 'node info' }],
};
UIAppRegistry.injectToolMeta(tools);
if (uiApp && uiApp.html) {
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
}
expect(mcpResponse._meta).toBeUndefined();
expect(tools[0]._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 } };
it('should inject _meta on matching tools and skip non-matching in a mixed list', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
{ name: 'get_node_info', description: 'Info', inputSchema: { type: 'object', properties: {} } },
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
];
expect(meta).toEqual({
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeDefined();
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
expect(tools[1]._meta).toBeUndefined();
expect(tools[2]._meta).toBeDefined();
expect(tools[2]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary');
});
it('should produce _meta with exact shape { ui: { resourceUri: string } }', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toEqual({
ui: {
app: 'n8n-mcp://ui/operation-result',
resourceUri: 'ui://n8n-mcp/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');
expect(Object.keys(tools[0]._meta)).toEqual(['ui']);
expect(Object.keys(tools[0]._meta.ui)).toEqual(['resourceUri']);
expect(typeof tools[0]._meta.ui.resourceUri).toBe('string');
});
});
@@ -101,114 +96,50 @@ describe('UI Meta Injection Logic', () => {
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();
it('should NOT add _meta even for matching tools', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
const mcpResponse: any = {
content: [{ type: 'text', text: 'result' }],
};
UIAppRegistry.injectToolMeta(tools);
if (uiApp && uiApp.html) {
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
}
expect(mcpResponse._meta).toBeUndefined();
expect(tools[0]._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();
it('should NOT add _meta for validation tools without HTML', () => {
const tools: any[] = [
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
];
const mcpResponse: any = {
content: [{ type: 'text', text: 'result' }],
};
UIAppRegistry.injectToolMeta(tools);
if (uiApp && uiApp.html) {
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
}
expect(mcpResponse._meta).toBeUndefined();
expect(tools[0]._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();
it('should NOT add _meta because registry is not loaded', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
const mcpResponse: any = {
content: [{ type: 'text', text: 'result' }],
};
UIAppRegistry.injectToolMeta(tools);
if (uiApp && uiApp.html) {
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
}
expect(mcpResponse._meta).toBeUndefined();
expect(tools[0]._meta).toBeUndefined();
});
});
describe('coexistence with structuredContent', () => {
describe('empty tool list', () => {
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');
it('should handle an empty tools array without error', () => {
const tools: any[] = [];
UIAppRegistry.injectToolMeta(tools);
expect(tools.length).toBe(0);
});
});
});

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);

View File

@@ -11,6 +11,7 @@
"preview": "vite preview"
},
"dependencies": {
"@modelcontextprotocol/ext-apps": "^1.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},

View File

@@ -5,13 +5,26 @@ import { useToolData } from '@shared/hooks/useToolData';
import type { OperationResultData } from '@shared/types';
export default function App() {
const data = useToolData<OperationResultData>();
const { data, error, isConnected } = useToolData<OperationResultData>();
if (!data) {
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Loading...</div>;
if (error) {
return <div style={{ padding: '16px', color: '#ef4444' }}>Error: {error}</div>;
}
const isSuccess = data.status === 'success';
if (!isConnected) {
return <div style={{ padding: '16px', color: '#9ca3af' }}>Connecting...</div>;
}
if (!data) {
return <div style={{ padding: '16px', color: '#9ca3af' }}>Waiting for data...</div>;
}
const isSuccess = data.success === true;
const workflowName = data.data?.name || data.data?.workflowName;
const workflowId = data.data?.id || data.data?.workflowId;
const nodeCount = data.data?.nodeCount;
const isDeleted = data.data?.deleted === true;
const operationsApplied = data.data?.operationsApplied;
return (
<div style={{ maxWidth: '480px' }}>
@@ -19,65 +32,34 @@ export default function App() {
<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>
{(workflowName || workflowId) && (
<Card title="Workflow">
<div style={{ fontSize: '14px' }}>
{workflowName && <div><strong>Name:</strong> {workflowName}</div>}
{workflowId && <div><strong>ID:</strong> {workflowId}</div>}
{nodeCount !== undefined && <div><strong>Nodes:</strong> {nodeCount}</div>}
{isDeleted && <div style={{ color: 'var(--n8n-warning)', marginTop: '4px' }}>Deleted</div>}
{operationsApplied !== undefined && (
<div><strong>Operations applied:</strong> {operationsApplied}</div>
)}
</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.message || data.error) && (
<Card>
<div style={{ fontSize: '13px' }}>{data.message || data.error}</div>
</Card>
)}
{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>
)}
</>
{data.details && (
<Expandable title="Details">
<pre style={{ fontSize: '11px', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{JSON.stringify(data.details, null, 2)}
</pre>
</Expandable>
)}
</div>
);

View File

@@ -2,40 +2,59 @@ 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';
import type { ValidationSummaryData, ValidationError, ValidationWarning } from '@shared/types';
export default function App() {
const data = useToolData<ValidationSummaryData>();
const { data: raw, error, isConnected } = useToolData<ValidationSummaryData>();
if (!data) {
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Loading...</div>;
if (error) {
return <div style={{ padding: '16px', color: '#ef4444' }}>Error: {error}</div>;
}
if (!isConnected) {
return <div style={{ padding: '16px', color: '#9ca3af' }}>Connecting...</div>;
}
if (!raw) {
return <div style={{ padding: '16px', color: '#9ca3af' }}>Waiting for data...</div>;
}
// n8n_validate_workflow wraps result in { success, data: {...} }
// validate_node and validate_workflow return data directly
const inner = raw.data || raw;
const valid = inner.valid ?? raw.valid ?? false;
const displayName = raw.displayName || raw.data?.workflowName;
const errors: ValidationError[] = inner.errors || raw.errors || [];
const warnings: ValidationWarning[] = inner.warnings || raw.warnings || [];
const suggestions: string[] = inner.suggestions || raw.suggestions || [];
const errorCount = raw.summary?.errorCount ?? inner.summary?.errorCount ?? errors.length;
const warningCount = raw.summary?.warningCount ?? inner.summary?.warningCount ?? warnings.length;
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 variant={valid ? 'success' : 'error'}>
{valid ? 'Valid' : 'Invalid'}
</Badge>
{data.displayName && (
<span style={{ fontSize: '14px', color: 'var(--n8n-text-muted)' }}>{data.displayName}</span>
{displayName && (
<span style={{ fontSize: '14px', color: 'var(--n8n-text-muted)' }}>{displayName}</span>
)}
</div>
<Card>
<div style={{ display: 'flex', gap: '16px', fontSize: '13px' }}>
<div>
<span style={{ color: 'var(--n8n-error)' }}>{data.errorCount}</span> errors
<span style={{ color: 'var(--n8n-error)' }}>{errorCount}</span> errors
</div>
<div>
<span style={{ color: 'var(--n8n-warning)' }}>{data.warningCount}</span> warnings
<span style={{ color: 'var(--n8n-warning)' }}>{warningCount}</span> warnings
</div>
</div>
</Card>
{data.errors.length > 0 && (
<Expandable title="Errors" count={data.errors.length} defaultOpen>
{data.errors.map((err, i) => (
{errors.length > 0 && (
<Expandable title="Errors" count={errors.length} defaultOpen>
{errors.map((err, i) => (
<div key={i} style={{
padding: '8px',
marginBottom: '6px',
@@ -44,7 +63,9 @@ export default function App() {
fontSize: '12px',
color: 'var(--n8n-error)',
}}>
<div style={{ fontWeight: 600 }}>{err.type}</div>
{(err.type || err.node) && (
<div style={{ fontWeight: 600 }}>{err.type || err.node}</div>
)}
{err.property && <div style={{ opacity: 0.8 }}>Property: {err.property}</div>}
<div>{err.message}</div>
{err.fix && (
@@ -55,9 +76,9 @@ export default function App() {
</Expandable>
)}
{data.warnings.length > 0 && (
<Expandable title="Warnings" count={data.warnings.length}>
{data.warnings.map((warn, i) => (
{warnings.length > 0 && (
<Expandable title="Warnings" count={warnings.length}>
{warnings.map((warn, i) => (
<div key={i} style={{
padding: '8px',
marginBottom: '6px',
@@ -66,7 +87,9 @@ export default function App() {
fontSize: '12px',
color: 'var(--n8n-warning)',
}}>
<div style={{ fontWeight: 600 }}>{warn.type}</div>
{(warn.type || warn.node) && (
<div style={{ fontWeight: 600 }}>{warn.type || warn.node}</div>
)}
{warn.property && <div style={{ opacity: 0.8 }}>Property: {warn.property}</div>}
<div>{warn.message}</div>
</div>
@@ -74,10 +97,10 @@ export default function App() {
</Expandable>
)}
{data.suggestions && data.suggestions.length > 0 && (
<Expandable title="Suggestions" count={data.suggestions.length}>
{suggestions.length > 0 && (
<Expandable title="Suggestions" count={suggestions.length}>
<ul style={{ paddingLeft: '16px', fontSize: '12px' }}>
{data.suggestions.map((suggestion, i) => (
{suggestions.map((suggestion, i) => (
<li key={i} style={{ padding: '2px 0', color: 'var(--n8n-info)' }}>{suggestion}</li>
))}
</ul>

View File

@@ -1,31 +1,41 @@
import { useState, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { useApp } from '@modelcontextprotocol/ext-apps/react';
declare global {
interface Window {
__MCP_DATA__?: unknown;
}
interface UseToolDataResult<T> {
data: T | null;
error: string | null;
isConnected: boolean;
}
export function useToolData<T>(): T | null {
export function useToolData<T>(): UseToolDataResult<T> {
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
const onAppCreated = useCallback((app: any) => {
app.ontoolresult = (result: any) => {
if (result?.content) {
const textItem = Array.isArray(result.content)
? result.content.find((c: any) => c.type === 'text')
: null;
if (textItem?.text) {
try {
setData(JSON.parse(textItem.text) as T);
} catch {
setData(textItem.text as unknown as T);
}
}
}
}
};
}, []);
return data;
const { isConnected, error } = useApp({
appInfo: { name: 'n8n-mcp-ui', version: '1.0.0' },
capabilities: {},
onAppCreated,
});
return {
data,
error: error?.message ?? null,
isConnected,
};
}

View File

@@ -1,38 +1,67 @@
// Matches the McpToolResponse format from handlers-n8n-manager.ts
export interface OperationResultData {
status: 'success' | 'error';
operation: string;
workflowName?: string;
workflowId?: string;
timestamp?: string;
message?: string;
changes?: {
nodesAdded?: string[];
nodesModified?: string[];
nodesRemoved?: string[];
success: boolean;
data?: {
id?: string;
name?: string;
active?: boolean;
nodeCount?: number;
workflowId?: string;
workflowName?: string;
deleted?: boolean;
operationsApplied?: number;
[key: string]: unknown;
};
message?: string;
error?: string;
details?: Record<string, unknown>;
}
export interface ValidationError {
type: string;
type?: string;
property?: string;
message: string;
fix?: string;
node?: string;
details?: unknown;
}
export interface ValidationWarning {
type: string;
type?: string;
property?: string;
message: string;
node?: string;
details?: unknown;
}
// Matches the validate_node / validate_workflow response format from server.ts
export interface ValidationSummaryData {
valid: boolean;
errorCount: number;
warningCount: number;
nodeType?: string;
displayName?: string;
errors: ValidationError[];
warnings: ValidationWarning[];
suggestions?: string[];
nodeType?: string;
displayName?: string;
summary?: {
errorCount?: number;
warningCount?: number;
hasErrors?: boolean;
suggestionCount?: number;
[key: string]: unknown;
};
// n8n_validate_workflow wraps result in success/data
success?: boolean;
data?: {
valid?: boolean;
workflowId?: string;
workflowName?: string;
errors?: ValidationError[];
warnings?: ValidationWarning[];
suggestions?: string[];
summary?: {
errorCount?: number;
warningCount?: number;
[key: string]: unknown;
};
};
}