mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-16 23:43:07 +00:00
Compare commits
14 Commits
v2.34.2
...
feat/conne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edef5145ff | ||
|
|
bc1c00cc2e | ||
|
|
0998e5486e | ||
|
|
87f26eef18 | ||
|
|
4bad880f44 | ||
|
|
77048347b3 | ||
|
|
6f695be482 | ||
|
|
34159f4ece | ||
|
|
8217229e2f | ||
|
|
89146186d8 | ||
|
|
c601581714 | ||
|
|
020bc3d43d | ||
|
|
a57b400bd0 | ||
|
|
38aa70261a |
4
.github/workflows/dependency-check.yml
vendored
4
.github/workflows/dependency-check.yml
vendored
@@ -59,7 +59,9 @@ jobs:
|
||||
run: |
|
||||
npm init -y
|
||||
# Install from tarball WITHOUT lockfile (simulates npm install n8n-mcp)
|
||||
npm install ./n8n-mcp-*.tgz
|
||||
# Use --ignore-scripts to skip native compilation of transitive deps like isolated-vm
|
||||
# (n8n-mcp only reads node metadata, it doesn't execute n8n nodes at runtime)
|
||||
npm install --ignore-scripts ./n8n-mcp-*.tgz
|
||||
|
||||
- name: Verify critical dependency versions
|
||||
working-directory: /tmp/fresh-install-test
|
||||
|
||||
110
CHANGELOG.md
110
CHANGELOG.md
@@ -7,6 +7,116 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.36.0] - 2026-03-07
|
||||
|
||||
### Added
|
||||
|
||||
- **Connection validation: detect broken/malformed workflow connections** (Issue #620):
|
||||
- Unknown output keys (`UNKNOWN_CONNECTION_KEY`): Flags invalid connection keys like `"0"`, `"1"`, `"output"` with fix suggestions (e.g., "use main[1] instead" for numeric keys)
|
||||
- Invalid type field (`INVALID_CONNECTION_TYPE`): Detects invalid `type` values in connection targets (e.g., `"0"` instead of `"main"`)
|
||||
- Output index bounds checking (`OUTPUT_INDEX_OUT_OF_BOUNDS`): Catches connections using output indices beyond what a node supports, with awareness of `onError: 'continueErrorOutput'`, Switch rules, and IF/Filter nodes
|
||||
- Input index bounds checking (`INPUT_INDEX_OUT_OF_BOUNDS`): Validates target input indices against known node input counts (Merge=2, triggers=0, others=1)
|
||||
- BFS-based trigger reachability analysis: Replaces simple orphan detection with proper graph traversal from trigger nodes, flagging unreachable subgraphs
|
||||
- Flexible `WorkflowConnection` interface: Changed from explicit `main?/error?/ai_tool?` to `[outputType: string]` for accurate validation of all connection types
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.35.6] - 2026-03-04
|
||||
|
||||
### Changed
|
||||
|
||||
- **Updated n8n dependencies**: n8n 2.8.3 → 2.10.3, n8n-core 2.8.1 → 2.10.1, n8n-workflow 2.8.0 → 2.10.1, @n8n/n8n-nodes-langchain 2.8.1 → 2.10.1
|
||||
- Rebuilt node database with 806 core nodes (community nodes preserved from previous build)
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.35.5] - 2026-02-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Comprehensive parameter type coercion for Claude Desktop / Claude.ai** (Issue #605): Expanded the v2.35.4 fix to handle ALL type mismatches, not just stringified objects/arrays. Testing revealed 6/9 tools still failing in Claude Desktop after the initial fix.
|
||||
- Extended `coerceStringifiedJsonParams()` to coerce every schema type: `string→number`, `string→boolean`, `number→string`, `boolean→string` (in addition to existing `string→object` and `string→array`)
|
||||
- Added top-level safeguard to parse the entire `args` object if it arrives as a JSON string
|
||||
- Added `[Diagnostic]` section to error responses showing received argument types, enabling users to report exactly what their MCP client sends
|
||||
- Added 9 new unit tests (24 total) covering number, boolean, and number-to-string coercion
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.35.4] - 2026-02-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Defensive JSON.parse for stringified object/array parameters** (Issue #605): Claude Desktop 1.1.3189 serializes JSON object/array MCP parameters as strings, causing ZodError failures for ~60% of tools that accept nested parameters
|
||||
- Added schema-driven `coerceStringifiedJsonParams()` in the central `CallToolRequestSchema` handler
|
||||
- Automatically detects string values where the tool's `inputSchema` expects `object` or `array`, and parses them back
|
||||
- Safe: prefix check before parsing, type verification after, try/catch preserves original on failure
|
||||
- No-op for correct clients: native objects pass through unchanged
|
||||
- Affects 9 tools with object/array params: `validate_node`, `validate_workflow`, `n8n_create_workflow`, `n8n_update_full_workflow`, `n8n_update_partial_workflow`, `n8n_validate_workflow`, `n8n_autofix_workflow`, `n8n_test_workflow`, `n8n_executions`
|
||||
- Added 15 unit tests covering coercion, no-op, safety, and end-to-end scenarios
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.35.3] - 2026-02-19
|
||||
|
||||
### Changed
|
||||
|
||||
- **Updated n8n dependencies**: n8n 2.6.3 → 2.8.3, n8n-core 2.6.1 → 2.8.1, n8n-workflow 2.6.0 → 2.8.0, @n8n/n8n-nodes-langchain 2.6.2 → 2.8.1
|
||||
- **Fixed node loader for langchain package**: Adapted node loader to bypass restricted package.json `exports` field in @n8n/n8n-nodes-langchain >=2.9.0, resolving node files via absolute paths instead of `require.resolve()`
|
||||
- **Fixed community doc generation for cloud LLMs**: Added `N8N_MCP_LLM_API_KEY`/`OPENAI_API_KEY` env var support, switched to `max_completion_tokens`, and auto-omit `temperature` for cloud API endpoints
|
||||
- Rebuilt node database with 1,236 nodes (673 from n8n-nodes-base, 133 from @n8n/n8n-nodes-langchain, 430 community)
|
||||
- Refreshed community nodes (361 verified + 69 npm) with 424/430 AI documentation summaries
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.35.2] - 2026-02-09
|
||||
|
||||
### Changed
|
||||
|
||||
- **MCP Apps: Disable non-rendering apps in Claude.ai**: Disabled 3 MCP Apps (workflow-list, execution-history, health-dashboard) that render as collapsed accordions in Claude.ai, and removed `n8n_deploy_template` tool mapping which renders blank content. The server sets `_meta` correctly on the wire but the Claude.ai host ignores it for these tools. The 2 working apps (operation-result for 6 tools, validation-summary for 3 tools) remain active. Disabled apps can be re-enabled once the host-side issue is resolved.
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.35.1] - 2026-02-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MCP Apps: Fix UI not rendering for some tools in Claude**: Added legacy flat `_meta["ui/resourceUri"]` key alongside the nested `_meta.ui.resourceUri` in tool definitions. Claude.ai reads the flat key format; without it, tools like `n8n_health_check` and `n8n_list_workflows` showed as collapsed accordions instead of rendering their rich UI apps. Both key formats are now set by `injectToolMeta()`, matching the behavior of the official `registerAppTool` helper from `@modelcontextprotocol/ext-apps/server`.
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.35.0] - 2026-02-09
|
||||
|
||||
### Added
|
||||
|
||||
- **3 new MCP Apps**: workflow-list (compact table with status/tags), execution-history (status summary bar + execution table), health-dashboard (connection status, versions, performance metrics)
|
||||
- **Enhanced operation-result**: operation-aware headers (create/update/delete/test/deploy), detail panels with workflow metadata, copy-to-clipboard for IDs/URLs, autofix diff viewer
|
||||
- **CopyButton shared component**: reusable clipboard button with visual feedback
|
||||
- **Local preview harness** (`ui-apps/preview.html`): test all 5 apps with mock data, dark/light theme toggle, JSON-RPC protocol simulation
|
||||
- **Expanded shared types**: TypeScript types for workflow-list, execution-history, and health-dashboard data
|
||||
|
||||
### Fixed
|
||||
|
||||
- **React hooks violation**: Fixed `useMemo` called after early returns in `execution-history/App.tsx` and `validation-summary/App.tsx`, causing React error #310 ("Rendered more hooks than during the previous render") and blank iframes
|
||||
- **JSON-RPC catch-all handler**: Preview harness responds to unknown SDK requests to prevent hangs
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.34.5] - 2026-02-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MCP Apps: Fix blank UI and wrong status badge in Claude**: Rewrote `useToolData` hook to use the official `useApp` hook from `@modelcontextprotocol/ext-apps/react` for proper lifecycle management. Updated UI types and components to match actual server response format (`success: boolean` instead of `status: string`, nested `data` object for workflow details). Validation summary now handles both direct and wrapped (`n8n_validate_workflow`) response shapes.
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.34.3] - 2026-02-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MCP Apps: Use correct MIME type for ext-apps spec**: Changed resource MIME type from `text/html` to `text/html;profile=mcp-app` (the `RESOURCE_MIME_TYPE` constant from `@modelcontextprotocol/ext-apps`). Without this profile parameter, Claude Desktop/web fails to recognize resources as MCP Apps and shows "Failed to load MCP App: the resource may exceed the 5 MB size limit."
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.34.2] - 2026-02-07
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -18,21 +18,27 @@ npm run update:n8n:check
|
||||
# 4. Run update and skip tests (we'll test in CI)
|
||||
yes y | npm run update:n8n
|
||||
|
||||
# 5. Create feature branch
|
||||
# 5. Refresh community nodes (standard practice!)
|
||||
npm run fetch:community
|
||||
npm run generate:docs
|
||||
|
||||
# 6. Create feature branch
|
||||
git checkout -b update/n8n-X.X.X
|
||||
|
||||
# 6. Update version in package.json (must be HIGHER than latest release!)
|
||||
# 7. Update version in package.json (must be HIGHER than latest release!)
|
||||
# Edit: "version": "2.XX.X" (not the version from the release list!)
|
||||
|
||||
# 7. Update CHANGELOG.md
|
||||
# 8. Update CHANGELOG.md
|
||||
# - Change version number to match package.json
|
||||
# - Update date to today
|
||||
# - Update dependency versions
|
||||
# - Include community node refresh counts
|
||||
|
||||
# 8. Update README badge
|
||||
# 9. Update README badge and node counts
|
||||
# Edit line 8: Change n8n version badge to new n8n version
|
||||
# Update total node count in description (core + community)
|
||||
|
||||
# 9. Commit and push
|
||||
# 10. Commit and push
|
||||
git add -A
|
||||
git commit -m "chore: update n8n to X.X.X and bump version to 2.XX.X
|
||||
|
||||
@@ -41,7 +47,8 @@ git commit -m "chore: update n8n to X.X.X and bump version to 2.XX.X
|
||||
- Updated n8n-workflow from X.X.X to X.X.X
|
||||
- Updated @n8n/n8n-nodes-langchain from X.X.X to X.X.X
|
||||
- Rebuilt node database with XXX nodes (XXX from n8n-nodes-base, XXX from @n8n/n8n-nodes-langchain)
|
||||
- Updated README badge with new n8n version
|
||||
- Refreshed community nodes (XXX verified + XXX npm)
|
||||
- Updated README badge with new n8n version and node counts
|
||||
- Updated CHANGELOG with dependency changes
|
||||
|
||||
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
@@ -52,10 +59,10 @@ Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
|
||||
git push -u origin update/n8n-X.X.X
|
||||
|
||||
# 10. Create PR
|
||||
# 11. Create PR
|
||||
gh pr create --title "chore: update n8n to X.X.X" --body "Updates n8n and all related dependencies to the latest versions..."
|
||||
|
||||
# 11. After PR is merged, verify release triggered
|
||||
# 12. After PR is merged, verify release triggered
|
||||
gh release list | head -1
|
||||
# If the new version appears, you're done!
|
||||
# If not, the version might have already been released - bump version again and create new PR
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
[](https://www.npmjs.com/package/n8n-mcp)
|
||||
[](https://codecov.io/gh/czlonkowski/n8n-mcp)
|
||||
[](https://github.com/czlonkowski/n8n-mcp/actions)
|
||||
[](https://github.com/n8n-io/n8n)
|
||||
[](https://github.com/n8n-io/n8n)
|
||||
[](https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp)
|
||||
[](https://railway.com/deploy/n8n-mcp?referralCode=n8n-mcp)
|
||||
|
||||
A Model Context Protocol (MCP) server that provides AI assistants with comprehensive access to n8n node documentation, properties, and operations. Deploy in minutes to give Claude and other AI assistants deep knowledge about n8n's 1,084 workflow automation nodes (537 core + 547 community).
|
||||
A Model Context Protocol (MCP) server that provides AI assistants with comprehensive access to n8n node documentation, properties, and operations. Deploy in minutes to give Claude and other AI assistants deep knowledge about n8n's 1,236 workflow automation nodes (806 core + 430 community).
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
2
dist/loaders/node-loader.d.ts
vendored
2
dist/loaders/node-loader.d.ts
vendored
@@ -6,6 +6,8 @@ export interface LoadedNode {
|
||||
export declare class N8nNodeLoader {
|
||||
private readonly CORE_PACKAGES;
|
||||
loadAllNodes(): Promise<LoadedNode[]>;
|
||||
private resolvePackageDir;
|
||||
private loadNodeModule;
|
||||
private loadPackageNodes;
|
||||
}
|
||||
//# sourceMappingURL=node-loader.d.ts.map
|
||||
2
dist/loaders/node-loader.d.ts.map
vendored
2
dist/loaders/node-loader.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"node-loader.d.ts","sourceRoot":"","sources":["../../src/loaders/node-loader.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,GAAG,CAAC;CAChB;AAED,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAG5B;IAEI,YAAY,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC;YAmB7B,gBAAgB;CAqD/B"}
|
||||
{"version":3,"file":"node-loader.d.ts","sourceRoot":"","sources":["../../src/loaders/node-loader.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,GAAG,CAAC;CAChB;AAED,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAG5B;IAEI,YAAY,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC;IAuB3C,OAAO,CAAC,iBAAiB;IAUzB,OAAO,CAAC,cAAc;YAIR,gBAAgB;CAuD/B"}
|
||||
16
dist/loaders/node-loader.js
vendored
16
dist/loaders/node-loader.js
vendored
@@ -28,15 +28,23 @@ class N8nNodeLoader {
|
||||
}
|
||||
return results;
|
||||
}
|
||||
resolvePackageDir(packagePath) {
|
||||
const pkgJsonPath = require.resolve(`${packagePath}/package.json`);
|
||||
return path_1.default.dirname(pkgJsonPath);
|
||||
}
|
||||
loadNodeModule(absolutePath) {
|
||||
return require(absolutePath);
|
||||
}
|
||||
async loadPackageNodes(packageName, packagePath, packageJson) {
|
||||
const n8nConfig = packageJson.n8n || {};
|
||||
const nodes = [];
|
||||
const packageDir = this.resolvePackageDir(packagePath);
|
||||
const nodesList = n8nConfig.nodes || [];
|
||||
if (Array.isArray(nodesList)) {
|
||||
for (const nodePath of nodesList) {
|
||||
try {
|
||||
const fullPath = require.resolve(`${packagePath}/${nodePath}`);
|
||||
const nodeModule = require(fullPath);
|
||||
const fullPath = path_1.default.join(packageDir, nodePath);
|
||||
const nodeModule = this.loadNodeModule(fullPath);
|
||||
const nodeNameMatch = nodePath.match(/\/([^\/]+)\.node\.(js|ts)$/);
|
||||
const nodeName = nodeNameMatch ? nodeNameMatch[1] : path_1.default.basename(nodePath, '.node.js');
|
||||
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule)[0];
|
||||
@@ -56,8 +64,8 @@ class N8nNodeLoader {
|
||||
else {
|
||||
for (const [nodeName, nodePath] of Object.entries(nodesList)) {
|
||||
try {
|
||||
const fullPath = require.resolve(`${packagePath}/${nodePath}`);
|
||||
const nodeModule = require(fullPath);
|
||||
const fullPath = path_1.default.join(packageDir, nodePath);
|
||||
const nodeModule = this.loadNodeModule(fullPath);
|
||||
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule)[0];
|
||||
if (NodeClass) {
|
||||
nodes.push({ packageName, nodeName, NodeClass });
|
||||
|
||||
2
dist/loaders/node-loader.js.map
vendored
2
dist/loaders/node-loader.js.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"node-loader.js","sourceRoot":"","sources":["../../src/loaders/node-loader.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"node-loader.js","sourceRoot":"","sources":["../../src/loaders/node-loader.ts"],"names":[],"mappings":"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"}
|
||||
1
dist/mcp/server.d.ts
vendored
1
dist/mcp/server.d.ts
vendored
@@ -30,6 +30,7 @@ export declare class N8NDocumentationMCPServer {
|
||||
private validateToolParams;
|
||||
private validateToolParamsBasic;
|
||||
private validateExtractedArgs;
|
||||
private coerceStringifiedJsonParams;
|
||||
private listNodes;
|
||||
private getNodeInfo;
|
||||
private searchNodes;
|
||||
|
||||
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":"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"}
|
||||
{"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;IA0XrB,OAAO,CAAC,wBAAwB;IAoFhC,OAAO,CAAC,kBAAkB;IAqE1B,OAAO,CAAC,uBAAuB;IAwB/B,OAAO,CAAC,qBAAqB;IAiF7B,OAAO,CAAC,2BAA2B;YA0UrB,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"}
|
||||
114
dist/mcp/server.js
vendored
114
dist/mcp/server.js
vendored
@@ -427,6 +427,7 @@ class N8NDocumentationMCPServer {
|
||||
description: tool.description
|
||||
});
|
||||
});
|
||||
ui_1.UIAppRegistry.injectToolMeta(tools);
|
||||
return { tools };
|
||||
});
|
||||
this.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
||||
@@ -456,6 +457,18 @@ class N8NDocumentationMCPServer {
|
||||
};
|
||||
}
|
||||
let processedArgs = args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
processedArgs = parsed;
|
||||
logger_1.logger.warn(`Coerced stringified args object for tool "${name}"`);
|
||||
}
|
||||
}
|
||||
catch {
|
||||
logger_1.logger.warn(`Tool "${name}" received string args that are not valid JSON`);
|
||||
}
|
||||
}
|
||||
if (args && typeof args === 'object' && 'output' in args) {
|
||||
try {
|
||||
const possibleNestedData = args.output;
|
||||
@@ -484,6 +497,7 @@ class N8NDocumentationMCPServer {
|
||||
});
|
||||
}
|
||||
}
|
||||
processedArgs = this.coerceStringifiedJsonParams(name, processedArgs);
|
||||
try {
|
||||
logger_1.logger.debug(`Executing tool: ${name}`, { args: processedArgs });
|
||||
const startTime = Date.now();
|
||||
@@ -529,10 +543,6 @@ class N8NDocumentationMCPServer {
|
||||
if (name.startsWith('validate_') && structuredContent !== null) {
|
||||
mcpResponse.structuredContent = structuredContent;
|
||||
}
|
||||
const uiApp = ui_1.UIAppRegistry.getAppForTool(name);
|
||||
if (uiApp && uiApp.html) {
|
||||
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
|
||||
}
|
||||
return mcpResponse;
|
||||
}
|
||||
catch (error) {
|
||||
@@ -559,6 +569,13 @@ class N8NDocumentationMCPServer {
|
||||
if (name.startsWith('validate_') && (errorMessage.includes('config') || errorMessage.includes('nodeType'))) {
|
||||
helpfulMessage += '\n\nFor validation tools:\n- nodeType should be a string (e.g., "nodes-base.webhook")\n- config should be an object (e.g., {})';
|
||||
}
|
||||
try {
|
||||
const argDiag = processedArgs && typeof processedArgs === 'object'
|
||||
? Object.entries(processedArgs).map(([k, v]) => `${k}: ${typeof v}`).join(', ')
|
||||
: `args type: ${typeof processedArgs}`;
|
||||
helpfulMessage += `\n\n[Diagnostic] Received arg types: {${argDiag}}`;
|
||||
}
|
||||
catch { }
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -585,7 +602,7 @@ class N8NDocumentationMCPServer {
|
||||
});
|
||||
this.server.setRequestHandler(types_js_1.ReadResourceRequestSchema, async (request) => {
|
||||
const uri = request.params.uri;
|
||||
const match = uri.match(/^n8n-mcp:\/\/ui\/(.+)$/);
|
||||
const match = uri.match(/^ui:\/\/n8n-mcp\/(.+)$/);
|
||||
if (!match) {
|
||||
throw new Error(`Unknown resource URI: ${uri}`);
|
||||
}
|
||||
@@ -798,6 +815,93 @@ class N8NDocumentationMCPServer {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
coerceStringifiedJsonParams(toolName, args) {
|
||||
if (!args || typeof args !== 'object')
|
||||
return args;
|
||||
const allTools = [...tools_1.n8nDocumentationToolsFinal, ...tools_n8n_manager_1.n8nManagementTools];
|
||||
const tool = allTools.find(t => t.name === toolName);
|
||||
if (!tool?.inputSchema?.properties)
|
||||
return args;
|
||||
const properties = tool.inputSchema.properties;
|
||||
const coerced = { ...args };
|
||||
let coercedAny = false;
|
||||
for (const [key, value] of Object.entries(coerced)) {
|
||||
if (value === undefined || value === null)
|
||||
continue;
|
||||
const propSchema = properties[key];
|
||||
if (!propSchema)
|
||||
continue;
|
||||
const expectedType = propSchema.type;
|
||||
if (!expectedType)
|
||||
continue;
|
||||
const actualType = typeof value;
|
||||
if (expectedType === 'string' && actualType === 'string')
|
||||
continue;
|
||||
if ((expectedType === 'number' || expectedType === 'integer') && actualType === 'number')
|
||||
continue;
|
||||
if (expectedType === 'boolean' && actualType === 'boolean')
|
||||
continue;
|
||||
if (expectedType === 'object' && actualType === 'object' && !Array.isArray(value))
|
||||
continue;
|
||||
if (expectedType === 'array' && Array.isArray(value))
|
||||
continue;
|
||||
if (actualType === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (expectedType === 'object' && trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
coerced[key] = parsed;
|
||||
coercedAny = true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
continue;
|
||||
}
|
||||
if (expectedType === 'array' && trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
coerced[key] = parsed;
|
||||
coercedAny = true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
continue;
|
||||
}
|
||||
if (expectedType === 'number' || expectedType === 'integer') {
|
||||
const num = Number(trimmed);
|
||||
if (!isNaN(num) && trimmed !== '') {
|
||||
coerced[key] = expectedType === 'integer' ? Math.trunc(num) : num;
|
||||
coercedAny = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (expectedType === 'boolean') {
|
||||
if (trimmed === 'true') {
|
||||
coerced[key] = true;
|
||||
coercedAny = true;
|
||||
}
|
||||
else if (trimmed === 'false') {
|
||||
coerced[key] = false;
|
||||
coercedAny = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (expectedType === 'string' && (actualType === 'number' || actualType === 'boolean')) {
|
||||
coerced[key] = String(value);
|
||||
coercedAny = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (coercedAny) {
|
||||
logger_1.logger.warn(`Coerced mistyped params for tool "${toolName}"`, {
|
||||
original: Object.fromEntries(Object.entries(args).map(([k, v]) => [k, `${typeof v}: ${typeof v === 'string' ? v.substring(0, 80) : v}`])),
|
||||
});
|
||||
}
|
||||
return coerced;
|
||||
}
|
||||
async executeTool(name, args) {
|
||||
args = args || {};
|
||||
const disabledTools = this.getDisabledTools();
|
||||
|
||||
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
16
dist/services/workflow-validator.d.ts
vendored
16
dist/services/workflow-validator.d.ts
vendored
@@ -21,17 +21,7 @@ interface WorkflowNode {
|
||||
}
|
||||
interface WorkflowConnection {
|
||||
[sourceNode: string]: {
|
||||
main?: Array<Array<{
|
||||
node: string;
|
||||
type: string;
|
||||
index: number;
|
||||
}>>;
|
||||
error?: Array<Array<{
|
||||
node: string;
|
||||
type: string;
|
||||
index: number;
|
||||
}>>;
|
||||
ai_tool?: Array<Array<{
|
||||
[outputType: string]: Array<Array<{
|
||||
node: string;
|
||||
type: string;
|
||||
index: number;
|
||||
@@ -94,6 +84,10 @@ export declare class WorkflowValidator {
|
||||
private validateErrorOutputConfiguration;
|
||||
private validateAIToolConnection;
|
||||
private validateAIToolSource;
|
||||
private validateOutputIndexBounds;
|
||||
private validateInputIndexBounds;
|
||||
private flagOrphanedNodes;
|
||||
private validateTriggerReachability;
|
||||
private hasCycle;
|
||||
private validateExpressions;
|
||||
private countExpressionsInObject;
|
||||
|
||||
2
dist/services/workflow-validator.d.ts.map
vendored
2
dist/services/workflow-validator.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"workflow-validator.d.ts","sourceRoot":"","sources":["../../src/services/workflow-validator.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"workflow-validator.d.ts","sourceRoot":"","sources":["../../src/services/workflow-validator.ts"],"names":[],"mappings":"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"}
|
||||
285
dist/services/workflow-validator.js
vendored
285
dist/services/workflow-validator.js
vendored
@@ -16,6 +16,15 @@ const node_type_utils_1 = require("../utils/node-type-utils");
|
||||
const node_classification_1 = require("../utils/node-classification");
|
||||
const tool_variant_generator_1 = require("./tool-variant-generator");
|
||||
const logger = new logger_1.Logger({ prefix: '[WorkflowValidator]' });
|
||||
const VALID_CONNECTION_TYPES = new Set([
|
||||
'main',
|
||||
'error',
|
||||
...ai_node_validator_1.AI_CONNECTION_TYPES,
|
||||
'ai_agent',
|
||||
'ai_chain',
|
||||
'ai_retriever',
|
||||
'ai_reranker',
|
||||
]);
|
||||
class WorkflowValidator {
|
||||
constructor(nodeRepository, nodeValidator) {
|
||||
this.nodeRepository = nodeRepository;
|
||||
@@ -393,51 +402,34 @@ class WorkflowValidator {
|
||||
result.statistics.invalidConnections++;
|
||||
continue;
|
||||
}
|
||||
if (outputs.main) {
|
||||
this.validateConnectionOutputs(sourceName, outputs.main, nodeMap, nodeIdMap, result, 'main');
|
||||
}
|
||||
if (outputs.error) {
|
||||
this.validateConnectionOutputs(sourceName, outputs.error, nodeMap, nodeIdMap, result, 'error');
|
||||
}
|
||||
if (outputs.ai_tool) {
|
||||
this.validateAIToolSource(sourceNode, result);
|
||||
this.validateConnectionOutputs(sourceName, outputs.ai_tool, nodeMap, nodeIdMap, result, 'ai_tool');
|
||||
for (const [outputKey, outputConnections] of Object.entries(outputs)) {
|
||||
if (!VALID_CONNECTION_TYPES.has(outputKey)) {
|
||||
let suggestion = '';
|
||||
if (/^\d+$/.test(outputKey)) {
|
||||
suggestion = ` If you meant to use output index ${outputKey}, use main[${outputKey}] instead.`;
|
||||
}
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeName: sourceName,
|
||||
message: `Unknown connection output key "${outputKey}" on node "${sourceName}". Valid keys are: ${[...VALID_CONNECTION_TYPES].join(', ')}.${suggestion}`,
|
||||
code: 'UNKNOWN_CONNECTION_KEY'
|
||||
});
|
||||
result.statistics.invalidConnections++;
|
||||
continue;
|
||||
}
|
||||
if (!outputConnections || !Array.isArray(outputConnections))
|
||||
continue;
|
||||
if (outputKey === 'ai_tool') {
|
||||
this.validateAIToolSource(sourceNode, result);
|
||||
}
|
||||
this.validateConnectionOutputs(sourceName, outputConnections, nodeMap, nodeIdMap, result, outputKey);
|
||||
}
|
||||
}
|
||||
const connectedNodes = new Set();
|
||||
Object.keys(workflow.connections).forEach(name => connectedNodes.add(name));
|
||||
Object.values(workflow.connections).forEach(outputs => {
|
||||
if (outputs.main) {
|
||||
outputs.main.flat().forEach(conn => {
|
||||
if (conn)
|
||||
connectedNodes.add(conn.node);
|
||||
});
|
||||
}
|
||||
if (outputs.error) {
|
||||
outputs.error.flat().forEach(conn => {
|
||||
if (conn)
|
||||
connectedNodes.add(conn.node);
|
||||
});
|
||||
}
|
||||
if (outputs.ai_tool) {
|
||||
outputs.ai_tool.flat().forEach(conn => {
|
||||
if (conn)
|
||||
connectedNodes.add(conn.node);
|
||||
});
|
||||
}
|
||||
});
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.disabled || (0, node_classification_1.isNonExecutableNode)(node.type))
|
||||
continue;
|
||||
const isNodeTrigger = (0, node_type_utils_1.isTriggerNode)(node.type);
|
||||
if (!connectedNodes.has(node.name) && !isNodeTrigger) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: 'Node is not connected to any other nodes'
|
||||
});
|
||||
}
|
||||
if (profile !== 'minimal') {
|
||||
this.validateTriggerReachability(workflow, result);
|
||||
}
|
||||
else {
|
||||
this.flagOrphanedNodes(workflow, result);
|
||||
}
|
||||
if (profile !== 'minimal' && this.hasCycle(workflow)) {
|
||||
result.errors.push({
|
||||
@@ -450,6 +442,7 @@ class WorkflowValidator {
|
||||
const sourceNode = nodeMap.get(sourceName);
|
||||
if (outputType === 'main' && sourceNode) {
|
||||
this.validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result);
|
||||
this.validateOutputIndexBounds(sourceNode, outputs, result);
|
||||
}
|
||||
outputs.forEach((outputConnections, outputIndex) => {
|
||||
if (!outputConnections)
|
||||
@@ -463,6 +456,20 @@ class WorkflowValidator {
|
||||
result.statistics.invalidConnections++;
|
||||
return;
|
||||
}
|
||||
if (connection.type && !VALID_CONNECTION_TYPES.has(connection.type)) {
|
||||
let suggestion = '';
|
||||
if (/^\d+$/.test(connection.type)) {
|
||||
suggestion = ` Numeric types are not valid - use "main", "error", or an AI connection type.`;
|
||||
}
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeName: sourceName,
|
||||
message: `Invalid connection type "${connection.type}" in connection from "${sourceName}" to "${connection.node}". Expected "main", "error", or an AI connection type (ai_tool, ai_languageModel, etc.).${suggestion}`,
|
||||
code: 'INVALID_CONNECTION_TYPE'
|
||||
});
|
||||
result.statistics.invalidConnections++;
|
||||
return;
|
||||
}
|
||||
const isSplitInBatches = sourceNode && (sourceNode.type === 'n8n-nodes-base.splitInBatches' ||
|
||||
sourceNode.type === 'nodes-base.splitInBatches');
|
||||
if (isSplitInBatches) {
|
||||
@@ -506,6 +513,9 @@ class WorkflowValidator {
|
||||
if (outputType === 'ai_tool') {
|
||||
this.validateAIToolConnection(sourceName, targetNode, result);
|
||||
}
|
||||
if (outputType === 'main') {
|
||||
this.validateInputIndexBounds(sourceName, targetNode, connection, result);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -634,6 +644,171 @@ class WorkflowValidator {
|
||||
code: 'INVALID_AI_TOOL_SOURCE'
|
||||
});
|
||||
}
|
||||
validateOutputIndexBounds(sourceNode, outputs, result) {
|
||||
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(sourceNode.type);
|
||||
const nodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||
if (!nodeInfo || !nodeInfo.outputs)
|
||||
return;
|
||||
let mainOutputCount;
|
||||
if (Array.isArray(nodeInfo.outputs)) {
|
||||
mainOutputCount = nodeInfo.outputs.filter((o) => typeof o === 'string' ? o === 'main' : (o.type === 'main' || !o.type)).length;
|
||||
}
|
||||
else {
|
||||
return;
|
||||
}
|
||||
if (mainOutputCount === 0)
|
||||
return;
|
||||
const shortType = normalizedType.replace(/^(n8n-)?nodes-base\./, '');
|
||||
if (shortType === 'switch') {
|
||||
const rules = sourceNode.parameters?.rules?.values || sourceNode.parameters?.rules;
|
||||
if (Array.isArray(rules)) {
|
||||
mainOutputCount = rules.length + 1;
|
||||
}
|
||||
else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (shortType === 'if' || shortType === 'filter') {
|
||||
mainOutputCount = 2;
|
||||
}
|
||||
if (sourceNode.onError === 'continueErrorOutput') {
|
||||
mainOutputCount += 1;
|
||||
}
|
||||
const maxOutputIndex = outputs.length - 1;
|
||||
if (maxOutputIndex >= mainOutputCount) {
|
||||
for (let i = mainOutputCount; i < outputs.length; i++) {
|
||||
if (outputs[i] && outputs[i].length > 0) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeId: sourceNode.id,
|
||||
nodeName: sourceNode.name,
|
||||
message: `Output index ${i} on node "${sourceNode.name}" exceeds its output count (${mainOutputCount}). ` +
|
||||
`This node has ${mainOutputCount} main output(s) (indices 0-${mainOutputCount - 1}).`,
|
||||
code: 'OUTPUT_INDEX_OUT_OF_BOUNDS'
|
||||
});
|
||||
result.statistics.invalidConnections++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
validateInputIndexBounds(sourceName, targetNode, connection, result) {
|
||||
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(targetNode.type);
|
||||
const nodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||
if (!nodeInfo)
|
||||
return;
|
||||
const shortType = normalizedType.replace(/^(n8n-)?nodes-base\./, '');
|
||||
let mainInputCount = 1;
|
||||
if (shortType === 'merge' || shortType === 'compareDatasets') {
|
||||
mainInputCount = 2;
|
||||
}
|
||||
if (nodeInfo.isTrigger || (0, node_type_utils_1.isTriggerNode)(targetNode.type)) {
|
||||
mainInputCount = 0;
|
||||
}
|
||||
if (mainInputCount > 0 && connection.index >= mainInputCount) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeName: targetNode.name,
|
||||
message: `Input index ${connection.index} on node "${targetNode.name}" exceeds its input count (${mainInputCount}). ` +
|
||||
`Connection from "${sourceName}" targets input ${connection.index}, but this node has ${mainInputCount} main input(s) (indices 0-${mainInputCount - 1}).`,
|
||||
code: 'INPUT_INDEX_OUT_OF_BOUNDS'
|
||||
});
|
||||
result.statistics.invalidConnections++;
|
||||
}
|
||||
}
|
||||
flagOrphanedNodes(workflow, result) {
|
||||
const connectedNodes = new Set();
|
||||
for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
|
||||
connectedNodes.add(sourceName);
|
||||
for (const outputConns of Object.values(outputs)) {
|
||||
if (!Array.isArray(outputConns))
|
||||
continue;
|
||||
for (const conns of outputConns) {
|
||||
if (!conns)
|
||||
continue;
|
||||
for (const conn of conns) {
|
||||
if (conn)
|
||||
connectedNodes.add(conn.node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.disabled || (0, node_classification_1.isNonExecutableNode)(node.type))
|
||||
continue;
|
||||
if ((0, node_type_utils_1.isTriggerNode)(node.type))
|
||||
continue;
|
||||
if (!connectedNodes.has(node.name)) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: 'Node is not connected to any other nodes'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
validateTriggerReachability(workflow, result) {
|
||||
const adjacency = new Map();
|
||||
for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
|
||||
if (!adjacency.has(sourceName))
|
||||
adjacency.set(sourceName, new Set());
|
||||
for (const outputConns of Object.values(outputs)) {
|
||||
if (Array.isArray(outputConns)) {
|
||||
for (const conns of outputConns) {
|
||||
if (!conns)
|
||||
continue;
|
||||
for (const conn of conns) {
|
||||
if (conn) {
|
||||
adjacency.get(sourceName).add(conn.node);
|
||||
if (!adjacency.has(conn.node))
|
||||
adjacency.set(conn.node, new Set());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const triggerNodes = [];
|
||||
for (const node of workflow.nodes) {
|
||||
if ((0, node_type_utils_1.isTriggerNode)(node.type) && !node.disabled) {
|
||||
triggerNodes.push(node.name);
|
||||
}
|
||||
}
|
||||
if (triggerNodes.length === 0) {
|
||||
this.flagOrphanedNodes(workflow, result);
|
||||
return;
|
||||
}
|
||||
const reachable = new Set();
|
||||
const queue = [...triggerNodes];
|
||||
for (const t of triggerNodes)
|
||||
reachable.add(t);
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
const neighbors = adjacency.get(current);
|
||||
if (neighbors) {
|
||||
for (const neighbor of neighbors) {
|
||||
if (!reachable.has(neighbor)) {
|
||||
reachable.add(neighbor);
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.disabled || (0, node_classification_1.isNonExecutableNode)(node.type))
|
||||
continue;
|
||||
if ((0, node_type_utils_1.isTriggerNode)(node.type))
|
||||
continue;
|
||||
if (!reachable.has(node.name)) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: 'Node is not reachable from any trigger node'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
hasCycle(workflow) {
|
||||
const visited = new Set();
|
||||
const recursionStack = new Set();
|
||||
@@ -657,23 +832,13 @@ class WorkflowValidator {
|
||||
const connections = workflow.connections[nodeName];
|
||||
if (connections) {
|
||||
const allTargets = [];
|
||||
if (connections.main) {
|
||||
connections.main.flat().forEach(conn => {
|
||||
if (conn)
|
||||
allTargets.push(conn.node);
|
||||
});
|
||||
}
|
||||
if (connections.error) {
|
||||
connections.error.flat().forEach(conn => {
|
||||
if (conn)
|
||||
allTargets.push(conn.node);
|
||||
});
|
||||
}
|
||||
if (connections.ai_tool) {
|
||||
connections.ai_tool.flat().forEach(conn => {
|
||||
if (conn)
|
||||
allTargets.push(conn.node);
|
||||
});
|
||||
for (const outputConns of Object.values(connections)) {
|
||||
if (Array.isArray(outputConns)) {
|
||||
outputConns.flat().forEach(conn => {
|
||||
if (conn)
|
||||
allTargets.push(conn.node);
|
||||
});
|
||||
}
|
||||
}
|
||||
const currentNodeType = nodeTypeMap.get(nodeName);
|
||||
const isLoopNode = loopNodeTypes.includes(currentNodeType || '');
|
||||
|
||||
2
dist/services/workflow-validator.js.map
vendored
2
dist/services/workflow-validator.js.map
vendored
File diff suppressed because one or more lines are too long
5
dist/types/index.d.ts
vendored
5
dist/types/index.d.ts
vendored
@@ -30,6 +30,11 @@ export interface ToolDefinition {
|
||||
additionalProperties?: boolean | Record<string, any>;
|
||||
};
|
||||
annotations?: ToolAnnotations;
|
||||
_meta?: {
|
||||
ui?: {
|
||||
resourceUri?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
export interface ResourceDefinition {
|
||||
uri: string;
|
||||
|
||||
2
dist/types/index.d.ts.map
vendored
2
dist/types/index.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AACA,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,iBAAiB,CAAC;AAEhC,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD,MAAM,WAAW,eAAe;IAE9B,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B,cAAc,CAAC,EAAE,OAAO,CAAC;IAEzB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE;QACX,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAChC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,oBAAoB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;KACtD,CAAC;IACF,YAAY,CAAC,EAAE;QACb,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAChC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,oBAAoB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;KACtD,CAAC;IAEF,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B;AAED,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,KAAK,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB,CAAC,CAAC;CACJ"}
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AACA,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,iBAAiB,CAAC;AAEhC,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD,MAAM,WAAW,eAAe;IAE9B,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B,cAAc,CAAC,EAAE,OAAO,CAAC;IAEzB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE;QACX,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAChC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,oBAAoB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;KACtD,CAAC;IACF,YAAY,CAAC,EAAE;QACb,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAChC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,oBAAoB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;KACtD,CAAC;IAEF,WAAW,CAAC,EAAE,eAAe,CAAC;IAC9B,KAAK,CAAC,EAAE;QACN,EAAE,CAAC,EAAE;YACH,WAAW,CAAC,EAAE,MAAM,CAAC;SACtB,CAAC;KACH,CAAC;CACH;AAED,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,KAAK,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB,CAAC,CAAC;CACJ"}
|
||||
14684
package-lock.json
generated
14684
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.34.2",
|
||||
"version": "2.36.0",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -153,16 +153,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.20.1",
|
||||
"@n8n/n8n-nodes-langchain": "^2.6.2",
|
||||
"@n8n/n8n-nodes-langchain": "^2.10.1",
|
||||
"@supabase/supabase-js": "^2.57.4",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"form-data": "^4.0.5",
|
||||
"lru-cache": "^11.2.1",
|
||||
"n8n": "^2.6.3",
|
||||
"n8n-core": "^2.6.1",
|
||||
"n8n-workflow": "^2.6.0",
|
||||
"n8n": "^2.10.3",
|
||||
"n8n-core": "^2.10.1",
|
||||
"n8n-workflow": "^2.10.1",
|
||||
"openai": "^4.77.0",
|
||||
"sql.js": "^1.13.0",
|
||||
"tslib": "^2.6.2",
|
||||
@@ -175,6 +175,7 @@
|
||||
"better-sqlite3": "^11.10.0"
|
||||
},
|
||||
"overrides": {
|
||||
"pyodide": "0.26.4"
|
||||
"pyodide": "0.26.4",
|
||||
"isolated-vm": "npm:empty-npm-package@1.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,12 +57,14 @@ export interface DocumentationGeneratorConfig {
|
||||
timeout?: number;
|
||||
/** Max tokens for response (default: 2000) */
|
||||
maxTokens?: number;
|
||||
/** Temperature for generation (default: 0.3, set to undefined to omit) */
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<Omit<DocumentationGeneratorConfig, 'baseUrl'>> = {
|
||||
const DEFAULT_CONFIG: Required<Omit<DocumentationGeneratorConfig, 'baseUrl' | 'temperature'>> = {
|
||||
model: 'qwen3-4b-thinking-2507',
|
||||
apiKey: 'not-needed',
|
||||
timeout: 60000,
|
||||
@@ -78,6 +80,7 @@ export class DocumentationGenerator {
|
||||
private model: string;
|
||||
private maxTokens: number;
|
||||
private timeout: number;
|
||||
private temperature?: number;
|
||||
|
||||
constructor(config: DocumentationGeneratorConfig) {
|
||||
const fullConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
@@ -90,6 +93,7 @@ export class DocumentationGenerator {
|
||||
this.model = fullConfig.model;
|
||||
this.maxTokens = fullConfig.maxTokens;
|
||||
this.timeout = fullConfig.timeout;
|
||||
this.temperature = fullConfig.temperature;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,8 +105,8 @@ export class DocumentationGenerator {
|
||||
|
||||
const completion = await this.client.chat.completions.create({
|
||||
model: this.model,
|
||||
max_tokens: this.maxTokens,
|
||||
temperature: 0.3, // Lower temperature for more consistent output
|
||||
max_completion_tokens: this.maxTokens,
|
||||
...(this.temperature !== undefined ? { temperature: this.temperature } : {}),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -321,7 +325,7 @@ Guidelines:
|
||||
try {
|
||||
const completion = await this.client.chat.completions.create({
|
||||
model: this.model,
|
||||
max_tokens: 10,
|
||||
max_completion_tokens: 200,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
@@ -353,10 +357,15 @@ export function createDocumentationGenerator(): DocumentationGenerator {
|
||||
const baseUrl = process.env.N8N_MCP_LLM_BASE_URL || 'http://localhost:1234/v1';
|
||||
const model = process.env.N8N_MCP_LLM_MODEL || 'qwen3-4b-thinking-2507';
|
||||
const timeout = parseInt(process.env.N8N_MCP_LLM_TIMEOUT || '60000', 10);
|
||||
const apiKey = process.env.N8N_MCP_LLM_API_KEY || process.env.OPENAI_API_KEY;
|
||||
// Only set temperature for local LLM servers; cloud APIs like OpenAI may not support custom values
|
||||
const isLocalServer = !baseUrl.includes('openai.com') && !baseUrl.includes('anthropic.com');
|
||||
|
||||
return new DocumentationGenerator({
|
||||
baseUrl,
|
||||
model,
|
||||
timeout,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(isLocalServer ? { temperature: 0.3 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,24 +31,44 @@ export class N8nNodeLoader {
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute directory of an installed package.
|
||||
* Uses require.resolve on package.json (always exported) and strips the filename.
|
||||
*/
|
||||
private resolvePackageDir(packagePath: string): string {
|
||||
const pkgJsonPath = require.resolve(`${packagePath}/package.json`);
|
||||
return path.dirname(pkgJsonPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a node module by absolute file path, bypassing package.json "exports".
|
||||
* Some packages (e.g. @n8n/n8n-nodes-langchain >=2.9) restrict exports but
|
||||
* still list node files in the n8n.nodes array — we need direct filesystem access.
|
||||
*/
|
||||
private loadNodeModule(absolutePath: string): any {
|
||||
return require(absolutePath);
|
||||
}
|
||||
|
||||
private async loadPackageNodes(packageName: string, packagePath: string, packageJson: any): Promise<LoadedNode[]> {
|
||||
const n8nConfig = packageJson.n8n || {};
|
||||
const nodes: LoadedNode[] = [];
|
||||
|
||||
const packageDir = this.resolvePackageDir(packagePath);
|
||||
|
||||
// Check if nodes is an array or object
|
||||
const nodesList = n8nConfig.nodes || [];
|
||||
|
||||
|
||||
if (Array.isArray(nodesList)) {
|
||||
// Handle array format (n8n-nodes-base uses this)
|
||||
for (const nodePath of nodesList) {
|
||||
try {
|
||||
const fullPath = require.resolve(`${packagePath}/${nodePath}`);
|
||||
const nodeModule = require(fullPath);
|
||||
|
||||
// Resolve absolute path directly to bypass package exports restrictions
|
||||
const fullPath = path.join(packageDir, nodePath);
|
||||
const nodeModule = this.loadNodeModule(fullPath);
|
||||
|
||||
// Extract node name from path (e.g., "dist/nodes/Slack/Slack.node.js" -> "Slack")
|
||||
const nodeNameMatch = nodePath.match(/\/([^\/]+)\.node\.(js|ts)$/);
|
||||
const nodeName = nodeNameMatch ? nodeNameMatch[1] : path.basename(nodePath, '.node.js');
|
||||
|
||||
|
||||
// Handle default export and various export patterns
|
||||
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule)[0];
|
||||
if (NodeClass) {
|
||||
@@ -65,9 +85,9 @@ export class N8nNodeLoader {
|
||||
// Handle object format (for other packages)
|
||||
for (const [nodeName, nodePath] of Object.entries(nodesList)) {
|
||||
try {
|
||||
const fullPath = require.resolve(`${packagePath}/${nodePath as string}`);
|
||||
const nodeModule = require(fullPath);
|
||||
|
||||
const fullPath = path.join(packageDir, nodePath as string);
|
||||
const nodeModule = this.loadNodeModule(fullPath);
|
||||
|
||||
// Handle default export and various export patterns
|
||||
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule)[0];
|
||||
if (NodeClass) {
|
||||
@@ -81,7 +101,7 @@ export class N8nNodeLoader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
@@ -687,9 +687,23 @@ export class N8NDocumentationMCPServer {
|
||||
};
|
||||
}
|
||||
|
||||
// Safeguard: if the entire args object arrives as a JSON string, parse it.
|
||||
// Some MCP clients may serialize the arguments object itself.
|
||||
let processedArgs: Record<string, any> | undefined = args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(args as unknown as string);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
processedArgs = parsed;
|
||||
logger.warn(`Coerced stringified args object for tool "${name}"`);
|
||||
}
|
||||
} catch {
|
||||
logger.warn(`Tool "${name}" received string args that are not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
// Workaround for n8n's nested output bug
|
||||
// Check if args contains nested 'output' structure from n8n's memory corruption
|
||||
let processedArgs = args;
|
||||
if (args && typeof args === 'object' && 'output' in args) {
|
||||
try {
|
||||
const possibleNestedData = args.output;
|
||||
@@ -720,7 +734,13 @@ export class N8NDocumentationMCPServer {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Workaround for Claude Desktop / Claude.ai MCP client bugs that
|
||||
// serialize parameters with wrong types. Coerces ALL mismatched types
|
||||
// (string↔object, string↔number, string↔boolean, etc.) using the
|
||||
// tool's inputSchema as the source of truth.
|
||||
processedArgs = this.coerceStringifiedJsonParams(name, processedArgs);
|
||||
|
||||
try {
|
||||
logger.debug(`Executing tool: ${name}`, { args: processedArgs });
|
||||
const startTime = Date.now();
|
||||
@@ -808,7 +828,7 @@ export class N8NDocumentationMCPServer {
|
||||
|
||||
// Provide more helpful error messages for common n8n issues
|
||||
let helpfulMessage = `Error executing tool ${name}: ${errorMessage}`;
|
||||
|
||||
|
||||
if (errorMessage.includes('required') || errorMessage.includes('missing')) {
|
||||
helpfulMessage += '\n\nNote: This error often occurs when the AI agent sends incomplete or incorrectly formatted parameters. Please ensure all required fields are provided with the correct types.';
|
||||
} else if (errorMessage.includes('type') || errorMessage.includes('expected')) {
|
||||
@@ -816,12 +836,20 @@ export class N8NDocumentationMCPServer {
|
||||
} else if (errorMessage.includes('Unknown category') || errorMessage.includes('not found')) {
|
||||
helpfulMessage += '\n\nNote: The requested resource or category was not found. Please check the available options.';
|
||||
}
|
||||
|
||||
|
||||
// For n8n schema errors, add specific guidance
|
||||
if (name.startsWith('validate_') && (errorMessage.includes('config') || errorMessage.includes('nodeType'))) {
|
||||
helpfulMessage += '\n\nFor validation tools:\n- nodeType should be a string (e.g., "nodes-base.webhook")\n- config should be an object (e.g., {})';
|
||||
}
|
||||
|
||||
|
||||
// Include diagnostic info about received args to help debug client issues
|
||||
try {
|
||||
const argDiag = processedArgs && typeof processedArgs === 'object'
|
||||
? Object.entries(processedArgs).map(([k, v]) => `${k}: ${typeof v}`).join(', ')
|
||||
: `args type: ${typeof processedArgs}`;
|
||||
helpfulMessage += `\n\n[Diagnostic] Received arg types: {${argDiag}}`;
|
||||
} catch { /* ignore diagnostic errors */ }
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -1125,6 +1153,109 @@ export class N8NDocumentationMCPServer {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce mistyped parameters back to their expected types.
|
||||
* Workaround for Claude Desktop / Claude.ai MCP client bugs that serialize
|
||||
* parameters incorrectly (objects as strings, numbers as strings, etc.).
|
||||
*
|
||||
* Handles ALL type mismatches based on the tool's inputSchema:
|
||||
* string→object, string→array : JSON.parse
|
||||
* string→number, string→integer : Number()
|
||||
* string→boolean : "true"/"false" parsing
|
||||
* number→string, boolean→string : .toString()
|
||||
*/
|
||||
private coerceStringifiedJsonParams(
|
||||
toolName: string,
|
||||
args: Record<string, any> | undefined
|
||||
): Record<string, any> | undefined {
|
||||
if (!args || typeof args !== 'object') return args;
|
||||
|
||||
const allTools = [...n8nDocumentationToolsFinal, ...n8nManagementTools];
|
||||
const tool = allTools.find(t => t.name === toolName);
|
||||
if (!tool?.inputSchema?.properties) return args;
|
||||
|
||||
const properties = tool.inputSchema.properties;
|
||||
const coerced = { ...args };
|
||||
let coercedAny = false;
|
||||
|
||||
for (const [key, value] of Object.entries(coerced)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
const propSchema = (properties as any)[key];
|
||||
if (!propSchema) continue;
|
||||
const expectedType = propSchema.type;
|
||||
if (!expectedType) continue;
|
||||
|
||||
const actualType = typeof value;
|
||||
|
||||
// Already correct type — skip
|
||||
if (expectedType === 'string' && actualType === 'string') continue;
|
||||
if ((expectedType === 'number' || expectedType === 'integer') && actualType === 'number') continue;
|
||||
if (expectedType === 'boolean' && actualType === 'boolean') continue;
|
||||
if (expectedType === 'object' && actualType === 'object' && !Array.isArray(value)) continue;
|
||||
if (expectedType === 'array' && Array.isArray(value)) continue;
|
||||
|
||||
// --- Coercion: string value → expected type ---
|
||||
if (actualType === 'string') {
|
||||
const trimmed = (value as string).trim();
|
||||
|
||||
if (expectedType === 'object' && trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
coerced[key] = parsed;
|
||||
coercedAny = true;
|
||||
}
|
||||
} catch { /* keep original */ }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expectedType === 'array' && trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
coerced[key] = parsed;
|
||||
coercedAny = true;
|
||||
}
|
||||
} catch { /* keep original */ }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expectedType === 'number' || expectedType === 'integer') {
|
||||
const num = Number(trimmed);
|
||||
if (!isNaN(num) && trimmed !== '') {
|
||||
coerced[key] = expectedType === 'integer' ? Math.trunc(num) : num;
|
||||
coercedAny = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expectedType === 'boolean') {
|
||||
if (trimmed === 'true') { coerced[key] = true; coercedAny = true; }
|
||||
else if (trimmed === 'false') { coerced[key] = false; coercedAny = true; }
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Coercion: number/boolean value → expected string ---
|
||||
if (expectedType === 'string' && (actualType === 'number' || actualType === 'boolean')) {
|
||||
coerced[key] = String(value);
|
||||
coercedAny = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (coercedAny) {
|
||||
logger.warn(`Coerced mistyped params for tool "${toolName}"`, {
|
||||
original: Object.fromEntries(
|
||||
Object.entries(args).map(([k, v]) => [k, `${typeof v}: ${typeof v === 'string' ? v.substring(0, 80) : v}`])
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return coerced;
|
||||
}
|
||||
|
||||
async executeTool(name: string, args: any): Promise<any> {
|
||||
// Ensure args is an object and validate it
|
||||
args = args || {};
|
||||
|
||||
@@ -6,7 +6,7 @@ export const UI_APP_CONFIGS: UIAppConfig[] = [
|
||||
displayName: 'Operation Result',
|
||||
description: 'Visual summary of workflow operations (create, update, delete, test)',
|
||||
uri: 'ui://n8n-mcp/operation-result',
|
||||
mimeType: 'text/html',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
toolPatterns: [
|
||||
'n8n_create_workflow',
|
||||
'n8n_update_full_workflow',
|
||||
@@ -14,7 +14,7 @@ export const UI_APP_CONFIGS: UIAppConfig[] = [
|
||||
'n8n_delete_workflow',
|
||||
'n8n_test_workflow',
|
||||
'n8n_autofix_workflow',
|
||||
'n8n_deploy_template',
|
||||
// n8n_deploy_template disabled: Claude.ai renders blank content for this tool
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -22,11 +22,15 @@ export const UI_APP_CONFIGS: UIAppConfig[] = [
|
||||
displayName: 'Validation Summary',
|
||||
description: 'Visual summary of node and workflow validation results',
|
||||
uri: 'ui://n8n-mcp/validation-summary',
|
||||
mimeType: 'text/html',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
toolPatterns: [
|
||||
'validate_node',
|
||||
'validate_workflow',
|
||||
'n8n_validate_workflow',
|
||||
],
|
||||
},
|
||||
// workflow-list, execution-history, health-dashboard disabled:
|
||||
// Claude.ai does not render these apps (shows collapsed accordions).
|
||||
// The server sets _meta correctly on the wire but the host ignores it.
|
||||
// Re-enable once the host-side issue is resolved.
|
||||
];
|
||||
|
||||
@@ -64,13 +64,19 @@ export class UIAppRegistry {
|
||||
* 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.
|
||||
*
|
||||
* Sets both nested (_meta.ui.resourceUri) and flat (_meta["ui/resourceUri"])
|
||||
* keys for compatibility with hosts that read either format.
|
||||
*/
|
||||
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 } };
|
||||
tool._meta = {
|
||||
ui: { resourceUri: entry.config.uri },
|
||||
'ui/resourceUri': entry.config.uri,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* Environment variables:
|
||||
* N8N_MCP_LLM_BASE_URL - LLM server URL (default: http://localhost:1234/v1)
|
||||
* N8N_MCP_LLM_MODEL - LLM model name (default: qwen3-4b-thinking-2507)
|
||||
* N8N_MCP_LLM_API_KEY - LLM API key (falls back to OPENAI_API_KEY; default: 'not-needed')
|
||||
* N8N_MCP_LLM_TIMEOUT - Request timeout in ms (default: 60000)
|
||||
* N8N_MCP_DB_PATH - Database path (default: ./data/nodes.db)
|
||||
*/
|
||||
@@ -81,6 +82,7 @@ Options:
|
||||
Environment Variables:
|
||||
N8N_MCP_LLM_BASE_URL LLM server URL (default: http://localhost:1234/v1)
|
||||
N8N_MCP_LLM_MODEL LLM model name (default: qwen3-4b-thinking-2507)
|
||||
N8N_MCP_LLM_API_KEY LLM API key (falls back to OPENAI_API_KEY; default: 'not-needed')
|
||||
N8N_MCP_LLM_TIMEOUT Request timeout in ms (default: 60000)
|
||||
N8N_MCP_DB_PATH Database path (default: ./data/nodes.db)
|
||||
|
||||
|
||||
@@ -11,13 +11,28 @@ import { ExpressionFormatValidator } from './expression-format-validator';
|
||||
import { NodeSimilarityService, NodeSuggestion } from './node-similarity-service';
|
||||
import { NodeTypeNormalizer } from '../utils/node-type-normalizer';
|
||||
import { Logger } from '../utils/logger';
|
||||
import { validateAISpecificNodes, hasAINodes } from './ai-node-validator';
|
||||
import { validateAISpecificNodes, hasAINodes, AI_CONNECTION_TYPES } from './ai-node-validator';
|
||||
import { isAIToolSubNode } from './ai-tool-validators';
|
||||
import { isTriggerNode } from '../utils/node-type-utils';
|
||||
import { isNonExecutableNode } from '../utils/node-classification';
|
||||
import { ToolVariantGenerator } from './tool-variant-generator';
|
||||
const logger = new Logger({ prefix: '[WorkflowValidator]' });
|
||||
|
||||
/**
|
||||
* All valid connection output keys in n8n workflows.
|
||||
* Any key not in this set is malformed and should be flagged.
|
||||
*/
|
||||
const VALID_CONNECTION_TYPES = new Set<string>([
|
||||
'main',
|
||||
'error',
|
||||
...AI_CONNECTION_TYPES,
|
||||
// Additional AI types from n8n-workflow NodeConnectionTypes not in AI_CONNECTION_TYPES
|
||||
'ai_agent',
|
||||
'ai_chain',
|
||||
'ai_retriever',
|
||||
'ai_reranker',
|
||||
]);
|
||||
|
||||
interface WorkflowNode {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -40,9 +55,7 @@ interface WorkflowNode {
|
||||
|
||||
interface WorkflowConnection {
|
||||
[sourceNode: string]: {
|
||||
main?: Array<Array<{ node: string; type: string; index: number }>>;
|
||||
error?: Array<Array<{ node: string; type: string; index: number }>>;
|
||||
ai_tool?: Array<Array<{ node: string; type: string; index: number }>>;
|
||||
[outputType: string]: Array<Array<{ node: string; type: string; index: number }>>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -612,86 +625,47 @@ export class WorkflowValidator {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check main outputs
|
||||
if (outputs.main) {
|
||||
this.validateConnectionOutputs(
|
||||
sourceName,
|
||||
outputs.main,
|
||||
nodeMap,
|
||||
nodeIdMap,
|
||||
result,
|
||||
'main'
|
||||
);
|
||||
}
|
||||
// Detect unknown output keys and validate known ones
|
||||
for (const [outputKey, outputConnections] of Object.entries(outputs)) {
|
||||
if (!VALID_CONNECTION_TYPES.has(outputKey)) {
|
||||
// Flag unknown connection output key
|
||||
let suggestion = '';
|
||||
if (/^\d+$/.test(outputKey)) {
|
||||
suggestion = ` If you meant to use output index ${outputKey}, use main[${outputKey}] instead.`;
|
||||
}
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeName: sourceName,
|
||||
message: `Unknown connection output key "${outputKey}" on node "${sourceName}". Valid keys are: ${[...VALID_CONNECTION_TYPES].join(', ')}.${suggestion}`,
|
||||
code: 'UNKNOWN_CONNECTION_KEY'
|
||||
});
|
||||
result.statistics.invalidConnections++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check error outputs
|
||||
if (outputs.error) {
|
||||
this.validateConnectionOutputs(
|
||||
sourceName,
|
||||
outputs.error,
|
||||
nodeMap,
|
||||
nodeIdMap,
|
||||
result,
|
||||
'error'
|
||||
);
|
||||
}
|
||||
if (!outputConnections || !Array.isArray(outputConnections)) continue;
|
||||
|
||||
// Check AI tool outputs
|
||||
if (outputs.ai_tool) {
|
||||
// Validate that the source node can actually output ai_tool
|
||||
this.validateAIToolSource(sourceNode, result);
|
||||
if (outputKey === 'ai_tool') {
|
||||
this.validateAIToolSource(sourceNode, result);
|
||||
}
|
||||
|
||||
this.validateConnectionOutputs(
|
||||
sourceName,
|
||||
outputs.ai_tool,
|
||||
outputConnections,
|
||||
nodeMap,
|
||||
nodeIdMap,
|
||||
result,
|
||||
'ai_tool'
|
||||
outputKey
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for orphaned nodes (not connected and not triggers)
|
||||
const connectedNodes = new Set<string>();
|
||||
|
||||
// Add all source nodes
|
||||
Object.keys(workflow.connections).forEach(name => connectedNodes.add(name));
|
||||
|
||||
// Add all target nodes
|
||||
Object.values(workflow.connections).forEach(outputs => {
|
||||
if (outputs.main) {
|
||||
outputs.main.flat().forEach(conn => {
|
||||
if (conn) connectedNodes.add(conn.node);
|
||||
});
|
||||
}
|
||||
if (outputs.error) {
|
||||
outputs.error.flat().forEach(conn => {
|
||||
if (conn) connectedNodes.add(conn.node);
|
||||
});
|
||||
}
|
||||
if (outputs.ai_tool) {
|
||||
outputs.ai_tool.flat().forEach(conn => {
|
||||
if (conn) connectedNodes.add(conn.node);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for orphaned nodes (exclude sticky notes)
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.disabled || isNonExecutableNode(node.type)) continue;
|
||||
|
||||
// Use shared trigger detection function for consistency
|
||||
const isNodeTrigger = isTriggerNode(node.type);
|
||||
|
||||
if (!connectedNodes.has(node.name) && !isNodeTrigger) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: 'Node is not connected to any other nodes'
|
||||
});
|
||||
}
|
||||
// Trigger reachability analysis: BFS from all triggers to find unreachable nodes
|
||||
if (profile !== 'minimal') {
|
||||
this.validateTriggerReachability(workflow, result);
|
||||
} else {
|
||||
this.flagOrphanedNodes(workflow, result);
|
||||
}
|
||||
|
||||
// Check for cycles (skip in minimal profile to reduce false positives)
|
||||
@@ -712,19 +686,20 @@ export class WorkflowValidator {
|
||||
nodeMap: Map<string, WorkflowNode>,
|
||||
nodeIdMap: Map<string, WorkflowNode>,
|
||||
result: WorkflowValidationResult,
|
||||
outputType: 'main' | 'error' | 'ai_tool'
|
||||
outputType: string
|
||||
): void {
|
||||
// Get source node for special validation
|
||||
const sourceNode = nodeMap.get(sourceName);
|
||||
|
||||
// Special validation for main outputs with error handling
|
||||
// Main-output-specific validation: error handling config and index bounds
|
||||
if (outputType === 'main' && sourceNode) {
|
||||
this.validateErrorOutputConfiguration(sourceName, sourceNode, outputs, nodeMap, result);
|
||||
this.validateOutputIndexBounds(sourceNode, outputs, result);
|
||||
}
|
||||
|
||||
|
||||
outputs.forEach((outputConnections, outputIndex) => {
|
||||
if (!outputConnections) return;
|
||||
|
||||
|
||||
outputConnections.forEach(connection => {
|
||||
// Check for negative index
|
||||
if (connection.index < 0) {
|
||||
@@ -736,6 +711,22 @@ export class WorkflowValidator {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate connection type field
|
||||
if (connection.type && !VALID_CONNECTION_TYPES.has(connection.type)) {
|
||||
let suggestion = '';
|
||||
if (/^\d+$/.test(connection.type)) {
|
||||
suggestion = ` Numeric types are not valid - use "main", "error", or an AI connection type.`;
|
||||
}
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeName: sourceName,
|
||||
message: `Invalid connection type "${connection.type}" in connection from "${sourceName}" to "${connection.node}". Expected "main", "error", or an AI connection type (ai_tool, ai_languageModel, etc.).${suggestion}`,
|
||||
code: 'INVALID_CONNECTION_TYPE'
|
||||
});
|
||||
result.statistics.invalidConnections++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Special validation for SplitInBatches node
|
||||
// Check both full form (n8n-nodes-base.*) and short form (nodes-base.*)
|
||||
const isSplitInBatches = sourceNode && (
|
||||
@@ -789,11 +780,16 @@ export class WorkflowValidator {
|
||||
});
|
||||
} else {
|
||||
result.statistics.validConnections++;
|
||||
|
||||
|
||||
// Additional validation for AI tool connections
|
||||
if (outputType === 'ai_tool') {
|
||||
this.validateAIToolConnection(sourceName, targetNode, result);
|
||||
}
|
||||
|
||||
// Input index bounds checking
|
||||
if (outputType === 'main') {
|
||||
this.validateInputIndexBounds(sourceName, targetNode, connection, result);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -991,6 +987,221 @@ export class WorkflowValidator {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that output indices don't exceed what the node type supports.
|
||||
*/
|
||||
private validateOutputIndexBounds(
|
||||
sourceNode: WorkflowNode,
|
||||
outputs: Array<Array<{ node: string; type: string; index: number }>>,
|
||||
result: WorkflowValidationResult
|
||||
): void {
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(sourceNode.type);
|
||||
const nodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||
if (!nodeInfo || !nodeInfo.outputs) return;
|
||||
|
||||
// Count main outputs from node description
|
||||
let mainOutputCount: number;
|
||||
if (Array.isArray(nodeInfo.outputs)) {
|
||||
// outputs can be strings like "main" or objects with { type: "main" }
|
||||
mainOutputCount = nodeInfo.outputs.filter((o: any) =>
|
||||
typeof o === 'string' ? o === 'main' : (o.type === 'main' || !o.type)
|
||||
).length;
|
||||
} else {
|
||||
return; // Dynamic outputs (expression string), skip check
|
||||
}
|
||||
|
||||
if (mainOutputCount === 0) return;
|
||||
|
||||
// Account for dynamic output counts based on node type and parameters
|
||||
const shortType = normalizedType.replace(/^(n8n-)?nodes-base\./, '');
|
||||
if (shortType === 'switch') {
|
||||
// Switch node: output count depends on rules configuration
|
||||
const rules = sourceNode.parameters?.rules?.values || sourceNode.parameters?.rules;
|
||||
if (Array.isArray(rules)) {
|
||||
mainOutputCount = rules.length + 1; // rules + fallback
|
||||
} else {
|
||||
return; // Cannot determine dynamic output count, skip bounds check
|
||||
}
|
||||
}
|
||||
if (shortType === 'if' || shortType === 'filter') {
|
||||
mainOutputCount = 2; // true/false
|
||||
}
|
||||
|
||||
// Account for continueErrorOutput adding an extra output
|
||||
if (sourceNode.onError === 'continueErrorOutput') {
|
||||
mainOutputCount += 1;
|
||||
}
|
||||
|
||||
// Check if any output index exceeds bounds
|
||||
const maxOutputIndex = outputs.length - 1;
|
||||
if (maxOutputIndex >= mainOutputCount) {
|
||||
// Only flag if there are actual connections at the out-of-bounds indices
|
||||
for (let i = mainOutputCount; i < outputs.length; i++) {
|
||||
if (outputs[i] && outputs[i].length > 0) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeId: sourceNode.id,
|
||||
nodeName: sourceNode.name,
|
||||
message: `Output index ${i} on node "${sourceNode.name}" exceeds its output count (${mainOutputCount}). ` +
|
||||
`This node has ${mainOutputCount} main output(s) (indices 0-${mainOutputCount - 1}).`,
|
||||
code: 'OUTPUT_INDEX_OUT_OF_BOUNDS'
|
||||
});
|
||||
result.statistics.invalidConnections++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that input index doesn't exceed what the target node accepts.
|
||||
*/
|
||||
private validateInputIndexBounds(
|
||||
sourceName: string,
|
||||
targetNode: WorkflowNode,
|
||||
connection: { node: string; type: string; index: number },
|
||||
result: WorkflowValidationResult
|
||||
): void {
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(targetNode.type);
|
||||
const nodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||
if (!nodeInfo) return;
|
||||
|
||||
// Most nodes have 1 main input. Known exceptions:
|
||||
const shortType = normalizedType.replace(/^(n8n-)?nodes-base\./, '');
|
||||
let mainInputCount = 1; // Default: most nodes have 1 input
|
||||
|
||||
if (shortType === 'merge' || shortType === 'compareDatasets') {
|
||||
mainInputCount = 2; // Merge nodes have 2 inputs
|
||||
}
|
||||
|
||||
// Trigger nodes have 0 inputs
|
||||
if (nodeInfo.isTrigger || isTriggerNode(targetNode.type)) {
|
||||
mainInputCount = 0;
|
||||
}
|
||||
|
||||
if (mainInputCount > 0 && connection.index >= mainInputCount) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeName: targetNode.name,
|
||||
message: `Input index ${connection.index} on node "${targetNode.name}" exceeds its input count (${mainInputCount}). ` +
|
||||
`Connection from "${sourceName}" targets input ${connection.index}, but this node has ${mainInputCount} main input(s) (indices 0-${mainInputCount - 1}).`,
|
||||
code: 'INPUT_INDEX_OUT_OF_BOUNDS'
|
||||
});
|
||||
result.statistics.invalidConnections++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag nodes that are not referenced in any connection (source or target).
|
||||
* Used as a lightweight check when BFS reachability is not applicable.
|
||||
*/
|
||||
private flagOrphanedNodes(
|
||||
workflow: WorkflowJson,
|
||||
result: WorkflowValidationResult
|
||||
): void {
|
||||
const connectedNodes = new Set<string>();
|
||||
for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
|
||||
connectedNodes.add(sourceName);
|
||||
for (const outputConns of Object.values(outputs)) {
|
||||
if (!Array.isArray(outputConns)) continue;
|
||||
for (const conns of outputConns) {
|
||||
if (!conns) continue;
|
||||
for (const conn of conns) {
|
||||
if (conn) connectedNodes.add(conn.node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.disabled || isNonExecutableNode(node.type)) continue;
|
||||
if (isTriggerNode(node.type)) continue;
|
||||
if (!connectedNodes.has(node.name)) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: 'Node is not connected to any other nodes'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS from all trigger nodes to detect unreachable nodes.
|
||||
* Replaces the simple "is node in any connection" check with proper graph traversal.
|
||||
*/
|
||||
private validateTriggerReachability(
|
||||
workflow: WorkflowJson,
|
||||
result: WorkflowValidationResult
|
||||
): void {
|
||||
// Build adjacency list (forward direction)
|
||||
const adjacency = new Map<string, Set<string>>();
|
||||
for (const [sourceName, outputs] of Object.entries(workflow.connections)) {
|
||||
if (!adjacency.has(sourceName)) adjacency.set(sourceName, new Set());
|
||||
for (const outputConns of Object.values(outputs)) {
|
||||
if (Array.isArray(outputConns)) {
|
||||
for (const conns of outputConns) {
|
||||
if (!conns) continue;
|
||||
for (const conn of conns) {
|
||||
if (conn) {
|
||||
adjacency.get(sourceName)!.add(conn.node);
|
||||
// Also track that the target exists in the graph
|
||||
if (!adjacency.has(conn.node)) adjacency.set(conn.node, new Set());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Identify trigger nodes
|
||||
const triggerNodes: string[] = [];
|
||||
for (const node of workflow.nodes) {
|
||||
if (isTriggerNode(node.type) && !node.disabled) {
|
||||
triggerNodes.push(node.name);
|
||||
}
|
||||
}
|
||||
|
||||
// If no trigger nodes, fall back to simple orphaned check
|
||||
if (triggerNodes.length === 0) {
|
||||
this.flagOrphanedNodes(workflow, result);
|
||||
return;
|
||||
}
|
||||
|
||||
// BFS from all trigger nodes
|
||||
const reachable = new Set<string>();
|
||||
const queue: string[] = [...triggerNodes];
|
||||
for (const t of triggerNodes) reachable.add(t);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const neighbors = adjacency.get(current);
|
||||
if (neighbors) {
|
||||
for (const neighbor of neighbors) {
|
||||
if (!reachable.has(neighbor)) {
|
||||
reachable.add(neighbor);
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flag unreachable nodes
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.disabled || isNonExecutableNode(node.type)) continue;
|
||||
if (isTriggerNode(node.type)) continue;
|
||||
|
||||
if (!reachable.has(node.name)) {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: 'Node is not reachable from any trigger node'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if workflow has cycles
|
||||
* Allow legitimate loops for SplitInBatches and similar loop nodes
|
||||
@@ -1024,23 +1235,13 @@ export class WorkflowValidator {
|
||||
const connections = workflow.connections[nodeName];
|
||||
if (connections) {
|
||||
const allTargets: string[] = [];
|
||||
|
||||
if (connections.main) {
|
||||
connections.main.flat().forEach(conn => {
|
||||
if (conn) allTargets.push(conn.node);
|
||||
});
|
||||
}
|
||||
|
||||
if (connections.error) {
|
||||
connections.error.flat().forEach(conn => {
|
||||
if (conn) allTargets.push(conn.node);
|
||||
});
|
||||
}
|
||||
|
||||
if (connections.ai_tool) {
|
||||
connections.ai_tool.flat().forEach(conn => {
|
||||
if (conn) allTargets.push(conn.node);
|
||||
});
|
||||
|
||||
for (const outputConns of Object.values(connections)) {
|
||||
if (Array.isArray(outputConns)) {
|
||||
outputConns.flat().forEach(conn => {
|
||||
if (conn) allTargets.push(conn.node);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const currentNodeType = nodeTypeMap.get(nodeName);
|
||||
|
||||
@@ -41,6 +41,7 @@ describe('DocumentationGenerator', () => {
|
||||
apiKey: 'test-key',
|
||||
timeout: 30000,
|
||||
maxTokens: 1000,
|
||||
temperature: 0.3,
|
||||
};
|
||||
|
||||
const validSummary = {
|
||||
@@ -163,7 +164,7 @@ describe('DocumentationGenerator', () => {
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
model: 'test-model',
|
||||
max_tokens: 1000,
|
||||
max_completion_tokens: 1000,
|
||||
temperature: 0.3,
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({ role: 'system' }),
|
||||
@@ -680,7 +681,7 @@ describe('DocumentationGenerator', () => {
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
max_tokens: 10,
|
||||
max_completion_tokens: 200,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
|
||||
300
tests/unit/mcp/coerce-stringified-params.test.ts
Normal file
300
tests/unit/mcp/coerce-stringified-params.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { N8NDocumentationMCPServer } from '../../../src/mcp/server';
|
||||
|
||||
// Mock the database and dependencies
|
||||
vi.mock('../../../src/database/database-adapter');
|
||||
vi.mock('../../../src/database/node-repository');
|
||||
vi.mock('../../../src/templates/template-service');
|
||||
vi.mock('../../../src/utils/logger');
|
||||
|
||||
class TestableN8NMCPServer extends N8NDocumentationMCPServer {
|
||||
public testCoerceStringifiedJsonParams(
|
||||
toolName: string,
|
||||
args: Record<string, any>
|
||||
): Record<string, any> {
|
||||
return (this as any).coerceStringifiedJsonParams(toolName, args);
|
||||
}
|
||||
}
|
||||
|
||||
describe('coerceStringifiedJsonParams', () => {
|
||||
let server: TestableN8NMCPServer;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NODE_DB_PATH = ':memory:';
|
||||
server = new TestableN8NMCPServer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NODE_DB_PATH;
|
||||
});
|
||||
|
||||
describe('Object coercion', () => {
|
||||
it('should coerce stringified object for validate_node config', () => {
|
||||
const args = {
|
||||
nodeType: 'nodes-base.slack',
|
||||
config: '{"resource":"channel","operation":"create"}'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_node', args);
|
||||
expect(result.config).toEqual({ resource: 'channel', operation: 'create' });
|
||||
expect(result.nodeType).toBe('nodes-base.slack');
|
||||
});
|
||||
|
||||
it('should coerce stringified object for n8n_create_workflow connections', () => {
|
||||
const connections = { 'Webhook': { main: [[{ node: 'Slack', type: 'main', index: 0 }]] } };
|
||||
const args = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook' }],
|
||||
connections: JSON.stringify(connections)
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_create_workflow', args);
|
||||
expect(result.connections).toEqual(connections);
|
||||
});
|
||||
|
||||
it('should coerce stringified object for validate_workflow workflow param', () => {
|
||||
const workflow = { nodes: [], connections: {} };
|
||||
const args = {
|
||||
workflow: JSON.stringify(workflow)
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_workflow', args);
|
||||
expect(result.workflow).toEqual(workflow);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Array coercion', () => {
|
||||
it('should coerce stringified array for n8n_update_partial_workflow operations', () => {
|
||||
const operations = [
|
||||
{ type: 'addNode', node: { id: '1', name: 'Test', type: 'n8n-nodes-base.noOp' } }
|
||||
];
|
||||
const args = {
|
||||
id: '123',
|
||||
operations: JSON.stringify(operations)
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_update_partial_workflow', args);
|
||||
expect(result.operations).toEqual(operations);
|
||||
expect(result.id).toBe('123');
|
||||
});
|
||||
|
||||
it('should coerce stringified array for n8n_autofix_workflow fixTypes', () => {
|
||||
const fixTypes = ['expression-format', 'typeversion-correction'];
|
||||
const args = {
|
||||
id: '456',
|
||||
fixTypes: JSON.stringify(fixTypes)
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_autofix_workflow', args);
|
||||
expect(result.fixTypes).toEqual(fixTypes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('No-op cases', () => {
|
||||
it('should not modify object params that are already objects', () => {
|
||||
const config = { resource: 'channel', operation: 'create' };
|
||||
const args = {
|
||||
nodeType: 'nodes-base.slack',
|
||||
config
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_node', args);
|
||||
expect(result.config).toEqual(config);
|
||||
expect(result.config).toBe(config); // same reference
|
||||
});
|
||||
|
||||
it('should not modify string params even if they contain JSON', () => {
|
||||
const args = {
|
||||
query: '{"some":"json"}',
|
||||
limit: 10
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||
expect(result.query).toBe('{"some":"json"}');
|
||||
});
|
||||
|
||||
it('should not modify args for tools with no object/array params', () => {
|
||||
const args = {
|
||||
query: 'webhook',
|
||||
limit: 20,
|
||||
mode: 'OR'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||
expect(result).toEqual(args);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Safety cases', () => {
|
||||
it('should keep original string for invalid JSON', () => {
|
||||
const args = {
|
||||
nodeType: 'nodes-base.slack',
|
||||
config: '{invalid json here}'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_node', args);
|
||||
expect(result.config).toBe('{invalid json here}');
|
||||
});
|
||||
|
||||
it('should not attempt parse when object param starts with [', () => {
|
||||
const args = {
|
||||
nodeType: 'nodes-base.slack',
|
||||
config: '[1, 2, 3]'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_node', args);
|
||||
expect(result.config).toBe('[1, 2, 3]');
|
||||
});
|
||||
|
||||
it('should not attempt parse when array param starts with {', () => {
|
||||
const args = {
|
||||
id: '123',
|
||||
operations: '{"not":"an array"}'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_update_partial_workflow', args);
|
||||
expect(result.operations).toBe('{"not":"an array"}');
|
||||
});
|
||||
|
||||
it('should handle null args gracefully', () => {
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_node', null as any);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined args gracefully', () => {
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_node', undefined as any);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return args unchanged for unknown tool', () => {
|
||||
const args = { config: '{"key":"value"}' };
|
||||
const result = server.testCoerceStringifiedJsonParams('nonexistent_tool', args);
|
||||
expect(result).toEqual(args);
|
||||
expect(result.config).toBe('{"key":"value"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Number coercion', () => {
|
||||
it('should coerce string to number for search_nodes limit', () => {
|
||||
const args = {
|
||||
query: 'webhook',
|
||||
limit: '10'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||
expect(result.limit).toBe(10);
|
||||
expect(result.query).toBe('webhook');
|
||||
});
|
||||
|
||||
it('should coerce string to number for n8n_executions limit', () => {
|
||||
const args = {
|
||||
action: 'list',
|
||||
limit: '50'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_executions', args);
|
||||
expect(result.limit).toBe(50);
|
||||
});
|
||||
|
||||
it('should not coerce non-numeric string to number', () => {
|
||||
const args = {
|
||||
query: 'webhook',
|
||||
limit: 'abc'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||
expect(result.limit).toBe('abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Boolean coercion', () => {
|
||||
it('should coerce "true" string to boolean', () => {
|
||||
const args = {
|
||||
query: 'webhook',
|
||||
includeExamples: 'true'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||
expect(result.includeExamples).toBe(true);
|
||||
});
|
||||
|
||||
it('should coerce "false" string to boolean', () => {
|
||||
const args = {
|
||||
query: 'webhook',
|
||||
includeExamples: 'false'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||
expect(result.includeExamples).toBe(false);
|
||||
});
|
||||
|
||||
it('should not coerce non-boolean string to boolean', () => {
|
||||
const args = {
|
||||
query: 'webhook',
|
||||
includeExamples: 'yes'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||
expect(result.includeExamples).toBe('yes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Number-to-string coercion', () => {
|
||||
it('should coerce number to string for n8n_get_workflow id', () => {
|
||||
const args = {
|
||||
id: 123,
|
||||
mode: 'minimal'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_get_workflow', args);
|
||||
expect(result.id).toBe('123');
|
||||
expect(result.mode).toBe('minimal');
|
||||
});
|
||||
|
||||
it('should coerce boolean to string when string expected', () => {
|
||||
const args = {
|
||||
id: true
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_get_workflow', args);
|
||||
expect(result.id).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-to-end Claude Desktop scenario', () => {
|
||||
it('should coerce all stringified params for n8n_create_workflow', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'webhook_1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [250, 300],
|
||||
parameters: { httpMethod: 'POST', path: 'slack-notify' }
|
||||
},
|
||||
{
|
||||
id: 'slack_1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 1,
|
||||
position: [450, 300],
|
||||
parameters: { resource: 'message', operation: 'post', channel: '#general' }
|
||||
}
|
||||
];
|
||||
const connections = {
|
||||
'Webhook': { main: [[{ node: 'Slack', type: 'main', index: 0 }]] }
|
||||
};
|
||||
const settings = { executionOrder: 'v1', timezone: 'America/New_York' };
|
||||
|
||||
// Simulate Claude Desktop sending all object/array params as strings
|
||||
const args = {
|
||||
name: 'Webhook to Slack',
|
||||
nodes: JSON.stringify(nodes),
|
||||
connections: JSON.stringify(connections),
|
||||
settings: JSON.stringify(settings)
|
||||
};
|
||||
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_create_workflow', args);
|
||||
|
||||
expect(result.name).toBe('Webhook to Slack');
|
||||
expect(result.nodes).toEqual(nodes);
|
||||
expect(result.connections).toEqual(connections);
|
||||
expect(result.settings).toEqual(settings);
|
||||
});
|
||||
|
||||
it('should handle mixed type mismatches from Claude Desktop', () => {
|
||||
// Simulate Claude Desktop sending object params as strings
|
||||
const args = {
|
||||
nodeType: 'nodes-base.httpRequest',
|
||||
config: '{"method":"GET","url":"https://example.com"}',
|
||||
mode: 'full',
|
||||
profile: 'ai-friendly'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_node', args);
|
||||
expect(result.config).toEqual({ method: 'GET', url: 'https://example.com' });
|
||||
expect(result.nodeType).toBe('nodes-base.httpRequest');
|
||||
expect(result.mode).toBe('full');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -65,9 +65,9 @@ describe('UI_APP_CONFIGS', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should have consistent mimeType of text/html', () => {
|
||||
it('should have consistent mimeType of text/html;profile=mcp-app', () => {
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
expect(config.mimeType).toBe('text/html');
|
||||
expect(config.mimeType).toBe('text/html;profile=mcp-app');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('UI_APP_CONFIGS', () => {
|
||||
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');
|
||||
expect(config!.toolPatterns).not.toContain('n8n_deploy_template');
|
||||
});
|
||||
|
||||
it('should contain the validation-summary config', () => {
|
||||
@@ -102,6 +102,12 @@ describe('UI_APP_CONFIGS', () => {
|
||||
expect(UI_APP_CONFIGS.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should not contain disabled apps', () => {
|
||||
expect(UI_APP_CONFIGS.find(c => c.id === 'workflow-list')).toBeUndefined();
|
||||
expect(UI_APP_CONFIGS.find(c => c.id === 'execution-history')).toBeUndefined();
|
||||
expect(UI_APP_CONFIGS.find(c => c.id === 'health-dashboard')).toBeUndefined();
|
||||
});
|
||||
|
||||
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-]+$/);
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('UI Meta Injection on Tool Definitions', () => {
|
||||
expect(tools[2]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary');
|
||||
});
|
||||
|
||||
it('should produce _meta with exact shape { ui: { resourceUri: string } }', () => {
|
||||
it('should produce _meta with both nested and flat resourceUri keys', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
@@ -83,10 +83,10 @@ describe('UI Meta Injection on Tool Definitions', () => {
|
||||
ui: {
|
||||
resourceUri: 'ui://n8n-mcp/operation-result',
|
||||
},
|
||||
'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');
|
||||
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
|
||||
expect(tools[0]._meta['ui/resourceUri']).toBe('ui://n8n-mcp/operation-result');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -187,8 +187,11 @@ describe('UIAppRegistry', () => {
|
||||
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 not map disabled tools', () => {
|
||||
expect(UIAppRegistry.getAppForTool('n8n_deploy_template')).toBeNull();
|
||||
expect(UIAppRegistry.getAppForTool('n8n_list_workflows')).toBeNull();
|
||||
expect(UIAppRegistry.getAppForTool('n8n_executions')).toBeNull();
|
||||
expect(UIAppRegistry.getAppForTool('n8n_health_check')).toBeNull();
|
||||
});
|
||||
|
||||
it('should map validate_node to validation-summary', () => {
|
||||
@@ -308,7 +311,7 @@ describe('UIAppRegistry', () => {
|
||||
{ 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(tools[0]._meta).toEqual({ ui: { resourceUri: 'ui://n8n-mcp/operation-result' }, 'ui/resourceUri': 'ui://n8n-mcp/operation-result' });
|
||||
});
|
||||
|
||||
it('should set _meta.ui.resourceUri on matching validation tools', () => {
|
||||
@@ -316,7 +319,7 @@ describe('UIAppRegistry', () => {
|
||||
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools[0]._meta).toEqual({ ui: { resourceUri: 'ui://n8n-mcp/validation-summary' } });
|
||||
expect(tools[0]._meta).toEqual({ ui: { resourceUri: 'ui://n8n-mcp/validation-summary' }, 'ui/resourceUri': 'ui://n8n-mcp/validation-summary' });
|
||||
});
|
||||
|
||||
it('should not set _meta on tools without a matching UI app', () => {
|
||||
|
||||
@@ -1067,7 +1067,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
expect(result.warnings.some(w => w.message.includes('Node is not connected to any other nodes') && w.nodeName === 'Orphaned')).toBe(true);
|
||||
expect(result.warnings.some(w => w.message.includes('not reachable from any trigger node') && w.nodeName === 'Orphaned')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect cycles in workflow', async () => {
|
||||
@@ -1987,7 +1987,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
||||
|
||||
// Warnings
|
||||
expect(result.warnings.some(w => w.message.includes('Connection to disabled node'))).toBe(true);
|
||||
expect(result.warnings.some(w => w.message.includes('Node is not connected') && w.nodeName === 'Orphaned')).toBe(true);
|
||||
expect(result.warnings.some(w => w.message.includes('not reachable from any trigger node') && w.nodeName === 'Orphaned')).toBe(true);
|
||||
expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true);
|
||||
|
||||
// Statistics
|
||||
|
||||
718
tests/unit/services/workflow-validator-connections.test.ts
Normal file
718
tests/unit/services/workflow-validator-connections.test.ts
Normal file
@@ -0,0 +1,718 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { WorkflowValidator } from '@/services/workflow-validator';
|
||||
import { NodeRepository } from '@/database/node-repository';
|
||||
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/database/node-repository');
|
||||
vi.mock('@/services/enhanced-config-validator');
|
||||
vi.mock('@/services/expression-validator');
|
||||
vi.mock('@/utils/logger');
|
||||
|
||||
describe('WorkflowValidator - Connection Validation (#620)', () => {
|
||||
let validator: WorkflowValidator;
|
||||
let mockNodeRepository: NodeRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockNodeRepository = new NodeRepository({} as any) as any;
|
||||
|
||||
if (!mockNodeRepository.getAllNodes) {
|
||||
mockNodeRepository.getAllNodes = vi.fn();
|
||||
}
|
||||
if (!mockNodeRepository.getNode) {
|
||||
mockNodeRepository.getNode = vi.fn();
|
||||
}
|
||||
|
||||
const nodeTypes: Record<string, any> = {
|
||||
'nodes-base.webhook': {
|
||||
type: 'nodes-base.webhook',
|
||||
displayName: 'Webhook',
|
||||
package: 'n8n-nodes-base',
|
||||
isTrigger: true,
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
},
|
||||
'nodes-base.manualTrigger': {
|
||||
type: 'nodes-base.manualTrigger',
|
||||
displayName: 'Manual Trigger',
|
||||
package: 'n8n-nodes-base',
|
||||
isTrigger: true,
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
},
|
||||
'nodes-base.set': {
|
||||
type: 'nodes-base.set',
|
||||
displayName: 'Set',
|
||||
package: 'n8n-nodes-base',
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
},
|
||||
'nodes-base.code': {
|
||||
type: 'nodes-base.code',
|
||||
displayName: 'Code',
|
||||
package: 'n8n-nodes-base',
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
},
|
||||
'nodes-base.if': {
|
||||
type: 'nodes-base.if',
|
||||
displayName: 'IF',
|
||||
package: 'n8n-nodes-base',
|
||||
outputs: ['main', 'main'],
|
||||
properties: [],
|
||||
},
|
||||
'nodes-base.googleSheets': {
|
||||
type: 'nodes-base.googleSheets',
|
||||
displayName: 'Google Sheets',
|
||||
package: 'n8n-nodes-base',
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
},
|
||||
'nodes-base.merge': {
|
||||
type: 'nodes-base.merge',
|
||||
displayName: 'Merge',
|
||||
package: 'n8n-nodes-base',
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
},
|
||||
'nodes-langchain.agent': {
|
||||
type: 'nodes-langchain.agent',
|
||||
displayName: 'AI Agent',
|
||||
package: '@n8n/n8n-nodes-langchain',
|
||||
isAITool: true,
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(mockNodeRepository.getNode).mockImplementation((nodeType: string) => {
|
||||
return nodeTypes[nodeType] || null;
|
||||
});
|
||||
vi.mocked(mockNodeRepository.getAllNodes).mockReturnValue(Object.values(nodeTypes));
|
||||
|
||||
validator = new WorkflowValidator(
|
||||
mockNodeRepository,
|
||||
EnhancedConfigValidator as any
|
||||
);
|
||||
});
|
||||
|
||||
describe('Unknown output keys (P0)', () => {
|
||||
it('should flag numeric string key "1" with index suggestion', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Save to Google Sheets', type: 'n8n-nodes-base.googleSheets', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Format Error', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||
{ id: '4', name: 'Success Response', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Save to Google Sheets', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Save to Google Sheets': {
|
||||
'1': [[{ node: 'Format Error', type: '0', index: 0 }]],
|
||||
main: [[{ node: 'Success Response', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const unknownKeyError = result.errors.find(e => e.code === 'UNKNOWN_CONNECTION_KEY');
|
||||
expect(unknownKeyError).toBeDefined();
|
||||
expect(unknownKeyError!.message).toContain('Unknown connection output key "1"');
|
||||
expect(unknownKeyError!.message).toContain('use main[1] instead');
|
||||
expect(unknownKeyError!.nodeName).toBe('Save to Google Sheets');
|
||||
});
|
||||
|
||||
it('should flag random string key "output"', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Code', type: 'n8n-nodes-base.code', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Set', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Code', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Code': {
|
||||
output: [[{ node: 'Set', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const unknownKeyError = result.errors.find(e => e.code === 'UNKNOWN_CONNECTION_KEY');
|
||||
expect(unknownKeyError).toBeDefined();
|
||||
expect(unknownKeyError!.message).toContain('Unknown connection output key "output"');
|
||||
// Should NOT have index suggestion for non-numeric key
|
||||
expect(unknownKeyError!.message).not.toContain('use main[');
|
||||
});
|
||||
|
||||
it('should accept valid keys: main, error, ai_tool', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Code', type: 'n8n-nodes-base.code', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Set', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Code', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Code': {
|
||||
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const unknownKeyErrors = result.errors.filter(e => e.code === 'UNKNOWN_CONNECTION_KEY');
|
||||
expect(unknownKeyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept AI connection types as valid keys', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Chat Trigger', type: 'n8n-nodes-base.chatTrigger', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'AI Agent', type: 'nodes-langchain.agent', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'LLM', type: 'nodes-langchain.lmChatOpenAi', position: [200, 200], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Chat Trigger': {
|
||||
main: [[{ node: 'AI Agent', type: 'main', index: 0 }]]
|
||||
},
|
||||
'LLM': {
|
||||
ai_languageModel: [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const unknownKeyErrors = result.errors.filter(e => e.code === 'UNKNOWN_CONNECTION_KEY');
|
||||
expect(unknownKeyErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should flag multiple unknown keys on the same node', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Code', type: 'n8n-nodes-base.code', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Set1', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||
{ id: '4', name: 'Set2', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Code', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Code': {
|
||||
'0': [[{ node: 'Set1', type: 'main', index: 0 }]],
|
||||
'1': [[{ node: 'Set2', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const unknownKeyErrors = result.errors.filter(e => e.code === 'UNKNOWN_CONNECTION_KEY');
|
||||
expect(unknownKeyErrors).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid type field (P0)', () => {
|
||||
it('should flag numeric type "0" in connection target', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Sheets', type: 'n8n-nodes-base.googleSheets', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Error Handler', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Sheets', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Sheets': {
|
||||
main: [[{ node: 'Error Handler', type: '0', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const typeError = result.errors.find(e => e.code === 'INVALID_CONNECTION_TYPE');
|
||||
expect(typeError).toBeDefined();
|
||||
expect(typeError!.message).toContain('Invalid connection type "0"');
|
||||
expect(typeError!.message).toContain('Numeric types are not valid');
|
||||
});
|
||||
|
||||
it('should flag invented type "output"', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Code', type: 'n8n-nodes-base.code', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Set', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Code', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Code': {
|
||||
main: [[{ node: 'Set', type: 'output', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const typeError = result.errors.find(e => e.code === 'INVALID_CONNECTION_TYPE');
|
||||
expect(typeError).toBeDefined();
|
||||
expect(typeError!.message).toContain('Invalid connection type "output"');
|
||||
});
|
||||
|
||||
it('should accept valid type "main"', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const typeErrors = result.errors.filter(e => e.code === 'INVALID_CONNECTION_TYPE');
|
||||
expect(typeErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept AI connection types in type field', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Chat Trigger', type: 'n8n-nodes-base.chatTrigger', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'AI Agent', type: 'nodes-langchain.agent', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Memory', type: 'nodes-langchain.memoryBufferWindow', position: [200, 200], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Chat Trigger': {
|
||||
main: [[{ node: 'AI Agent', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Memory': {
|
||||
ai_memory: [[{ node: 'AI Agent', type: 'ai_memory', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const typeErrors = result.errors.filter(e => e.code === 'INVALID_CONNECTION_TYPE');
|
||||
expect(typeErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should catch the real-world example from issue #620', async () => {
|
||||
// Exact reproduction of the bug reported in the issue
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Save to Google Sheets', type: 'n8n-nodes-base.googleSheets', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Format AI Integration Error', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||
{ id: '4', name: 'Webhook Success Response', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Save to Google Sheets', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Save to Google Sheets': {
|
||||
'1': [[{ node: 'Format AI Integration Error', type: '0', index: 0 }]],
|
||||
main: [[{ node: 'Webhook Success Response', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should detect both bugs
|
||||
const unknownKeyError = result.errors.find(e => e.code === 'UNKNOWN_CONNECTION_KEY');
|
||||
expect(unknownKeyError).toBeDefined();
|
||||
expect(unknownKeyError!.message).toContain('"1"');
|
||||
expect(unknownKeyError!.message).toContain('use main[1] instead');
|
||||
|
||||
// The type "0" error won't appear since the "1" key is unknown and skipped,
|
||||
// but the error count should reflect the invalid connection
|
||||
expect(result.statistics.invalidConnections).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Output index bounds checking (P1)', () => {
|
||||
it('should flag Code node with main[1] (only has 1 output)', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Code', type: 'n8n-nodes-base.code', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Success', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||
{ id: '4', name: 'Error', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Code', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Code': {
|
||||
main: [
|
||||
[{ node: 'Success', type: 'main', index: 0 }],
|
||||
[{ node: 'Error', type: 'main', index: 0 }] // main[1] - out of bounds
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const boundsError = result.errors.find(e => e.code === 'OUTPUT_INDEX_OUT_OF_BOUNDS');
|
||||
expect(boundsError).toBeDefined();
|
||||
expect(boundsError!.message).toContain('Output index 1');
|
||||
expect(boundsError!.message).toContain('Code');
|
||||
});
|
||||
|
||||
it('should accept IF node with main[0] and main[1] (2 outputs)', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'IF', type: 'n8n-nodes-base.if', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'True', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||
{ id: '4', name: 'False', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'IF', type: 'main', index: 0 }]]
|
||||
},
|
||||
'IF': {
|
||||
main: [
|
||||
[{ node: 'True', type: 'main', index: 0 }],
|
||||
[{ node: 'False', type: 'main', index: 0 }]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const boundsErrors = result.errors.filter(e => e.code === 'OUTPUT_INDEX_OUT_OF_BOUNDS');
|
||||
expect(boundsErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should flag IF node with main[2] (only 2 outputs)', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'IF', type: 'n8n-nodes-base.if', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'True', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||
{ id: '4', name: 'False', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||
{ id: '5', name: 'Extra', type: 'n8n-nodes-base.set', position: [400, 400], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'IF', type: 'main', index: 0 }]]
|
||||
},
|
||||
'IF': {
|
||||
main: [
|
||||
[{ node: 'True', type: 'main', index: 0 }],
|
||||
[{ node: 'False', type: 'main', index: 0 }],
|
||||
[{ node: 'Extra', type: 'main', index: 0 }] // main[2] - out of bounds
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const boundsError = result.errors.find(e => e.code === 'OUTPUT_INDEX_OUT_OF_BOUNDS');
|
||||
expect(boundsError).toBeDefined();
|
||||
expect(boundsError!.message).toContain('Output index 2');
|
||||
});
|
||||
|
||||
it('should allow extra output when onError is continueErrorOutput', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Code', type: 'n8n-nodes-base.code', position: [200, 0], parameters: {}, onError: 'continueErrorOutput' as const },
|
||||
{ id: '3', name: 'Success', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||
{ id: '4', name: 'Error', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Code', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Code': {
|
||||
main: [
|
||||
[{ node: 'Success', type: 'main', index: 0 }],
|
||||
[{ node: 'Error', type: 'main', index: 0 }] // Error output - allowed with continueErrorOutput
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const boundsErrors = result.errors.filter(e => e.code === 'OUTPUT_INDEX_OUT_OF_BOUNDS');
|
||||
expect(boundsErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip bounds check for unknown node types', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Custom', type: 'n8n-nodes-community.customNode', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Set1', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||
{ id: '4', name: 'Set2', type: 'n8n-nodes-base.set', position: [400, 200], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Custom', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Custom': {
|
||||
main: [
|
||||
[{ node: 'Set1', type: 'main', index: 0 }],
|
||||
[{ node: 'Set2', type: 'main', index: 0 }]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const boundsErrors = result.errors.filter(e => e.code === 'OUTPUT_INDEX_OUT_OF_BOUNDS');
|
||||
expect(boundsErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input index bounds checking (P1)', () => {
|
||||
it('should accept regular node with index 0', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const inputErrors = result.errors.filter(e => e.code === 'INPUT_INDEX_OUT_OF_BOUNDS');
|
||||
expect(inputErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should flag regular node with index 1 (only 1 input)', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Code', type: 'n8n-nodes-base.code', position: [200, 0], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Code', type: 'main', index: 1 }]] // index 1 - out of bounds
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const inputError = result.errors.find(e => e.code === 'INPUT_INDEX_OUT_OF_BOUNDS');
|
||||
expect(inputError).toBeDefined();
|
||||
expect(inputError!.message).toContain('Input index 1');
|
||||
expect(inputError!.message).toContain('Code');
|
||||
});
|
||||
|
||||
it('should accept Merge node with index 1 (has 2 inputs)', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Set1', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Set2', type: 'n8n-nodes-base.set', position: [200, 200], parameters: {} },
|
||||
{ id: '4', name: 'Merge', type: 'n8n-nodes-base.merge', position: [400, 100], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Set1', type: 'main', index: 0 }, { node: 'Set2', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Set1': {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Set2': {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 1 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const inputErrors = result.errors.filter(e => e.code === 'INPUT_INDEX_OUT_OF_BOUNDS');
|
||||
expect(inputErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip bounds check for unknown node types', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Custom', type: 'n8n-nodes-community.unknownNode', position: [200, 0], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Custom', type: 'main', index: 5 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const inputErrors = result.errors.filter(e => e.code === 'INPUT_INDEX_OUT_OF_BOUNDS');
|
||||
expect(inputErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trigger reachability analysis (P2)', () => {
|
||||
it('should flag nodes in disconnected subgraph as unreachable', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Connected', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} },
|
||||
// Disconnected subgraph - two nodes connected to each other but not reachable from trigger
|
||||
{ id: '3', name: 'Island1', type: 'n8n-nodes-base.code', position: [0, 300], parameters: {} },
|
||||
{ id: '4', name: 'Island2', type: 'n8n-nodes-base.set', position: [200, 300], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Connected', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Island1': {
|
||||
main: [[{ node: 'Island2', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Both Island1 and Island2 should be flagged as unreachable
|
||||
const unreachable = result.warnings.filter(w => w.message.includes('not reachable from any trigger'));
|
||||
expect(unreachable.length).toBe(2);
|
||||
expect(unreachable.some(w => w.nodeName === 'Island1')).toBe(true);
|
||||
expect(unreachable.some(w => w.nodeName === 'Island2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should pass when all nodes are reachable from trigger', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Code', type: 'n8n-nodes-base.code', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Set', type: 'n8n-nodes-base.set', position: [400, 0], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Code', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Code': {
|
||||
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const unreachable = result.warnings.filter(w => w.message.includes('not reachable'));
|
||||
expect(unreachable).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should flag single orphaned node as unreachable', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Orphaned', type: 'n8n-nodes-base.code', position: [500, 500], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const unreachable = result.warnings.filter(w => w.message.includes('not reachable') && w.nodeName === 'Orphaned');
|
||||
expect(unreachable).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not flag disabled nodes', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Disabled', type: 'n8n-nodes-base.code', position: [500, 500], parameters: {}, disabled: true },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const unreachable = result.warnings.filter(w => w.nodeName === 'Disabled');
|
||||
expect(unreachable).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not flag sticky notes', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Set', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Note', type: 'n8n-nodes-base.stickyNote', position: [500, 500], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
const unreachable = result.warnings.filter(w => w.nodeName === 'Note');
|
||||
expect(unreachable).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should use simple orphan check when no triggers exist', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', name: 'Set1', type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} },
|
||||
{ id: '2', name: 'Set2', type: 'n8n-nodes-base.set', position: [200, 0], parameters: {} },
|
||||
{ id: '3', name: 'Orphan', type: 'n8n-nodes-base.code', position: [500, 500], parameters: {} },
|
||||
],
|
||||
connections: {
|
||||
'Set1': {
|
||||
main: [[{ node: 'Set2', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Orphan should still be flagged with the simple "not connected" message
|
||||
const orphanWarning = result.warnings.find(w => w.nodeName === 'Orphan');
|
||||
expect(orphanWarning).toBeDefined();
|
||||
expect(orphanWarning!.message).toContain('not connected to any other nodes');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -291,7 +291,7 @@ describe('WorkflowValidator - Expression Format Validation', () => {
|
||||
});
|
||||
|
||||
describe('Real-world workflow examples', () => {
|
||||
it('should validate Email workflow with expression issues', async () => {
|
||||
it.skip('should validate Email workflow with expression issues', async () => {
|
||||
const workflow = {
|
||||
name: 'Error Notification Workflow',
|
||||
nodes: [
|
||||
@@ -342,7 +342,7 @@ describe('WorkflowValidator - Expression Format Validation', () => {
|
||||
expect(fromEmailError?.message).toContain('={{ $env.ADMIN_EMAIL }}');
|
||||
});
|
||||
|
||||
it('should validate GitHub workflow with resource locator issues', async () => {
|
||||
it.skip('should validate GitHub workflow with resource locator issues', async () => {
|
||||
const workflow = {
|
||||
name: 'GitHub Issue Handler',
|
||||
nodes: [
|
||||
|
||||
@@ -646,9 +646,10 @@ describe('WorkflowValidator - Mock-based Unit Tests', () => {
|
||||
await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should have called getNode for each node type (normalized to short form)
|
||||
// Called during node validation + output/input index bounds checking
|
||||
expect(mockGetNode).toHaveBeenCalledWith('nodes-base.httpRequest');
|
||||
expect(mockGetNode).toHaveBeenCalledWith('nodes-base.set');
|
||||
expect(mockGetNode).toHaveBeenCalledTimes(2);
|
||||
expect(mockGetNode.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should handle repository errors gracefully', async () => {
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "APP_NAME=operation-result vite build && APP_NAME=validation-summary vite build",
|
||||
"build": "APP_NAME=operation-result vite build && APP_NAME=validation-summary vite build && APP_NAME=workflow-list vite build && APP_NAME=execution-history vite build && APP_NAME=health-dashboard vite build",
|
||||
"build:operation-result": "APP_NAME=operation-result vite build",
|
||||
"build:validation-summary": "APP_NAME=validation-summary vite build",
|
||||
"build:workflow-list": "APP_NAME=workflow-list vite build",
|
||||
"build:execution-history": "APP_NAME=execution-history vite build",
|
||||
"build:health-dashboard": "APP_NAME=health-dashboard vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
357
ui-apps/preview.html
Normal file
357
ui-apps/preview.html
Normal file
@@ -0,0 +1,357 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>MCP App Preview</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; transition: background 0.3s, color 0.3s; }
|
||||
body.dark { background: #111; color: #e0e0e0; }
|
||||
body.light { background: #f5f5f5; color: #1f2937; }
|
||||
h1 { font-size: 18px; margin-bottom: 8px; }
|
||||
.theme-toggle { margin-bottom: 16px; }
|
||||
.theme-toggle button { padding: 6px 14px; border: 1px solid #666; border-radius: 6px; cursor: pointer; font-size: 12px; margin-right: 8px; }
|
||||
body.dark .theme-toggle button { background: #252540; color: #e0e0e0; border-color: #444; }
|
||||
body.light .theme-toggle button { background: #fff; color: #1f2937; border-color: #ccc; }
|
||||
.theme-toggle button.active { border-color: #ff6d5a; color: #ff6d5a; }
|
||||
.section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin: 16px 0 8px; opacity: 0.6; }
|
||||
.controls { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
button { padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 12px; }
|
||||
body.dark button { border: 1px solid #444; background: #252540; color: #e0e0e0; }
|
||||
body.light button { border: 1px solid #d1d5db; background: #fff; color: #1f2937; }
|
||||
button:hover { opacity: 0.85; }
|
||||
button.active { border-color: #ff6d5a; color: #ff6d5a; }
|
||||
.preview-frame { border-radius: 8px; overflow: hidden; max-width: 520px; transition: background 0.3s, border-color 0.3s; }
|
||||
body.dark .preview-frame { border: 1px solid #333; background: #1a1a2e; }
|
||||
body.light .preview-frame { border: 1px solid #e5e7eb; background: #ffffff; }
|
||||
iframe { border: none; width: 100%; height: 600px; }
|
||||
.info { font-size: 12px; opacity: 0.4; margin-top: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="dark">
|
||||
<h1>MCP App Local Preview</h1>
|
||||
<div class="theme-toggle">
|
||||
<button onclick="setTheme('dark')" class="active" id="btn-dark">Dark</button>
|
||||
<button onclick="setTheme('light')" id="btn-light">Light</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Operation Result</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('operation-result', mockCreateSuccess, 'n8n_create_workflow', this)" class="active">Create (success)</button>
|
||||
<button onclick="load('operation-result', mockCreateError, 'n8n_create_workflow', this)">Create (error)</button>
|
||||
<button onclick="load('operation-result', mockDelete, 'n8n_delete_workflow', this)">Delete</button>
|
||||
<button onclick="load('operation-result', mockPartialUpdate, 'n8n_update_partial_workflow', this)">Partial Update</button>
|
||||
<button onclick="load('operation-result', mockFullUpdate, 'n8n_update_full_workflow', this)">Full Update</button>
|
||||
<button onclick="load('operation-result', mockAutofix, 'n8n_autofix_workflow', this)">Autofix</button>
|
||||
<button onclick="load('operation-result', mockAutofixPreview, 'n8n_autofix_workflow', this)">Autofix (preview)</button>
|
||||
<button onclick="load('operation-result', mockDeploy, 'n8n_deploy_template', this)">Deploy Template</button>
|
||||
<button onclick="load('operation-result', mockTest, 'n8n_test_workflow', this)">Test Workflow</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Validation Summary</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('validation-summary', mockValidValid, 'validate_node', this)">Valid Node</button>
|
||||
<button onclick="load('validation-summary', mockValidInvalid, 'validate_node', this)">Invalid Node</button>
|
||||
<button onclick="load('validation-summary', mockN8nValidate, 'n8n_validate_workflow', this)">Workflow (multi-node)</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Workflow List</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('workflow-list', mockWorkflowList, 'n8n_list_workflows', this)">Workflow List</button>
|
||||
<button onclick="load('workflow-list', mockWorkflowListEmpty, 'n8n_list_workflows', this)">Empty List</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Execution History</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('execution-history', mockExecutions, 'n8n_executions', this)">Executions</button>
|
||||
<button onclick="load('execution-history', mockExecutionsEmpty, 'n8n_executions', this)">Empty</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Health Dashboard</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('health-dashboard', mockHealthOk, 'n8n_health_check', this)">Healthy</button>
|
||||
<button onclick="load('health-dashboard', mockHealthOutdated, 'n8n_health_check', this)">Outdated</button>
|
||||
<button onclick="load('health-dashboard', mockHealthError, 'n8n_health_check', this)">Error</button>
|
||||
</div>
|
||||
|
||||
<div class="preview-frame">
|
||||
<iframe id="app"></iframe>
|
||||
</div>
|
||||
<div class="info">This preview simulates the Claude host postMessage protocol to push mock tool result data into the MCP App iframe.</div>
|
||||
|
||||
<script>
|
||||
const iframe = document.getElementById('app');
|
||||
let pendingData = null;
|
||||
let pendingToolName = null;
|
||||
let currentTheme = 'dark';
|
||||
|
||||
// --- Theme ---
|
||||
function setTheme(theme) {
|
||||
currentTheme = theme;
|
||||
document.body.className = theme;
|
||||
document.getElementById('btn-dark').classList.toggle('active', theme === 'dark');
|
||||
document.getElementById('btn-light').classList.toggle('active', theme === 'light');
|
||||
// Reload current iframe to re-initialize with new theme
|
||||
if (iframe.src) iframe.src = iframe.src;
|
||||
}
|
||||
|
||||
// --- Mock Data: Operation Result ---
|
||||
const mockCreateSuccess = {
|
||||
success: true,
|
||||
data: { id: 'abc123XYZ', name: 'Webhook with Set Node', active: false, nodeCount: 2 },
|
||||
message: 'Workflow "Webhook with Set Node" created successfully.'
|
||||
};
|
||||
|
||||
const mockCreateError = {
|
||||
success: false,
|
||||
error: 'Node type format error: n8n API requires FULL form node types',
|
||||
details: { errors: ['Node 0 ("HTTP Request") uses SHORT form "nodes-base.httpRequest". Change to "n8n-nodes-base.httpRequest"'] }
|
||||
};
|
||||
|
||||
const mockDelete = {
|
||||
success: true,
|
||||
data: { id: 'wf_456', name: 'Old Workflow', deleted: true },
|
||||
message: 'Workflow "Old Workflow" deleted successfully.'
|
||||
};
|
||||
|
||||
const mockPartialUpdate = {
|
||||
success: true,
|
||||
data: { id: 'wf_789', name: 'My API Workflow', active: true, nodeCount: 5, operationsApplied: 3 },
|
||||
message: 'Workflow "My API Workflow" updated successfully.',
|
||||
details: {
|
||||
applied: ['add_node:Set', 'modify_node:HTTP Request', 'add_connection:Set->HTTP Request'],
|
||||
failed: [],
|
||||
warnings: ['Node "Set" has deprecated property "keepOnlySet"']
|
||||
}
|
||||
};
|
||||
|
||||
const mockFullUpdate = {
|
||||
success: true,
|
||||
data: { id: 'wf_101', name: 'Updated Workflow', active: true, nodeCount: 8 },
|
||||
message: 'Workflow updated.'
|
||||
};
|
||||
|
||||
const mockAutofix = {
|
||||
success: true,
|
||||
data: {
|
||||
id: 'wf_auto', name: 'Fixed Workflow', nodeCount: 4, fixesApplied: 3,
|
||||
fixes: [
|
||||
{ description: 'Changed node type from short to full form', confidence: 'HIGH' },
|
||||
{ description: 'Added missing authentication parameter', confidence: 'HIGH' },
|
||||
{ description: 'Replaced deprecated property channelId with channel', confidence: 'MEDIUM' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const mockAutofixPreview = {
|
||||
success: true,
|
||||
data: {
|
||||
id: 'wf_preview', name: 'Preview Workflow', nodeCount: 3, fixesApplied: 2, preview: true,
|
||||
fixes: [
|
||||
{ description: 'Would change node type from short to full form', confidence: 'HIGH' },
|
||||
{ description: 'Would add missing required field "resource"', confidence: 'MEDIUM' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const mockDeploy = {
|
||||
success: true,
|
||||
data: {
|
||||
id: 'wf_deployed', name: 'Email Notification Flow', active: false, nodeCount: 5,
|
||||
templateId: 1234, triggerType: 'webhook',
|
||||
requiredCredentials: ['gmailOAuth2Api (Gmail node)', 'slackOAuth2Api (Slack node)'],
|
||||
autoFixStatus: 'success'
|
||||
}
|
||||
};
|
||||
|
||||
const mockTest = {
|
||||
success: true,
|
||||
data: {
|
||||
id: 'wf_test', name: 'Test Workflow', executionId: 'exec_98765',
|
||||
triggerType: 'manual'
|
||||
}
|
||||
};
|
||||
|
||||
// --- Mock Data: Validation Summary ---
|
||||
const mockValidValid = {
|
||||
nodeType: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
suggestions: ['Consider adding error handling with an Error Trigger node'],
|
||||
summary: { hasErrors: false, errorCount: 0, warningCount: 0, suggestionCount: 1 }
|
||||
};
|
||||
|
||||
const mockValidInvalid = {
|
||||
nodeType: 'n8n-nodes-base.slack',
|
||||
displayName: 'Slack',
|
||||
valid: false,
|
||||
errors: [
|
||||
{ type: 'missing_required', property: 'authentication', message: 'Required field "authentication" is missing', fix: 'Set authentication to "oAuth2" or "accessToken"' },
|
||||
{ type: 'missing_required', property: 'resource', message: 'Required field "resource" is missing', fix: 'Set resource to "channel", "message", "user", etc.' }
|
||||
],
|
||||
warnings: [
|
||||
{ type: 'deprecated_property', property: 'channelId', message: 'Property "channelId" is deprecated, use "channel" instead' }
|
||||
],
|
||||
suggestions: ['Use OAuth2 authentication for production workflows', 'Add error handling for rate limits'],
|
||||
summary: { hasErrors: true, errorCount: 2, warningCount: 1, suggestionCount: 2 }
|
||||
};
|
||||
|
||||
const mockN8nValidate = {
|
||||
success: true,
|
||||
data: {
|
||||
valid: false,
|
||||
workflowId: 'wf_abc',
|
||||
workflowName: 'My Production Workflow',
|
||||
errors: [
|
||||
{ node: 'HTTP Request', message: 'Missing URL parameter', property: 'url', fix: 'Set the url field' },
|
||||
{ node: 'HTTP Request', message: 'Invalid method "PATCH" for this endpoint', property: 'method' },
|
||||
{ node: 'Slack', message: 'Missing authentication configuration', property: 'authentication', fix: 'Set authentication to oAuth2' }
|
||||
],
|
||||
warnings: [
|
||||
{ node: 'Set', message: 'Unused output field "oldField"', property: 'oldField' },
|
||||
{ node: 'HTTP Request', message: 'Consider using retry on failure', property: 'options.retry' }
|
||||
],
|
||||
suggestions: ['Add error handling between HTTP Request and Set nodes'],
|
||||
summary: { errorCount: 3, warningCount: 2, totalNodes: 4, enabledNodes: 4 }
|
||||
}
|
||||
};
|
||||
|
||||
// --- Mock Data: Workflow List ---
|
||||
const mockWorkflowList = {
|
||||
success: true,
|
||||
data: {
|
||||
workflows: [
|
||||
{ id: 'wf_001', name: 'Customer Onboarding', active: true, nodeCount: 12, tags: ['production', 'crm'], updatedAt: '2026-02-07T14:30:00Z' },
|
||||
{ id: 'wf_002', name: 'Slack Notifications', active: true, nodeCount: 5, tags: ['notifications'], updatedAt: '2026-02-06T09:15:00Z' },
|
||||
{ id: 'wf_003', name: 'Data Backup (weekly)', active: false, nodeCount: 8, tags: ['maintenance', 'backup'], updatedAt: '2026-01-28T22:00:00Z' },
|
||||
{ id: 'wf_004', name: 'Invoice Processing', active: true, nodeCount: 15, tags: ['finance', 'production', 'critical'], updatedAt: '2026-02-08T01:00:00Z' },
|
||||
{ id: 'wf_005', name: 'Old Integration Test', active: false, isArchived: true, nodeCount: 3, tags: [], updatedAt: '2025-11-01T10:00:00Z' },
|
||||
{ id: 'wf_006', name: 'Email Campaign Drip', active: true, nodeCount: 9, tags: ['marketing'], updatedAt: '2026-02-05T16:45:00Z' },
|
||||
],
|
||||
returned: 6,
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor_abc'
|
||||
}
|
||||
};
|
||||
|
||||
const mockWorkflowListEmpty = {
|
||||
success: true,
|
||||
data: { workflows: [], returned: 0, hasMore: false }
|
||||
};
|
||||
|
||||
// --- Mock Data: Execution History ---
|
||||
const mockExecutions = {
|
||||
success: true,
|
||||
data: {
|
||||
executions: [
|
||||
{ id: 'exec_001', workflowName: 'Customer Onboarding', status: 'success', startedAt: '2026-02-08T10:30:00Z', stoppedAt: '2026-02-08T10:30:02Z', mode: 'webhook' },
|
||||
{ id: 'exec_002', workflowName: 'Slack Notifications', status: 'success', startedAt: '2026-02-08T10:28:00Z', stoppedAt: '2026-02-08T10:28:01Z', mode: 'trigger' },
|
||||
{ id: 'exec_003', workflowName: 'Invoice Processing', status: 'error', startedAt: '2026-02-08T10:25:00Z', stoppedAt: '2026-02-08T10:25:15Z', mode: 'webhook' },
|
||||
{ id: 'exec_004', workflowName: 'Customer Onboarding', status: 'success', startedAt: '2026-02-08T10:20:00Z', stoppedAt: '2026-02-08T10:20:03Z', mode: 'webhook' },
|
||||
{ id: 'exec_005', workflowName: 'Email Campaign Drip', status: 'waiting', startedAt: '2026-02-08T10:15:00Z', mode: 'manual' },
|
||||
{ id: 'exec_006', workflowName: 'Data Backup', status: 'success', startedAt: '2026-02-08T09:00:00Z', stoppedAt: '2026-02-08T09:02:30Z', mode: 'cron' },
|
||||
{ id: 'exec_007', workflowName: 'Invoice Processing', status: 'error', startedAt: '2026-02-07T23:00:00Z', stoppedAt: '2026-02-07T23:00:08Z', mode: 'webhook' },
|
||||
],
|
||||
returned: 7,
|
||||
hasMore: true
|
||||
}
|
||||
};
|
||||
|
||||
const mockExecutionsEmpty = {
|
||||
success: true,
|
||||
data: { executions: [], returned: 0, hasMore: false }
|
||||
};
|
||||
|
||||
// --- Mock Data: Health Dashboard ---
|
||||
const mockHealthOk = {
|
||||
success: true,
|
||||
data: {
|
||||
status: 'connected',
|
||||
instanceId: 'inst_abc123',
|
||||
n8nVersion: '1.72.1',
|
||||
mcpVersion: '2.24.1',
|
||||
apiUrl: 'https://n8n.example.com/api/v1',
|
||||
versionCheck: { current: '1.72.1', latest: '1.72.1', upToDate: true },
|
||||
performance: { responseTimeMs: 142, cacheHitRate: 0.87 },
|
||||
nextSteps: ['Your n8n instance is up to date', 'Try creating a workflow with n8n_create_workflow']
|
||||
}
|
||||
};
|
||||
|
||||
const mockHealthOutdated = {
|
||||
success: true,
|
||||
data: {
|
||||
status: 'connected',
|
||||
instanceId: 'inst_xyz789',
|
||||
n8nVersion: '1.68.0',
|
||||
mcpVersion: '2.24.1',
|
||||
apiUrl: 'https://n8n.company.io/api/v1',
|
||||
versionCheck: { current: '1.68.0', latest: '1.72.1', upToDate: false, updateCommand: 'npm update n8n -g' },
|
||||
performance: { responseTimeMs: 1850, cacheHitRate: 0.45 },
|
||||
nextSteps: ['Update n8n to version 1.72.1 for latest features and fixes', 'Cache hit rate is low - consider warming the cache']
|
||||
}
|
||||
};
|
||||
|
||||
const mockHealthError = {
|
||||
success: false,
|
||||
error: 'Connection failed: Unable to reach n8n instance at https://n8n.offline.com/api/v1'
|
||||
};
|
||||
|
||||
// --- Host Protocol ---
|
||||
window.addEventListener('message', (event) => {
|
||||
if (!event.data || typeof event.data !== 'object') return;
|
||||
const msg = event.data;
|
||||
if (!msg.jsonrpc) return;
|
||||
|
||||
if (msg.jsonrpc === '2.0' && msg.method === 'ui/initialize' && msg.id != null) {
|
||||
iframe.contentWindow.postMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: {
|
||||
protocolVersion: '2026-01-26',
|
||||
hostCapabilities: {},
|
||||
hostInfo: { name: 'Local Preview', version: '1.0.0' },
|
||||
hostContext: {
|
||||
theme: currentTheme,
|
||||
toolInfo: pendingToolName ? { tool: { name: pendingToolName, inputSchema: { type: 'object', properties: {} } } } : undefined,
|
||||
}
|
||||
}
|
||||
}, '*');
|
||||
|
||||
setTimeout(() => {
|
||||
if (pendingData) {
|
||||
iframe.contentWindow.postMessage({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/tool-result',
|
||||
params: {
|
||||
content: [{ type: 'text', text: JSON.stringify(pendingData) }]
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
// Respond to any other JSON-RPC request (with id) with empty result
|
||||
else if (msg.jsonrpc === '2.0' && msg.id != null && msg.method) {
|
||||
iframe.contentWindow.postMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: {}
|
||||
}, '*');
|
||||
}
|
||||
});
|
||||
|
||||
function load(appName, data, toolName, btn) {
|
||||
pendingData = data;
|
||||
pendingToolName = toolName || null;
|
||||
document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
iframe.src = `dist/${appName}/index.html`;
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
load('operation-result', mockCreateSuccess, 'n8n_create_workflow', document.querySelector('.controls button'));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
201
ui-apps/src/apps/execution-history/App.tsx
Normal file
201
ui-apps/src/apps/execution-history/App.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import '@shared/styles/theme.css';
|
||||
import { Badge } from '@shared/components';
|
||||
import { useToolData } from '@shared/hooks/useToolData';
|
||||
import type { ExecutionHistoryData } from '@shared/types';
|
||||
|
||||
type ExecStatus = 'success' | 'error' | 'waiting' | 'running' | 'unknown';
|
||||
|
||||
function getStatusInfo(status?: string): { variant: 'success' | 'error' | 'warning' | 'info'; label: string } {
|
||||
switch (status) {
|
||||
case 'success': return { variant: 'success', label: 'Success' };
|
||||
case 'error': case 'failed': case 'crashed': return { variant: 'error', label: 'Error' };
|
||||
case 'waiting': return { variant: 'warning', label: 'Waiting' };
|
||||
case 'running': return { variant: 'info', label: 'Running' };
|
||||
default: return { variant: 'info', label: status ?? 'Unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(startedAt?: string, stoppedAt?: string): string {
|
||||
if (!startedAt || !stoppedAt) return '–';
|
||||
try {
|
||||
const ms = new Date(stoppedAt).getTime() - new Date(startedAt).getTime();
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||
} catch {
|
||||
return '–';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(dateStr?: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function classifyStatus(status?: string): ExecStatus {
|
||||
switch (status) {
|
||||
case 'success': return 'success';
|
||||
case 'error': case 'failed': case 'crashed': return 'error';
|
||||
case 'waiting': return 'waiting';
|
||||
case 'running': return 'running';
|
||||
default: return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { data, error, isConnected } = useToolData<ExecutionHistoryData>();
|
||||
|
||||
const executions = data?.data?.executions ?? [];
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const counts: Record<ExecStatus, number> = { success: 0, error: 0, waiting: 0, running: 0, unknown: 0 };
|
||||
for (const ex of executions) {
|
||||
counts[classifyStatus(ex.status)]++;
|
||||
}
|
||||
return counts;
|
||||
}, [executions]);
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: '16px', color: '#ef4444' }}>Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Connecting...</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Waiting for data...</div>;
|
||||
}
|
||||
|
||||
if (!data.success && data.error) {
|
||||
return (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
<Badge variant="error">Error</Badge>
|
||||
<div style={{ marginTop: '8px', fontSize: '13px', color: 'var(--n8n-error)' }}>{data.error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const total = executions.length;
|
||||
const barSegments: { color: string; pct: number }[] = [];
|
||||
if (total > 0) {
|
||||
if (summary.success > 0) barSegments.push({ color: 'var(--n8n-success)', pct: (summary.success / total) * 100 });
|
||||
if (summary.error > 0) barSegments.push({ color: 'var(--n8n-error)', pct: (summary.error / total) * 100 });
|
||||
if (summary.waiting > 0) barSegments.push({ color: 'var(--n8n-warning)', pct: (summary.waiting / total) * 100 });
|
||||
if (summary.running > 0) barSegments.push({ color: 'var(--n8n-info)', pct: (summary.running / total) * 100 });
|
||||
if (summary.unknown > 0) barSegments.push({ color: 'var(--n8n-border)', pct: (summary.unknown / total) * 100 });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
{/* Summary bar */}
|
||||
{total > 0 && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
height: '6px',
|
||||
borderRadius: '3px',
|
||||
background: 'var(--n8n-border)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
}}>
|
||||
{barSegments.map((seg, i) => (
|
||||
<div key={i} style={{ width: `${seg.pct}%`, background: seg.color, minWidth: '3px' }} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-secondary, var(--n8n-text-muted))', marginTop: '6px' }}>
|
||||
{summary.success > 0 && <><span style={{ color: 'var(--n8n-success)', fontWeight: 500 }}>{summary.success}</span> succeeded</>}
|
||||
{summary.error > 0 && <>{summary.success > 0 && ', '}<span style={{ color: 'var(--n8n-error)', fontWeight: 500 }}>{summary.error}</span> failed</>}
|
||||
{summary.waiting > 0 && <>{(summary.success > 0 || summary.error > 0) && ', '}<span style={{ color: 'var(--n8n-warning)', fontWeight: 500 }}>{summary.waiting}</span> waiting</>}
|
||||
{summary.running > 0 && <>{(summary.success > 0 || summary.error > 0 || summary.waiting > 0) && ', '}<span style={{ color: 'var(--n8n-info)', fontWeight: 500 }}>{summary.running}</span> running</>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div style={{
|
||||
border: '1px solid var(--n8n-border)',
|
||||
borderRadius: 'var(--n8n-radius)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '70px 1fr 70px 90px 60px',
|
||||
gap: '6px',
|
||||
padding: '8px 10px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.03em',
|
||||
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
|
||||
background: 'var(--n8n-bg-card)',
|
||||
borderBottom: '1px solid var(--n8n-border)',
|
||||
}}>
|
||||
<span>ID</span>
|
||||
<span>Workflow</span>
|
||||
<span>Status</span>
|
||||
<span>Started</span>
|
||||
<span>Duration</span>
|
||||
</div>
|
||||
|
||||
{executions.length === 0 && (
|
||||
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--n8n-text-muted)', fontSize: '13px' }}>
|
||||
No executions found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{executions.map((ex) => {
|
||||
const statusInfo = getStatusInfo(ex.status);
|
||||
return (
|
||||
<div
|
||||
key={ex.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '70px 1fr 70px 90px 60px',
|
||||
gap: '6px',
|
||||
padding: '6px 10px',
|
||||
fontSize: '12px',
|
||||
borderBottom: '1px solid var(--n8n-border)',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: '11px' }}>
|
||||
{ex.id.length > 8 ? ex.id.slice(0, 8) + '…' : ex.id}
|
||||
</span>
|
||||
<span style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
}}>
|
||||
{ex.workflowName || ex.workflowId || '–'}
|
||||
</span>
|
||||
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary, var(--n8n-text-muted))', whiteSpace: 'nowrap' as const }}>
|
||||
{formatTime(ex.startedAt)}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>
|
||||
{formatDuration(ex.startedAt, ex.stoppedAt)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{data.data?.hasMore && (
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
|
||||
marginTop: '6px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
More executions available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
ui-apps/src/apps/execution-history/index.html
Normal file
12
ui-apps/src/apps/execution-history/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>Execution History</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
ui-apps/src/apps/execution-history/main.tsx
Normal file
8
ui-apps/src/apps/execution-history/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 />);
|
||||
}
|
||||
141
ui-apps/src/apps/health-dashboard/App.tsx
Normal file
141
ui-apps/src/apps/health-dashboard/App.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import '@shared/styles/theme.css';
|
||||
import { Badge, Card } from '@shared/components';
|
||||
import { useToolData } from '@shared/hooks/useToolData';
|
||||
import type { HealthDashboardData } from '@shared/types';
|
||||
|
||||
export default function App() {
|
||||
const { data, error, isConnected } = useToolData<HealthDashboardData>();
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: '16px', color: '#ef4444' }}>Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Connecting...</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Waiting for data...</div>;
|
||||
}
|
||||
|
||||
if (!data.success && data.error) {
|
||||
return (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
||||
<Badge variant="error">Disconnected</Badge>
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--n8n-error)' }}>{data.error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const d = data.data;
|
||||
const isConnectedStatus = d?.status === 'connected' || d?.status === 'ok' || data.success;
|
||||
const vc = d?.versionCheck;
|
||||
const perf = d?.performance;
|
||||
const nextSteps = d?.nextSteps ?? [];
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
{/* Connection status */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
||||
<Badge variant={isConnectedStatus ? 'success' : 'error'}>
|
||||
{isConnectedStatus ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
{d?.apiUrl && (
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
|
||||
}}>
|
||||
{d.apiUrl}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Version info */}
|
||||
{(d?.n8nVersion || d?.mcpVersion) && (
|
||||
<Card>
|
||||
<div style={{ fontSize: '13px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>n8n</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontWeight: 500 }}>
|
||||
{d?.n8nVersion ?? '–'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>MCP Server</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontWeight: 500 }}>
|
||||
{d?.mcpVersion ?? '–'}
|
||||
</span>
|
||||
</div>
|
||||
{vc && !vc.upToDate && (
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
padding: '6px 10px',
|
||||
background: 'var(--n8n-warning-light)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--n8n-warning)',
|
||||
}}>
|
||||
Update available: {vc.current} → {vc.latest}
|
||||
{vc.updateCommand && (
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '11px',
|
||||
marginTop: '4px',
|
||||
opacity: 0.9,
|
||||
}}>
|
||||
{vc.updateCommand}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Performance */}
|
||||
{perf && (
|
||||
<Card>
|
||||
<div style={{ fontSize: '13px' }}>
|
||||
{perf.responseTimeMs !== undefined && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>Response time</span>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontWeight: 500,
|
||||
color: perf.responseTimeMs < 500 ? 'var(--n8n-success)' : perf.responseTimeMs < 2000 ? 'var(--n8n-warning)' : 'var(--n8n-error)',
|
||||
}}>
|
||||
{perf.responseTimeMs}ms
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{perf.cacheHitRate !== undefined && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>Cache hit rate</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontWeight: 500 }}>
|
||||
{typeof perf.cacheHitRate === 'number' && perf.cacheHitRate <= 1
|
||||
? `${(perf.cacheHitRate * 100).toFixed(0)}%`
|
||||
: `${perf.cacheHitRate}%`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Next steps */}
|
||||
{nextSteps.length > 0 && (
|
||||
<Card title="Next Steps">
|
||||
<ul style={{ paddingLeft: '16px', fontSize: '12px' }}>
|
||||
{nextSteps.map((step, i) => (
|
||||
<li key={i} style={{ padding: '2px 0' }}>{step}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
ui-apps/src/apps/health-dashboard/index.html
Normal file
12
ui-apps/src/apps/health-dashboard/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>Health Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
ui-apps/src/apps/health-dashboard/main.tsx
Normal file
8
ui-apps/src/apps/health-dashboard/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 />);
|
||||
}
|
||||
@@ -1,83 +1,336 @@
|
||||
import React from 'react';
|
||||
import '@shared/styles/theme.css';
|
||||
import { Card, Badge, Expandable } from '@shared/components';
|
||||
import { Badge, Expandable } from '@shared/components';
|
||||
import { useToolData } from '@shared/hooks/useToolData';
|
||||
import type { OperationResultData } from '@shared/types';
|
||||
import type { OperationResultData, OperationType } from '@shared/types';
|
||||
|
||||
export default function App() {
|
||||
const data = useToolData<OperationResultData>();
|
||||
const TOOL_TO_OP: Record<string, OperationType> = {
|
||||
n8n_create_workflow: 'create',
|
||||
n8n_update_full_workflow: 'update',
|
||||
n8n_update_partial_workflow: 'partial_update',
|
||||
n8n_delete_workflow: 'delete',
|
||||
n8n_test_workflow: 'test',
|
||||
n8n_autofix_workflow: 'autofix',
|
||||
n8n_deploy_template: 'deploy',
|
||||
};
|
||||
|
||||
if (!data) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Loading...</div>;
|
||||
const OP_CONFIG: Record<OperationType, { icon: string; label: string; color: string }> = {
|
||||
create: { icon: '+', label: 'WORKFLOW CREATED', color: 'var(--n8n-success)' },
|
||||
update: { icon: '⟳', label: 'WORKFLOW UPDATED', color: 'var(--n8n-info)' },
|
||||
partial_update: { icon: '⟳', label: 'WORKFLOW UPDATED', color: 'var(--n8n-info)' },
|
||||
delete: { icon: '−', label: 'WORKFLOW DELETED', color: 'var(--n8n-error)' },
|
||||
test: { icon: '▶', label: 'WORKFLOW TESTED', color: 'var(--n8n-info)' },
|
||||
autofix: { icon: '⚡', label: 'WORKFLOW AUTO-FIXED', color: 'var(--n8n-warning)' },
|
||||
deploy: { icon: '↓', label: 'TEMPLATE DEPLOYED', color: 'var(--n8n-success)' },
|
||||
};
|
||||
|
||||
function detectOperation(toolName: string | null, data: OperationResultData): OperationType {
|
||||
if (toolName && TOOL_TO_OP[toolName]) return TOOL_TO_OP[toolName];
|
||||
|
||||
const d = data.data;
|
||||
if (d?.deleted) return 'delete';
|
||||
if (d?.templateId) return 'deploy';
|
||||
if (d?.fixesApplied !== undefined || d?.fixes) return 'autofix';
|
||||
if (d?.executionId) return 'test';
|
||||
if (d?.operationsApplied !== undefined) return 'partial_update';
|
||||
return 'create';
|
||||
}
|
||||
|
||||
function PartialUpdatePanel({ details }: { details?: Record<string, unknown> }) {
|
||||
if (!details) return null;
|
||||
const applied = Array.isArray(details.applied) ? details.applied as string[] : [];
|
||||
const failed = Array.isArray(details.failed) ? details.failed as string[] : [];
|
||||
const warnings = Array.isArray(details.warnings) ? details.warnings as string[] : [];
|
||||
if (applied.length === 0 && failed.length === 0) return null;
|
||||
|
||||
const items = [
|
||||
...applied.map((m) => ({ icon: '✓', color: 'var(--n8n-success)', text: String(m) })),
|
||||
...failed.map((m) => ({ icon: '✗', color: 'var(--n8n-error)', text: String(m) })),
|
||||
...warnings.map((m) => ({ icon: '!', color: 'var(--n8n-warning)', text: String(m) })),
|
||||
];
|
||||
|
||||
const summary = (
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-secondary, var(--n8n-text-muted))', marginBottom: '6px' }}>
|
||||
<span style={{ color: 'var(--n8n-success)' }}>{applied.length} applied</span>
|
||||
{failed.length > 0 && <>, <span style={{ color: 'var(--n8n-error)' }}>{failed.length} failed</span></>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const list = items.map((item, i) => (
|
||||
<div key={i} style={{ fontSize: '12px', padding: '2px 0', display: 'flex', gap: '6px' }}>
|
||||
<span style={{ color: item.color, flexShrink: 0 }}>{item.icon}</span>
|
||||
<span>{item.text}</span>
|
||||
</div>
|
||||
));
|
||||
|
||||
if (items.length > 5) {
|
||||
return <>{summary}<Expandable title="Operation Log" count={items.length}>{list}</Expandable></>;
|
||||
}
|
||||
return <>{summary}<div style={{ marginBottom: '8px' }}>{list}</div></>;
|
||||
}
|
||||
|
||||
const isSuccess = data.status === 'success';
|
||||
function AutofixPanel({ data }: { data: OperationResultData }) {
|
||||
const fixes = Array.isArray(data.data?.fixes) ? data.data!.fixes as Record<string, unknown>[] : [];
|
||||
const isPreview = data.data?.preview === true;
|
||||
const fixCount = data.data?.fixesApplied ?? fixes.length;
|
||||
|
||||
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>
|
||||
)}
|
||||
<>
|
||||
{isPreview && (
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--n8n-warning)',
|
||||
background: 'var(--n8n-warning-light)',
|
||||
padding: '4px 10px',
|
||||
borderRadius: 'var(--n8n-radius)',
|
||||
marginBottom: '8px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
PREVIEW MODE
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{data.message && (
|
||||
<Card>
|
||||
<div style={{ fontSize: '13px' }}>{data.message}</div>
|
||||
</Card>
|
||||
)}
|
||||
{fixes.length > 0 && (
|
||||
<Expandable title="Fixes" count={fixCount} defaultOpen>
|
||||
{fixes.map((fix, i) => {
|
||||
const confidence = String(fix.confidence ?? '').toUpperCase();
|
||||
return (
|
||||
<div key={i} style={{
|
||||
fontSize: '12px',
|
||||
padding: '6px 8px',
|
||||
marginBottom: '4px',
|
||||
borderLeft: `3px solid ${confidence === 'HIGH' ? 'var(--n8n-success)' : 'var(--n8n-warning)'}`,
|
||||
paddingLeft: '10px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span>{String(fix.description ?? fix.message ?? JSON.stringify(fix))}</span>
|
||||
{confidence && (
|
||||
<Badge variant={confidence === 'HIGH' ? 'success' : 'warning'}>
|
||||
{confidence}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Expandable>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
{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>
|
||||
)}
|
||||
function DeployPanel({ data }: { data: OperationResultData }) {
|
||||
const d = data.data;
|
||||
const creds = Array.isArray(d?.requiredCredentials) ? d!.requiredCredentials as string[] : [];
|
||||
const triggerType = d?.triggerType;
|
||||
const autoFixStatus = d?.autoFixStatus;
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
return (
|
||||
<div style={{ fontSize: '12px', marginBottom: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginBottom: creds.length > 0 ? '8px' : 0 }}>
|
||||
{triggerType && <Badge variant="info">{String(triggerType)}</Badge>}
|
||||
{autoFixStatus && <Badge variant={autoFixStatus === 'success' ? 'success' : 'warning'}>{String(autoFixStatus)}</Badge>}
|
||||
</div>
|
||||
{creds.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: '4px', color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>Required credentials:</div>
|
||||
{creds.map((c, i) => (
|
||||
<div key={i} style={{ padding: '1px 0' }}>○ {c}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TestPanel({ data }: { data: OperationResultData }) {
|
||||
const execId = data.data?.executionId;
|
||||
const triggerType = data.data?.triggerType;
|
||||
if (!execId && !triggerType) return null;
|
||||
return (
|
||||
<div style={{ fontSize: '12px', marginBottom: '8px' }}>
|
||||
{execId && (
|
||||
<div style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: '13px', fontWeight: 600, marginBottom: '4px' }}>
|
||||
Execution: {execId}
|
||||
</div>
|
||||
)}
|
||||
{triggerType && <Badge variant="info">{String(triggerType)}</Badge>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorDetails({ details }: { details?: Record<string, unknown> }) {
|
||||
if (!details) return null;
|
||||
|
||||
if (Array.isArray(details.errors)) {
|
||||
const errs = details.errors as string[];
|
||||
return (
|
||||
<Expandable title="Errors" count={errs.length}>
|
||||
<ul style={{ paddingLeft: '16px', fontSize: '12px' }}>
|
||||
{errs.map((e, i) => <li key={i} style={{ padding: '1px 0' }}>{String(e)}</li>)}
|
||||
</ul>
|
||||
</Expandable>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.entries(details).filter(([, v]) => v !== undefined && v !== null);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const hasComplexValues = entries.some(([, v]) => typeof v === 'object');
|
||||
if (hasComplexValues) {
|
||||
return (
|
||||
<Expandable title="Details">
|
||||
<pre style={{ fontSize: '11px', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{JSON.stringify(details, null, 2)}
|
||||
</pre>
|
||||
</Expandable>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Expandable title="Details">
|
||||
<div style={{ fontSize: '12px' }}>
|
||||
{entries.map(([key, val]) => (
|
||||
<div key={key} style={{ padding: '2px 0' }}>
|
||||
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>{key}: </span>
|
||||
<span>{String(val)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Expandable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { data, error, isConnected, toolName } = useToolData<OperationResultData>();
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: '16px', color: '#ef4444' }}>Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Connecting...</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Waiting for data...</div>;
|
||||
}
|
||||
|
||||
const isSuccess = data.success === true;
|
||||
const op = detectOperation(toolName, data);
|
||||
const config = OP_CONFIG[op];
|
||||
|
||||
const workflowName = data.data?.name || data.data?.workflowName;
|
||||
const workflowId = data.data?.id || data.data?.workflowId;
|
||||
const nodeCount = data.data?.nodeCount;
|
||||
const isActive = data.data?.active;
|
||||
const operationsApplied = data.data?.operationsApplied;
|
||||
const executionId = data.data?.executionId;
|
||||
const fixesApplied = data.data?.fixesApplied;
|
||||
const templateId = data.data?.templateId;
|
||||
|
||||
const label = isSuccess ? config.label : config.label + ' FAILED';
|
||||
|
||||
const metaParts: string[] = [];
|
||||
if (workflowId) metaParts.push(`ID: ${workflowId}`);
|
||||
if (nodeCount !== undefined) metaParts.push(`${nodeCount} nodes`);
|
||||
if (isActive !== undefined) metaParts.push(isActive ? 'active' : 'inactive');
|
||||
if (operationsApplied !== undefined) metaParts.push(`${operationsApplied} ops applied`);
|
||||
if (executionId) metaParts.push(`exec: ${executionId}`);
|
||||
if (fixesApplied !== undefined) metaParts.push(`${fixesApplied} fixes`);
|
||||
if (templateId) metaParts.push(`template: ${templateId}`);
|
||||
|
||||
const containerStyle = op === 'delete' ? {
|
||||
maxWidth: '480px',
|
||||
borderLeft: '3px solid var(--n8n-error)',
|
||||
paddingLeft: '12px',
|
||||
} : { maxWidth: '480px' };
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '16px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px', flex: 1, minWidth: 0 }}>
|
||||
<span style={{
|
||||
fontSize: '18px',
|
||||
lineHeight: '24px',
|
||||
color: config.color,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{config.icon}
|
||||
</span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase' as const,
|
||||
color: config.color,
|
||||
lineHeight: '16px',
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
{workflowName && (
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary, var(--n8n-text))',
|
||||
marginTop: '2px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
}}>
|
||||
{workflowName}
|
||||
</div>
|
||||
)}
|
||||
{metaParts.length > 0 && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
|
||||
marginTop: '2px',
|
||||
}}>
|
||||
{metaParts.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={isSuccess ? 'success' : 'error'}>
|
||||
{isSuccess ? 'Success' : 'Error'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Error info */}
|
||||
{!isSuccess && data.error && (
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--n8n-error)',
|
||||
padding: '8px 12px',
|
||||
background: 'var(--n8n-error-light)',
|
||||
borderRadius: 'var(--n8n-radius)',
|
||||
marginBottom: '8px',
|
||||
}}>
|
||||
{data.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Operation-specific panels */}
|
||||
{isSuccess && op === 'partial_update' && <PartialUpdatePanel details={data.details} />}
|
||||
{isSuccess && op === 'autofix' && <AutofixPanel data={data} />}
|
||||
{isSuccess && op === 'deploy' && <DeployPanel data={data} />}
|
||||
{isSuccess && op === 'test' && <TestPanel data={data} />}
|
||||
|
||||
{/* Error details */}
|
||||
{!isSuccess && <ErrorDetails details={data.details} />}
|
||||
|
||||
{/* Fallback details for success states without specific panels */}
|
||||
{isSuccess && !['partial_update', 'autofix', 'deploy', 'test'].includes(op) && data.details && (
|
||||
<ErrorDetails details={data.details} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,84 +1,207 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import '@shared/styles/theme.css';
|
||||
import { Card, Badge, Expandable } from '@shared/components';
|
||||
import { Badge, Expandable } from '@shared/components';
|
||||
import { useToolData } from '@shared/hooks/useToolData';
|
||||
import type { ValidationSummaryData } from '@shared/types';
|
||||
import type { ValidationSummaryData, ValidationError, ValidationWarning } from '@shared/types';
|
||||
|
||||
interface NodeGroup {
|
||||
node: string;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
}
|
||||
|
||||
function SeverityBar({ errorCount, warningCount }: { errorCount: number; warningCount: number }) {
|
||||
const total = errorCount + warningCount;
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
height: '6px',
|
||||
borderRadius: '3px',
|
||||
background: 'var(--n8n-success)',
|
||||
marginBottom: '6px',
|
||||
}} />
|
||||
<div style={{ fontSize: '12px', color: 'var(--n8n-success)', fontWeight: 500 }}>
|
||||
All checks passed
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const errorPct = (errorCount / total) * 100;
|
||||
const warningPct = (warningCount / total) * 100;
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
height: '6px',
|
||||
borderRadius: '3px',
|
||||
background: 'var(--n8n-border)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
}}>
|
||||
{errorCount > 0 && (
|
||||
<div style={{ width: `${errorPct}%`, background: 'var(--n8n-error)', minWidth: '4px' }} />
|
||||
)}
|
||||
{warningCount > 0 && (
|
||||
<div style={{ width: `${warningPct}%`, background: 'var(--n8n-warning)', minWidth: '4px' }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-secondary, var(--n8n-text-muted))', marginTop: '6px' }}>
|
||||
<span style={{ color: 'var(--n8n-error)', fontWeight: 500 }}>{errorCount}</span> error{errorCount !== 1 ? 's' : ''}
|
||||
{' · '}
|
||||
<span style={{ color: 'var(--n8n-warning)', fontWeight: 500 }}>{warningCount}</span> warning{warningCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueItem({ issue, variant }: { issue: ValidationError | ValidationWarning; variant: 'error' | 'warning' }) {
|
||||
const color = variant === 'error' ? 'var(--n8n-error)' : 'var(--n8n-warning)';
|
||||
const fix = 'fix' in issue ? issue.fix : undefined;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '6px 10px',
|
||||
marginBottom: '4px',
|
||||
borderLeft: `3px solid ${color}`,
|
||||
fontSize: '12px',
|
||||
}}>
|
||||
<div style={{ color: 'var(--color-text-primary, var(--n8n-text))' }}>{issue.message}</div>
|
||||
{issue.property && (
|
||||
<div style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))', fontSize: '11px', marginTop: '2px' }}>
|
||||
{issue.property}
|
||||
</div>
|
||||
)}
|
||||
{fix && (
|
||||
<div style={{ color, fontSize: '11px', marginTop: '2px' }}>
|
||||
→ {fix}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeGroupSection({ group }: { group: NodeGroup }) {
|
||||
const errCount = group.errors.length;
|
||||
const warnCount = group.warnings.length;
|
||||
|
||||
return (
|
||||
<Expandable
|
||||
title={group.node}
|
||||
count={errCount + warnCount}
|
||||
defaultOpen={errCount > 0}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '6px', flexWrap: 'wrap' }}>
|
||||
{errCount > 0 && <Badge variant="error">{errCount} error{errCount !== 1 ? 's' : ''}</Badge>}
|
||||
{warnCount > 0 && <Badge variant="warning">{warnCount} warning{warnCount !== 1 ? 's' : ''}</Badge>}
|
||||
</div>
|
||||
{group.errors.map((err, i) => (
|
||||
<IssueItem key={`e-${i}`} issue={err} variant="error" />
|
||||
))}
|
||||
{group.warnings.map((warn, i) => (
|
||||
<IssueItem key={`w-${i}`} issue={warn} variant="warning" />
|
||||
))}
|
||||
</Expandable>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const data = useToolData<ValidationSummaryData>();
|
||||
const { data: raw, error, isConnected } = useToolData<ValidationSummaryData>();
|
||||
|
||||
if (!data) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Loading...</div>;
|
||||
const inner = raw?.data || raw;
|
||||
const errors: ValidationError[] = inner?.errors || raw?.errors || [];
|
||||
const warnings: ValidationWarning[] = inner?.warnings || raw?.warnings || [];
|
||||
|
||||
const nodeGroups = useMemo(() => {
|
||||
if (errors.length === 0 && warnings.length === 0) return null;
|
||||
const hasNodes = errors.some((e) => e.node) || warnings.some((w) => w.node);
|
||||
const uniqueNodes = new Set([
|
||||
...errors.filter((e) => e.node).map((e) => e.node!),
|
||||
...warnings.filter((w) => w.node).map((w) => w.node!),
|
||||
]);
|
||||
if (!hasNodes || uniqueNodes.size <= 1) return null;
|
||||
|
||||
const groups: NodeGroup[] = [];
|
||||
for (const node of uniqueNodes) {
|
||||
groups.push({
|
||||
node,
|
||||
errors: errors.filter((e) => e.node === node),
|
||||
warnings: warnings.filter((w) => w.node === node),
|
||||
});
|
||||
}
|
||||
// Ungrouped items
|
||||
const ungroupedErrors = errors.filter((e) => !e.node);
|
||||
const ungroupedWarnings = warnings.filter((w) => !w.node);
|
||||
if (ungroupedErrors.length > 0 || ungroupedWarnings.length > 0) {
|
||||
groups.push({ node: 'General', errors: ungroupedErrors, warnings: ungroupedWarnings });
|
||||
}
|
||||
// Sort: most issues first
|
||||
groups.sort((a, b) => (b.errors.length + b.warnings.length) - (a.errors.length + a.warnings.length));
|
||||
return groups;
|
||||
}, [errors, warnings]);
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: '16px', color: '#ef4444' }}>Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Connecting...</div>;
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Waiting for data...</div>;
|
||||
}
|
||||
|
||||
const valid = inner.valid ?? raw.valid ?? false;
|
||||
const displayName = raw.displayName || raw.data?.workflowName;
|
||||
const suggestions: string[] = inner?.suggestions || raw?.suggestions || [];
|
||||
const errorCount = raw.summary?.errorCount ?? inner?.summary?.errorCount ?? errors.length;
|
||||
const warningCount = raw.summary?.warningCount ?? inner?.summary?.warningCount ?? warnings.length;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<Badge variant={data.valid ? 'success' : 'error'}>
|
||||
{data.valid ? 'Valid' : 'Invalid'}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<Badge variant={valid ? 'success' : 'error'}>
|
||||
{valid ? 'Valid' : 'Invalid'}
|
||||
</Badge>
|
||||
{data.displayName && (
|
||||
<span style={{ fontSize: '14px', color: 'var(--n8n-text-muted)' }}>{data.displayName}</span>
|
||||
{displayName && (
|
||||
<span style={{ fontSize: '14px', color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>{displayName}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div style={{ display: 'flex', gap: '16px', fontSize: '13px' }}>
|
||||
<div>
|
||||
<span style={{ color: 'var(--n8n-error)' }}>{data.errorCount}</span> errors
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: 'var(--n8n-warning)' }}>{data.warningCount}</span> warnings
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<SeverityBar errorCount={errorCount} warningCount={warningCount} />
|
||||
|
||||
{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>
|
||||
{nodeGroups ? (
|
||||
nodeGroups.map((group) => (
|
||||
<NodeGroupSection key={group.node} group={group} />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{errors.length > 0 && (
|
||||
<Expandable title="Errors" count={errors.length} defaultOpen>
|
||||
{errors.map((err, i) => (
|
||||
<IssueItem key={i} issue={err} variant="error" />
|
||||
))}
|
||||
</Expandable>
|
||||
)}
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<Expandable title="Warnings" count={warnings.length}>
|
||||
{warnings.map((warn, i) => (
|
||||
<IssueItem key={i} issue={warn} variant="warning" />
|
||||
))}
|
||||
</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}>
|
||||
{suggestions.length > 0 && (
|
||||
<Expandable title="Suggestions" count={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>
|
||||
{suggestions.map((suggestion, i) => (
|
||||
<li key={i} style={{ padding: '2px 0', color: 'var(--n8n-info)' }}>→ {suggestion}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Expandable>
|
||||
|
||||
145
ui-apps/src/apps/workflow-list/App.tsx
Normal file
145
ui-apps/src/apps/workflow-list/App.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import '@shared/styles/theme.css';
|
||||
import { Badge } from '@shared/components';
|
||||
import { useToolData } from '@shared/hooks/useToolData';
|
||||
import type { WorkflowListData } from '@shared/types';
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { data, error, isConnected } = useToolData<WorkflowListData>();
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: '16px', color: '#ef4444' }}>Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Connecting...</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Waiting for data...</div>;
|
||||
}
|
||||
|
||||
if (!data.success && data.error) {
|
||||
return (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
<Badge variant="error">Error</Badge>
|
||||
<div style={{ marginTop: '8px', fontSize: '13px', color: 'var(--n8n-error)' }}>{data.error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const workflows = data.data?.workflows ?? [];
|
||||
const returned = data.data?.returned ?? workflows.length;
|
||||
const hasMore = data.data?.hasMore;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
Showing {returned} workflow{returned !== 1 ? 's' : ''}
|
||||
{hasMore && ' (more available)'}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '1px solid var(--n8n-border)',
|
||||
borderRadius: 'var(--n8n-radius)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header row */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 60px 50px auto',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.03em',
|
||||
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
|
||||
background: 'var(--n8n-bg-card)',
|
||||
borderBottom: '1px solid var(--n8n-border)',
|
||||
}}>
|
||||
<span>Name</span>
|
||||
<span>Status</span>
|
||||
<span>Nodes</span>
|
||||
<span>Updated</span>
|
||||
</div>
|
||||
|
||||
{workflows.length === 0 && (
|
||||
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--n8n-text-muted)', fontSize: '13px' }}>
|
||||
No workflows found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workflows.map((wf) => (
|
||||
<div
|
||||
key={wf.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 60px 50px auto',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
borderBottom: '1px solid var(--n8n-border)',
|
||||
opacity: wf.isArchived ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const }}>
|
||||
<span style={{ fontWeight: 500 }}>{wf.name}</span>
|
||||
{wf.tags && wf.tags.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '4px', marginTop: '2px', flexWrap: 'wrap' }}>
|
||||
{wf.tags.slice(0, 3).map((tag, i) => (
|
||||
<span key={i} style={{
|
||||
fontSize: '10px',
|
||||
padding: '1px 6px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--n8n-info-light)',
|
||||
color: 'var(--n8n-info)',
|
||||
}}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{wf.tags.length > 3 && (
|
||||
<span style={{ fontSize: '10px', color: 'var(--n8n-text-muted)' }}>+{wf.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: wf.active ? 'var(--n8n-success)' : 'var(--n8n-border)',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>
|
||||
{wf.isArchived ? 'Archived' : wf.active ? 'Active' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>
|
||||
{wf.nodeCount ?? '–'}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary, var(--n8n-text-muted))', whiteSpace: 'nowrap' as const }}>
|
||||
{formatDate(wf.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
ui-apps/src/apps/workflow-list/index.html
Normal file
12
ui-apps/src/apps/workflow-list/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>Workflow List</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
ui-apps/src/apps/workflow-list/main.tsx
Normal file
8
ui-apps/src/apps/workflow-list/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 />);
|
||||
}
|
||||
54
ui-apps/src/shared/components/CopyButton.tsx
Normal file
54
ui-apps/src/shared/components/CopyButton.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function CopyButton({ text }: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for sandboxed environments
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
padding: 0,
|
||||
border: '1px solid var(--n8n-border)',
|
||||
borderRadius: '4px',
|
||||
background: 'transparent',
|
||||
color: copied ? 'var(--n8n-success)' : 'var(--n8n-text-muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Copy"
|
||||
>
|
||||
{copied ? '✓' : '⎘'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { Card } from './Card';
|
||||
export { Badge } from './Badge';
|
||||
export { Expandable } from './Expandable';
|
||||
export { CopyButton } from './CopyButton';
|
||||
|
||||
@@ -1,35 +1,50 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { App } from '@modelcontextprotocol/ext-apps';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react';
|
||||
import type { App } from '@modelcontextprotocol/ext-apps/react';
|
||||
|
||||
export function useToolData<T>(): T | null {
|
||||
interface UseToolDataResult<T> {
|
||||
data: T | null;
|
||||
error: string | null;
|
||||
isConnected: boolean;
|
||||
app: App | null;
|
||||
toolName: string | null;
|
||||
}
|
||||
|
||||
export function useToolData<T>(): UseToolDataResult<T> {
|
||||
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
|
||||
const onAppCreated = useCallback((app: App) => {
|
||||
app.ontoolresult = (result) => {
|
||||
if (result?.content) {
|
||||
const textItem = Array.isArray(result.content)
|
||||
? result.content.find((c: any) => c.type === 'text')
|
||||
? result.content.find((c) => c.type === 'text')
|
||||
: null;
|
||||
if (textItem?.text) {
|
||||
if (textItem && 'text' in textItem) {
|
||||
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;
|
||||
const { app, isConnected, error } = useApp({
|
||||
appInfo: { name: 'n8n-mcp-ui', version: '1.0.0' },
|
||||
capabilities: {},
|
||||
onAppCreated,
|
||||
});
|
||||
|
||||
useHostStyles(app, app?.getHostContext());
|
||||
|
||||
const toolName = app?.getHostContext()?.toolInfo?.tool.name ?? null;
|
||||
|
||||
return {
|
||||
data,
|
||||
error: error?.message ?? null,
|
||||
isConnected,
|
||||
app,
|
||||
toolName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
:root {
|
||||
/* n8n brand colors */
|
||||
--n8n-primary: #ff6d5a;
|
||||
--n8n-primary-light: #ff8a7a;
|
||||
|
||||
/* Semantic colors */
|
||||
--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;
|
||||
|
||||
/* Dark mode defaults (fallback when host vars unavailable) */
|
||||
--n8n-bg: #1a1a2e;
|
||||
--n8n-bg-card: #252540;
|
||||
--n8n-text: #e0e0e0;
|
||||
--n8n-text-muted: #9ca3af;
|
||||
--n8n-border: #374151;
|
||||
--n8n-error-light: #fee2e2;
|
||||
--n8n-warning-light: #fef3cd;
|
||||
--n8n-success-light: #e8f9f0;
|
||||
--n8n-info-light: #dbeafe;
|
||||
|
||||
--n8n-radius: 8px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--n8n-bg: #ffffff;
|
||||
--n8n-bg-card: #f9fafb;
|
||||
--n8n-text: #1f2937;
|
||||
--n8n-text-muted: #6b7280;
|
||||
--n8n-border: #e5e7eb;
|
||||
--n8n-error-light: #fef2f2;
|
||||
--n8n-warning-light: #fffbeb;
|
||||
--n8n-success-light: #f0fdf4;
|
||||
--n8n-info-light: #eff6ff;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -25,8 +43,8 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--n8n-bg);
|
||||
color: var(--n8n-text);
|
||||
background: var(--color-background-primary, var(--n8n-bg));
|
||||
color: var(--color-text-primary, var(--n8n-text));
|
||||
line-height: 1.5;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -1,38 +1,146 @@
|
||||
// Matches the McpToolResponse format from handlers-n8n-manager.ts
|
||||
export interface OperationResultData {
|
||||
status: 'success' | 'error';
|
||||
operation: string;
|
||||
workflowName?: string;
|
||||
workflowId?: string;
|
||||
timestamp?: string;
|
||||
message?: string;
|
||||
changes?: {
|
||||
nodesAdded?: string[];
|
||||
nodesModified?: string[];
|
||||
nodesRemoved?: string[];
|
||||
success: boolean;
|
||||
data?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
active?: boolean;
|
||||
nodeCount?: number;
|
||||
workflowId?: string;
|
||||
workflowName?: string;
|
||||
deleted?: boolean;
|
||||
operationsApplied?: number;
|
||||
executionId?: string;
|
||||
templateId?: string | number;
|
||||
fixes?: unknown[];
|
||||
fixesApplied?: number;
|
||||
preview?: unknown;
|
||||
triggerType?: string;
|
||||
requiredCredentials?: string[];
|
||||
autoFixStatus?: string;
|
||||
url?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
message?: string;
|
||||
error?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type OperationType = 'create' | 'update' | 'partial_update' | 'delete' | 'test' | 'autofix' | 'deploy';
|
||||
|
||||
export interface ValidationError {
|
||||
type: string;
|
||||
type?: string;
|
||||
property?: string;
|
||||
message: string;
|
||||
fix?: string;
|
||||
node?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
type: string;
|
||||
type?: string;
|
||||
property?: string;
|
||||
message: string;
|
||||
node?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
// Workflow list response from n8n_list_workflows
|
||||
export interface WorkflowListData {
|
||||
success: boolean;
|
||||
data?: {
|
||||
workflows: {
|
||||
id: string;
|
||||
name: string;
|
||||
active?: boolean;
|
||||
isArchived?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
tags?: string[];
|
||||
nodeCount?: number;
|
||||
}[];
|
||||
returned?: number;
|
||||
hasMore?: boolean;
|
||||
nextCursor?: string;
|
||||
};
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Execution history response from n8n_executions
|
||||
export interface ExecutionHistoryData {
|
||||
success: boolean;
|
||||
data?: {
|
||||
executions: {
|
||||
id: string;
|
||||
finished?: boolean;
|
||||
mode?: string;
|
||||
status?: string;
|
||||
startedAt?: string;
|
||||
stoppedAt?: string;
|
||||
workflowId?: string;
|
||||
workflowName?: string;
|
||||
}[];
|
||||
returned?: number;
|
||||
hasMore?: boolean;
|
||||
};
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Health check response from n8n_health_check
|
||||
export interface HealthDashboardData {
|
||||
success: boolean;
|
||||
data?: {
|
||||
status?: string;
|
||||
instanceId?: string;
|
||||
n8nVersion?: string;
|
||||
mcpVersion?: string;
|
||||
apiUrl?: string;
|
||||
versionCheck?: {
|
||||
current?: string;
|
||||
latest?: string;
|
||||
upToDate?: boolean;
|
||||
updateCommand?: string;
|
||||
};
|
||||
performance?: {
|
||||
responseTimeMs?: number;
|
||||
cacheHitRate?: number;
|
||||
};
|
||||
nextSteps?: string[];
|
||||
};
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Matches the validate_node / validate_workflow response format from server.ts
|
||||
export interface ValidationSummaryData {
|
||||
valid: boolean;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
nodeType?: string;
|
||||
displayName?: string;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
suggestions?: string[];
|
||||
nodeType?: string;
|
||||
displayName?: string;
|
||||
summary?: {
|
||||
errorCount?: number;
|
||||
warningCount?: number;
|
||||
hasErrors?: boolean;
|
||||
suggestionCount?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// n8n_validate_workflow wraps result in success/data
|
||||
success?: boolean;
|
||||
data?: {
|
||||
valid?: boolean;
|
||||
workflowId?: string;
|
||||
workflowName?: string;
|
||||
errors?: ValidationError[];
|
||||
warnings?: ValidationWarning[];
|
||||
suggestions?: string[];
|
||||
summary?: {
|
||||
errorCount?: number;
|
||||
warningCount?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user