mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-07 14:03:08 +00:00
Compare commits
3 Commits
update/n8n
...
v2.34.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23b90d01a6 | ||
|
|
1f45cc6dcc | ||
|
|
6814880410 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -138,5 +138,9 @@ n8n-mcp-wrapper.sh
|
|||||||
# MCP configuration files
|
# MCP configuration files
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
|
||||||
|
# UI Apps build output
|
||||||
|
ui-apps/dist/
|
||||||
|
ui-apps/node_modules/
|
||||||
|
|
||||||
# Telemetry configuration (user-specific)
|
# Telemetry configuration (user-specific)
|
||||||
~/.n8n-mcp/
|
~/.n8n-mcp/
|
||||||
|
|||||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
- **MCP Apps**: Rich HTML UIs rendered by MCP hosts alongside tool results via `_meta.ui` and the MCP resources protocol
|
||||||
|
- Server-side UI module (`src/mcp/ui/`) with tool-to-UI mapping and `_meta.ui` injection
|
||||||
|
- `UIAppRegistry` static class for loading and serving self-contained HTML apps
|
||||||
|
- `UI_APP_CONFIGS` mapping tools to their corresponding UI apps
|
||||||
|
|
||||||
|
- **Operation Result UI**: Visual summary for workflow operation tools
|
||||||
|
- Status badge, operation type, workflow details card
|
||||||
|
- Expandable sections for nodes added, modified, and removed
|
||||||
|
- Mapped to: `n8n_create_workflow`, `n8n_update_full_workflow`, `n8n_update_partial_workflow`, `n8n_delete_workflow`, `n8n_test_workflow`, `n8n_autofix_workflow`, `n8n_deploy_template`
|
||||||
|
|
||||||
|
- **Validation Summary UI**: Visual summary for validation tools
|
||||||
|
- Valid/invalid badge with error and warning counts
|
||||||
|
- Expandable error list with type, property, message, and fix
|
||||||
|
- Expandable warning list and suggestions
|
||||||
|
- Mapped to: `validate_node`, `validate_workflow`, `n8n_validate_workflow`
|
||||||
|
|
||||||
|
- **React + Vite Build Pipeline** (`ui-apps/`):
|
||||||
|
- React 19, Vite 6, vite-plugin-singlefile for self-contained HTML output
|
||||||
|
- Shared component library: Card, Badge, Expandable
|
||||||
|
- `useToolData` hook for reading data from `window.__MCP_DATA__` or embedded JSON
|
||||||
|
- n8n-branded dark theme with CSS custom properties
|
||||||
|
- Per-app builds via `APP_NAME` environment variable
|
||||||
|
|
||||||
|
- **MCP Resources Protocol**: Server now exposes `resources` capability
|
||||||
|
- `ListResources` handler returns available UI apps
|
||||||
|
- `ReadResource` handler serves self-contained HTML via `n8n-mcp://ui/{id}` URIs
|
||||||
|
|
||||||
|
- **New Scripts**:
|
||||||
|
- `build:ui`: Build UI apps (`cd ui-apps && npm install && npm run build`)
|
||||||
|
- `build:all`: Build UI apps then server (`npm run build:ui && npm run build`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **MCP Server**: Added `resources: {}` to server capabilities alongside existing `tools: {}`
|
||||||
|
- **Tool Responses**: Tools with matching UI apps now include `_meta.ui.app` URI pointing to their visual representation
|
||||||
|
- **Graceful Degradation**: Server starts and operates normally without `ui-apps/dist/`; UI metadata is only injected when HTML is available
|
||||||
|
|
||||||
|
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||||
|
|
||||||
## [2.33.6] - 2026-02-06
|
## [2.33.6] - 2026-02-06
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
2
dist/index.d.ts
vendored
2
dist/index.d.ts
vendored
@@ -5,6 +5,8 @@ export { N8NDocumentationMCPServer } from './mcp/server';
|
|||||||
export type { InstanceContext } from './types/instance-context';
|
export type { InstanceContext } from './types/instance-context';
|
||||||
export { validateInstanceContext, isInstanceContext } from './types/instance-context';
|
export { validateInstanceContext, isInstanceContext } from './types/instance-context';
|
||||||
export type { SessionState } from './types/session-state';
|
export type { SessionState } from './types/session-state';
|
||||||
|
export type { UIAppConfig, UIMetadata } from './mcp/ui/types';
|
||||||
|
export { UI_APP_CONFIGS } from './mcp/ui/app-configs';
|
||||||
export type { Tool, CallToolResult, ListToolsResult } from '@modelcontextprotocol/sdk/types.js';
|
export type { Tool, CallToolResult, ListToolsResult } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import N8NMCPEngine from './mcp-engine';
|
import N8NMCPEngine from './mcp-engine';
|
||||||
export default N8NMCPEngine;
|
export default N8NMCPEngine;
|
||||||
|
|||||||
2
dist/index.d.ts.map
vendored
2
dist/index.d.ts.map
vendored
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACzD,OAAO,EAAE,yBAAyB,EAAE,MAAM,cAAc,CAAC;AAGzD,YAAY,EACV,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,uBAAuB,EACvB,iBAAiB,EAClB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACV,YAAY,EACb,MAAM,uBAAuB,CAAC;AAG/B,YAAY,EACV,IAAI,EACJ,cAAc,EACd,eAAe,EAChB,MAAM,oCAAoC,CAAC;AAG5C,OAAO,YAAY,MAAM,cAAc,CAAC;AACxC,eAAe,YAAY,CAAC"}
|
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACzD,OAAO,EAAE,yBAAyB,EAAE,MAAM,cAAc,CAAC;AAGzD,YAAY,EACV,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,uBAAuB,EACvB,iBAAiB,EAClB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACV,YAAY,EACb,MAAM,uBAAuB,CAAC;AAG/B,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC9D,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAGtD,YAAY,EACV,IAAI,EACJ,cAAc,EACd,eAAe,EAChB,MAAM,oCAAoC,CAAC;AAG5C,OAAO,YAAY,MAAM,cAAc,CAAC;AACxC,eAAe,YAAY,CAAC"}
|
||||||
4
dist/index.js
vendored
4
dist/index.js
vendored
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.isInstanceContext = exports.validateInstanceContext = exports.N8NDocumentationMCPServer = exports.ConsoleManager = exports.SingleSessionHTTPServer = exports.N8NMCPEngine = void 0;
|
exports.UI_APP_CONFIGS = exports.isInstanceContext = exports.validateInstanceContext = exports.N8NDocumentationMCPServer = exports.ConsoleManager = exports.SingleSessionHTTPServer = exports.N8NMCPEngine = void 0;
|
||||||
var mcp_engine_1 = require("./mcp-engine");
|
var mcp_engine_1 = require("./mcp-engine");
|
||||||
Object.defineProperty(exports, "N8NMCPEngine", { enumerable: true, get: function () { return mcp_engine_1.N8NMCPEngine; } });
|
Object.defineProperty(exports, "N8NMCPEngine", { enumerable: true, get: function () { return mcp_engine_1.N8NMCPEngine; } });
|
||||||
var http_server_single_session_1 = require("./http-server-single-session");
|
var http_server_single_session_1 = require("./http-server-single-session");
|
||||||
@@ -15,6 +15,8 @@ Object.defineProperty(exports, "N8NDocumentationMCPServer", { enumerable: true,
|
|||||||
var instance_context_1 = require("./types/instance-context");
|
var instance_context_1 = require("./types/instance-context");
|
||||||
Object.defineProperty(exports, "validateInstanceContext", { enumerable: true, get: function () { return instance_context_1.validateInstanceContext; } });
|
Object.defineProperty(exports, "validateInstanceContext", { enumerable: true, get: function () { return instance_context_1.validateInstanceContext; } });
|
||||||
Object.defineProperty(exports, "isInstanceContext", { enumerable: true, get: function () { return instance_context_1.isInstanceContext; } });
|
Object.defineProperty(exports, "isInstanceContext", { enumerable: true, get: function () { return instance_context_1.isInstanceContext; } });
|
||||||
|
var app_configs_1 = require("./mcp/ui/app-configs");
|
||||||
|
Object.defineProperty(exports, "UI_APP_CONFIGS", { enumerable: true, get: function () { return app_configs_1.UI_APP_CONFIGS; } });
|
||||||
const mcp_engine_2 = __importDefault(require("./mcp-engine"));
|
const mcp_engine_2 = __importDefault(require("./mcp-engine"));
|
||||||
exports.default = mcp_engine_2.default;
|
exports.default = mcp_engine_2.default;
|
||||||
//# sourceMappingURL=index.js.map
|
//# sourceMappingURL=index.js.map
|
||||||
2
dist/index.js.map
vendored
2
dist/index.js.map
vendored
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;AAOA,2CAAyE;AAAhE,0GAAA,YAAY,OAAA;AACrB,2EAAuE;AAA9D,qIAAA,uBAAuB,OAAA;AAChC,2DAAyD;AAAhD,iHAAA,cAAc,OAAA;AACvB,uCAAyD;AAAhD,mHAAA,yBAAyB,OAAA;AAMlC,6DAGkC;AAFhC,2HAAA,uBAAuB,OAAA;AACvB,qHAAA,iBAAiB,OAAA;AAcnB,8DAAwC;AACxC,kBAAe,oBAAY,CAAC"}
|
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;AAOA,2CAAyE;AAAhE,0GAAA,YAAY,OAAA;AACrB,2EAAuE;AAA9D,qIAAA,uBAAuB,OAAA;AAChC,2DAAyD;AAAhD,iHAAA,cAAc,OAAA;AACvB,uCAAyD;AAAhD,mHAAA,yBAAyB,OAAA;AAMlC,6DAGkC;AAFhC,2HAAA,uBAAuB,OAAA;AACvB,qHAAA,iBAAiB,OAAA;AAQnB,oDAAsD;AAA7C,6GAAA,cAAc,OAAA;AAUvB,8DAAwC;AACxC,kBAAe,oBAAY,CAAC"}
|
||||||
2
dist/mcp/server.d.ts.map
vendored
2
dist/mcp/server.d.ts.map
vendored
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AAuCA,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAE5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAmGnE,qBAAa,yBAAyB;IACpC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,EAAE,CAAgC;IAC1C,OAAO,CAAC,UAAU,CAA+B;IACjD,OAAO,CAAC,eAAe,CAAgC;IACvD,OAAO,CAAC,WAAW,CAAgB;IACnC,OAAO,CAAC,KAAK,CAAqB;IAClC,OAAO,CAAC,UAAU,CAAa;IAC/B,OAAO,CAAC,eAAe,CAAC,CAAkB;IAC1C,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,qBAAqB,CAAsB;IACnD,OAAO,CAAC,WAAW,CAAiC;IACpD,OAAO,CAAC,kBAAkB,CAA4B;IACtD,OAAO,CAAC,iBAAiB,CAAkB;IAC3C,OAAO,CAAC,aAAa,CAAoC;IACzD,OAAO,CAAC,UAAU,CAAkB;gBAExB,eAAe,CAAC,EAAE,eAAe,EAAE,WAAW,CAAC,EAAE,gBAAgB;IAqGvE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YA+Cd,kBAAkB;YAiDlB,wBAAwB;IA0BtC,OAAO,CAAC,kBAAkB;YA6CZ,iBAAiB;IAa/B,OAAO,CAAC,eAAe,CAAkB;YAE3B,sBAAsB;IAgDpC,OAAO,CAAC,gBAAgB;IAqCxB,OAAO,CAAC,aAAa;IAoTrB,OAAO,CAAC,wBAAwB;IAoFhC,OAAO,CAAC,kBAAkB;IAqE1B,OAAO,CAAC,uBAAuB;IAwB/B,OAAO,CAAC,qBAAqB;YAoTf,SAAS;YA2DT,WAAW;YAkFX,WAAW;YA0CX,cAAc;YA8Md,gBAAgB;IAqD9B,OAAO,CAAC,mBAAmB;IAwE3B,OAAO,CAAC,eAAe;YAsBT,eAAe;IA2L7B,OAAO,CAAC,kBAAkB;IAQ1B,OAAO,CAAC,uBAAuB;IA0D/B,OAAO,CAAC,iBAAiB;YAqFX,WAAW;YAgCX,oBAAoB;IAuFlC,OAAO,CAAC,aAAa;YAQP,qBAAqB;YAwDrB,iBAAiB;YAiKjB,OAAO;YAgDP,cAAc;YAwFd,iBAAiB;IAqC/B,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,eAAe;IAwCvB,OAAO,CAAC,kBAAkB;IAiC1B,OAAO,CAAC,aAAa;IAoCrB,OAAO,CAAC,0BAA0B;IAgClC,OAAO,CAAC,4BAA4B;YAKtB,oBAAoB;IAsDlC,OAAO,CAAC,gBAAgB;YAiBV,SAAS;YA6CT,kBAAkB;YAqElB,uBAAuB;YAsDvB,iBAAiB;IAqE/B,OAAO,CAAC,qBAAqB;IA8C7B,OAAO,CAAC,uBAAuB;IA4D/B,OAAO,CAAC,wBAAwB;IAkChC,OAAO,CAAC,iBAAiB;YAoDX,mBAAmB;YAoEnB,qBAAqB;IAS7B,OAAO,CAAC,SAAS,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;YAS9B,aAAa;YAcb,iBAAiB;YAoBjB,WAAW;YAwBX,eAAe;YAqBf,mBAAmB;YAwBnB,yBAAyB;IA4CvC,OAAO,CAAC,kBAAkB;YAiBZ,gBAAgB;YA6HhB,2BAA2B;YAiE3B,2BAA2B;IAyEnC,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IA0BpB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAgEhC"}
|
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AA0CA,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAE5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAmGnE,qBAAa,yBAAyB;IACpC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,EAAE,CAAgC;IAC1C,OAAO,CAAC,UAAU,CAA+B;IACjD,OAAO,CAAC,eAAe,CAAgC;IACvD,OAAO,CAAC,WAAW,CAAgB;IACnC,OAAO,CAAC,KAAK,CAAqB;IAClC,OAAO,CAAC,UAAU,CAAa;IAC/B,OAAO,CAAC,eAAe,CAAC,CAAkB;IAC1C,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,qBAAqB,CAAsB;IACnD,OAAO,CAAC,WAAW,CAAiC;IACpD,OAAO,CAAC,kBAAkB,CAA4B;IACtD,OAAO,CAAC,iBAAiB,CAAkB;IAC3C,OAAO,CAAC,aAAa,CAAoC;IACzD,OAAO,CAAC,UAAU,CAAkB;gBAExB,eAAe,CAAC,EAAE,eAAe,EAAE,WAAW,CAAC,EAAE,gBAAgB;IAuGvE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YA+Cd,kBAAkB;YAiDlB,wBAAwB;IA0BtC,OAAO,CAAC,kBAAkB;YA6CZ,iBAAiB;IAa/B,OAAO,CAAC,eAAe,CAAkB;YAE3B,sBAAsB;IAgDpC,OAAO,CAAC,gBAAgB;IAqCxB,OAAO,CAAC,aAAa;IAmWrB,OAAO,CAAC,wBAAwB;IAoFhC,OAAO,CAAC,kBAAkB;IAqE1B,OAAO,CAAC,uBAAuB;IAwB/B,OAAO,CAAC,qBAAqB;YAoTf,SAAS;YA2DT,WAAW;YAkFX,WAAW;YA0CX,cAAc;YA8Md,gBAAgB;IAqD9B,OAAO,CAAC,mBAAmB;IAwE3B,OAAO,CAAC,eAAe;YAsBT,eAAe;IA2L7B,OAAO,CAAC,kBAAkB;IAQ1B,OAAO,CAAC,uBAAuB;IA0D/B,OAAO,CAAC,iBAAiB;YAqFX,WAAW;YAgCX,oBAAoB;IAuFlC,OAAO,CAAC,aAAa;YAQP,qBAAqB;YAwDrB,iBAAiB;YAiKjB,OAAO;YAgDP,cAAc;YAwFd,iBAAiB;IAqC/B,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,eAAe;IAwCvB,OAAO,CAAC,kBAAkB;IAiC1B,OAAO,CAAC,aAAa;IAoCrB,OAAO,CAAC,0BAA0B;IAgClC,OAAO,CAAC,4BAA4B;YAKtB,oBAAoB;IAsDlC,OAAO,CAAC,gBAAgB;YAiBV,SAAS;YA6CT,kBAAkB;YAqElB,uBAAuB;YAsDvB,iBAAiB;IAqE/B,OAAO,CAAC,qBAAqB;IA8C7B,OAAO,CAAC,uBAAuB;IA4D/B,OAAO,CAAC,wBAAwB;IAkChC,OAAO,CAAC,iBAAiB;YAoDX,mBAAmB;YAoEnB,qBAAqB;IAS7B,OAAO,CAAC,SAAS,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;YAS9B,aAAa;YAcb,iBAAiB;YAoBjB,WAAW;YAwBX,eAAe;YAqBf,mBAAmB;YAwBnB,yBAAyB;IA4CvC,OAAO,CAAC,kBAAkB;YAiBZ,gBAAgB;YA6HhB,2BAA2B;YAiE3B,2BAA2B;IAyEnC,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IA0BpB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAgEhC"}
|
||||||
41
dist/mcp/server.js
vendored
41
dist/mcp/server.js
vendored
@@ -43,6 +43,7 @@ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|||||||
const fs_1 = require("fs");
|
const fs_1 = require("fs");
|
||||||
const path_1 = __importDefault(require("path"));
|
const path_1 = __importDefault(require("path"));
|
||||||
const tools_1 = require("./tools");
|
const tools_1 = require("./tools");
|
||||||
|
const ui_1 = require("./ui");
|
||||||
const tools_n8n_manager_1 = require("./tools-n8n-manager");
|
const tools_n8n_manager_1 = require("./tools-n8n-manager");
|
||||||
const tools_n8n_friendly_1 = require("./tools-n8n-friendly");
|
const tools_n8n_friendly_1 = require("./tools-n8n-friendly");
|
||||||
const workflow_examples_1 = require("./workflow-examples");
|
const workflow_examples_1 = require("./workflow-examples");
|
||||||
@@ -148,8 +149,10 @@ class N8NDocumentationMCPServer {
|
|||||||
}, {
|
}, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
|
resources: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
ui_1.UIAppRegistry.load();
|
||||||
this.setupHandlers();
|
this.setupHandlers();
|
||||||
}
|
}
|
||||||
async close() {
|
async close() {
|
||||||
@@ -368,6 +371,7 @@ class N8NDocumentationMCPServer {
|
|||||||
protocolVersion: negotiationResult.version,
|
protocolVersion: negotiationResult.version,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
|
resources: {},
|
||||||
},
|
},
|
||||||
serverInfo: {
|
serverInfo: {
|
||||||
name: 'n8n-documentation-mcp',
|
name: 'n8n-documentation-mcp',
|
||||||
@@ -525,6 +529,10 @@ class N8NDocumentationMCPServer {
|
|||||||
if (name.startsWith('validate_') && structuredContent !== null) {
|
if (name.startsWith('validate_') && structuredContent !== null) {
|
||||||
mcpResponse.structuredContent = structuredContent;
|
mcpResponse.structuredContent = structuredContent;
|
||||||
}
|
}
|
||||||
|
const uiApp = ui_1.UIAppRegistry.getAppForTool(name);
|
||||||
|
if (uiApp && uiApp.html) {
|
||||||
|
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
||||||
|
}
|
||||||
return mcpResponse;
|
return mcpResponse;
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -562,6 +570,39 @@ class N8NDocumentationMCPServer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.server.setRequestHandler(types_js_1.ListResourcesRequestSchema, async () => {
|
||||||
|
const apps = ui_1.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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
this.server.setRequestHandler(types_js_1.ReadResourceRequestSchema, async (request) => {
|
||||||
|
const uri = request.params.uri;
|
||||||
|
const match = uri.match(/^n8n-mcp:\/\/ui\/(.+)$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Unknown resource URI: ${uri}`);
|
||||||
|
}
|
||||||
|
const app = ui_1.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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
sanitizeValidationResult(result, toolName) {
|
sanitizeValidationResult(result, toolName) {
|
||||||
if (!result || typeof result !== 'object') {
|
if (!result || typeof result !== 'object') {
|
||||||
|
|||||||
2
dist/mcp/server.js.map
vendored
2
dist/mcp/server.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.33.6",
|
"version": "2.34.1",
|
||||||
"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",
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"build:ui": "cd ui-apps && npm install && npm run build",
|
||||||
|
"build:all": "npm run build:ui && npm run build",
|
||||||
"rebuild": "node dist/scripts/rebuild.js",
|
"rebuild": "node dist/scripts/rebuild.js",
|
||||||
"rebuild:optimized": "node dist/scripts/rebuild-optimized.js",
|
"rebuild:optimized": "node dist/scripts/rebuild-optimized.js",
|
||||||
"validate": "node dist/scripts/validate.js",
|
"validate": "node dist/scripts/validate.js",
|
||||||
@@ -123,6 +125,7 @@
|
|||||||
"homepage": "https://github.com/czlonkowski/n8n-mcp#readme",
|
"homepage": "https://github.com/czlonkowski/n8n-mcp#readme",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
|
"ui-apps/dist/**/*",
|
||||||
"data/nodes.db",
|
"data/nodes.db",
|
||||||
".env.example",
|
".env.example",
|
||||||
"README.md",
|
"README.md",
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export type {
|
|||||||
SessionState
|
SessionState
|
||||||
} from './types/session-state';
|
} from './types/session-state';
|
||||||
|
|
||||||
|
// UI module exports
|
||||||
|
export type { UIAppConfig, UIMetadata } from './mcp/ui/types';
|
||||||
|
export { UI_APP_CONFIGS } from './mcp/ui/app-configs';
|
||||||
|
|
||||||
// Re-export MCP SDK types for convenience
|
// Re-export MCP SDK types for convenience
|
||||||
export type {
|
export type {
|
||||||
Tool,
|
Tool,
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import {
|
|||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
InitializeRequestSchema,
|
InitializeRequestSchema,
|
||||||
|
ListResourcesRequestSchema,
|
||||||
|
ReadResourceRequestSchema,
|
||||||
} from '@modelcontextprotocol/sdk/types.js';
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { existsSync, promises as fs } from 'fs';
|
import { existsSync, promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { n8nDocumentationToolsFinal } from './tools';
|
import { n8nDocumentationToolsFinal } from './tools';
|
||||||
|
import { UIAppRegistry } from './ui';
|
||||||
import { n8nManagementTools } from './tools-n8n-manager';
|
import { n8nManagementTools } from './tools-n8n-manager';
|
||||||
import { makeToolsN8nFriendly } from './tools-n8n-friendly';
|
import { makeToolsN8nFriendly } from './tools-n8n-friendly';
|
||||||
import { getWorkflowExampleString } from './workflow-examples';
|
import { getWorkflowExampleString } from './workflow-examples';
|
||||||
@@ -235,10 +238,12 @@ export class N8NDocumentationMCPServer {
|
|||||||
{
|
{
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
|
resources: {},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
UIAppRegistry.load();
|
||||||
this.setupHandlers();
|
this.setupHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,6 +568,7 @@ export class N8NDocumentationMCPServer {
|
|||||||
protocolVersion: negotiationResult.version,
|
protocolVersion: negotiationResult.version,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
|
resources: {},
|
||||||
},
|
},
|
||||||
serverInfo: {
|
serverInfo: {
|
||||||
name: 'n8n-documentation-mcp',
|
name: 'n8n-documentation-mcp',
|
||||||
@@ -645,6 +651,7 @@ export class N8NDocumentationMCPServer {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
return { tools };
|
return { tools };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -826,6 +833,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 ui://n8n-mcp/{id} pattern
|
||||||
|
const match = uri.match(/^ui:\/\/n8n-mcp\/(.+)$/);
|
||||||
|
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: 'ui://n8n-mcp/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: 'ui://n8n-mcp/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';
|
||||||
84
src/mcp/ui/registry.ts
Normal file
84
src/mcp/ui/registry.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
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: {
|
||||||
|
resourceUri: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UIAppEntry {
|
||||||
|
config: UIAppConfig;
|
||||||
|
html: string | null;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
110
tests/unit/mcp/ui/app-configs.test.ts
Normal file
110
tests/unit/mcp/ui/app-configs.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { UI_APP_CONFIGS } from '@/mcp/ui/app-configs';
|
||||||
|
|
||||||
|
describe('UI_APP_CONFIGS', () => {
|
||||||
|
it('should have all required fields for every config', () => {
|
||||||
|
for (const config of UI_APP_CONFIGS) {
|
||||||
|
expect(config.id).toBeDefined();
|
||||||
|
expect(typeof config.id).toBe('string');
|
||||||
|
expect(config.id.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(config.displayName).toBeDefined();
|
||||||
|
expect(typeof config.displayName).toBe('string');
|
||||||
|
expect(config.displayName.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(config.description).toBeDefined();
|
||||||
|
expect(typeof config.description).toBe('string');
|
||||||
|
expect(config.description.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(config.uri).toBeDefined();
|
||||||
|
expect(typeof config.uri).toBe('string');
|
||||||
|
|
||||||
|
expect(config.mimeType).toBeDefined();
|
||||||
|
expect(typeof config.mimeType).toBe('string');
|
||||||
|
|
||||||
|
expect(config.toolPatterns).toBeDefined();
|
||||||
|
expect(Array.isArray(config.toolPatterns)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have URIs following ui://n8n-mcp/{id} pattern', () => {
|
||||||
|
for (const config of UI_APP_CONFIGS) {
|
||||||
|
expect(config.uri).toBe(`ui://n8n-mcp/${config.id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have unique IDs', () => {
|
||||||
|
const ids = UI_APP_CONFIGS.map(c => c.id);
|
||||||
|
const uniqueIds = new Set(ids);
|
||||||
|
expect(uniqueIds.size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have non-empty toolPatterns arrays', () => {
|
||||||
|
for (const config of UI_APP_CONFIGS) {
|
||||||
|
expect(config.toolPatterns.length).toBeGreaterThan(0);
|
||||||
|
for (const pattern of config.toolPatterns) {
|
||||||
|
expect(typeof pattern).toBe('string');
|
||||||
|
expect(pattern.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not have duplicate tool patterns across configs', () => {
|
||||||
|
const allPatterns: string[] = [];
|
||||||
|
for (const config of UI_APP_CONFIGS) {
|
||||||
|
allPatterns.push(...config.toolPatterns);
|
||||||
|
}
|
||||||
|
const uniquePatterns = new Set(allPatterns);
|
||||||
|
expect(uniquePatterns.size).toBe(allPatterns.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not have duplicate tool patterns within a single config', () => {
|
||||||
|
for (const config of UI_APP_CONFIGS) {
|
||||||
|
const unique = new Set(config.toolPatterns);
|
||||||
|
expect(unique.size).toBe(config.toolPatterns.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have consistent mimeType of text/html', () => {
|
||||||
|
for (const config of UI_APP_CONFIGS) {
|
||||||
|
expect(config.mimeType).toBe('text/html');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have URIs that start with the ui://n8n-mcp/ scheme', () => {
|
||||||
|
for (const config of UI_APP_CONFIGS) {
|
||||||
|
expect(config.uri).toMatch(/^ui:\/\/n8n-mcp\//);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: verify expected configs are present
|
||||||
|
it('should contain the operation-result config', () => {
|
||||||
|
const config = UI_APP_CONFIGS.find(c => c.id === 'operation-result');
|
||||||
|
expect(config).toBeDefined();
|
||||||
|
expect(config!.displayName).toBe('Operation Result');
|
||||||
|
expect(config!.toolPatterns).toContain('n8n_create_workflow');
|
||||||
|
expect(config!.toolPatterns).toContain('n8n_update_full_workflow');
|
||||||
|
expect(config!.toolPatterns).toContain('n8n_delete_workflow');
|
||||||
|
expect(config!.toolPatterns).toContain('n8n_test_workflow');
|
||||||
|
expect(config!.toolPatterns).toContain('n8n_deploy_template');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain the validation-summary config', () => {
|
||||||
|
const config = UI_APP_CONFIGS.find(c => c.id === 'validation-summary');
|
||||||
|
expect(config).toBeDefined();
|
||||||
|
expect(config!.displayName).toBe('Validation Summary');
|
||||||
|
expect(config!.toolPatterns).toContain('validate_node');
|
||||||
|
expect(config!.toolPatterns).toContain('validate_workflow');
|
||||||
|
expect(config!.toolPatterns).toContain('n8n_validate_workflow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have exactly 2 configs', () => {
|
||||||
|
expect(UI_APP_CONFIGS.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have IDs that are valid URI path segments (no spaces or special chars)', () => {
|
||||||
|
for (const config of UI_APP_CONFIGS) {
|
||||||
|
expect(config.id).toMatch(/^[a-z0-9-]+$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
145
tests/unit/mcp/ui/meta-injection.test.ts
Normal file
145
tests/unit/mcp/ui/meta-injection.test.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { UIAppRegistry } from '@/mcp/ui/registry';
|
||||||
|
|
||||||
|
vi.mock('fs', () => ({
|
||||||
|
existsSync: vi.fn(),
|
||||||
|
readFileSync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { existsSync, readFileSync } from 'fs';
|
||||||
|
|
||||||
|
const mockExistsSync = vi.mocked(existsSync);
|
||||||
|
const mockReadFileSync = vi.mocked(readFileSync);
|
||||||
|
|
||||||
|
describe('UI Meta Injection on Tool Definitions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
UIAppRegistry.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when HTML is loaded', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue('<html>ui content</html>');
|
||||||
|
UIAppRegistry.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add _meta.ui.resourceUri to matching tool definitions', () => {
|
||||||
|
const tools: any[] = [
|
||||||
|
{ name: 'n8n_create_workflow', description: 'Create workflow', inputSchema: { type: 'object', properties: {} } },
|
||||||
|
];
|
||||||
|
|
||||||
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
|
|
||||||
|
expect(tools[0]._meta).toBeDefined();
|
||||||
|
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add _meta.ui.resourceUri to validation tool definitions', () => {
|
||||||
|
const tools: any[] = [
|
||||||
|
{ name: 'validate_workflow', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||||
|
];
|
||||||
|
|
||||||
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
|
|
||||||
|
expect(tools[0]._meta).toBeDefined();
|
||||||
|
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT add _meta to non-matching tool definitions', () => {
|
||||||
|
const tools: any[] = [
|
||||||
|
{ name: 'get_node_info', description: 'Get info', inputSchema: { type: 'object', properties: {} } },
|
||||||
|
];
|
||||||
|
|
||||||
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
|
|
||||||
|
expect(tools[0]._meta).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
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: {} } },
|
||||||
|
];
|
||||||
|
|
||||||
|
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: {
|
||||||
|
resourceUri: 'ui://n8n-mcp/operation-result',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when HTML is not loaded', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
UIAppRegistry.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT add _meta even for matching tools', () => {
|
||||||
|
const tools: any[] = [
|
||||||
|
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||||
|
];
|
||||||
|
|
||||||
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
|
|
||||||
|
expect(tools[0]._meta).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT add _meta for validation tools without HTML', () => {
|
||||||
|
const tools: any[] = [
|
||||||
|
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||||
|
];
|
||||||
|
|
||||||
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
|
|
||||||
|
expect(tools[0]._meta).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when registry has not been loaded at all', () => {
|
||||||
|
it('should NOT add _meta because registry is not loaded', () => {
|
||||||
|
const tools: any[] = [
|
||||||
|
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||||
|
];
|
||||||
|
|
||||||
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
|
|
||||||
|
expect(tools[0]._meta).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('empty tool list', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue('<html>ui</html>');
|
||||||
|
UIAppRegistry.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle an empty tools array without error', () => {
|
||||||
|
const tools: any[] = [];
|
||||||
|
UIAppRegistry.injectToolMeta(tools);
|
||||||
|
expect(tools.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
374
tests/unit/mcp/ui/registry.test.ts
Normal file
374
tests/unit/mcp/ui/registry.test.ts
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { UIAppRegistry } from '@/mcp/ui/registry';
|
||||||
|
import { UI_APP_CONFIGS } from '@/mcp/ui/app-configs';
|
||||||
|
|
||||||
|
vi.mock('fs', () => ({
|
||||||
|
existsSync: vi.fn(),
|
||||||
|
readFileSync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { existsSync, readFileSync } from 'fs';
|
||||||
|
|
||||||
|
const mockExistsSync = vi.mocked(existsSync);
|
||||||
|
const mockReadFileSync = vi.mocked(readFileSync);
|
||||||
|
|
||||||
|
describe('UIAppRegistry', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
UIAppRegistry.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('load()', () => {
|
||||||
|
it('should load HTML files when dist directory exists', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue('<html>test</html>');
|
||||||
|
|
||||||
|
UIAppRegistry.load();
|
||||||
|
|
||||||
|
const apps = UIAppRegistry.getAllApps();
|
||||||
|
expect(apps.length).toBe(UI_APP_CONFIGS.length);
|
||||||
|
for (const app of apps) {
|
||||||
|
expect(app.html).toBe('<html>test</html>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing dist directory gracefully', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
|
||||||
|
UIAppRegistry.load();
|
||||||
|
|
||||||
|
const apps = UIAppRegistry.getAllApps();
|
||||||
|
expect(apps.length).toBe(UI_APP_CONFIGS.length);
|
||||||
|
for (const app of apps) {
|
||||||
|
expect(app.html).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle read errors gracefully', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockImplementation(() => {
|
||||||
|
throw new Error('Permission denied');
|
||||||
|
});
|
||||||
|
|
||||||
|
UIAppRegistry.load();
|
||||||
|
|
||||||
|
const apps = UIAppRegistry.getAllApps();
|
||||||
|
expect(apps.length).toBe(UI_APP_CONFIGS.length);
|
||||||
|
for (const app of apps) {
|
||||||
|
expect(app.html).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set loaded flag so getters work', () => {
|
||||||
|
expect(UIAppRegistry.getAllApps()).toEqual([]);
|
||||||
|
expect(UIAppRegistry.getAppById('operation-result')).toBeNull();
|
||||||
|
expect(UIAppRegistry.getAppForTool('n8n_create_workflow')).toBeNull();
|
||||||
|
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
UIAppRegistry.load();
|
||||||
|
|
||||||
|
expect(UIAppRegistry.getAllApps().length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace previous entries when called twice', () => {
|
||||||
|
// First load: files exist
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue('<html>first</html>');
|
||||||
|
UIAppRegistry.load();
|
||||||
|
|
||||||
|
expect(UIAppRegistry.getAppById('operation-result')!.html).toBe('<html>first</html>');
|
||||||
|
|
||||||
|
// Second load: files missing
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
UIAppRegistry.load();
|
||||||
|
|
||||||
|
expect(UIAppRegistry.getAppById('operation-result')!.html).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty HTML file content', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue('');
|
||||||
|
|
||||||
|
UIAppRegistry.load();
|
||||||
|
|
||||||
|
const app = UIAppRegistry.getAppById('operation-result');
|
||||||
|
expect(app).not.toBeNull();
|
||||||
|
// Empty string is still a string, not null
|
||||||
|
expect(app!.html).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should build the correct number of tool index entries', () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue('<html>app</html>');
|
||||||
|
UIAppRegistry.load();
|
||||||
|
|
||||||
|
// Every tool pattern from every config should be resolvable
|
||||||
|
for (const config of UI_APP_CONFIGS) {
|
||||||
|
for (const pattern of config.toolPatterns) {
|
||||||
|
const entry = UIAppRegistry.getAppForTool(pattern);
|
||||||
|
expect(entry).not.toBeNull();
|
||||||
|
expect(entry!.config.id).toBe(config.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call existsSync for each config', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
UIAppRegistry.load();
|
||||||
|
|
||||||
|
expect(mockExistsSync).toHaveBeenCalledTimes(UI_APP_CONFIGS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only call readFileSync when existsSync returns true', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
UIAppRegistry.load();
|
||||||
|
|
||||||
|
expect(mockReadFileSync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAppForTool()', () => {
|
||||||
|
it('should return null before load() is called', () => {
|
||||||
|
const entry = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
||||||
|
expect(entry).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('after loading', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue('<html>loaded</html>');
|
||||||
|
UIAppRegistry.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct entry for known tool patterns', () => {
|
||||||
|
const entry = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
||||||
|
expect(entry).not.toBeNull();
|
||||||
|
expect(entry!.config.id).toBe('operation-result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct entry for validation tools', () => {
|
||||||
|
const entry = UIAppRegistry.getAppForTool('validate_node');
|
||||||
|
expect(entry).not.toBeNull();
|
||||||
|
expect(entry!.config.id).toBe('validation-summary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for unknown tools', () => {
|
||||||
|
const entry = UIAppRegistry.getAppForTool('unknown_tool');
|
||||||
|
expect(entry).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for empty string tool name', () => {
|
||||||
|
const entry = UIAppRegistry.getAppForTool('');
|
||||||
|
expect(entry).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression: verify specific tools ARE mapped so config changes break the test
|
||||||
|
it('should map n8n_create_workflow to operation-result', () => {
|
||||||
|
expect(UIAppRegistry.getAppForTool('n8n_create_workflow')!.config.id).toBe('operation-result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map n8n_update_full_workflow to operation-result', () => {
|
||||||
|
expect(UIAppRegistry.getAppForTool('n8n_update_full_workflow')!.config.id).toBe('operation-result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map n8n_update_partial_workflow to operation-result', () => {
|
||||||
|
expect(UIAppRegistry.getAppForTool('n8n_update_partial_workflow')!.config.id).toBe('operation-result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map n8n_delete_workflow to operation-result', () => {
|
||||||
|
expect(UIAppRegistry.getAppForTool('n8n_delete_workflow')!.config.id).toBe('operation-result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map n8n_test_workflow to operation-result', () => {
|
||||||
|
expect(UIAppRegistry.getAppForTool('n8n_test_workflow')!.config.id).toBe('operation-result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map n8n_autofix_workflow to operation-result', () => {
|
||||||
|
expect(UIAppRegistry.getAppForTool('n8n_autofix_workflow')!.config.id).toBe('operation-result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map n8n_deploy_template to operation-result', () => {
|
||||||
|
expect(UIAppRegistry.getAppForTool('n8n_deploy_template')!.config.id).toBe('operation-result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map validate_node to validation-summary', () => {
|
||||||
|
expect(UIAppRegistry.getAppForTool('validate_node')!.config.id).toBe('validation-summary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map validate_workflow to validation-summary', () => {
|
||||||
|
expect(UIAppRegistry.getAppForTool('validate_workflow')!.config.id).toBe('validation-summary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map n8n_validate_workflow to validation-summary', () => {
|
||||||
|
expect(UIAppRegistry.getAppForTool('n8n_validate_workflow')!.config.id).toBe('validation-summary');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAppById()', () => {
|
||||||
|
it('should return null before load() is called', () => {
|
||||||
|
const entry = UIAppRegistry.getAppById('operation-result');
|
||||||
|
expect(entry).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('after loading', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
mockReadFileSync.mockReturnValue('<html>app</html>');
|
||||||
|
UIAppRegistry.load();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct entry for operation-result', () => {
|
||||||
|
const entry = UIAppRegistry.getAppById('operation-result');
|
||||||
|
expect(entry).not.toBeNull();
|
||||||
|
expect(entry!.config.displayName).toBe('Operation Result');
|
||||||
|
expect(entry!.html).toBe('<html>app</html>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct entry for validation-summary', () => {
|
||||||
|
const entry = UIAppRegistry.getAppById('validation-summary');
|
||||||
|
expect(entry).not.toBeNull();
|
||||||
|
expect(entry!.config.displayName).toBe('Validation Summary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for unknown id', () => {
|
||||||
|
const entry = UIAppRegistry.getAppById('nonexistent');
|
||||||
|
expect(entry).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for empty string id', () => {
|
||||||
|
const entry = UIAppRegistry.getAppById('');
|
||||||
|
expect(entry).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllApps()', () => {
|
||||||
|
it('should return empty array before load() is called', () => {
|
||||||
|
const apps = UIAppRegistry.getAllApps();
|
||||||
|
expect(apps).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return all entries after load', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
UIAppRegistry.load();
|
||||||
|
|
||||||
|
const apps = UIAppRegistry.getAllApps();
|
||||||
|
expect(apps.length).toBe(UI_APP_CONFIGS.length);
|
||||||
|
expect(apps.map(a => a.config.id)).toContain('operation-result');
|
||||||
|
expect(apps.map(a => a.config.id)).toContain('validation-summary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include entries with null html when dist is missing', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
UIAppRegistry.load();
|
||||||
|
|
||||||
|
const apps = UIAppRegistry.getAllApps();
|
||||||
|
for (const app of apps) {
|
||||||
|
expect(app.html).toBeNull();
|
||||||
|
}
|
||||||
|
// Entries are still present even with null html
|
||||||
|
expect(apps.length).toBe(UI_APP_CONFIGS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return entries with full config objects', () => {
|
||||||
|
mockExistsSync.mockReturnValue(false);
|
||||||
|
UIAppRegistry.load();
|
||||||
|
|
||||||
|
for (const app of UIAppRegistry.getAllApps()) {
|
||||||
|
expect(app.config).toBeDefined();
|
||||||
|
expect(app.config.id).toBeDefined();
|
||||||
|
expect(app.config.displayName).toBeDefined();
|
||||||
|
expect(app.config.uri).toBeDefined();
|
||||||
|
expect(app.config.mimeType).toBeDefined();
|
||||||
|
expect(app.config.toolPatterns).toBeDefined();
|
||||||
|
expect(app.config.description).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
mockReadFileSync.mockReturnValue('<html>x</html>');
|
||||||
|
UIAppRegistry.load();
|
||||||
|
|
||||||
|
expect(UIAppRegistry.getAllApps().length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
UIAppRegistry.reset();
|
||||||
|
|
||||||
|
expect(UIAppRegistry.getAllApps()).toEqual([]);
|
||||||
|
expect(UIAppRegistry.getAppById('operation-result')).toBeNull();
|
||||||
|
expect(UIAppRegistry.getAppForTool('n8n_create_workflow')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
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
26
ui-apps/package.json
Normal file
26
ui-apps/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"@modelcontextprotocol/ext-apps": "^1.0.1",
|
||||||
|
"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';
|
||||||
35
ui-apps/src/shared/hooks/useToolData.ts
Normal file
35
ui-apps/src/shared/hooks/useToolData.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { App } from '@modelcontextprotocol/ext-apps';
|
||||||
|
|
||||||
|
export function useToolData<T>(): T | null {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const app = new App();
|
||||||
|
|
||||||
|
app.ontoolresult = (result: any) => {
|
||||||
|
// The host pushes tool result content; parse the first text item as JSON
|
||||||
|
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 {
|
||||||
|
// Not JSON — use raw text as-is
|
||||||
|
setData(textItem.text as unknown as T);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
app.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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