mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-08 06:13:07 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b328d8168 | ||
|
|
23b90d01a6 |
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -283,8 +283,8 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build project
|
- name: Build project (server + UI apps)
|
||||||
run: npm run build
|
run: npm run build:all
|
||||||
|
|
||||||
# Database is already built and committed during development
|
# Database is already built and committed during development
|
||||||
# Rebuilding here causes segfault due to memory pressure (exit code 139)
|
# Rebuilding here causes segfault due to memory pressure (exit code 139)
|
||||||
@@ -322,8 +322,8 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build project
|
- name: Build project (server + UI apps)
|
||||||
run: npm run build
|
run: npm run build:all
|
||||||
|
|
||||||
# Database is already built and committed during development
|
# Database is already built and committed during development
|
||||||
- name: Verify database exists
|
- name: Verify database exists
|
||||||
@@ -347,6 +347,8 @@ jobs:
|
|||||||
# Copy necessary files
|
# Copy necessary files
|
||||||
cp -r dist $PUBLISH_DIR/
|
cp -r dist $PUBLISH_DIR/
|
||||||
cp -r data $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 README.md $PUBLISH_DIR/
|
||||||
cp LICENSE $PUBLISH_DIR/
|
cp LICENSE $PUBLISH_DIR/
|
||||||
cp .env.example $PUBLISH_DIR/
|
cp .env.example $PUBLISH_DIR/
|
||||||
@@ -377,7 +379,7 @@ jobs:
|
|||||||
pkg.license = 'MIT';
|
pkg.license = 'MIT';
|
||||||
pkg.bugs = { url: 'https://github.com/czlonkowski/n8n-mcp/issues' };
|
pkg.bugs = { url: 'https://github.com/czlonkowski/n8n-mcp/issues' };
|
||||||
pkg.homepage = 'https://github.com/czlonkowski/n8n-mcp#readme';
|
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;
|
delete pkg.private;
|
||||||
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));
|
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));
|
||||||
"
|
"
|
||||||
|
|||||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
## [2.34.0] - 2026-02-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.34.0",
|
"version": "2.34.2",
|
||||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -651,6 +651,7 @@ export class N8NDocumentationMCPServer {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
return { tools };
|
return { tools };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -781,12 +782,6 @@ export class N8NDocumentationMCPServer {
|
|||||||
mcpResponse.structuredContent = structuredContent;
|
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;
|
return mcpResponse;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error executing tool ${name}`, error);
|
logger.error(`Error executing tool ${name}`, error);
|
||||||
@@ -857,8 +852,8 @@ export class N8NDocumentationMCPServer {
|
|||||||
// Handle ReadResource for UI apps
|
// Handle ReadResource for UI apps
|
||||||
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||||
const uri = request.params.uri;
|
const uri = request.params.uri;
|
||||||
// Parse n8n-mcp://ui/{id} pattern
|
// Parse ui://n8n-mcp/{id} pattern
|
||||||
const match = uri.match(/^n8n-mcp:\/\/ui\/(.+)$/);
|
const match = uri.match(/^ui:\/\/n8n-mcp\/(.+)$/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error(`Unknown resource URI: ${uri}`);
|
throw new Error(`Unknown resource URI: ${uri}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const UI_APP_CONFIGS: UIAppConfig[] = [
|
|||||||
id: 'operation-result',
|
id: 'operation-result',
|
||||||
displayName: 'Operation Result',
|
displayName: 'Operation Result',
|
||||||
description: 'Visual summary of workflow operations (create, update, delete, test)',
|
description: 'Visual summary of workflow operations (create, update, delete, test)',
|
||||||
uri: 'n8n-mcp://ui/operation-result',
|
uri: 'ui://n8n-mcp/operation-result',
|
||||||
mimeType: 'text/html',
|
mimeType: 'text/html',
|
||||||
toolPatterns: [
|
toolPatterns: [
|
||||||
'n8n_create_workflow',
|
'n8n_create_workflow',
|
||||||
@@ -21,7 +21,7 @@ export const UI_APP_CONFIGS: UIAppConfig[] = [
|
|||||||
id: 'validation-summary',
|
id: 'validation-summary',
|
||||||
displayName: 'Validation Summary',
|
displayName: 'Validation Summary',
|
||||||
description: 'Visual summary of node and workflow validation results',
|
description: 'Visual summary of node and workflow validation results',
|
||||||
uri: 'n8n-mcp://ui/validation-summary',
|
uri: 'ui://n8n-mcp/validation-summary',
|
||||||
mimeType: 'text/html',
|
mimeType: 'text/html',
|
||||||
toolPatterns: [
|
toolPatterns: [
|
||||||
'validate_node',
|
'validate_node',
|
||||||
|
|||||||
@@ -60,6 +60,21 @@ export class UIAppRegistry {
|
|||||||
return Array.from(this.entries.values());
|
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. */
|
/** Reset registry state. Intended for testing only. */
|
||||||
static reset(): void {
|
static reset(): void {
|
||||||
this.entries.clear();
|
this.entries.clear();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface UIAppConfig {
|
|||||||
|
|
||||||
export interface UIMetadata {
|
export interface UIMetadata {
|
||||||
ui: {
|
ui: {
|
||||||
app: string;
|
resourceUri: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ export interface ToolDefinition {
|
|||||||
};
|
};
|
||||||
/** Tool behavior hints for AI assistants */
|
/** Tool behavior hints for AI assistants */
|
||||||
annotations?: ToolAnnotations;
|
annotations?: ToolAnnotations;
|
||||||
|
_meta?: {
|
||||||
|
ui?: {
|
||||||
|
resourceUri?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceDefinition {
|
export interface ResourceDefinition {
|
||||||
|
|||||||
@@ -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) {
|
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}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,9 +71,9 @@ describe('UI_APP_CONFIGS', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
for (const config of UI_APP_CONFIGS) {
|
||||||
expect(config.uri).toMatch(/^n8n-mcp:\/\/ui\//);
|
expect(config.uri).toMatch(/^ui:\/\/n8n-mcp\//);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { existsSync, readFileSync } from 'fs';
|
|||||||
const mockExistsSync = vi.mocked(existsSync);
|
const mockExistsSync = vi.mocked(existsSync);
|
||||||
const mockReadFileSync = vi.mocked(readFileSync);
|
const mockReadFileSync = vi.mocked(readFileSync);
|
||||||
|
|
||||||
describe('UI Meta Injection Logic', () => {
|
describe('UI Meta Injection on Tool Definitions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
UIAppRegistry.reset();
|
UIAppRegistry.reset();
|
||||||
@@ -24,74 +24,69 @@ describe('UI Meta Injection Logic', () => {
|
|||||||
UIAppRegistry.load();
|
UIAppRegistry.load();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add _meta.ui for matching tools', () => {
|
it('should add _meta.ui.resourceUri to matching tool definitions', () => {
|
||||||
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
const tools: any[] = [
|
||||||
expect(uiApp).not.toBeNull();
|
{ name: 'n8n_create_workflow', description: 'Create workflow', inputSchema: { type: 'object', properties: {} } },
|
||||||
expect(uiApp!.html).not.toBeNull();
|
];
|
||||||
|
|
||||||
// Simulate the injection logic from server.ts
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
const mcpResponse: any = {
|
|
||||||
content: [{ type: 'text', text: 'result' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (uiApp && uiApp.html) {
|
expect(tools[0]._meta).toBeDefined();
|
||||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
|
||||||
}
|
|
||||||
|
|
||||||
expect(mcpResponse._meta).toBeDefined();
|
|
||||||
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/operation-result');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add _meta.ui for validation tools', () => {
|
it('should add _meta.ui.resourceUri to validation tool definitions', () => {
|
||||||
const uiApp = UIAppRegistry.getAppForTool('validate_workflow');
|
const tools: any[] = [
|
||||||
expect(uiApp).not.toBeNull();
|
{ name: 'validate_workflow', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||||
|
];
|
||||||
|
|
||||||
const mcpResponse: any = {
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
content: [{ type: 'text', text: 'validation result' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (uiApp && uiApp.html) {
|
expect(tools[0]._meta).toBeDefined();
|
||||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary');
|
||||||
}
|
|
||||||
|
|
||||||
expect(mcpResponse._meta).toBeDefined();
|
|
||||||
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/validation-summary');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT add _meta.ui for non-matching tools', () => {
|
it('should NOT add _meta to non-matching tool definitions', () => {
|
||||||
const uiApp = UIAppRegistry.getAppForTool('get_node_info');
|
const tools: any[] = [
|
||||||
expect(uiApp).toBeNull();
|
{ name: 'get_node_info', description: 'Get info', inputSchema: { type: 'object', properties: {} } },
|
||||||
|
];
|
||||||
|
|
||||||
const mcpResponse: any = {
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
content: [{ type: 'text', text: 'node info' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (uiApp && uiApp.html) {
|
expect(tools[0]._meta).toBeUndefined();
|
||||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(mcpResponse._meta).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should produce _meta with exact shape { ui: { app: string } }', () => {
|
it('should inject _meta on matching tools and skip non-matching in a mixed list', () => {
|
||||||
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow')!;
|
const tools: any[] = [
|
||||||
const meta = { ui: { app: uiApp.config.uri } };
|
{ 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: {
|
ui: {
|
||||||
app: 'n8n-mcp://ui/operation-result',
|
resourceUri: 'ui://n8n-mcp/operation-result',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(Object.keys(meta)).toEqual(['ui']);
|
expect(Object.keys(tools[0]._meta)).toEqual(['ui']);
|
||||||
expect(Object.keys(meta.ui)).toEqual(['app']);
|
expect(Object.keys(tools[0]._meta.ui)).toEqual(['resourceUri']);
|
||||||
expect(typeof meta.ui.app).toBe('string');
|
expect(typeof tools[0]._meta.ui.resourceUri).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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,114 +96,50 @@ describe('UI Meta Injection Logic', () => {
|
|||||||
UIAppRegistry.load();
|
UIAppRegistry.load();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT add _meta.ui even for matching tools', () => {
|
it('should NOT add _meta even for matching tools', () => {
|
||||||
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
const tools: any[] = [
|
||||||
expect(uiApp).not.toBeNull();
|
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||||
expect(uiApp!.html).toBeNull();
|
];
|
||||||
|
|
||||||
const mcpResponse: any = {
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
content: [{ type: 'text', text: 'result' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (uiApp && uiApp.html) {
|
expect(tools[0]._meta).toBeUndefined();
|
||||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(mcpResponse._meta).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT add _meta.ui for validation tools without HTML', () => {
|
it('should NOT add _meta for validation tools without HTML', () => {
|
||||||
const uiApp = UIAppRegistry.getAppForTool('validate_node');
|
const tools: any[] = [
|
||||||
expect(uiApp).not.toBeNull();
|
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||||
expect(uiApp!.html).toBeNull();
|
];
|
||||||
|
|
||||||
const mcpResponse: any = {
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
content: [{ type: 'text', text: 'result' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (uiApp && uiApp.html) {
|
expect(tools[0]._meta).toBeUndefined();
|
||||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(mcpResponse._meta).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when registry has not been loaded at all', () => {
|
describe('when registry has not been loaded at all', () => {
|
||||||
it('should NOT add _meta because getAppForTool returns null', () => {
|
it('should NOT add _meta because registry is not loaded', () => {
|
||||||
// Registry never loaded - reset() was called in beforeEach
|
const tools: any[] = [
|
||||||
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||||
expect(uiApp).toBeNull();
|
];
|
||||||
|
|
||||||
const mcpResponse: any = {
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
content: [{ type: 'text', text: 'result' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (uiApp && uiApp.html) {
|
expect(tools[0]._meta).toBeUndefined();
|
||||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(mcpResponse._meta).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('coexistence with structuredContent', () => {
|
describe('empty tool list', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockExistsSync.mockReturnValue(true);
|
mockExistsSync.mockReturnValue(true);
|
||||||
mockReadFileSync.mockReturnValue('<html>ui</html>');
|
mockReadFileSync.mockReturnValue('<html>ui</html>');
|
||||||
UIAppRegistry.load();
|
UIAppRegistry.load();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should coexist with structuredContent on the response', () => {
|
it('should handle an empty tools array without error', () => {
|
||||||
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
const tools: any[] = [];
|
||||||
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
const mcpResponse: any = {
|
expect(tools.length).toBe(0);
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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()', () => {
|
describe('reset()', () => {
|
||||||
it('should clear loaded state so getters return defaults', () => {
|
it('should clear loaded state so getters return defaults', () => {
|
||||||
mockExistsSync.mockReturnValue(true);
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/ext-apps": "^1.0.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,30 +1,34 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { App } from '@modelcontextprotocol/ext-apps';
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__MCP_DATA__?: unknown;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useToolData<T>(): T | null {
|
export function useToolData<T>(): T | null {
|
||||||
const [data, setData] = useState<T | null>(null);
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Try window.__MCP_DATA__ first (injected by host)
|
const app = new App();
|
||||||
if (window.__MCP_DATA__) {
|
|
||||||
setData(window.__MCP_DATA__ as T);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try embedded script tag
|
app.ontoolresult = (result: any) => {
|
||||||
const scriptEl = document.getElementById('mcp-data');
|
// The host pushes tool result content; parse the first text item as JSON
|
||||||
if (scriptEl?.textContent) {
|
if (result?.content) {
|
||||||
|
const textItem = Array.isArray(result.content)
|
||||||
|
? result.content.find((c: any) => c.type === 'text')
|
||||||
|
: null;
|
||||||
|
if (textItem?.text) {
|
||||||
try {
|
try {
|
||||||
setData(JSON.parse(scriptEl.textContent) as T);
|
setData(JSON.parse(textItem.text) as T);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore parse errors
|
// Not JSON — use raw text as-is
|
||||||
|
setData(textItem.text as unknown as T);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
app.close();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
Reference in New Issue
Block a user