Compare commits

..

12 Commits

Author SHA1 Message Date
Romuald Członkowski
4bad880f44 fix: defensive JSON.parse for stringified object/array params (#605) (#606)
* chore: update n8n to 2.8.3 and bump version to 2.35.3

- Updated n8n from 2.6.3 to 2.8.3
- Updated n8n-core from 2.6.1 to 2.8.1
- Updated n8n-workflow from 2.6.0 to 2.8.0
- Updated @n8n/n8n-nodes-langchain from 2.6.2 to 2.8.1
- Fixed node loader to bypass restricted package.json exports in
  @n8n/n8n-nodes-langchain >=2.9.0 (resolves via absolute paths)
- Fixed community doc generator for cloud LLMs: added API key env var
  support, switched to max_completion_tokens, auto-omit temperature
- Rebuilt node database with 1,236 nodes (673 n8n-nodes-base,
  133 @n8n/n8n-nodes-langchain, 430 community)
- Refreshed community nodes (361 verified + 69 npm) with 424 AI summaries
- 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: update documentation-generator tests for max_completion_tokens

- Updated test assertions from max_tokens to max_completion_tokens
- Updated testConnection token limit expectation from 10 to 200
- Added temperature to test config to match new configurable behavior

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

* fix: defensive JSON.parse for stringified object/array params (#605)

Claude Desktop 1.1.3189 serializes object/array MCP parameters as JSON
strings, causing ZodError failures for ~60% of tools. Add schema-driven
coercion in the central CallToolRequestSchema handler to detect and parse
them back automatically.

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

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 16:56:25 +01:00
Romuald Członkowski
77048347b3 chore: update n8n to 2.8.3 (#603)
* chore: update n8n to 2.8.3 and bump version to 2.35.3

- Updated n8n from 2.6.3 to 2.8.3
- Updated n8n-core from 2.6.1 to 2.8.1
- Updated n8n-workflow from 2.6.0 to 2.8.0
- Updated @n8n/n8n-nodes-langchain from 2.6.2 to 2.8.1
- Fixed node loader to bypass restricted package.json exports in
  @n8n/n8n-nodes-langchain >=2.9.0 (resolves via absolute paths)
- Fixed community doc generator for cloud LLMs: added API key env var
  support, switched to max_completion_tokens, auto-omit temperature
- Rebuilt node database with 1,236 nodes (673 n8n-nodes-base,
  133 @n8n/n8n-nodes-langchain, 430 community)
- Refreshed community nodes (361 verified + 69 npm) with 424 AI summaries
- 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: update documentation-generator tests for max_completion_tokens

- Updated test assertions from max_tokens to max_completion_tokens
- Updated testConnection token limit expectation from 10 to 200
- Added temperature to test config to match new configurable behavior

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 02:15:15 +01:00
Romuald Członkowski
6f695be482 fix: disable MCP Apps that don't render in Claude.ai (#586)
Disable 3 MCP Apps (workflow-list, execution-history, health-dashboard)
that show as collapsed accordions and remove n8n_deploy_template tool
mapping that renders blank content. The server sets _meta correctly on
the wire but the Claude.ai host ignores it for these tools. Keep the 2
working apps (operation-result, validation-summary) active.

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

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 02:26:40 +01:00
Romuald Członkowski
34159f4ece fix: add legacy flat _meta key for MCP App rendering in Claude (#585)
Claude.ai reads the flat `_meta["ui/resourceUri"]` key to discover UI apps,
not the nested `_meta.ui.resourceUri`. Without the flat key, tools like
n8n_health_check and n8n_list_workflows showed as collapsed accordions
instead of rendering rich UI. Now sets both keys, matching the behavior
of the official registerAppTool helper from @modelcontextprotocol/ext-apps.

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

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 06:40:52 +01:00
Romuald Członkowski
8217229e2f chore: bump version to 2.35.0 and update CHANGELOG (#584)
Conceived by Romuald Członkowski - www.aiadvisors.pl/en

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 04:38:56 +01:00
Romuald Członkowski
89146186d8 feat: UI/UX redesign for MCP Apps - 3 new apps + enhanced existing (#583)
Add workflow-list, execution-history, and health-dashboard apps.
Redesign operation-result with operation-aware headers, detail panels,
and copy-to-clipboard. Fix React hooks violations in validation-summary
and execution-history (useMemo after early returns). Add local preview
harness for development. Update tests for 5-app config.

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

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 03:36:27 +01:00
Romuald Członkowski
c601581714 Fix/mcp app blank UI (#580)
* fix: use official ext-apps useApp hook to fix blank MCP App rendering

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

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

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

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

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

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

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

* chore: bump version to 2.34.5 for npm publish

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 05:40:21 +01:00
Romuald Członkowski
23b90d01a6 fix: align MCP Apps with official ext-apps spec (#574)
* feat: add MCP Apps with rich HTML UIs for tool results

Add MCP Apps infrastructure that allows MCP hosts like Claude Desktop
to render rich HTML UIs alongside tool results via `_meta.ui` and the
MCP resources protocol.

- Server-side UI module (src/mcp/ui/) with UIAppRegistry, tool-to-UI
  mapping, and _meta.ui injection into tool responses
- React + Vite build pipeline (ui-apps/) producing self-contained HTML
  per app using vite-plugin-singlefile
- Operation Result UI for workflow CRUD tools (create, update, delete,
  test, autofix, deploy)
- Validation Summary UI for validation tools (validate_node,
  validate_workflow, n8n_validate_workflow)
- Shared component library (Card, Badge, Expandable) with n8n dark theme
- MCP resources protocol support (ListResources, ReadResource handlers)
- Graceful degradation when ui-apps/dist/ is not built
- 22 unit tests across 3 test files

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

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

* test: improve MCP Apps test coverage and add security hardening

- Expand test suite from 22 to 57 tests across 3 test files
- Add UIAppRegistry.reset() for proper test isolation between tests
- Replace some fs mocks with real temp directory tests in registry
- Add edge case coverage: empty strings, pre-load state, double load,
  malformed URIs, duplicate tool patterns, empty HTML files
- Add regression tests for specific tool-to-UI mappings
- Add URI format consistency validation across all configs
- Improve _meta.ui injection tests with structuredContent coexistence
- Coverage: statements 79.4% -> 80%, lines 79.4% -> 80%

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

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

* fix: align MCP Apps with official ext-apps spec

Update URI scheme from n8n-mcp://ui/ to ui://n8n-mcp/ per MCP spec.
Move _meta.ui.resourceUri to tool definitions (tools/list) instead of
tool call responses. Rewrite UI apps hook to use @modelcontextprotocol/ext-apps
App class instead of window.__MCP_DATA__.

Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 05:16:15 +01:00
47 changed files with 11542 additions and 5683 deletions

View File

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

View File

@@ -7,6 +7,107 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
- **CI: UI apps missing from npm package**: Release pipeline only ran `npm run build` (TypeScript), so `ui-apps/dist/` was never built and excluded from published packages
- Changed build step to `npm run build:all` in `build-and-verify` and `publish-npm` jobs
- Added `ui-apps/dist/` to npm publish staging directory
- Added `ui-apps/dist/**/*` to published package files list
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
## [2.34.1] - 2026-02-07
### Changed
- **MCP Apps: Align with official ext-apps spec** for Claude Desktop/web compatibility
- URI scheme changed from `n8n-mcp://ui/{id}` to `ui://n8n-mcp/{id}` per MCP ext-apps spec
- `_meta.ui.resourceUri` now set on tool definitions (`tools/list`) instead of tool call responses
- `UIMetadata.ui.app` renamed to `UIMetadata.ui.resourceUri`
- Added `_meta` field to `ToolDefinition` type
- Added `UIAppRegistry.injectToolMeta()` method for enriching tool definitions
- UI apps now use `@modelcontextprotocol/ext-apps` `App` class instead of `window.__MCP_DATA__`
- Updated `ReadResource` URI parser to match new `ui://` scheme
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
## [2.34.0] - 2026-02-07
### Added

View File

@@ -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

View File

@@ -5,11 +5,11 @@
[![npm version](https://img.shields.io/npm/v/n8n-mcp.svg)](https://www.npmjs.com/package/n8n-mcp)
[![codecov](https://codecov.io/gh/czlonkowski/n8n-mcp/graph/badge.svg?token=YOUR_TOKEN)](https://codecov.io/gh/czlonkowski/n8n-mcp)
[![Tests](https://img.shields.io/badge/tests-3336%20passing-brightgreen.svg)](https://github.com/czlonkowski/n8n-mcp/actions)
[![n8n version](https://img.shields.io/badge/n8n-2.6.3-orange.svg)](https://github.com/n8n-io/n8n)
[![n8n version](https://img.shields.io/badge/n8n-2.8.3-orange.svg)](https://github.com/n8n-io/n8n)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fczlonkowski%2Fn8n--mcp-green.svg)](https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp)
[![Deploy on Railway](https://railway.com/button.svg)](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

Binary file not shown.

View File

@@ -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

View File

@@ -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"}

View File

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

View File

@@ -1 +1 @@
{"version":3,"file":"node-loader.js","sourceRoot":"","sources":["../../src/loaders/node-loader.ts"],"names":[],"mappings":";;;;;;AAAA,gDAAwB;AAQxB,MAAa,aAAa;IAA1B;QACmB,kBAAa,GAAG;YAC/B,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,gBAAgB,EAAE;YAClD,EAAE,IAAI,EAAE,0BAA0B,EAAE,IAAI,EAAE,0BAA0B,EAAE;SACvE,CAAC;IA0EJ,CAAC;IAxEC,KAAK,CAAC,YAAY;QAChB,MAAM,OAAO,GAAiB,EAAE,CAAC;QAEjC,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,aAAa,EAAE,CAAC;YACrC,IAAI,CAAC;gBACH,OAAO,CAAC,GAAG,CAAC,yBAAyB,GAAG,CAAC,IAAI,SAAS,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC;gBAElE,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,GAAG,CAAC,IAAI,eAAe,CAAC,CAAC;gBACxD,OAAO,CAAC,GAAG,CAAC,WAAW,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,GAAG,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,wBAAwB,CAAC,CAAC;gBACjG,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,WAAW,CAAC,CAAC;gBAC3E,OAAO,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,CAAC;YACzB,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,OAAO,CAAC,KAAK,CAAC,kBAAkB,GAAG,CAAC,IAAI,GAAG,EAAE,KAAK,CAAC,CAAC;YACtD,CAAC;QACH,CAAC;QAED,OAAO,OAAO,CAAC;IACjB,CAAC;IAEO,KAAK,CAAC,gBAAgB,CAAC,WAAmB,EAAE,WAAmB,EAAE,WAAgB;QACvF,MAAM,SAAS,GAAG,WAAW,CAAC,GAAG,IAAI,EAAE,CAAC;QACxC,MAAM,KAAK,GAAiB,EAAE,CAAC;QAG/B,MAAM,SAAS,GAAG,SAAS,CAAC,KAAK,IAAI,EAAE,CAAC;QAExC,IAAI,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,CAAC;YAE7B,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;gBACjC,IAAI,CAAC;oBACH,MAAM,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,WAAW,IAAI,QAAQ,EAAE,CAAC,CAAC;oBAC/D,MAAM,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;oBAGrC,MAAM,aAAa,GAAG,QAAQ,CAAC,KAAK,CAAC,4BAA4B,CAAC,CAAC;oBACnE,MAAM,QAAQ,GAAG,aAAa,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,cAAI,CAAC,QAAQ,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;oBAGxF,MAAM,SAAS,GAAG,UAAU,CAAC,OAAO,IAAI,UAAU,CAAC,QAAQ,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;oBAC7F,IAAI,SAAS,EAAE,CAAC;wBACd,KAAK,CAAC,IAAI,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC,CAAC;wBACjD,OAAO,CAAC,GAAG,CAAC,cAAc,QAAQ,SAAS,WAAW,EAAE,CAAC,CAAC;oBAC5D,CAAC;yBAAM,CAAC;wBACN,OAAO,CAAC,IAAI,CAAC,iCAAiC,QAAQ,OAAO,WAAW,EAAE,CAAC,CAAC;oBAC9E,CAAC;gBACH,CAAC;gBAAC,OAAO,KAAK,EAAE,CAAC;oBACf,OAAO,CAAC,KAAK,CAAC,gCAAgC,WAAW,IAAI,QAAQ,GAAG,EAAG,KAAe,CAAC,OAAO,CAAC,CAAC;gBACtG,CAAC;YACH,CAAC;QACH,CAAC;aAAM,CAAC;YAEN,KAAK,MAAM,CAAC,QAAQ,EAAE,QAAQ,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,CAAC;gBAC7D,IAAI,CAAC;oBACH,MAAM,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,WAAW,IAAI,QAAkB,EAAE,CAAC,CAAC;oBACzE,MAAM,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;oBAGrC,MAAM,SAAS,GAAG,UAAU,CAAC,OAAO,IAAI,UAAU,CAAC,QAAQ,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;oBAC7F,IAAI,SAAS,EAAE,CAAC;wBACd,KAAK,CAAC,IAAI,CAAC,EAAE,WAAW,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC,CAAC;wBACjD,OAAO,CAAC,GAAG,CAAC,cAAc,QAAQ,SAAS,WAAW,EAAE,CAAC,CAAC;oBAC5D,CAAC;yBAAM,CAAC;wBACN,OAAO,CAAC,IAAI,CAAC,iCAAiC,QAAQ,OAAO,WAAW,EAAE,CAAC,CAAC;oBAC9E,CAAC;gBACH,CAAC;gBAAC,OAAO,KAAK,EAAE,CAAC;oBACf,OAAO,CAAC,KAAK,CAAC,2BAA2B,QAAQ,SAAS,WAAW,GAAG,EAAG,KAAe,CAAC,OAAO,CAAC,CAAC;gBACtG,CAAC;YACH,CAAC;QACH,CAAC;QAED,OAAO,KAAK,CAAC;IACf,CAAC;CACF;AA9ED,sCA8EC"}
{"version":3,"file":"node-loader.js","sourceRoot":"","sources":["../../src/loaders/node-loader.ts"],"names":[],"mappings":"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"}

View File

@@ -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;IA8VrB,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"}

7
dist/mcp/server.js vendored
View File

@@ -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) => {
@@ -529,10 +530,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) {
@@ -585,7 +582,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}`);
}

File diff suppressed because one or more lines are too long

View File

@@ -30,6 +30,11 @@ export interface ToolDefinition {
additionalProperties?: boolean | Record<string, any>;
};
annotations?: ToolAnnotations;
_meta?: {
ui?: {
resourceUri?: string;
};
};
}
export interface ResourceDefinition {
uri: string;

View File

@@ -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"}

14515
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "n8n-mcp",
"version": "2.34.0",
"version": "2.35.4",
"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.8.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.8.3",
"n8n-core": "^2.8.1",
"n8n-workflow": "^2.8.0",
"openai": "^4.77.0",
"sql.js": "^1.13.0",
"tslib": "^2.6.2",

View File

@@ -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 } : {}),
});
}

View File

@@ -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;
}
}

View File

@@ -651,6 +651,7 @@ export class N8NDocumentationMCPServer {
});
});
UIAppRegistry.injectToolMeta(tools);
return { tools };
});
@@ -719,7 +720,12 @@ export class N8NDocumentationMCPServer {
});
}
}
// Workaround for Claude Desktop 1.1.3189 string serialization bug.
// The MCP client serializes object/array parameters as JSON strings.
// Use the tool's inputSchema to detect and parse them back.
processedArgs = this.coerceStringifiedJsonParams(name, processedArgs);
try {
logger.debug(`Executing tool: ${name}`, { args: processedArgs });
const startTime = Date.now();
@@ -781,12 +787,6 @@ export class N8NDocumentationMCPServer {
mcpResponse.structuredContent = structuredContent;
}
// Inject UI app metadata if available
const uiApp = UIAppRegistry.getAppForTool(name);
if (uiApp && uiApp.html) {
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
}
return mcpResponse;
} catch (error) {
logger.error(`Error executing tool ${name}`, error);
@@ -857,8 +857,8 @@ export class N8NDocumentationMCPServer {
// Handle ReadResource for UI apps
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const uri = request.params.uri;
// Parse n8n-mcp://ui/{id} pattern
const match = uri.match(/^n8n-mcp:\/\/ui\/(.+)$/);
// Parse ui://n8n-mcp/{id} pattern
const match = uri.match(/^ui:\/\/n8n-mcp\/(.+)$/);
if (!match) {
throw new Error(`Unknown resource URI: ${uri}`);
}
@@ -1130,6 +1130,50 @@ export class N8NDocumentationMCPServer {
return true;
}
/**
* Coerce stringified JSON parameters back to objects/arrays.
* Workaround for Claude Desktop 1.1.3189 which serializes object/array
* params as JSON strings before sending them to MCP servers.
*/
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 };
for (const [key, value] of Object.entries(coerced)) {
if (typeof value !== 'string') continue;
const expectedType = (properties as any)[key]?.type;
if (expectedType !== 'object' && expectedType !== 'array') continue;
const trimmed = value.trim();
const validPrefix = (expectedType === 'object' && trimmed.startsWith('{'))
|| (expectedType === 'array' && trimmed.startsWith('['));
if (!validPrefix) continue;
try {
const parsed = JSON.parse(trimmed);
const isArray = Array.isArray(parsed);
if ((expectedType === 'object' && typeof parsed === 'object' && !isArray)
|| (expectedType === 'array' && isArray)) {
coerced[key] = parsed;
logger.warn(`Coerced stringified ${expectedType} param "${key}" for tool "${toolName}"`);
}
} catch {
// Not valid JSON — keep original string, downstream validation will report the error
}
}
return coerced;
}
async executeTool(name: string, args: any): Promise<any> {
// Ensure args is an object and validate it
args = args || {};

View File

@@ -5,8 +5,8 @@ export const UI_APP_CONFIGS: UIAppConfig[] = [
id: 'operation-result',
displayName: 'Operation Result',
description: 'Visual summary of workflow operations (create, update, delete, test)',
uri: 'n8n-mcp://ui/operation-result',
mimeType: 'text/html',
uri: 'ui://n8n-mcp/operation-result',
mimeType: 'text/html;profile=mcp-app',
toolPatterns: [
'n8n_create_workflow',
'n8n_update_full_workflow',
@@ -14,19 +14,23 @@ 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
],
},
{
id: 'validation-summary',
displayName: 'Validation Summary',
description: 'Visual summary of node and workflow validation results',
uri: 'n8n-mcp://ui/validation-summary',
mimeType: 'text/html',
uri: 'ui://n8n-mcp/validation-summary',
mimeType: 'text/html;profile=mcp-app',
toolPatterns: [
'validate_node',
'validate_workflow',
'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.
];

View File

@@ -60,6 +60,27 @@ export class UIAppRegistry {
return Array.from(this.entries.values());
}
/**
* Enrich tool definitions with _meta.ui.resourceUri for tools that have
* a matching UI app. Per MCP ext-apps spec, this goes on the tool
* definition (tools/list), not the tool call response.
*
* 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 },
'ui/resourceUri': entry.config.uri,
};
}
}
}
/** Reset registry state. Intended for testing only. */
static reset(): void {
this.entries.clear();

View File

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

View File

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

View File

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

View File

@@ -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',

View File

@@ -0,0 +1,207 @@
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('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);
});
});
});

View File

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

View File

@@ -11,7 +11,7 @@ import { existsSync, readFileSync } from 'fs';
const mockExistsSync = vi.mocked(existsSync);
const mockReadFileSync = vi.mocked(readFileSync);
describe('UI Meta Injection Logic', () => {
describe('UI Meta Injection on Tool Definitions', () => {
beforeEach(() => {
vi.clearAllMocks();
UIAppRegistry.reset();
@@ -24,74 +24,69 @@ describe('UI Meta Injection Logic', () => {
UIAppRegistry.load();
});
it('should add _meta.ui for matching tools', () => {
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
expect(uiApp).not.toBeNull();
expect(uiApp!.html).not.toBeNull();
it('should add _meta.ui.resourceUri to matching tool definitions', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create workflow', inputSchema: { type: 'object', properties: {} } },
];
// Simulate the injection logic from server.ts
const mcpResponse: any = {
content: [{ type: 'text', text: 'result' }],
};
UIAppRegistry.injectToolMeta(tools);
if (uiApp && uiApp.html) {
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
}
expect(mcpResponse._meta).toBeDefined();
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/operation-result');
expect(tools[0]._meta).toBeDefined();
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
});
it('should add _meta.ui for validation tools', () => {
const uiApp = UIAppRegistry.getAppForTool('validate_workflow');
expect(uiApp).not.toBeNull();
it('should add _meta.ui.resourceUri to validation tool definitions', () => {
const tools: any[] = [
{ name: 'validate_workflow', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
];
const mcpResponse: any = {
content: [{ type: 'text', text: 'validation result' }],
};
UIAppRegistry.injectToolMeta(tools);
if (uiApp && uiApp.html) {
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
}
expect(mcpResponse._meta).toBeDefined();
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/validation-summary');
expect(tools[0]._meta).toBeDefined();
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary');
});
it('should NOT add _meta.ui for non-matching tools', () => {
const uiApp = UIAppRegistry.getAppForTool('get_node_info');
expect(uiApp).toBeNull();
it('should NOT add _meta to non-matching tool definitions', () => {
const tools: any[] = [
{ name: 'get_node_info', description: 'Get info', inputSchema: { type: 'object', properties: {} } },
];
const mcpResponse: any = {
content: [{ type: 'text', text: 'node info' }],
};
UIAppRegistry.injectToolMeta(tools);
if (uiApp && uiApp.html) {
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
}
expect(mcpResponse._meta).toBeUndefined();
expect(tools[0]._meta).toBeUndefined();
});
it('should produce _meta with exact shape { ui: { app: string } }', () => {
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow')!;
const meta = { ui: { app: uiApp.config.uri } };
it('should inject _meta on matching tools and skip non-matching in a mixed list', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
{ name: 'get_node_info', description: 'Info', inputSchema: { type: 'object', properties: {} } },
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
];
expect(meta).toEqual({
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeDefined();
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
expect(tools[1]._meta).toBeUndefined();
expect(tools[2]._meta).toBeDefined();
expect(tools[2]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary');
});
it('should produce _meta with both nested and flat resourceUri keys', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toEqual({
ui: {
app: 'n8n-mcp://ui/operation-result',
resourceUri: 'ui://n8n-mcp/operation-result',
},
'ui/resourceUri': 'ui://n8n-mcp/operation-result',
});
expect(Object.keys(meta)).toEqual(['ui']);
expect(Object.keys(meta.ui)).toEqual(['app']);
expect(typeof meta.ui.app).toBe('string');
});
it('should produce _meta.ui.app that matches the config uri', () => {
const uiApp = UIAppRegistry.getAppForTool('validate_node')!;
const meta = { ui: { app: uiApp.config.uri } };
expect(meta.ui.app).toBe(uiApp.config.uri);
expect(meta.ui.app).toBe('n8n-mcp://ui/validation-summary');
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
expect(tools[0]._meta['ui/resourceUri']).toBe('ui://n8n-mcp/operation-result');
});
});
@@ -101,114 +96,50 @@ describe('UI Meta Injection Logic', () => {
UIAppRegistry.load();
});
it('should NOT add _meta.ui even for matching tools', () => {
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
expect(uiApp).not.toBeNull();
expect(uiApp!.html).toBeNull();
it('should NOT add _meta even for matching tools', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
const mcpResponse: any = {
content: [{ type: 'text', text: 'result' }],
};
UIAppRegistry.injectToolMeta(tools);
if (uiApp && uiApp.html) {
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
}
expect(mcpResponse._meta).toBeUndefined();
expect(tools[0]._meta).toBeUndefined();
});
it('should NOT add _meta.ui for validation tools without HTML', () => {
const uiApp = UIAppRegistry.getAppForTool('validate_node');
expect(uiApp).not.toBeNull();
expect(uiApp!.html).toBeNull();
it('should NOT add _meta for validation tools without HTML', () => {
const tools: any[] = [
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
];
const mcpResponse: any = {
content: [{ type: 'text', text: 'result' }],
};
UIAppRegistry.injectToolMeta(tools);
if (uiApp && uiApp.html) {
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
}
expect(mcpResponse._meta).toBeUndefined();
expect(tools[0]._meta).toBeUndefined();
});
});
describe('when registry has not been loaded at all', () => {
it('should NOT add _meta because getAppForTool returns null', () => {
// Registry never loaded - reset() was called in beforeEach
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
expect(uiApp).toBeNull();
it('should NOT add _meta because registry is not loaded', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
const mcpResponse: any = {
content: [{ type: 'text', text: 'result' }],
};
UIAppRegistry.injectToolMeta(tools);
if (uiApp && uiApp.html) {
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
}
expect(mcpResponse._meta).toBeUndefined();
expect(tools[0]._meta).toBeUndefined();
});
});
describe('coexistence with structuredContent', () => {
describe('empty tool list', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>ui</html>');
UIAppRegistry.load();
});
it('should coexist with structuredContent on the response', () => {
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
const mcpResponse: any = {
content: [{ type: 'text', text: 'result' }],
structuredContent: { workflowId: '123', status: 'created' },
};
if (uiApp && uiApp.html) {
mcpResponse._meta = { ui: { app: uiApp.config.uri } };
}
expect(mcpResponse.structuredContent).toBeDefined();
expect(mcpResponse.structuredContent.workflowId).toBe('123');
expect(mcpResponse._meta).toBeDefined();
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/operation-result');
});
it('should not overwrite existing _meta properties when merging', () => {
const uiApp = UIAppRegistry.getAppForTool('n8n_create_workflow');
const mcpResponse: any = {
content: [{ type: 'text', text: 'result' }],
_meta: { existingProp: 'value' },
};
if (uiApp && uiApp.html) {
mcpResponse._meta = { ...mcpResponse._meta, ui: { app: uiApp.config.uri } };
}
expect(mcpResponse._meta.existingProp).toBe('value');
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/operation-result');
});
it('should work with responses that have both structuredContent and existing _meta', () => {
const uiApp = UIAppRegistry.getAppForTool('validate_workflow');
const mcpResponse: any = {
content: [{ type: 'text', text: 'validation ok' }],
structuredContent: { valid: true, errors: [] },
_meta: { timing: 42 },
};
if (uiApp && uiApp.html) {
mcpResponse._meta = { ...mcpResponse._meta, ui: { app: uiApp.config.uri } };
}
expect(mcpResponse.structuredContent.valid).toBe(true);
expect(mcpResponse._meta.timing).toBe(42);
expect(mcpResponse._meta.ui.app).toBe('n8n-mcp://ui/validation-summary');
it('should handle an empty tools array without error', () => {
const tools: any[] = [];
UIAppRegistry.injectToolMeta(tools);
expect(tools.length).toBe(0);
});
});
});

View File

@@ -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', () => {
@@ -287,6 +290,75 @@ describe('UIAppRegistry', () => {
});
});
describe('injectToolMeta()', () => {
it('should not modify tools before load() is called', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeUndefined();
});
describe('after loading with HTML', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>loaded</html>');
UIAppRegistry.load();
});
it('should set _meta.ui.resourceUri on matching operation tools', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toEqual({ ui: { resourceUri: 'ui://n8n-mcp/operation-result' }, 'ui/resourceUri': 'ui://n8n-mcp/operation-result' });
});
it('should set _meta.ui.resourceUri on matching validation tools', () => {
const tools: any[] = [
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toEqual({ ui: { resourceUri: 'ui://n8n-mcp/validation-summary' }, 'ui/resourceUri': 'ui://n8n-mcp/validation-summary' });
});
it('should not set _meta on tools without a matching UI app', () => {
const tools: any[] = [
{ name: 'search_nodes', description: 'Search', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeUndefined();
});
it('should handle a mix of matching and non-matching tools', () => {
const tools: any[] = [
{ name: 'n8n_delete_workflow', description: 'Delete', inputSchema: { type: 'object', properties: {} } },
{ name: 'get_node_essentials', description: 'Essentials', inputSchema: { type: 'object', properties: {} } },
{ name: 'validate_workflow', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta?.ui?.resourceUri).toBe('ui://n8n-mcp/operation-result');
expect(tools[1]._meta).toBeUndefined();
expect(tools[2]._meta?.ui?.resourceUri).toBe('ui://n8n-mcp/validation-summary');
});
});
describe('after loading without HTML', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
});
it('should not set _meta when HTML is not available', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeUndefined();
});
});
});
describe('reset()', () => {
it('should clear loaded state so getters return defaults', () => {
mockExistsSync.mockReturnValue(true);

View File

@@ -5,12 +5,16 @@
"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": {
"@modelcontextprotocol/ext-apps": "^1.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},

357
ui-apps/preview.html Normal file
View 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>

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

View 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>

View 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 />);
}

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

View 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>

View 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 />);
}

View File

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

View File

@@ -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>

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

View 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>

View 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 />);
}

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

View File

@@ -1,3 +1,4 @@
export { Card } from './Card';
export { Badge } from './Badge';
export { Expandable } from './Expandable';
export { CopyButton } from './CopyButton';

View File

@@ -1,31 +1,50 @@
import { useState, useEffect } from 'react';
import { useState, useCallback } from 'react';
import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react';
import type { App } from '@modelcontextprotocol/ext-apps/react';
declare global {
interface Window {
__MCP_DATA__?: unknown;
}
interface UseToolDataResult<T> {
data: T | null;
error: string | null;
isConnected: boolean;
app: App | null;
toolName: string | null;
}
export function useToolData<T>(): T | null {
export function useToolData<T>(): UseToolDataResult<T> {
const [data, setData] = useState<T | null>(null);
useEffect(() => {
// Try window.__MCP_DATA__ first (injected by host)
if (window.__MCP_DATA__) {
setData(window.__MCP_DATA__ as T);
return;
}
// Try embedded script tag
const scriptEl = document.getElementById('mcp-data');
if (scriptEl?.textContent) {
try {
setData(JSON.parse(scriptEl.textContent) as T);
} catch {
// Ignore parse errors
const onAppCreated = useCallback((app: App) => {
app.ontoolresult = (result) => {
if (result?.content) {
const textItem = Array.isArray(result.content)
? result.content.find((c) => c.type === 'text')
: null;
if (textItem && 'text' in textItem) {
try {
setData(JSON.parse(textItem.text) as T);
} catch {
setData(textItem.text as unknown as T);
}
}
}
}
};
}, []);
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,
};
}

View File

@@ -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;
}

View File

@@ -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;
};
};
}