Compare commits

..

17 Commits

Author SHA1 Message Date
czlonkowski
3365878d03 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>
2026-02-20 08:41:17 +08:00
czlonkowski
07f8be8995 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>
2026-02-20 07:23:42 +08: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
Romuald Członkowski
1f45cc6dcc feat: MCP Apps - rich HTML UIs for tool results (#573)
* 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>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 04:11:21 +01:00
Romuald Członkowski
6814880410 chore: update n8n to 2.6.3 and bump version to 2.33.6 (#571) 2026-02-06 09:09:37 +01:00
Romuald Członkowski
c8c76e435d fix: critical memory leak from per-session database connections (#554)
* fix: critical memory leak from per-session database connections (#542)

Each MCP session was creating its own database connection (~900MB),
causing OOM kills every ~20 minutes with 3-4 concurrent sessions.

Changes:
- Add SharedDatabase singleton pattern - all sessions share ONE connection
- Reduce session timeout from 30 min to 5 min (configurable)
- Add eager cleanup for reconnecting instances
- Fix telemetry event listener leak

Memory impact: ~900MB/session → ~68MB shared + ~5MB/session overhead

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

* fix: resolve test failures from shared database race conditions

- Fix `shutdown()` to respect shared database pattern (was directly closing)
- Add `await this.initialized` in both `close()` and `shutdown()` to prevent
  race condition where cleanup runs while initialization is in progress
- Add double-shutdown protection with `isShutdown` flag
- Export `SharedDatabaseState` type for proper typing
- Include error details in debug logs
- Add MCP server close to `shutdown()` for consistency with `close()`
- Null out `earlyLogger` in `shutdown()` for consistency

The CI test failure "The database connection is not open" was caused by:
1. `shutdown()` directly calling `this.db.close()` which closed the SHARED
   database connection, breaking subsequent tests
2. Race condition where `shutdown()` ran before initialization completed

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

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

* test: add unit tests for shared-database module

Add comprehensive unit tests covering:
- getSharedDatabase: initialization, reuse, different path error, concurrent requests
- releaseSharedDatabase: refCount decrement, double-release guard
- closeSharedDatabase: state clearing, error handling, re-initialization
- Helper functions: isSharedDatabaseInitialized, getSharedDatabaseRefCount

21 tests covering the singleton database connection pattern used to prevent
~900MB memory leaks per session.

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:51:22 +01:00
Romuald Członkowski
fad3437977 fix: memory leak in SSE session reset (#542) (#544)
When SSE sessions are recreated every 5 minutes, the old session's MCP
server was not being closed, causing:
- SimpleCache cleanup timer continuing to run indefinitely
- Database connections remaining open
- Cached data (~50-100MB per session) persisting in memory

Added server.close() call before transport.close() in resetSessionSSE(),
mirroring the existing cleanup pattern in removeSession().

Fixes #542

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:56:16 +01:00
Romuald Członkowski
0f15b82f1e chore: update n8n to 2.4.4 (#543)
* chore: update n8n to 2.4.4 and bump version to 2.33.3

- Updated n8n from 2.2.3 to 2.4.4
- Updated n8n-core from 2.2.2 to 2.4.2
- Updated n8n-workflow from 2.2.2 to 2.4.2
- Updated @n8n/n8n-nodes-langchain from 2.2.2 to 2.4.3
- Added new `icon` NodePropertyType (now 23 types total)
- Rebuilt node database with 803 nodes (541 from n8n-nodes-base, 262 from @n8n/n8n-nodes-langchain)
- Updated README badge with new n8n version
- 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 <noreply@anthropic.com>

* fix: update n8n-workflow version in Dockerfile for icon type support

The Docker build was using n8n-workflow@^1.96.0 which doesn't have the new
'icon' NodePropertyType. Updated to n8n-workflow@^2.4.2 to match the project's
package.json version.

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

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

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

* fix: update comments to reflect 23 NodePropertyTypes

- Updated test comment from '22 standard types' to '23 standard types'
- Updated header comment from n8n-workflow v1.120.3 to v2.4.2

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-21 11:22:26 +01:00
89 changed files with 21968 additions and 3622 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));
"

4
.gitignore vendored
View File

@@ -138,5 +138,9 @@ n8n-mcp-wrapper.sh
# MCP configuration files
.mcp.json
# UI Apps build output
ui-apps/dist/
ui-apps/node_modules/
# Telemetry configuration (user-specific)
~/.n8n-mcp/

View File

@@ -7,6 +7,215 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
- **MCP Apps**: Rich HTML UIs rendered by MCP hosts alongside tool results via `_meta.ui` and the MCP resources protocol
- Server-side UI module (`src/mcp/ui/`) with tool-to-UI mapping and `_meta.ui` injection
- `UIAppRegistry` static class for loading and serving self-contained HTML apps
- `UI_APP_CONFIGS` mapping tools to their corresponding UI apps
- **Operation Result UI**: Visual summary for workflow operation tools
- Status badge, operation type, workflow details card
- Expandable sections for nodes added, modified, and removed
- Mapped to: `n8n_create_workflow`, `n8n_update_full_workflow`, `n8n_update_partial_workflow`, `n8n_delete_workflow`, `n8n_test_workflow`, `n8n_autofix_workflow`, `n8n_deploy_template`
- **Validation Summary UI**: Visual summary for validation tools
- Valid/invalid badge with error and warning counts
- Expandable error list with type, property, message, and fix
- Expandable warning list and suggestions
- Mapped to: `validate_node`, `validate_workflow`, `n8n_validate_workflow`
- **React + Vite Build Pipeline** (`ui-apps/`):
- React 19, Vite 6, vite-plugin-singlefile for self-contained HTML output
- Shared component library: Card, Badge, Expandable
- `useToolData` hook for reading data from `window.__MCP_DATA__` or embedded JSON
- n8n-branded dark theme with CSS custom properties
- Per-app builds via `APP_NAME` environment variable
- **MCP Resources Protocol**: Server now exposes `resources` capability
- `ListResources` handler returns available UI apps
- `ReadResource` handler serves self-contained HTML via `n8n-mcp://ui/{id}` URIs
- **New Scripts**:
- `build:ui`: Build UI apps (`cd ui-apps && npm install && npm run build`)
- `build:all`: Build UI apps then server (`npm run build:ui && npm run build`)
### Changed
- **MCP Server**: Added `resources: {}` to server capabilities alongside existing `tools: {}`
- **Tool Responses**: Tools with matching UI apps now include `_meta.ui.app` URI pointing to their visual representation
- **Graceful Degradation**: Server starts and operates normally without `ui-apps/dist/`; UI metadata is only injected when HTML is available
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
## [2.33.6] - 2026-02-06
### Changed
- Updated n8n from 2.4.4 to 2.6.3
- Updated n8n-core from 2.4.2 to 2.6.1
- Updated n8n-workflow from 2.4.2 to 2.6.0
- Updated @n8n/n8n-nodes-langchain from 2.4.3 to 2.6.2
- Rebuilt node database with 806 nodes (544 from n8n-nodes-base, 262 from @n8n/n8n-nodes-langchain)
- Updated README badge with new n8n version
## [2.33.5] - 2026-01-23
### Fixed
- **Critical memory leak: per-session database connections** (Issue #542): Fixed severe memory leak where each MCP session created its own database connection (~900MB per session)
- Root cause: `N8NDocumentationMCPServer` called `createDatabaseAdapter()` for every new session, duplicating the entire 68MB database in memory
- With 3-4 sessions, memory would exceed 4GB causing OOM kills every ~20 minutes
- Fix: Implemented singleton `SharedDatabase` pattern - all sessions now share ONE database connection
- Memory impact: Reduced from ~900MB per session to ~68MB total (shared) + ~5MB per session overhead
- Added `getSharedDatabase()` and `releaseSharedDatabase()` for thread-safe connection management
- Added reference counting to track active sessions using the shared connection
- **Session timeout optimization**: Reduced default session timeout from 30 minutes to 5 minutes
- Faster cleanup of stale sessions reduces memory buildup
- Configurable via `SESSION_TIMEOUT_MINUTES` environment variable
- **Eager instance cleanup**: When a client reconnects, previous sessions for the same instanceId are now immediately cleaned up
- Prevents memory accumulation from reconnecting clients in multi-tenant deployments
- **Telemetry event listener leak**: Fixed event listeners in `TelemetryBatchProcessor` that were never removed
- Added proper cleanup in `stop()` method
- Added guard against multiple `start()` calls
### Added
- **New module: `src/database/shared-database.ts`** - Singleton database manager
- `getSharedDatabase(dbPath)`: Thread-safe initialization with promise lock pattern
- `releaseSharedDatabase(state)`: Reference counting for cleanup
- `closeSharedDatabase()`: Graceful shutdown for process termination
- `isSharedDatabaseInitialized()` and `getSharedDatabaseRefCount()`: Monitoring helpers
### Changed
- **`N8NDocumentationMCPServer.close()`**: Now releases shared database reference instead of closing the connection
- **`SingleSessionHTTPServer.shutdown()`**: Calls `closeSharedDatabase()` during graceful shutdown
## [2.33.4] - 2026-01-21
### Fixed
- **Memory leak in SSE session reset** (Issue #542): Fixed memory leak when SSE sessions are recreated every 5 minutes
- Root cause: `resetSessionSSE()` only closed the transport but not the MCP server
- This left the SimpleCache cleanup timer (60-second interval) running indefinitely
- Database connections and cached data (~50-100MB per session) persisted in memory
- Fix: Added `server.close()` call before `transport.close()`, mirroring the existing cleanup pattern in `removeSession()`
- Impact: Prevents ~288 leaked server instances per day in long-running HTTP deployments
## [2.33.3] - 2026-01-21
### Changed
- **Updated n8n dependencies to latest versions**
- n8n: 2.3.3 → 2.4.4
- n8n-core: 2.3.2 → 2.4.2
- n8n-workflow: 2.3.2 → 2.4.2
- @n8n/n8n-nodes-langchain: 2.3.2 → 2.4.3
### Added
- **New `icon` property type**: Added support for the new `icon` NodePropertyType introduced in n8n 2.4.x
- Added type structure definition in `src/constants/type-structures.ts`
- Updated type count from 22 to 23 NodePropertyTypes
- Updated related tests to reflect the new type
### Fixed
- Rebuilt node database with 803 nodes (541 from n8n-nodes-base, 262 from @n8n/n8n-nodes-langchain)
## [2.33.2] - 2026-01-13
### Changed
@@ -90,163 +299,628 @@ N8N_MCP_LLM_TIMEOUT=60000 # Request timeout
**Statistics:**
- 538/547 community nodes have README content
- 537/547 community nodes have AI-generated summaries
- 537/547 community nodes have AI summaries
- Generation takes ~30 min for all nodes with local LLM
## [2.32.1] - 2026-01-08
### Fixed
- **Community node case sensitivity bug**: Fixed `extractNodeNameFromPackage` to use lowercase node names, matching n8n's community node convention (e.g., `chatwoot` instead of `Chatwoot`). This resolves validation failures for community nodes with incorrect casing.
- **Case-insensitive node lookup**: Added fallback in `getNode` to handle case differences between stored and requested node types for better robustness.
- **Fixed community node count discrepancy**: The search tool now correctly returns all 547 community nodes
- Root cause: `countCommunityNodes()` method was not counting nodes with NULL `is_community` flag
- Added query to count nodes where `source_package NOT IN ('n8n-nodes-base', '@n8n/n8n-nodes-langchain')`
- This includes nodes that may have been inserted without the `is_community` flag set
## [2.32.0] - 2026-01-07
## [2.32.0] - 2026-01-08
### Added
**Community Nodes Support (Issues #23, #490)**
- **Community Node Search Integration**: Added `source` filter to `search_nodes` tool
- Filter by `"core"` for official n8n nodes (n8n-nodes-base + langchain)
- Filter by `"community"` for verified community integrations
- Filter by `"all"` (default) for all nodes
- Example: `search_nodes({ query: "google", source: "community" })`
Added comprehensive support for n8n community nodes, expanding the node database from 537 core nodes to 1,084 total nodes (537 core + 547 community).
**New Features:**
- **547 community nodes** indexed (301 verified + 246 popular npm packages)
- **`source` filter** for `search_nodes`: Filter by `all`, `core`, `community`, or `verified`
- **Community metadata** in search results: `isCommunity`, `isVerified`, `authorName`, `npmDownloads`
- **Full schema support** for verified community nodes (no additional parsing needed)
**Data Sources:**
- Verified nodes fetched from n8n Strapi API (`api.n8n.io/api/community-nodes`)
- Popular npm packages from npm registry (keyword: `n8n-community-node-package`)
**New CLI Commands:**
```bash
npm run fetch:community # Full rebuild (verified + top 100 npm)
npm run fetch:community:verified # Verified nodes only (fast)
npm run fetch:community:update # Incremental update (skip existing)
```
**Example Usage:**
```javascript
// Search only community nodes
search_nodes({query: "scraping", source: "community"})
// Search verified community nodes
search_nodes({query: "pdf", source: "verified"})
// Results include community metadata
{
nodeType: "n8n-nodes-brightdata.brightData",
displayName: "BrightData",
isCommunity: true,
isVerified: true,
authorName: "brightdata.com",
npmDownloads: 1234
}
```
**Files Added:**
- `src/community/community-node-service.ts` - Business logic for syncing community nodes
- `src/community/community-node-fetcher.ts` - API integration for Strapi and npm
- `src/scripts/fetch-community-nodes.ts` - CLI script for fetching community nodes
**Files Modified:**
- `src/database/schema.sql` - Added community columns and indexes
- `src/database/node-repository.ts` - Extended for community node fields
- `src/mcp/tools.ts` - Added `source` parameter to `search_nodes`
- `src/mcp/server.ts` - Added source filtering and community metadata to results
- `src/mcp/tool-docs/discovery/search-nodes.ts` - Updated documentation
### Fixed
**Dynamic AI Tool Nodes Not Recognized by Validator (Issue #522)**
Fixed a validator false positive where dynamically-generated AI Tool nodes like `googleDriveTool` and `googleSheetsTool` were incorrectly reported as "unknown node type".
**Root Cause:** n8n creates Tool variants at runtime when ANY node is connected to an AI Agent's tool slot (e.g., `googleDrive``googleDriveTool`). These dynamic nodes don't exist in npm packages, so the MCP database couldn't discover them during rebuild.
**Solution:** Added validation-time inference that checks if the base node exists when a `*Tool` node type is not found. If the base node exists, the Tool variant is treated as valid with an informative warning.
**Changes:**
- `workflow-validator.ts`: Added inference logic for dynamic Tool variants
- `node-similarity-service.ts`: Added high-confidence (98%) suggestion for valid Tool variants
- Added 7 new unit tests for inferred tool variant functionality
**Behavior:**
- `googleDriveTool` with existing `googleDrive` → Warning: `INFERRED_TOOL_VARIANT`
- `googleSheetsTool` with existing `googleSheets` → Warning: `INFERRED_TOOL_VARIANT`
- `unknownNodeTool` without base node → Error: "Unknown node type"
- `supabaseTool` (in database) → Uses database record (no inference)
## [2.31.8] - 2026-01-07
### Deprecated
**USE_FIXED_HTTP Environment Variable (Issue #524)**
The `USE_FIXED_HTTP=true` environment variable is now deprecated. The fixed HTTP implementation does not support SSE (Server-Sent Events) streaming required by clients like OpenAI Codex.
**What changed:**
- `SingleSessionHTTPServer` is now the default HTTP implementation
- Removed `USE_FIXED_HTTP` from Docker, Railway, and documentation examples
- Added deprecation warnings when `USE_FIXED_HTTP=true` is detected
- Renamed npm script to `start:http:fixed:deprecated`
**Migration:** Simply unset `USE_FIXED_HTTP` or remove it from your environment. The `SingleSessionHTTPServer` supports both JSON-RPC and SSE streaming automatically.
**Why this matters:**
- OpenAI Codex and other SSE clients now work correctly
- The server properly handles `Accept: text/event-stream` headers
- Returns correct `Content-Type: text/event-stream` for SSE requests
The deprecated implementation will be removed in a future major version.
## [2.31.7] - 2026-01-06
- **Community Node Statistics**: Added community node counts to search results
- Shows `communityNodeCount` in search results when searching all sources
- Indicates how many results come from verified community packages
### Changed
- Updated n8n from 2.1.5 to 2.2.3
- Updated n8n-core from 2.1.4 to 2.2.2
- Updated n8n-workflow from 2.1.1 to 2.2.2
- Updated @n8n/n8n-nodes-langchain from 2.1.4 to 2.2.2
- Rebuilt node database with 540 nodes (434 from n8n-nodes-base, 106 from @n8n/n8n-nodes-langchain)
- **Search Results Enhancement**: Search results now include source information
- Each result shows whether it's from core or community packages
- Helps users identify and discover community integrations
## [2.31.6] - 2026-01-03
### Technical Details
### Changed
- Added `source` parameter to `searchNodes()` method in NodeRepository
- Updated `search_nodes` tool schema with new `source` parameter
- Community nodes identified by `is_community=1` flag in database
- 547 verified community nodes available from 301 npm packages
**Dependencies Update**
- Updated n8n from 2.1.4 to 2.1.5
- Updated n8n-core from 2.1.3 to 2.1.4
- Updated @n8n/n8n-nodes-langchain from 2.1.3 to 2.1.4
- Rebuilt node database with 540 nodes (434 from n8n-nodes-base, 106 from @n8n/n8n-nodes-langchain)
## [2.31.5] - 2026-01-02
## [2.31.0] - 2026-01-08
### Added
**MCP Tool Annotations (PR #512)**
- **Community Node Support**: Full integration of verified n8n community nodes
- Added 547 verified community nodes from 301 npm packages
- Automatic fetching from n8n's verified integrations API
- NPM package metadata extraction (version, downloads, repository)
- Node property extraction via tarball analysis
- CLI commands: `npm run fetch:community`, `npm run fetch:community:rebuild`
Added MCP tool annotations to all 20 tools following the [MCP specification](https://spec.modelcontextprotocol.io/specification/2025-03-26/server/tools/#annotations). These annotations help AI assistants understand tool behavior and capabilities.
- **Database Schema Updates**:
- Added `is_community` boolean flag for community node identification
- Added `npm_package_name` for npm registry reference
- Added `npm_version` for installed package version
- Added `npm_downloads` for weekly download counts
- Added `npm_repository` for GitHub/source links
- Added unique constraint `idx_nodes_unique_type` on `node_type`
**Annotations added:**
- `title`: Human-readable name for each tool
- `readOnlyHint`: True for tools that don't modify state (11 tools)
- `destructiveHint`: True for delete operations (3 tools)
- `idempotentHint`: True for operations that produce same result when called repeatedly (14 tools)
- `openWorldHint`: True for tools accessing external n8n API (13 tools)
- **New MCP Tool Features**:
- `search_nodes` now includes community nodes in results
- `get_node` returns community metadata (npm package, downloads, repo)
- Community nodes have full property/operation support
**Documentation tools** (7): All marked `readOnlyHint=true`, `idempotentHint=true`
- `tools_documentation`, `search_nodes`, `get_node`, `validate_node`, `get_template`, `search_templates`, `validate_workflow`
### Technical Details
**Management tools** (13): All marked `openWorldHint=true`
- Read-only: `n8n_get_workflow`, `n8n_list_workflows`, `n8n_validate_workflow`, `n8n_health_check`
- Idempotent updates: `n8n_update_full_workflow`, `n8n_update_partial_workflow`, `n8n_autofix_workflow`
- Destructive: `n8n_delete_workflow`, `n8n_executions` (delete action), `n8n_workflow_versions` (delete/truncate)
- Community node fetcher with retry logic and rate limiting
- Tarball extraction for node class analysis
- Support for multi-node packages (e.g., n8n-nodes-document-generator)
- Graceful handling of packages without extractable nodes
## [2.31.4] - 2026-01-02
## [2.30.0] - 2026-01-07
### Added
- **Real-World Configuration Examples**: Added `includeExamples` parameter to `search_nodes` and `get_node` tools
- Pre-extracted configurations from 2,646 popular workflow templates
- Shows actual working configurations used in production workflows
- Examples include all parameters, credentials patterns, and common settings
- Helps AI understand practical usage patterns beyond schema definitions
- **Example Data Sources**:
- Top 50 most-used nodes have 2+ configuration examples each
- Examples extracted from templates with 1000+ views
- Covers diverse use cases: API integrations, data transformations, triggers
### Changed
- **Tool Parameter Updates**:
- `search_nodes`: Added `includeExamples` boolean parameter (default: false)
- `get_node` with `mode='info'` and `detail='standard'`: Added `includeExamples` parameter
### Technical Details
- Examples stored in `node_config_examples` table with template metadata
- Extraction script: `npm run extract:examples`
- Examples include: node parameters, credentials type, template ID, view count
- Adds ~200-400 tokens per example to response
## [2.29.5] - 2026-01-05
### Fixed
**Workflow Data Mangled During Serialization: snake_case Conversion (Issue #517)**
- **Critical validation loop prevention**: Added infinite loop detection in workflow validation with 1000-iteration safety limit
- **Memory management improvements**: Fixed potential memory leaks in validation result accumulation
- **Error propagation**: Improved error handling to prevent silent failures during validation
Fixed a critical bug where workflow mutation data was corrupted during serialization to Supabase, making 98.9% of collected workflow data invalid for n8n API operations.
### Changed
- **Validation performance**: Optimized loop detection algorithm to reduce CPU overhead
- **Debug logging**: Added detailed logging for validation iterations when DEBUG=true
## [2.29.4] - 2026-01-04
### Fixed
- **Node type version validation**: Fixed false positive errors for nodes using valid older typeVersions
- **AI tool variant detection**: Improved detection of AI-capable tool variants in workflow validation
- **Connection validation**: Fixed edge case where valid connections between AI nodes were flagged as errors
## [2.29.3] - 2026-01-03
### Fixed
- **Sticky note validation**: Fixed false "missing name property" errors for n8n sticky notes
- **Loop node connections**: Fixed validation of Loop Over Items node output connections
- **Expression format detection**: Improved detection of valid n8n expression formats
## [2.29.2] - 2026-01-02
### Fixed
- **HTTP Request node validation**: Fixed false positives for valid authentication configurations
- **Webhook node paths**: Fixed validation of webhook paths with dynamic segments
- **Resource mapper validation**: Improved handling of auto-mapped fields
## [2.29.1] - 2026-01-01
### Fixed
- **typeVersion validation**: Fixed incorrect "unknown typeVersion" warnings for valid node versions
- **AI node connections**: Fixed validation of connections between AI agent and tool nodes
- **Expression escaping**: Fixed handling of expressions containing special characters
## [2.29.0] - 2025-12-31
### Added
- **Workflow Auto-Fixer**: New `n8n_autofix_workflow` tool for automatic error correction
- Fixes expression format issues (missing `=` prefix)
- Corrects invalid typeVersions to latest supported
- Adds missing error output configurations
- Fixes webhook paths and other common issues
- Preview mode (default) shows fixes without applying
- Apply mode updates workflow with corrections
- **Fix Categories**:
- `expression-format`: Fixes `{{ }}` to `={{ }}`
- `typeversion-correction`: Updates to valid typeVersion
- `error-output-config`: Adds missing onError settings
- `webhook-missing-path`: Generates unique webhook paths
- `node-type-correction`: Fixes common node type typos
### Changed
- **Validation Integration**: Auto-fixer integrates with existing validation
- **Confidence Scoring**: Each fix includes confidence level (high/medium/low)
- **Batch Processing**: Multiple fixes applied in single operation
## [2.28.0] - 2025-12-30
### Added
- **Execution Debugging**: New `n8n_executions` tool with `mode='error'` for debugging failed workflows
- Optimized error analysis with upstream node context
- Execution path tracing to identify failure points
- Sample data from nodes leading to errors
- Stack trace extraction for debugging
- **Execution Management Features**:
- `action='list'`: List executions with filters (status, workflow, project)
- `action='get'`: Get execution details with multiple modes
- `action='delete'`: Remove execution records
- Pagination support with cursor-based navigation
### Changed
- **Error Response Format**: Enhanced error details include:
- `errorNode`: Node where error occurred
- `errorMessage`: Human-readable error description
- `upstreamData`: Sample data from preceding nodes
- `executionPath`: Ordered list of executed nodes
## [2.27.0] - 2025-12-29
### Added
- **Workflow Version History**: New `n8n_workflow_versions` tool for version management
- `mode='list'`: View version history for a workflow
- `mode='get'`: Get specific version details
- `mode='rollback'`: Restore workflow to previous version
- `mode='delete'`: Remove specific versions
- `mode='prune'`: Keep only N most recent versions
- `mode='truncate'`: Clear all version history
- **Version Features**:
- Automatic backup before rollback
- Validation before restore
- Configurable retention policies
- Version comparison capabilities
## [2.26.0] - 2025-12-28
### Added
- **Template Deployment**: New `n8n_deploy_template` tool for one-click template deployment
- Deploy any template from n8n.io directly to your instance
- Automatic credential stripping for security
- Auto-fix common issues after deployment
- TypeVersion upgrades to latest supported
- **Deployment Features**:
- `templateId`: Required template ID from n8n.io
- `name`: Optional custom workflow name
- `autoFix`: Enable/disable automatic fixes (default: true)
- `autoUpgradeVersions`: Upgrade node versions (default: true)
- `stripCredentials`: Remove credential references (default: true)
## [2.25.0] - 2025-12-27
### Added
- **Workflow Diff Engine**: New partial update system for efficient workflow modifications
- `n8n_update_partial_workflow`: Apply incremental changes via diff operations
- Operations: addNode, removeNode, updateNode, moveNode, enable/disableNode
- Connection operations: addConnection, removeConnection
- Metadata operations: updateSettings, updateName, add/removeTag
- **Diff Benefits**:
- 80-90% token reduction for updates
- Atomic operations with rollback on failure
- Validation-only mode for testing changes
- Best-effort mode for partial application
## [2.24.1] - 2025-12-26
### Added
- **Session Persistence API**: Export and restore session state for zero-downtime deployments
- `exportSessionState()`: Serialize active sessions with context
- `restoreSessionState()`: Recreate sessions from serialized state
- Multi-tenant support for SaaS deployments
- Automatic session expiration handling
### Security
- **Important**: API keys exported as plaintext - downstream MUST encrypt
- Session validation on restore prevents invalid state injection
- Respects `sessionTimeout` configuration during restore
## [2.24.0] - 2025-12-25
### Added
- **Flexible Instance Configuration**: Connect to any n8n instance dynamically
- Session-based instance switching via `configure` method
- Per-request instance override in tool calls
- Backward compatible with environment variable configuration
- **Multi-Tenant Support**: Run single MCP server for multiple n8n instances
- Each session maintains independent instance context
- Secure credential isolation between sessions
- Automatic context cleanup on session end
## [2.23.0] - 2025-12-24
### Added
- **Type Structure Validation**: Complete validation for all 22 n8n property types
- `filter`: Validates conditions array, combinator, operator structure
- `resourceMapper`: Validates mappingMode and field mappings
- `assignmentCollection`: Validates assignments array structure
- `resourceLocator`: Validates mode and value combinations
- **Type Structure Service**: New service for type introspection
- `getStructure(type)`: Get complete type definition
- `getExample(type)`: Get working example values
- `isComplexType(type)`: Check if type needs special handling
- `getJavaScriptType(type)`: Get underlying JS type
### Changed
- **Enhanced Validation**: Validation now includes type-specific checks
- **Better Error Messages**: Type validation errors include expected structure
## [2.22.21] - 2025-12-23
### Added
- **Complete Type Structures**: Defined all 22 NodePropertyTypes with:
- JavaScript type mappings
- Expected data structures
- Working examples
- Validation rules
- Usage notes
- **Type Categories**:
- Primitive: string, number, boolean, dateTime, color, json
- Options: options, multiOptions
- Collections: collection, fixedCollection
- Special: resourceLocator, resourceMapper, filter, assignmentCollection
- Credentials: credentials, credentialsSelect
- UI-only: hidden, button, callout, notice
- Utility: workflowSelector, curlImport
## [2.22.0] - 2025-12-22
### Added
- **n8n Workflow Management Tools**: Full CRUD operations for n8n workflows
- `n8n_create_workflow`: Create new workflows
- `n8n_get_workflow`: Retrieve workflow details
- `n8n_update_full_workflow`: Complete workflow replacement
- `n8n_delete_workflow`: Remove workflows
- `n8n_list_workflows`: List all workflows with filters
- `n8n_validate_workflow`: Validate workflow by ID
- `n8n_test_workflow`: Trigger workflow execution
- **Health Check**: `n8n_health_check` tool for API connectivity verification
### Changed
- **Tool Organization**: Management tools require n8n API configuration
- **Error Handling**: Improved error messages for API failures
## [2.21.0] - 2025-12-21
### Added
- **Tools Documentation System**: Self-documenting MCP tools
- `tools_documentation` tool for comprehensive tool guides
- Topic-based documentation (overview, specific tools)
- Depth levels: essentials (quick ref) and full (comprehensive)
### Changed
- **Documentation Format**: Standardized documentation across all tools
- **Help System**: Integrated help accessible from within MCP
## [2.20.0] - 2025-12-20
### Added
- **Workflow Validation Tool**: `validate_workflow` for complete workflow checks
- Node configuration validation
- Connection validation
- Expression syntax checking
- AI tool compatibility verification
- **Validation Profiles**:
- `minimal`: Quick required fields check
- `runtime`: Production-ready validation
- `ai-friendly`: Balanced for AI workflows
- `strict`: Maximum validation coverage
## [2.19.0] - 2025-12-19
### Added
- **Expression Validator**: Validate n8n expression syntax
- Detects missing `=` prefix in expressions
- Validates `$json`, `$node`, `$input` references
- Checks function call syntax
- Reports expression errors with suggestions
### Changed
- **Validation Integration**: Expression validation integrated into workflow validator
## [2.18.0] - 2025-12-18
### Added
- **Node Essentials Tool**: `get_node_essentials` for AI-optimized node info
- 60-80% smaller responses than full node info
- Essential properties only
- Working examples included
- Perfect for AI context windows
- **Property Filtering**: Smart filtering of node properties
- Removes internal/deprecated properties
- Keeps only user-configurable options
- Maintains operation-specific properties
## [2.17.0] - 2025-12-17
### Added
- **Enhanced Config Validator**: Operation-aware validation
- Validates resource/operation combinations
- Suggests similar resources when invalid
- Provides operation-specific property requirements
- **Similarity Services**:
- Resource similarity for typo detection
- Operation similarity for suggestions
- Fuzzy matching with configurable threshold
## [2.16.0] - 2025-12-16
### Added
- **Template System**: Workflow templates from n8n.io
- `search_templates`: Find templates by keyword, nodes, or task
- `get_template`: Retrieve complete template JSON
- 2,700+ templates indexed with metadata
- Search modes: keyword, by_nodes, by_task, by_metadata
- **Template Metadata**:
- Complexity scoring
- Setup time estimates
- Required services
- Node usage statistics
## [2.15.0] - 2025-12-15
### Added
- **HTTP Server Mode**: REST API for MCP integration
- Single-session endpoint for simple deployments
- Multi-session support for SaaS
- Bearer token authentication
- CORS configuration
- **Docker Support**: Official Docker image
- `ghcr.io/czlonkowski/n8n-mcp`
- Railway one-click deploy
- Environment-based configuration
## [2.14.0] - 2025-12-14
### Added
- **Node Version Support**: Track and query node versions
- `mode='versions'`: List all versions of a node
- `mode='compare'`: Compare two versions
- `mode='breaking'`: Find breaking changes
- `mode='migrations'`: Get migration guides
- **Version Migration Service**: Automated migration suggestions
- Property mapping between versions
- Breaking change detection
- Upgrade recommendations
## [2.13.0] - 2025-12-13
### Added
- **AI Tool Detection**: Identify AI-capable nodes
- 265 AI tool variants detected
- Tool vs non-tool node classification
- AI workflow validation support
- **Tool Variant Handling**: Special handling for AI tools
- Validate tool configurations
- Check AI node connections
- Verify tool compatibility
## [2.12.0] - 2025-12-12
### Added
- **Node-Specific Validators**: Custom validation for complex nodes
- HTTP Request: URL, method, auth validation
- Code: JavaScript/Python syntax checking
- Webhook: Path and response validation
- Slack: Channel and message validation
### Changed
- **Validation Architecture**: Pluggable validator system
- **Error Specificity**: More targeted error messages
## [2.11.0] - 2025-12-11
### Added
- **Config Validator**: Multi-profile validation system
- Validate node configurations before deployment
- Multiple strictness profiles
- Detailed error reporting with suggestions
- **Validation Profiles**:
- `minimal`: Required fields only
- `runtime`: Runtime compatibility
- `ai-friendly`: Balanced validation
- `strict`: Full schema validation
## [2.10.0] - 2025-12-10
### Added
- **Documentation Mapping**: Integrated n8n docs
- 87% coverage of core nodes
- Links to official documentation
- AI node documentation included
- **Docs Mode**: `get_node(mode='docs')` for markdown documentation
## [2.9.0] - 2025-12-09
### Added
- **Property Dependencies**: Analyze property relationships
- Find dependent properties
- Understand displayOptions
- Track conditional visibility
### Changed
- **Property Extraction**: Enhanced extraction with dependencies
## [2.8.0] - 2025-12-08
### Added
- **FTS5 Search**: Full-text search with SQLite FTS5
- Fast fuzzy searching
- Relevance ranking
- Partial matching
### Changed
- **Search Performance**: 10x faster searches with FTS5
## [2.7.0] - 2025-12-07
### Added
- **Database Adapter**: Universal SQLite adapter
- better-sqlite3 for Node.js
- sql.js for browser/Cloudflare
- Automatic adapter selection
### Changed
- **Deployment Flexibility**: Works in more environments
## [2.6.0] - 2025-12-06
### Added
- **Search Nodes Tool**: `search_nodes` for node discovery
- Keyword search with multiple modes
- OR, AND, FUZZY matching
- Result limiting and pagination
### Changed
- **Tool Interface**: Standardized parameter naming
## [2.5.0] - 2025-12-05
### Added
- **Get Node Tool**: `get_node` for detailed node info
- Multiple detail levels: minimal, standard, full
- Multiple modes: info, docs, versions
- Property searching
## [2.4.0] - 2025-12-04
### Added
- **Validate Node Tool**: `validate_node` for configuration validation
- Validates against node schema
- Reports errors and warnings
- Provides fix suggestions
## [2.3.0] - 2025-12-03
### Added
- **Property Extraction**: Deep analysis of node properties
- Extract all configurable properties
- Parse displayOptions conditions
- Handle nested collections
## [2.2.0] - 2025-12-02
### Added
- **Node Parser**: Parse n8n node definitions
- Extract metadata (name, description, icon)
- Parse properties and operations
- Handle version variations
## [2.1.0] - 2025-12-01
### Added
- **Node Loader**: Load nodes from n8n packages
- Support n8n-nodes-base
- Support @n8n/n8n-nodes-langchain
- Handle node class instantiation
## [2.0.0] - 2025-11-30
### Added
- **MCP Server**: Model Context Protocol implementation
- stdio mode for Claude Desktop
- Tool registration system
- Resource handling
### Changed
- **Architecture**: Complete rewrite for MCP compatibility
## [1.0.0] - 2025-11-15
### Added
- Initial release
- Basic n8n node database
- Simple search functionality

View File

@@ -14,7 +14,7 @@ RUN --mount=type=cache,target=/root/.npm \
echo '{}' > package.json && \
npm install --no-save typescript@^5.8.3 @types/node@^22.15.30 @types/express@^5.0.3 \
@modelcontextprotocol/sdk@1.20.1 dotenv@^16.5.0 express@^5.1.0 axios@^1.10.0 \
n8n-workflow@^1.96.0 uuid@^11.0.5 @types/uuid@^10.0.0 \
n8n-workflow@^2.4.2 uuid@^11.0.5 @types/uuid@^10.0.0 \
openai@^4.77.0 zod@3.24.1 lru-cache@^11.2.1 @supabase/supabase-js@^2.57.4
# Copy source and build

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.3.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

@@ -1 +1 @@
{"version":3,"file":"type-structures.d.ts","sourceRoot":"","sources":["../../src/constants/type-structures.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAe9D,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,iBAAiB,EAAE,aAAa,CAilBpE,CAAC;AAUF,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4GjC,CAAC"}
{"version":3,"file":"type-structures.d.ts","sourceRoot":"","sources":["../../src/constants/type-structures.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAe9D,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,iBAAiB,EAAE,aAAa,CAkmBpE,CAAC;AAUF,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4GjC,CAAC"}

View File

@@ -545,6 +545,22 @@ exports.TYPE_STRUCTURES = {
'One-time import feature',
],
},
icon: {
type: 'primitive',
jsType: 'string',
description: 'Icon identifier for visual representation',
example: 'fa:envelope',
examples: ['fa:envelope', 'fa:user', 'fa:cog', 'file:slack.svg'],
validation: {
allowEmpty: false,
allowExpressions: false,
},
notes: [
'References icon by name or file path',
'Supports Font Awesome icons (fa:) and file paths (file:)',
'Used for visual customization in UI',
],
},
};
exports.COMPLEX_TYPE_EXAMPLES = {
collection: {

File diff suppressed because one or more lines are too long

View File

@@ -311,6 +311,17 @@ class SQLJSStatement {
this.stmt = stmt;
this.onModify = onModify;
this.boundParams = null;
this.freed = false;
}
freeStatement() {
if (!this.freed && this.stmt) {
try {
this.stmt.free();
this.freed = true;
}
catch (e) {
}
}
}
run(...params) {
try {
@@ -331,6 +342,9 @@ class SQLJSStatement {
this.stmt.reset();
throw error;
}
finally {
this.freeStatement();
}
}
get(...params) {
try {
@@ -352,6 +366,9 @@ class SQLJSStatement {
this.stmt.reset();
throw error;
}
finally {
this.freeStatement();
}
}
all(...params) {
try {
@@ -372,6 +389,9 @@ class SQLJSStatement {
this.stmt.reset();
throw error;
}
finally {
this.freeStatement();
}
}
iterate(...params) {
return this.all(...params)[Symbol.iterator]();

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"http-server-single-session.d.ts","sourceRoot":"","sources":["../src/http-server-single-session.ts"],"names":[],"mappings":";AAMA,OAAO,OAAO,MAAM,SAAS,CAAC;AAoB9B,OAAO,EAAE,eAAe,EAA2B,MAAM,0BAA0B,CAAC;AACpF,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAuErD,qBAAa,uBAAuB;IAElC,OAAO,CAAC,UAAU,CAA8D;IAChF,OAAO,CAAC,OAAO,CAA0D;IACzE,OAAO,CAAC,eAAe,CAAsE;IAC7F,OAAO,CAAC,eAAe,CAA4D;IACnF,OAAO,CAAC,kBAAkB,CAAyC;IACnE,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,cAAc,CAAwB;IAC9C,OAAO,CAAC,aAAa,CAAM;IAC3B,OAAO,CAAC,cAAc,CAAkB;IACxC,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAA+B;;IAcnD,OAAO,CAAC,mBAAmB;IAmB3B,OAAO,CAAC,sBAAsB;YAqChB,aAAa;IAuC3B,OAAO,CAAC,qBAAqB;IAO7B,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,gBAAgB;IASxB,OAAO,CAAC,sBAAsB;IAkC9B,OAAO,CAAC,mBAAmB;YASb,oBAAoB;YAwBpB,oBAAoB;IAwBlC,OAAO,CAAC,iBAAiB;IAsBzB,OAAO,CAAC,aAAa;IA2BrB,OAAO,CAAC,mBAAmB;IAoDrB,aAAa,CACjB,GAAG,EAAE,OAAO,CAAC,OAAO,EACpB,GAAG,EAAE,OAAO,CAAC,QAAQ,EACrB,eAAe,CAAC,EAAE,eAAe,GAChC,OAAO,CAAC,IAAI,CAAC;YAmOF,eAAe;IA8C7B,OAAO,CAAC,SAAS;IAYjB,OAAO,CAAC,gBAAgB;IASlB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAgnBtB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAkD/B,cAAc,IAAI;QAChB,MAAM,EAAE,OAAO,CAAC;QAChB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,QAAQ,CAAC,EAAE;YACT,KAAK,EAAE,MAAM,CAAC;YACd,MAAM,EAAE,MAAM,CAAC;YACf,OAAO,EAAE,MAAM,CAAC;YAChB,GAAG,EAAE,MAAM,CAAC;YACZ,UAAU,EAAE,MAAM,EAAE,CAAC;SACtB,CAAC;KACH;IAmDM,kBAAkB,IAAI,YAAY,EAAE;IAoEpC,mBAAmB,CAAC,QAAQ,EAAE,YAAY,EAAE,GAAG,MAAM;CAsG7D"}
{"version":3,"file":"http-server-single-session.d.ts","sourceRoot":"","sources":["../src/http-server-single-session.ts"],"names":[],"mappings":";AAMA,OAAO,OAAO,MAAM,SAAS,CAAC;AAoB9B,OAAO,EAAE,eAAe,EAA2B,MAAM,0BAA0B,CAAC;AACpF,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAwErD,qBAAa,uBAAuB;IAElC,OAAO,CAAC,UAAU,CAA8D;IAChF,OAAO,CAAC,OAAO,CAA0D;IACzE,OAAO,CAAC,eAAe,CAAsE;IAC7F,OAAO,CAAC,eAAe,CAA4D;IACnF,OAAO,CAAC,kBAAkB,CAAyC;IACnE,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,cAAc,CAAwB;IAC9C,OAAO,CAAC,aAAa,CAAM;IAI3B,OAAO,CAAC,cAAc,CAER;IACd,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAA+B;;IAcnD,OAAO,CAAC,mBAAmB;IAmB3B,OAAO,CAAC,sBAAsB;YAqChB,aAAa;IAuC3B,OAAO,CAAC,qBAAqB;IAO7B,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,gBAAgB;IASxB,OAAO,CAAC,sBAAsB;IAkC9B,OAAO,CAAC,mBAAmB;YASb,oBAAoB;YAwBpB,oBAAoB;IAwBlC,OAAO,CAAC,iBAAiB;IAsBzB,OAAO,CAAC,aAAa;IA2BrB,OAAO,CAAC,mBAAmB;IAoDrB,aAAa,CACjB,GAAG,EAAE,OAAO,CAAC,OAAO,EACpB,GAAG,EAAE,OAAO,CAAC,QAAQ,EACrB,eAAe,CAAC,EAAE,eAAe,GAChC,OAAO,CAAC,IAAI,CAAC;YA0PF,eAAe;IA4D7B,OAAO,CAAC,SAAS;IAYjB,OAAO,CAAC,gBAAgB;IASlB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAgnBtB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IA2D/B,cAAc,IAAI;QAChB,MAAM,EAAE,OAAO,CAAC;QAChB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,QAAQ,CAAC,EAAE;YACT,KAAK,EAAE,MAAM,CAAC;YACd,MAAM,EAAE,MAAM,CAAC;YACf,OAAO,EAAE,MAAM,CAAC;YAChB,GAAG,EAAE,MAAM,CAAC;YACZ,UAAU,EAAE,MAAM,EAAE,CAAC;SACtB,CAAC;KACH;IAmDM,kBAAkB,IAAI,YAAY,EAAE;IAoEpC,mBAAmB,CAAC,QAAQ,EAAE,YAAY,EAAE,GAAG,MAAM;CAsG7D"}

View File

@@ -22,6 +22,7 @@ const crypto_1 = require("crypto");
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
const protocol_version_1 = require("./utils/protocol-version");
const instance_context_1 = require("./types/instance-context");
const shared_database_1 = require("./database/shared-database");
dotenv_1.default.config();
const DEFAULT_PROTOCOL_VERSION = protocol_version_1.STANDARD_PROTOCOL_VERSION;
const MAX_SESSIONS = Math.max(1, parseInt(process.env.N8N_MCP_MAX_SESSIONS || '100', 10));
@@ -52,7 +53,7 @@ class SingleSessionHTTPServer {
this.contextSwitchLocks = new Map();
this.session = null;
this.consoleManager = new console_manager_1.ConsoleManager();
this.sessionTimeout = 30 * 60 * 1000;
this.sessionTimeout = parseInt(process.env.SESSION_TIMEOUT_MINUTES || '5', 10) * 60 * 1000;
this.authToken = null;
this.cleanupTimer = null;
this.validateEnvironment();
@@ -290,6 +291,25 @@ class SingleSessionHTTPServer {
return;
}
logger_1.logger.info('handleRequest: Creating new transport for initialize request');
if (instanceContext?.instanceId) {
const sessionsToRemove = [];
for (const [existingSessionId, context] of Object.entries(this.sessionContexts)) {
if (context?.instanceId === instanceContext.instanceId) {
sessionsToRemove.push(existingSessionId);
}
}
for (const oldSessionId of sessionsToRemove) {
if (!this.transports[oldSessionId]) {
continue;
}
logger_1.logger.info('Cleaning up previous session for instance', {
instanceId: instanceContext.instanceId,
oldSession: oldSessionId,
reason: 'instance_reconnect'
});
await this.removeSession(oldSessionId, 'instance_reconnect');
}
}
let sessionIdToUse;
const isMultiTenantEnabled = process.env.ENABLE_MULTI_TENANT === 'true';
const sessionStrategy = process.env.MULTI_TENANT_SESSION_STRATEGY || 'instance';
@@ -434,12 +454,21 @@ class SingleSessionHTTPServer {
}
async resetSessionSSE(res) {
if (this.session) {
const sessionId = this.session.sessionId;
logger_1.logger.info('Closing previous session for SSE', { sessionId });
if (this.session.server && typeof this.session.server.close === 'function') {
try {
await this.session.server.close();
}
catch (serverError) {
logger_1.logger.warn('Error closing server for SSE session', { sessionId, error: serverError });
}
}
try {
logger_1.logger.info('Closing previous session for SSE', { sessionId: this.session.sessionId });
await this.session.transport.close();
}
catch (error) {
logger_1.logger.warn('Error closing previous session:', error);
catch (transportError) {
logger_1.logger.warn('Error closing transport for SSE session', { sessionId, error: transportError });
}
}
try {
@@ -1014,6 +1043,13 @@ class SingleSessionHTTPServer {
});
});
}
try {
await (0, shared_database_1.closeSharedDatabase)();
logger_1.logger.info('Shared database closed');
}
catch (error) {
logger_1.logger.warn('Error closing shared database:', error);
}
logger_1.logger.info('Single-Session HTTP server shutdown completed');
}
getSessionInfo() {

File diff suppressed because one or more lines are too long

2
dist/index.d.ts vendored
View File

@@ -5,6 +5,8 @@ export { N8NDocumentationMCPServer } from './mcp/server';
export type { InstanceContext } from './types/instance-context';
export { validateInstanceContext, isInstanceContext } from './types/instance-context';
export type { SessionState } from './types/session-state';
export type { UIAppConfig, UIMetadata } from './mcp/ui/types';
export { UI_APP_CONFIGS } from './mcp/ui/app-configs';
export type { Tool, CallToolResult, ListToolsResult } from '@modelcontextprotocol/sdk/types.js';
import N8NMCPEngine from './mcp-engine';
export default N8NMCPEngine;

2
dist/index.d.ts.map vendored
View File

@@ -1 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACzD,OAAO,EAAE,yBAAyB,EAAE,MAAM,cAAc,CAAC;AAGzD,YAAY,EACV,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,uBAAuB,EACvB,iBAAiB,EAClB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACV,YAAY,EACb,MAAM,uBAAuB,CAAC;AAG/B,YAAY,EACV,IAAI,EACJ,cAAc,EACd,eAAe,EAChB,MAAM,oCAAoC,CAAC;AAG5C,OAAO,YAAY,MAAM,cAAc,CAAC;AACxC,eAAe,YAAY,CAAC"}
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACzD,OAAO,EAAE,yBAAyB,EAAE,MAAM,cAAc,CAAC;AAGzD,YAAY,EACV,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,uBAAuB,EACvB,iBAAiB,EAClB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACV,YAAY,EACb,MAAM,uBAAuB,CAAC;AAG/B,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC9D,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAGtD,YAAY,EACV,IAAI,EACJ,cAAc,EACd,eAAe,EAChB,MAAM,oCAAoC,CAAC;AAG5C,OAAO,YAAY,MAAM,cAAc,CAAC;AACxC,eAAe,YAAY,CAAC"}

4
dist/index.js vendored
View File

@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.isInstanceContext = exports.validateInstanceContext = exports.N8NDocumentationMCPServer = exports.ConsoleManager = exports.SingleSessionHTTPServer = exports.N8NMCPEngine = void 0;
exports.UI_APP_CONFIGS = exports.isInstanceContext = exports.validateInstanceContext = exports.N8NDocumentationMCPServer = exports.ConsoleManager = exports.SingleSessionHTTPServer = exports.N8NMCPEngine = void 0;
var mcp_engine_1 = require("./mcp-engine");
Object.defineProperty(exports, "N8NMCPEngine", { enumerable: true, get: function () { return mcp_engine_1.N8NMCPEngine; } });
var http_server_single_session_1 = require("./http-server-single-session");
@@ -15,6 +15,8 @@ Object.defineProperty(exports, "N8NDocumentationMCPServer", { enumerable: true,
var instance_context_1 = require("./types/instance-context");
Object.defineProperty(exports, "validateInstanceContext", { enumerable: true, get: function () { return instance_context_1.validateInstanceContext; } });
Object.defineProperty(exports, "isInstanceContext", { enumerable: true, get: function () { return instance_context_1.isInstanceContext; } });
var app_configs_1 = require("./mcp/ui/app-configs");
Object.defineProperty(exports, "UI_APP_CONFIGS", { enumerable: true, get: function () { return app_configs_1.UI_APP_CONFIGS; } });
const mcp_engine_2 = __importDefault(require("./mcp-engine"));
exports.default = mcp_engine_2.default;
//# sourceMappingURL=index.js.map

2
dist/index.js.map vendored
View File

@@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;AAOA,2CAAyE;AAAhE,0GAAA,YAAY,OAAA;AACrB,2EAAuE;AAA9D,qIAAA,uBAAuB,OAAA;AAChC,2DAAyD;AAAhD,iHAAA,cAAc,OAAA;AACvB,uCAAyD;AAAhD,mHAAA,yBAAyB,OAAA;AAMlC,6DAGkC;AAFhC,2HAAA,uBAAuB,OAAA;AACvB,qHAAA,iBAAiB,OAAA;AAcnB,8DAAwC;AACxC,kBAAe,oBAAY,CAAC"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;AAOA,2CAAyE;AAAhE,0GAAA,YAAY,OAAA;AACrB,2EAAuE;AAA9D,qIAAA,uBAAuB,OAAA;AAChC,2DAAyD;AAAhD,iHAAA,cAAc,OAAA;AACvB,uCAAyD;AAAhD,mHAAA,yBAAyB,OAAA;AAMlC,6DAGkC;AAFhC,2HAAA,uBAAuB,OAAA;AACvB,qHAAA,iBAAiB,OAAA;AAQnB,oDAAsD;AAA7C,6GAAA,cAAc,OAAA;AAUvB,8DAAwC;AACxC,kBAAe,oBAAY,CAAC"}

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":";;;;;;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;IA8FJ,CAAC;IA5FC,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;IAMO,iBAAiB,CAAC,WAAmB;QAC3C,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,WAAW,eAAe,CAAC,CAAC;QACnE,OAAO,cAAI,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACnC,CAAC;IAOO,cAAc,CAAC,YAAoB;QACzC,OAAO,OAAO,CAAC,YAAY,CAAC,CAAC;IAC/B,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;QAC/B,MAAM,UAAU,GAAG,IAAI,CAAC,iBAAiB,CAAC,WAAW,CAAC,CAAC;QAGvD,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;oBAEH,MAAM,QAAQ,GAAG,cAAI,CAAC,IAAI,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;oBACjD,MAAM,UAAU,GAAG,IAAI,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC;oBAGjD,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,cAAI,CAAC,IAAI,CAAC,UAAU,EAAE,QAAkB,CAAC,CAAC;oBAC3D,MAAM,UAAU,GAAG,IAAI,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC;oBAGjD,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;AAlGD,sCAkGC"}

View File

@@ -13,6 +13,9 @@ export declare class N8NDocumentationMCPServer {
private previousToolTimestamp;
private earlyLogger;
private disabledToolsCache;
private useSharedDatabase;
private sharedDbState;
private isShutdown;
constructor(instanceContext?: InstanceContext, earlyLogger?: EarlyErrorLogger);
close(): Promise<void>;
private initializeDatabase;

View File

@@ -1 +1 @@
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AAsCA,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;gBAE1C,eAAe,CAAC,EAAE,eAAe,EAAE,WAAW,CAAC,EAAE,gBAAgB;IAiGvE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YA6Bd,kBAAkB;YAwClB,wBAAwB;IA0BtC,OAAO,CAAC,kBAAkB;YA6CZ,iBAAiB;IAa/B,OAAO,CAAC,eAAe,CAAkB;YAE3B,sBAAsB;IAgDpC,OAAO,CAAC,gBAAgB;IAqCxB,OAAO,CAAC,aAAa;IAoTrB,OAAO,CAAC,wBAAwB;IAoFhC,OAAO,CAAC,kBAAkB;IAqE1B,OAAO,CAAC,uBAAuB;IAwB/B,OAAO,CAAC,qBAAqB;YAoTf,SAAS;YA2DT,WAAW;YAkFX,WAAW;YA0CX,cAAc;YA8Md,gBAAgB;IAqD9B,OAAO,CAAC,mBAAmB;IAwE3B,OAAO,CAAC,eAAe;YAsBT,eAAe;IA2L7B,OAAO,CAAC,kBAAkB;IAQ1B,OAAO,CAAC,uBAAuB;IA0D/B,OAAO,CAAC,iBAAiB;YAqFX,WAAW;YAgCX,oBAAoB;IAuFlC,OAAO,CAAC,aAAa;YAQP,qBAAqB;YAwDrB,iBAAiB;YAiKjB,OAAO;YAgDP,cAAc;YAwFd,iBAAiB;IAqC/B,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,eAAe;IAwCvB,OAAO,CAAC,kBAAkB;IAiC1B,OAAO,CAAC,aAAa;IAoCrB,OAAO,CAAC,0BAA0B;IAgClC,OAAO,CAAC,4BAA4B;YAKtB,oBAAoB;IAsDlC,OAAO,CAAC,gBAAgB;YAiBV,SAAS;YA6CT,kBAAkB;YAqElB,uBAAuB;YAsDvB,iBAAiB;IAqE/B,OAAO,CAAC,qBAAqB;IA8C7B,OAAO,CAAC,uBAAuB;IA4D/B,OAAO,CAAC,wBAAwB;IAkChC,OAAO,CAAC,iBAAiB;YAoDX,mBAAmB;YAoEnB,qBAAqB;IAS7B,OAAO,CAAC,SAAS,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;YAS9B,aAAa;YAcb,iBAAiB;YAoBjB,WAAW;YAwBX,eAAe;YAqBf,mBAAmB;YAwBnB,yBAAyB;IA4CvC,OAAO,CAAC,kBAAkB;YAiBZ,gBAAgB;YA6HhB,2BAA2B;YAiE3B,2BAA2B;IAyEnC,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IA0BpB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAuBhC"}
{"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"}

114
dist/mcp/server.js vendored
View File

@@ -43,12 +43,14 @@ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
const fs_1 = require("fs");
const path_1 = __importDefault(require("path"));
const tools_1 = require("./tools");
const ui_1 = require("./ui");
const tools_n8n_manager_1 = require("./tools-n8n-manager");
const tools_n8n_friendly_1 = require("./tools-n8n-friendly");
const workflow_examples_1 = require("./workflow-examples");
const logger_1 = require("../utils/logger");
const node_repository_1 = require("../database/node-repository");
const database_adapter_1 = require("../database/database-adapter");
const shared_database_1 = require("../database/shared-database");
const property_filter_1 = require("../services/property-filter");
const task_templates_1 = require("../services/task-templates");
const config_validator_1 = require("../services/config-validator");
@@ -80,6 +82,9 @@ class N8NDocumentationMCPServer {
this.previousToolTimestamp = Date.now();
this.earlyLogger = null;
this.disabledToolsCache = null;
this.useSharedDatabase = false;
this.sharedDbState = null;
this.isShutdown = false;
this.dbHealthChecked = false;
this.instanceContext = instanceContext;
this.earlyLogger = earlyLogger || null;
@@ -144,15 +149,29 @@ class N8NDocumentationMCPServer {
}, {
capabilities: {
tools: {},
resources: {},
},
});
ui_1.UIAppRegistry.load();
this.setupHandlers();
}
async close() {
try {
await this.initialized;
}
catch (error) {
logger_1.logger.debug('Initialization had failed, proceeding with cleanup', {
error: error instanceof Error ? error.message : String(error)
});
}
try {
await this.server.close();
this.cache.destroy();
if (this.db) {
if (this.useSharedDatabase && this.sharedDbState) {
(0, shared_database_1.releaseSharedDatabase)(this.sharedDbState);
logger_1.logger.debug('Released shared database reference');
}
else if (this.db) {
try {
this.db.close();
}
@@ -166,6 +185,7 @@ class N8NDocumentationMCPServer {
this.repository = null;
this.templateService = null;
this.earlyLogger = null;
this.sharedDbState = null;
}
catch (error) {
logger_1.logger.warn('Error closing MCP server', { error: error instanceof Error ? error.message : String(error) });
@@ -177,17 +197,27 @@ class N8NDocumentationMCPServer {
this.earlyLogger.logCheckpoint(startup_checkpoints_1.STARTUP_CHECKPOINTS.DATABASE_CONNECTING);
}
logger_1.logger.debug('Database initialization starting...', { dbPath });
this.db = await (0, database_adapter_1.createDatabaseAdapter)(dbPath);
logger_1.logger.debug('Database adapter created');
if (dbPath === ':memory:') {
this.db = await (0, database_adapter_1.createDatabaseAdapter)(dbPath);
logger_1.logger.debug('Database adapter created (in-memory mode)');
await this.initializeInMemorySchema();
logger_1.logger.debug('In-memory schema initialized');
this.repository = new node_repository_1.NodeRepository(this.db);
this.templateService = new template_service_1.TemplateService(this.db);
enhanced_config_validator_1.EnhancedConfigValidator.initializeSimilarityServices(this.repository);
this.useSharedDatabase = false;
}
else {
const sharedState = await (0, shared_database_1.getSharedDatabase)(dbPath);
this.db = sharedState.db;
this.repository = sharedState.repository;
this.templateService = sharedState.templateService;
this.sharedDbState = sharedState;
this.useSharedDatabase = true;
logger_1.logger.debug('Using shared database connection');
}
this.repository = new node_repository_1.NodeRepository(this.db);
logger_1.logger.debug('Node repository initialized');
this.templateService = new template_service_1.TemplateService(this.db);
logger_1.logger.debug('Template service initialized');
enhanced_config_validator_1.EnhancedConfigValidator.initializeSimilarityServices(this.repository);
logger_1.logger.debug('Similarity services initialized');
if (this.earlyLogger) {
this.earlyLogger.logCheckpoint(startup_checkpoints_1.STARTUP_CHECKPOINTS.DATABASE_CONNECTED);
@@ -341,6 +371,7 @@ class N8NDocumentationMCPServer {
protocolVersion: negotiationResult.version,
capabilities: {
tools: {},
resources: {},
},
serverInfo: {
name: 'n8n-documentation-mcp',
@@ -396,6 +427,7 @@ class N8NDocumentationMCPServer {
description: tool.description
});
});
ui_1.UIAppRegistry.injectToolMeta(tools);
return { tools };
});
this.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
@@ -535,6 +567,39 @@ class N8NDocumentationMCPServer {
};
}
});
this.server.setRequestHandler(types_js_1.ListResourcesRequestSchema, async () => {
const apps = ui_1.UIAppRegistry.getAllApps();
return {
resources: apps
.filter(app => app.html !== null)
.map(app => ({
uri: app.config.uri,
name: app.config.displayName,
description: app.config.description,
mimeType: app.config.mimeType,
})),
};
});
this.server.setRequestHandler(types_js_1.ReadResourceRequestSchema, async (request) => {
const uri = request.params.uri;
const match = uri.match(/^ui:\/\/n8n-mcp\/(.+)$/);
if (!match) {
throw new Error(`Unknown resource URI: ${uri}`);
}
const app = ui_1.UIAppRegistry.getAppById(match[1]);
if (!app || !app.html) {
throw new Error(`UI app not found or not built: ${match[1]}`);
}
return {
contents: [
{
uri: app.config.uri,
mimeType: app.config.mimeType,
text: app.html,
},
],
};
});
}
sanitizeValidationResult(result, toolName) {
if (!result || typeof result !== 'object') {
@@ -2889,7 +2954,26 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
process.stdin.resume();
}
async shutdown() {
if (this.isShutdown) {
logger_1.logger.debug('Shutdown already called, skipping');
return;
}
this.isShutdown = true;
logger_1.logger.info('Shutting down MCP server...');
try {
await this.initialized;
}
catch (error) {
logger_1.logger.debug('Initialization had failed, proceeding with cleanup', {
error: error instanceof Error ? error.message : String(error)
});
}
try {
await this.server.close();
}
catch (error) {
logger_1.logger.error('Error closing MCP server:', error);
}
if (this.cache) {
try {
this.cache.destroy();
@@ -2899,15 +2983,29 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
logger_1.logger.error('Error cleaning up cache:', error);
}
}
if (this.db) {
if (this.useSharedDatabase && this.sharedDbState) {
try {
await this.db.close();
(0, shared_database_1.releaseSharedDatabase)(this.sharedDbState);
logger_1.logger.info('Released shared database reference');
}
catch (error) {
logger_1.logger.error('Error releasing shared database:', error);
}
}
else if (this.db) {
try {
this.db.close();
logger_1.logger.info('Database connection closed');
}
catch (error) {
logger_1.logger.error('Error closing database:', error);
}
}
this.db = null;
this.repository = null;
this.templateService = null;
this.earlyLogger = null;
this.sharedDbState = null;
}
}
exports.N8NDocumentationMCPServer = N8NDocumentationMCPServer;

File diff suppressed because one or more lines are too long

View File

@@ -26,10 +26,10 @@ export declare const workflowNodeSchema: z.ZodObject<{
parameters: Record<string, unknown>;
credentials?: Record<string, unknown> | undefined;
retryOnFail?: boolean | undefined;
continueOnFail?: boolean | undefined;
maxTries?: number | undefined;
waitBetweenTries?: number | undefined;
alwaysOutputData?: boolean | undefined;
continueOnFail?: boolean | undefined;
executeOnce?: boolean | undefined;
disabled?: boolean | undefined;
notes?: string | undefined;
@@ -43,10 +43,10 @@ export declare const workflowNodeSchema: z.ZodObject<{
parameters: Record<string, unknown>;
credentials?: Record<string, unknown> | undefined;
retryOnFail?: boolean | undefined;
continueOnFail?: boolean | undefined;
maxTries?: number | undefined;
waitBetweenTries?: number | undefined;
alwaysOutputData?: boolean | undefined;
continueOnFail?: boolean | undefined;
executeOnce?: boolean | undefined;
disabled?: boolean | undefined;
notes?: string | undefined;

View File

@@ -12,6 +12,8 @@ export declare class TelemetryBatchProcessor {
private flushTimes;
private deadLetterQueue;
private readonly maxDeadLetterSize;
private eventListeners;
private started;
constructor(supabase: SupabaseClient | null, isEnabled: () => boolean);
start(): void;
stop(): void;

View File

@@ -1 +1 @@
{"version":3,"file":"batch-processor.d.ts","sourceRoot":"","sources":["../../src/telemetry/batch-processor.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,sBAAsB,EAAoB,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAoClI,qBAAa,uBAAuB;IAoBhC,OAAO,CAAC,QAAQ;IAChB,OAAO,CAAC,SAAS;IApBnB,OAAO,CAAC,UAAU,CAAC,CAAiB;IACpC,OAAO,CAAC,gBAAgB,CAAkB;IAC1C,OAAO,CAAC,mBAAmB,CAAkB;IAC7C,OAAO,CAAC,mBAAmB,CAAkB;IAC7C,OAAO,CAAC,cAAc,CAA0B;IAChD,OAAO,CAAC,OAAO,CAQb;IACF,OAAO,CAAC,UAAU,CAAgB;IAClC,OAAO,CAAC,eAAe,CAAuE;IAC9F,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAO;gBAG/B,QAAQ,EAAE,cAAc,GAAG,IAAI,EAC/B,SAAS,EAAE,MAAM,OAAO;IAQlC,KAAK,IAAI,IAAI;IA+Bb,IAAI,IAAI,IAAI;IAWN,KAAK,CAAC,MAAM,CAAC,EAAE,cAAc,EAAE,EAAE,SAAS,CAAC,EAAE,iBAAiB,EAAE,EAAE,SAAS,CAAC,EAAE,sBAAsB,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;YAgD9G,WAAW;YAmDX,cAAc;YAuDd,cAAc;YAiEd,gBAAgB;IAgD9B,OAAO,CAAC,aAAa;IAarB,OAAO,CAAC,oBAAoB;IAiB5B,OAAO,CAAC,oBAAoB;YAmBd,sBAAsB;IAgCpC,OAAO,CAAC,eAAe;IAiBvB,UAAU,IAAI,gBAAgB,GAAG;QAAE,mBAAmB,EAAE,GAAG,CAAC;QAAC,mBAAmB,EAAE,MAAM,CAAA;KAAE;IAW1F,YAAY,IAAI,IAAI;CAarB"}
{"version":3,"file":"batch-processor.d.ts","sourceRoot":"","sources":["../../src/telemetry/batch-processor.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,sBAAsB,EAAoB,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAoClI,qBAAa,uBAAuB;IA2BhC,OAAO,CAAC,QAAQ;IAChB,OAAO,CAAC,SAAS;IA3BnB,OAAO,CAAC,UAAU,CAAC,CAAiB;IACpC,OAAO,CAAC,gBAAgB,CAAkB;IAC1C,OAAO,CAAC,mBAAmB,CAAkB;IAC7C,OAAO,CAAC,mBAAmB,CAAkB;IAC7C,OAAO,CAAC,cAAc,CAA0B;IAChD,OAAO,CAAC,OAAO,CAQb;IACF,OAAO,CAAC,UAAU,CAAgB;IAClC,OAAO,CAAC,eAAe,CAAuE;IAC9F,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAO;IAEzC,OAAO,CAAC,cAAc,CAIf;IACP,OAAO,CAAC,OAAO,CAAkB;gBAGvB,QAAQ,EAAE,cAAc,GAAG,IAAI,EAC/B,SAAS,EAAE,MAAM,OAAO;IAQlC,KAAK,IAAI,IAAI;IA0Cb,IAAI,IAAI,IAAI;IAyBN,KAAK,CAAC,MAAM,CAAC,EAAE,cAAc,EAAE,EAAE,SAAS,CAAC,EAAE,iBAAiB,EAAE,EAAE,SAAS,CAAC,EAAE,sBAAsB,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;YAgD9G,WAAW;YAmDX,cAAc;YAuDd,cAAc;YAiEd,gBAAgB;IAgD9B,OAAO,CAAC,aAAa;IAarB,OAAO,CAAC,oBAAoB;IAiB5B,OAAO,CAAC,oBAAoB;YAmBd,sBAAsB;IAgCpC,OAAO,CAAC,eAAe;IAiBvB,UAAU,IAAI,gBAAgB,GAAG;QAAE,mBAAmB,EAAE,GAAG,CAAC;QAAC,mBAAmB,EAAE,MAAM,CAAA;KAAE;IAW1F,YAAY,IAAI,IAAI;CAarB"}

View File

@@ -33,26 +33,36 @@ class TelemetryBatchProcessor {
this.flushTimes = [];
this.deadLetterQueue = [];
this.maxDeadLetterSize = 100;
this.eventListeners = {};
this.started = false;
this.circuitBreaker = new telemetry_error_1.TelemetryCircuitBreaker();
}
start() {
if (!this.isEnabled() || !this.supabase)
return;
if (this.started) {
logger_1.logger.debug('Telemetry batch processor already started, skipping');
return;
}
this.flushTimer = setInterval(() => {
this.flush();
}, telemetry_types_1.TELEMETRY_CONFIG.BATCH_FLUSH_INTERVAL);
if (typeof this.flushTimer === 'object' && 'unref' in this.flushTimer) {
this.flushTimer.unref();
}
process.on('beforeExit', () => this.flush());
process.on('SIGINT', () => {
this.eventListeners.beforeExit = () => this.flush();
this.eventListeners.sigint = () => {
this.flush();
process.exit(0);
});
process.on('SIGTERM', () => {
};
this.eventListeners.sigterm = () => {
this.flush();
process.exit(0);
});
};
process.on('beforeExit', this.eventListeners.beforeExit);
process.on('SIGINT', this.eventListeners.sigint);
process.on('SIGTERM', this.eventListeners.sigterm);
this.started = true;
logger_1.logger.debug('Telemetry batch processor started');
}
stop() {
@@ -60,6 +70,17 @@ class TelemetryBatchProcessor {
clearInterval(this.flushTimer);
this.flushTimer = undefined;
}
if (this.eventListeners.beforeExit) {
process.removeListener('beforeExit', this.eventListeners.beforeExit);
}
if (this.eventListeners.sigint) {
process.removeListener('SIGINT', this.eventListeners.sigint);
}
if (this.eventListeners.sigterm) {
process.removeListener('SIGTERM', this.eventListeners.sigterm);
}
this.eventListeners = {};
this.started = false;
logger_1.logger.debug('Telemetry batch processor stopped');
}
async flush(events, workflows, mutations) {

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

18689
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.33.2",
"version": "2.35.3",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -16,6 +16,8 @@
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"build:ui": "cd ui-apps && npm install && npm run build",
"build:all": "npm run build:ui && npm run build",
"rebuild": "node dist/scripts/rebuild.js",
"rebuild:optimized": "node dist/scripts/rebuild-optimized.js",
"validate": "node dist/scripts/validate.js",
@@ -123,6 +125,7 @@
"homepage": "https://github.com/czlonkowski/n8n-mcp#readme",
"files": [
"dist/**/*",
"ui-apps/dist/**/*",
"data/nodes.db",
".env.example",
"README.md",
@@ -150,16 +153,16 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.20.1",
"@n8n/n8n-nodes-langchain": "^2.3.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.3.3",
"n8n-core": "^2.3.2",
"n8n-workflow": "^2.3.2",
"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

@@ -5,7 +5,7 @@
* These structures define the expected data format, JavaScript type,
* validation rules, and examples for each property type.
*
* Based on n8n-workflow v1.120.3 NodePropertyTypes
* Based on n8n-workflow v2.4.2 NodePropertyTypes
*
* @module constants/type-structures
* @since 2.23.0
@@ -15,7 +15,7 @@ import type { NodePropertyTypes } from 'n8n-workflow';
import type { TypeStructure } from '../types/type-structures';
/**
* Complete type structure definitions for all 22 NodePropertyTypes
* Complete type structure definitions for all 23 NodePropertyTypes
*
* Each entry defines:
* - type: Category (primitive/object/collection/special)
@@ -620,6 +620,23 @@ export const TYPE_STRUCTURES: Record<NodePropertyTypes, TypeStructure> = {
'One-time import feature',
],
},
icon: {
type: 'primitive',
jsType: 'string',
description: 'Icon identifier for visual representation',
example: 'fa:envelope',
examples: ['fa:envelope', 'fa:user', 'fa:cog', 'file:slack.svg'],
validation: {
allowEmpty: false,
allowExpressions: false,
},
notes: [
'References icon by name or file path',
'Supports Font Awesome icons (fa:) and file paths (file:)',
'Used for visual customization in UI',
],
},
};
/**

View File

@@ -419,12 +419,36 @@ class BetterSQLiteStatement implements PreparedStatement {
/**
* Statement wrapper for sql.js
*
* IMPORTANT: sql.js requires explicit memory management via Statement.free().
* This wrapper automatically frees statement memory after each operation
* to prevent memory leaks during sustained traffic.
*
* See: https://sql.js.org/documentation/Statement.html
* "After calling db.prepare() you must manually free the assigned memory
* by calling Statement.free()."
*/
class SQLJSStatement implements PreparedStatement {
private boundParams: any = null;
private freed: boolean = false;
constructor(private stmt: any, private onModify: () => void) {}
/**
* Free the underlying sql.js statement memory.
* Safe to call multiple times - subsequent calls are no-ops.
*/
private freeStatement(): void {
if (!this.freed && this.stmt) {
try {
this.stmt.free();
this.freed = true;
} catch (e) {
// Statement may already be freed or invalid - ignore
}
}
}
run(...params: any[]): RunResult {
try {
if (params.length > 0) {
@@ -433,10 +457,10 @@ class SQLJSStatement implements PreparedStatement {
this.stmt.bind(this.boundParams);
}
}
this.stmt.run();
this.onModify();
// sql.js doesn't provide changes/lastInsertRowid easily
return {
changes: 1, // Assume success means 1 change
@@ -445,9 +469,12 @@ class SQLJSStatement implements PreparedStatement {
} catch (error) {
this.stmt.reset();
throw error;
} finally {
// Free statement memory after write operation completes
this.freeStatement();
}
}
get(...params: any[]): any {
try {
if (params.length > 0) {
@@ -456,21 +483,24 @@ class SQLJSStatement implements PreparedStatement {
this.stmt.bind(this.boundParams);
}
}
if (this.stmt.step()) {
const result = this.stmt.getAsObject();
this.stmt.reset();
return this.convertIntegerColumns(result);
}
this.stmt.reset();
return undefined;
} catch (error) {
this.stmt.reset();
throw error;
} finally {
// Free statement memory after read operation completes
this.freeStatement();
}
}
all(...params: any[]): any[] {
try {
if (params.length > 0) {
@@ -479,17 +509,20 @@ class SQLJSStatement implements PreparedStatement {
this.stmt.bind(this.boundParams);
}
}
const results: any[] = [];
while (this.stmt.step()) {
results.push(this.convertIntegerColumns(this.stmt.getAsObject()));
}
this.stmt.reset();
return results;
} catch (error) {
this.stmt.reset();
throw error;
} finally {
// Free statement memory after read operation completes
this.freeStatement();
}
}

View File

@@ -0,0 +1,197 @@
/**
* Shared Database Manager - Singleton for cross-session database connection
*
* This module implements a singleton pattern to share a single database connection
* across all MCP server sessions. This prevents memory leaks caused by each session
* creating its own database connection (~900MB per session).
*
* Memory impact: Reduces per-session memory from ~900MB to near-zero by sharing
* a single ~68MB database connection across all sessions.
*
* Issue: https://github.com/czlonkowski/n8n-mcp/issues/XXX
*/
import { DatabaseAdapter, createDatabaseAdapter } from './database-adapter';
import { NodeRepository } from './node-repository';
import { TemplateService } from '../templates/template-service';
import { EnhancedConfigValidator } from '../services/enhanced-config-validator';
import { logger } from '../utils/logger';
/**
* Shared database state - holds the singleton connection and services
*/
export interface SharedDatabaseState {
db: DatabaseAdapter;
repository: NodeRepository;
templateService: TemplateService;
dbPath: string;
refCount: number;
initialized: boolean;
}
// Module-level singleton state
let sharedState: SharedDatabaseState | null = null;
let initializationPromise: Promise<SharedDatabaseState> | null = null;
/**
* Get or create the shared database connection
*
* Thread-safe initialization using a promise lock pattern.
* Multiple concurrent calls will wait for the same initialization.
*
* @param dbPath - Path to the SQLite database file
* @returns Shared database state with connection and services
*/
export async function getSharedDatabase(dbPath: string): Promise<SharedDatabaseState> {
// If already initialized with the same path, increment ref count and return
if (sharedState && sharedState.initialized && sharedState.dbPath === dbPath) {
sharedState.refCount++;
logger.debug('Reusing shared database connection', {
refCount: sharedState.refCount,
dbPath
});
return sharedState;
}
// If already initialized with a DIFFERENT path, this is a configuration error
if (sharedState && sharedState.initialized && sharedState.dbPath !== dbPath) {
logger.error('Attempted to initialize shared database with different path', {
existingPath: sharedState.dbPath,
requestedPath: dbPath
});
throw new Error(`Shared database already initialized with different path: ${sharedState.dbPath}`);
}
// If initialization is in progress, wait for it
if (initializationPromise) {
try {
const state = await initializationPromise;
state.refCount++;
logger.debug('Reusing shared database (waited for init)', {
refCount: state.refCount,
dbPath
});
return state;
} catch (error) {
// Initialization failed while we were waiting, clear promise and rethrow
initializationPromise = null;
throw error;
}
}
// Start new initialization
initializationPromise = initializeSharedDatabase(dbPath);
try {
const state = await initializationPromise;
// Clear the promise on success to allow future re-initialization after close
initializationPromise = null;
return state;
} catch (error) {
// Clear promise on failure to allow retry
initializationPromise = null;
throw error;
}
}
/**
* Initialize the shared database connection and services
*/
async function initializeSharedDatabase(dbPath: string): Promise<SharedDatabaseState> {
logger.info('Initializing shared database connection', { dbPath });
const db = await createDatabaseAdapter(dbPath);
const repository = new NodeRepository(db);
const templateService = new TemplateService(db);
// Initialize similarity services for enhanced validation
EnhancedConfigValidator.initializeSimilarityServices(repository);
sharedState = {
db,
repository,
templateService,
dbPath,
refCount: 1,
initialized: true
};
logger.info('Shared database initialized successfully', {
dbPath,
refCount: sharedState.refCount
});
return sharedState;
}
/**
* Release a reference to the shared database
*
* Decrements the reference count. Does NOT close the database
* as it's shared across all sessions for the lifetime of the process.
*
* @param state - The shared database state to release
*/
export function releaseSharedDatabase(state: SharedDatabaseState): void {
if (!state || !sharedState) {
return;
}
// Guard against double-release (refCount going negative)
if (sharedState.refCount <= 0) {
logger.warn('Attempted to release shared database with refCount already at or below 0', {
refCount: sharedState.refCount
});
return;
}
sharedState.refCount--;
logger.debug('Released shared database reference', {
refCount: sharedState.refCount
});
// Note: We intentionally do NOT close the database even when refCount hits 0
// The database should remain open for the lifetime of the process to handle
// new sessions. Only process shutdown should close it.
}
/**
* Force close the shared database (for graceful shutdown only)
*
* This should only be called during process shutdown, not during normal
* session cleanup. Closing the database would break other active sessions.
*/
export async function closeSharedDatabase(): Promise<void> {
if (!sharedState) {
return;
}
logger.info('Closing shared database connection', {
refCount: sharedState.refCount
});
try {
sharedState.db.close();
} catch (error) {
logger.warn('Error closing shared database', {
error: error instanceof Error ? error.message : String(error)
});
}
sharedState = null;
initializationPromise = null;
}
/**
* Check if shared database is initialized
*/
export function isSharedDatabaseInitialized(): boolean {
return sharedState !== null && sharedState.initialized;
}
/**
* Get current reference count (for debugging/monitoring)
*/
export function getSharedDatabaseRefCount(): number {
return sharedState?.refCount ?? 0;
}

View File

@@ -26,6 +26,7 @@ import {
} from './utils/protocol-version';
import { InstanceContext, validateInstanceContext } from './types/instance-context';
import { SessionState } from './types/session-state';
import { closeSharedDatabase } from './database/shared-database';
dotenv.config();
@@ -106,7 +107,12 @@ export class SingleSessionHTTPServer {
private session: Session | null = null; // Keep for SSE compatibility
private consoleManager = new ConsoleManager();
private expressServer: any;
private sessionTimeout = 30 * 60 * 1000; // 30 minutes
// Session timeout reduced from 30 minutes to 5 minutes for faster cleanup
// Configurable via SESSION_TIMEOUT_MINUTES environment variable
// This prevents memory buildup from stale sessions
private sessionTimeout = parseInt(
process.env.SESSION_TIMEOUT_MINUTES || '5', 10
) * 60 * 1000;
private authToken: string | null = null;
private cleanupTimer: NodeJS.Timeout | null = null;
@@ -492,6 +498,29 @@ export class SingleSessionHTTPServer {
// For initialize requests: always create new transport and server
logger.info('handleRequest: Creating new transport for initialize request');
// EAGER CLEANUP: Remove existing sessions for the same instance
// This prevents memory buildup when clients reconnect without proper cleanup
if (instanceContext?.instanceId) {
const sessionsToRemove: string[] = [];
for (const [existingSessionId, context] of Object.entries(this.sessionContexts)) {
if (context?.instanceId === instanceContext.instanceId) {
sessionsToRemove.push(existingSessionId);
}
}
for (const oldSessionId of sessionsToRemove) {
// Double-check session still exists (may have been cleaned by concurrent request)
if (!this.transports[oldSessionId]) {
continue;
}
logger.info('Cleaning up previous session for instance', {
instanceId: instanceContext.instanceId,
oldSession: oldSessionId,
reason: 'instance_reconnect'
});
await this.removeSession(oldSessionId, 'instance_reconnect');
}
}
// Generate session ID based on multi-tenant configuration
let sessionIdToUse: string;
@@ -677,11 +706,25 @@ export class SingleSessionHTTPServer {
private async resetSessionSSE(res: express.Response): Promise<void> {
// Clean up old session if exists
if (this.session) {
const sessionId = this.session.sessionId;
logger.info('Closing previous session for SSE', { sessionId });
// Close server first to free resources (database, cache timer, etc.)
// This mirrors the cleanup pattern in removeSession() (issue #542)
// Handle server close errors separately so transport close still runs
if (this.session.server && typeof this.session.server.close === 'function') {
try {
await this.session.server.close();
} catch (serverError) {
logger.warn('Error closing server for SSE session', { sessionId, error: serverError });
}
}
// Close transport last - always attempt even if server.close() failed
try {
logger.info('Closing previous session for SSE', { sessionId: this.session.sessionId });
await this.session.transport.close();
} catch (error) {
logger.warn('Error closing previous session:', error);
} catch (transportError) {
logger.warn('Error closing transport for SSE session', { sessionId, error: transportError });
}
}
@@ -1408,7 +1451,16 @@ export class SingleSessionHTTPServer {
});
});
}
// Close the shared database connection (only during process shutdown)
// This must happen after all sessions are closed
try {
await closeSharedDatabase();
logger.info('Shared database closed');
} catch (error) {
logger.warn('Error closing shared database:', error);
}
logger.info('Single-Session HTTP server shutdown completed');
}

View File

@@ -22,6 +22,10 @@ export type {
SessionState
} from './types/session-state';
// UI module exports
export type { UIAppConfig, UIMetadata } from './mcp/ui/types';
export { UI_APP_CONFIGS } from './mcp/ui/app-configs';
// Re-export MCP SDK types for convenience
export type {
Tool,

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

@@ -1,19 +1,23 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
import {
CallToolRequestSchema,
ListToolsRequestSchema,
InitializeRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { existsSync, promises as fs } from 'fs';
import path from 'path';
import { n8nDocumentationToolsFinal } from './tools';
import { UIAppRegistry } from './ui';
import { n8nManagementTools } from './tools-n8n-manager';
import { makeToolsN8nFriendly } from './tools-n8n-friendly';
import { getWorkflowExampleString } from './workflow-examples';
import { logger } from '../utils/logger';
import { NodeRepository } from '../database/node-repository';
import { DatabaseAdapter, createDatabaseAdapter } from '../database/database-adapter';
import { getSharedDatabase, releaseSharedDatabase, SharedDatabaseState } from '../database/shared-database';
import { PropertyFilter } from '../services/property-filter';
import { TaskTemplates } from '../services/task-templates';
import { ConfigValidator } from '../services/config-validator';
@@ -150,6 +154,9 @@ export class N8NDocumentationMCPServer {
private previousToolTimestamp: number = Date.now();
private earlyLogger: EarlyErrorLogger | null = null;
private disabledToolsCache: Set<string> | null = null;
private useSharedDatabase: boolean = false; // Track if using shared DB for cleanup
private sharedDbState: SharedDatabaseState | null = null; // Reference to shared DB state for release
private isShutdown: boolean = false; // Prevent double-shutdown
constructor(instanceContext?: InstanceContext, earlyLogger?: EarlyErrorLogger) {
this.instanceContext = instanceContext;
@@ -231,10 +238,12 @@ export class N8NDocumentationMCPServer {
{
capabilities: {
tools: {},
resources: {},
},
}
);
UIAppRegistry.load();
this.setupHandlers();
}
@@ -245,18 +254,39 @@ export class N8NDocumentationMCPServer {
* Order of cleanup:
* 1. Close MCP server connection
* 2. Destroy cache (clears entries AND stops cleanup timer)
* 3. Close database connection
* 3. Release shared database OR close dedicated connection
* 4. Null out references to help GC
*
* IMPORTANT: For shared databases, we only release the reference (decrement refCount),
* NOT close the database. The database stays open for other sessions.
* For in-memory databases (tests), we close the dedicated connection.
*/
async close(): Promise<void> {
// Wait for initialization to complete (or fail) before cleanup
// This prevents race conditions where close runs while init is in progress
try {
await this.initialized;
} catch (error) {
// Initialization failed - that's OK, we still need to clean up
logger.debug('Initialization had failed, proceeding with cleanup', {
error: error instanceof Error ? error.message : String(error)
});
}
try {
await this.server.close();
// Use destroy() not clear() - also stops the cleanup timer
this.cache.destroy();
// Close database connection before nullifying reference
if (this.db) {
// Handle database cleanup based on whether it's shared or dedicated
if (this.useSharedDatabase && this.sharedDbState) {
// Shared database: release reference, don't close
// The database stays open for other sessions
releaseSharedDatabase(this.sharedDbState);
logger.debug('Released shared database reference');
} else if (this.db) {
// Dedicated database (in-memory for tests): close it
try {
this.db.close();
} catch (dbError) {
@@ -271,6 +301,7 @@ export class N8NDocumentationMCPServer {
this.repository = null;
this.templateService = null;
this.earlyLogger = null;
this.sharedDbState = null;
} catch (error) {
// Log but don't throw - cleanup should be best-effort
logger.warn('Error closing MCP server', { error: error instanceof Error ? error.message : String(error) });
@@ -286,23 +317,32 @@ export class N8NDocumentationMCPServer {
logger.debug('Database initialization starting...', { dbPath });
this.db = await createDatabaseAdapter(dbPath);
logger.debug('Database adapter created');
// If using in-memory database for tests, initialize schema
// For in-memory databases (tests), create a dedicated connection
// For regular databases, use the shared connection to prevent memory leaks
if (dbPath === ':memory:') {
this.db = await createDatabaseAdapter(dbPath);
logger.debug('Database adapter created (in-memory mode)');
await this.initializeInMemorySchema();
logger.debug('In-memory schema initialized');
this.repository = new NodeRepository(this.db);
this.templateService = new TemplateService(this.db);
// Initialize similarity services for enhanced validation
EnhancedConfigValidator.initializeSimilarityServices(this.repository);
this.useSharedDatabase = false;
} else {
// Use shared database connection to prevent ~900MB memory leak per session
// See: Memory leak fix - database was being duplicated per session
const sharedState = await getSharedDatabase(dbPath);
this.db = sharedState.db;
this.repository = sharedState.repository;
this.templateService = sharedState.templateService;
this.sharedDbState = sharedState;
this.useSharedDatabase = true;
logger.debug('Using shared database connection');
}
this.repository = new NodeRepository(this.db);
logger.debug('Node repository initialized');
this.templateService = new TemplateService(this.db);
logger.debug('Template service initialized');
// Initialize similarity services for enhanced validation
EnhancedConfigValidator.initializeSimilarityServices(this.repository);
logger.debug('Similarity services initialized');
// Checkpoint: Database connected (v2.18.3)
@@ -528,6 +568,7 @@ export class N8NDocumentationMCPServer {
protocolVersion: negotiationResult.version,
capabilities: {
tools: {},
resources: {},
},
serverInfo: {
name: 'n8n-documentation-mcp',
@@ -610,6 +651,7 @@ export class N8NDocumentationMCPServer {
});
});
UIAppRegistry.injectToolMeta(tools);
return { tools };
});
@@ -739,7 +781,7 @@ export class N8NDocumentationMCPServer {
if (name.startsWith('validate_') && structuredContent !== null) {
mcpResponse.structuredContent = structuredContent;
}
return mcpResponse;
} catch (error) {
logger.error(`Error executing tool ${name}`, error);
@@ -791,6 +833,46 @@ export class N8NDocumentationMCPServer {
};
}
});
// Handle ListResources for UI apps
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
const apps = UIAppRegistry.getAllApps();
return {
resources: apps
.filter(app => app.html !== null)
.map(app => ({
uri: app.config.uri,
name: app.config.displayName,
description: app.config.description,
mimeType: app.config.mimeType,
})),
};
});
// Handle ReadResource for UI apps
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const uri = request.params.uri;
// Parse ui://n8n-mcp/{id} pattern
const match = uri.match(/^ui:\/\/n8n-mcp\/(.+)$/);
if (!match) {
throw new Error(`Unknown resource URI: ${uri}`);
}
const app = UIAppRegistry.getAppById(match[1]);
if (!app || !app.html) {
throw new Error(`UI app not found or not built: ${match[1]}`);
}
return {
contents: [
{
uri: app.config.uri,
mimeType: app.config.mimeType,
text: app.html,
},
],
};
});
}
/**
@@ -3910,8 +3992,33 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
}
async shutdown(): Promise<void> {
// Prevent double-shutdown
if (this.isShutdown) {
logger.debug('Shutdown already called, skipping');
return;
}
this.isShutdown = true;
logger.info('Shutting down MCP server...');
// Wait for initialization to complete (or fail) before cleanup
// This prevents race conditions where shutdown runs while init is in progress
try {
await this.initialized;
} catch (error) {
// Initialization failed - that's OK, we still need to clean up
logger.debug('Initialization had failed, proceeding with cleanup', {
error: error instanceof Error ? error.message : String(error)
});
}
// Close MCP server connection (for consistency with close() method)
try {
await this.server.close();
} catch (error) {
logger.error('Error closing MCP server:', error);
}
// Clean up cache timers to prevent memory leaks
if (this.cache) {
try {
@@ -3921,15 +4028,31 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
logger.error('Error cleaning up cache:', error);
}
}
// Close database connection if it exists
if (this.db) {
// Handle database cleanup based on whether it's shared or dedicated
// For shared databases, we only release the reference (decrement refCount)
// For dedicated databases (in-memory for tests), we close the connection
if (this.useSharedDatabase && this.sharedDbState) {
try {
await this.db.close();
releaseSharedDatabase(this.sharedDbState);
logger.info('Released shared database reference');
} catch (error) {
logger.error('Error releasing shared database:', error);
}
} else if (this.db) {
try {
this.db.close();
logger.info('Database connection closed');
} catch (error) {
logger.error('Error closing database:', error);
}
}
// Null out references to help garbage collection
this.db = null;
this.repository = null;
this.templateService = null;
this.earlyLogger = null;
this.sharedDbState = null;
}
}

36
src/mcp/ui/app-configs.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { UIAppConfig } from './types';
export const UI_APP_CONFIGS: UIAppConfig[] = [
{
id: 'operation-result',
displayName: 'Operation Result',
description: 'Visual summary of workflow operations (create, update, delete, test)',
uri: 'ui://n8n-mcp/operation-result',
mimeType: 'text/html;profile=mcp-app',
toolPatterns: [
'n8n_create_workflow',
'n8n_update_full_workflow',
'n8n_update_partial_workflow',
'n8n_delete_workflow',
'n8n_test_workflow',
'n8n_autofix_workflow',
// 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: '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.
];

3
src/mcp/ui/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export type { UIAppConfig, UIMetadata, UIAppEntry } from './types';
export { UI_APP_CONFIGS } from './app-configs';
export { UIAppRegistry } from './registry';

90
src/mcp/ui/registry.ts Normal file
View File

@@ -0,0 +1,90 @@
import { existsSync, readFileSync } from 'fs';
import path from 'path';
import { logger } from '../../utils/logger';
import type { UIAppConfig, UIAppEntry } from './types';
import { UI_APP_CONFIGS } from './app-configs';
export class UIAppRegistry {
private static entries: Map<string, UIAppEntry> = new Map();
private static toolIndex: Map<string, UIAppEntry> = new Map();
private static loaded = false;
static load(): void {
// Resolve dist directory relative to package root
// In production: package-root/ui-apps/dist/
// __dirname will be src/mcp/ui or dist/mcp/ui
const packageRoot = path.resolve(__dirname, '..', '..', '..');
const distDir = path.join(packageRoot, 'ui-apps', 'dist');
this.entries.clear();
this.toolIndex.clear();
for (const config of UI_APP_CONFIGS) {
let html: string | null = null;
const htmlPath = path.join(distDir, config.id, 'index.html');
if (existsSync(htmlPath)) {
try {
html = readFileSync(htmlPath, 'utf-8');
logger.info(`Loaded UI app: ${config.id}`);
} catch (err) {
logger.warn(`Failed to read UI app HTML: ${config.id}`, err);
}
}
const entry: UIAppEntry = { config, html };
this.entries.set(config.id, entry);
// Build tool -> entry index
for (const pattern of config.toolPatterns) {
this.toolIndex.set(pattern, entry);
}
}
this.loaded = true;
logger.info(`UI App Registry loaded: ${this.entries.size} apps, ${this.toolIndex.size} tool mappings`);
}
static getAppForTool(toolName: string): UIAppEntry | null {
if (!this.loaded) return null;
return this.toolIndex.get(toolName) ?? null;
}
static getAppById(id: string): UIAppEntry | null {
if (!this.loaded) return null;
return this.entries.get(id) ?? null;
}
static getAllApps(): UIAppEntry[] {
if (!this.loaded) return [];
return Array.from(this.entries.values());
}
/**
* Enrich tool definitions with _meta.ui.resourceUri for tools that have
* a matching UI app. Per MCP ext-apps spec, this goes on the tool
* definition (tools/list), not the tool call response.
*
* 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();
this.toolIndex.clear();
this.loaded = false;
}
}

23
src/mcp/ui/types.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* MCP Apps UI type definitions
*/
export interface UIAppConfig {
id: string;
displayName: string;
description: string;
uri: string;
mimeType: string;
toolPatterns: string[];
}
export interface UIMetadata {
ui: {
resourceUri: string;
};
}
export interface UIAppEntry {
config: UIAppConfig;
html: string | null;
}

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

@@ -90,7 +90,7 @@ export class TypeStructureService {
/**
* Get all type structure definitions
*
* Returns a record of all 22 NodePropertyTypes with their structures.
* Returns a record of all 23 NodePropertyTypes with their structures.
* Useful for documentation, validation setup, or UI generation.
*
* @returns Record mapping all types to their structures

View File

@@ -58,6 +58,13 @@ export class TelemetryBatchProcessor {
private flushTimes: number[] = [];
private deadLetterQueue: (TelemetryEvent | WorkflowTelemetry | WorkflowMutationRecord)[] = [];
private readonly maxDeadLetterSize = 100;
// Track event listeners for proper cleanup to prevent memory leaks
private eventListeners: {
beforeExit?: () => void;
sigint?: () => void;
sigterm?: () => void;
} = {};
private started: boolean = false;
constructor(
private supabase: SupabaseClient | null,
@@ -72,6 +79,12 @@ export class TelemetryBatchProcessor {
start(): void {
if (!this.isEnabled() || !this.supabase) return;
// Guard against multiple starts (prevents event listener accumulation)
if (this.started) {
logger.debug('Telemetry batch processor already started, skipping');
return;
}
// Set up periodic flushing
this.flushTimer = setInterval(() => {
this.flush();
@@ -83,17 +96,22 @@ export class TelemetryBatchProcessor {
this.flushTimer.unref();
}
// Set up process exit handlers
process.on('beforeExit', () => this.flush());
process.on('SIGINT', () => {
// Set up process exit handlers with stored references for cleanup
this.eventListeners.beforeExit = () => this.flush();
this.eventListeners.sigint = () => {
this.flush();
process.exit(0);
});
process.on('SIGTERM', () => {
};
this.eventListeners.sigterm = () => {
this.flush();
process.exit(0);
});
};
process.on('beforeExit', this.eventListeners.beforeExit);
process.on('SIGINT', this.eventListeners.sigint);
process.on('SIGTERM', this.eventListeners.sigterm);
this.started = true;
logger.debug('Telemetry batch processor started');
}
@@ -105,6 +123,20 @@ export class TelemetryBatchProcessor {
clearInterval(this.flushTimer);
this.flushTimer = undefined;
}
// Remove event listeners to prevent memory leaks
if (this.eventListeners.beforeExit) {
process.removeListener('beforeExit', this.eventListeners.beforeExit);
}
if (this.eventListeners.sigint) {
process.removeListener('SIGINT', this.eventListeners.sigint);
}
if (this.eventListeners.sigterm) {
process.removeListener('SIGTERM', this.eventListeners.sigterm);
}
this.eventListeners = {};
this.started = false;
logger.debug('Telemetry batch processor stopped');
}

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

@@ -11,7 +11,7 @@ import { isTypeStructure } from '@/types/type-structures';
import type { NodePropertyTypes } from 'n8n-workflow';
describe('TYPE_STRUCTURES', () => {
// All 22 NodePropertyTypes from n8n-workflow
// All 23 NodePropertyTypes from n8n-workflow
const ALL_PROPERTY_TYPES: NodePropertyTypes[] = [
'boolean',
'button',
@@ -20,6 +20,7 @@ describe('TYPE_STRUCTURES', () => {
'dateTime',
'fixedCollection',
'hidden',
'icon',
'json',
'callout',
'notice',
@@ -38,16 +39,16 @@ describe('TYPE_STRUCTURES', () => {
];
describe('Completeness', () => {
it('should define all 22 NodePropertyTypes', () => {
it('should define all 23 NodePropertyTypes', () => {
const definedTypes = Object.keys(TYPE_STRUCTURES);
expect(definedTypes).toHaveLength(22);
expect(definedTypes).toHaveLength(23);
for (const type of ALL_PROPERTY_TYPES) {
expect(TYPE_STRUCTURES).toHaveProperty(type);
}
});
it('should not have extra types beyond the 22 standard types', () => {
it('should not have extra types beyond the 23 standard types', () => {
const definedTypes = Object.keys(TYPE_STRUCTURES);
const extraTypes = definedTypes.filter((type) => !ALL_PROPERTY_TYPES.includes(type as NodePropertyTypes));

View File

@@ -0,0 +1,302 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock dependencies at module level
const mockDb = {
prepare: vi.fn().mockReturnValue({
get: vi.fn(),
all: vi.fn(),
run: vi.fn()
}),
exec: vi.fn(),
close: vi.fn(),
pragma: vi.fn(),
inTransaction: false,
transaction: vi.fn(),
checkFTS5Support: vi.fn()
};
vi.mock('../../../src/database/database-adapter', () => ({
createDatabaseAdapter: vi.fn().mockResolvedValue(mockDb)
}));
vi.mock('../../../src/database/node-repository', () => ({
NodeRepository: vi.fn().mockImplementation(() => ({
getNodeTypes: vi.fn().mockReturnValue([])
}))
}));
vi.mock('../../../src/templates/template-service', () => ({
TemplateService: vi.fn().mockImplementation(() => ({}))
}));
vi.mock('../../../src/services/enhanced-config-validator', () => ({
EnhancedConfigValidator: {
initializeSimilarityServices: vi.fn()
}
}));
vi.mock('../../../src/utils/logger', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
}));
describe('Shared Database Module', () => {
let sharedDbModule: typeof import('../../../src/database/shared-database');
let createDatabaseAdapter: ReturnType<typeof vi.fn>;
beforeEach(async () => {
// Reset all mocks
vi.clearAllMocks();
mockDb.close.mockReset();
// Reset modules to get fresh state
vi.resetModules();
// Import fresh module
sharedDbModule = await import('../../../src/database/shared-database');
// Get the mocked function
const adapterModule = await import('../../../src/database/database-adapter');
createDatabaseAdapter = adapterModule.createDatabaseAdapter as ReturnType<typeof vi.fn>;
});
afterEach(async () => {
// Clean up any shared state by closing
try {
await sharedDbModule.closeSharedDatabase();
} catch {
// Ignore errors during cleanup
}
});
describe('getSharedDatabase', () => {
it('should initialize database on first call', async () => {
const state = await sharedDbModule.getSharedDatabase('/path/to/db');
expect(state).toBeDefined();
expect(state.db).toBe(mockDb);
expect(state.dbPath).toBe('/path/to/db');
expect(state.refCount).toBe(1);
expect(state.initialized).toBe(true);
expect(createDatabaseAdapter).toHaveBeenCalledWith('/path/to/db');
});
it('should reuse existing connection and increment refCount', async () => {
// First call initializes
const state1 = await sharedDbModule.getSharedDatabase('/path/to/db');
expect(state1.refCount).toBe(1);
// Second call reuses
const state2 = await sharedDbModule.getSharedDatabase('/path/to/db');
expect(state2.refCount).toBe(2);
// Same object
expect(state1).toBe(state2);
// Only initialized once
expect(createDatabaseAdapter).toHaveBeenCalledTimes(1);
});
it('should throw error when called with different path', async () => {
await sharedDbModule.getSharedDatabase('/path/to/db1');
await expect(sharedDbModule.getSharedDatabase('/path/to/db2'))
.rejects.toThrow('Shared database already initialized with different path');
});
it('should handle concurrent initialization requests', async () => {
// Start two requests concurrently
const [state1, state2] = await Promise.all([
sharedDbModule.getSharedDatabase('/path/to/db'),
sharedDbModule.getSharedDatabase('/path/to/db')
]);
// Both should get the same state
expect(state1).toBe(state2);
// RefCount should be 2 (one for each call)
expect(state1.refCount).toBe(2);
// Only one actual initialization
expect(createDatabaseAdapter).toHaveBeenCalledTimes(1);
});
it('should handle initialization failure', async () => {
createDatabaseAdapter.mockRejectedValueOnce(new Error('DB error'));
await expect(sharedDbModule.getSharedDatabase('/path/to/db'))
.rejects.toThrow('DB error');
// After failure, should not be initialized
expect(sharedDbModule.isSharedDatabaseInitialized()).toBe(false);
});
it('should allow retry after initialization failure', async () => {
// First call fails
createDatabaseAdapter.mockRejectedValueOnce(new Error('DB error'));
await expect(sharedDbModule.getSharedDatabase('/path/to/db'))
.rejects.toThrow('DB error');
// Reset mock for successful call
createDatabaseAdapter.mockResolvedValueOnce(mockDb);
// Second call succeeds
const state = await sharedDbModule.getSharedDatabase('/path/to/db');
expect(state).toBeDefined();
expect(state.initialized).toBe(true);
});
});
describe('releaseSharedDatabase', () => {
it('should decrement refCount', async () => {
const state = await sharedDbModule.getSharedDatabase('/path/to/db');
expect(state.refCount).toBe(1);
sharedDbModule.releaseSharedDatabase(state);
expect(sharedDbModule.getSharedDatabaseRefCount()).toBe(0);
});
it('should not decrement below 0', async () => {
const state = await sharedDbModule.getSharedDatabase('/path/to/db');
// Release once (refCount: 1 -> 0)
sharedDbModule.releaseSharedDatabase(state);
expect(sharedDbModule.getSharedDatabaseRefCount()).toBe(0);
// Release again (should stay at 0, not go negative)
sharedDbModule.releaseSharedDatabase(state);
expect(sharedDbModule.getSharedDatabaseRefCount()).toBe(0);
});
it('should handle null state gracefully', () => {
// Should not throw
sharedDbModule.releaseSharedDatabase(null as any);
});
it('should not close database when refCount hits 0', async () => {
const state = await sharedDbModule.getSharedDatabase('/path/to/db');
sharedDbModule.releaseSharedDatabase(state);
expect(sharedDbModule.getSharedDatabaseRefCount()).toBe(0);
expect(mockDb.close).not.toHaveBeenCalled();
// Database should still be accessible
expect(sharedDbModule.isSharedDatabaseInitialized()).toBe(true);
});
});
describe('closeSharedDatabase', () => {
it('should close database and clear state', async () => {
// Get state
await sharedDbModule.getSharedDatabase('/path/to/db');
expect(sharedDbModule.isSharedDatabaseInitialized()).toBe(true);
expect(sharedDbModule.getSharedDatabaseRefCount()).toBe(1);
await sharedDbModule.closeSharedDatabase();
// State should be cleared
expect(sharedDbModule.isSharedDatabaseInitialized()).toBe(false);
expect(sharedDbModule.getSharedDatabaseRefCount()).toBe(0);
});
it('should handle close error gracefully', async () => {
await sharedDbModule.getSharedDatabase('/path/to/db');
mockDb.close.mockImplementationOnce(() => {
throw new Error('Close error');
});
// Should not throw
await sharedDbModule.closeSharedDatabase();
// State should still be cleared
expect(sharedDbModule.isSharedDatabaseInitialized()).toBe(false);
});
it('should be idempotent when already closed', async () => {
// Close without ever initializing
await sharedDbModule.closeSharedDatabase();
// Should not throw
await sharedDbModule.closeSharedDatabase();
});
it('should allow re-initialization after close', async () => {
// Initialize
const state1 = await sharedDbModule.getSharedDatabase('/path/to/db');
expect(state1.refCount).toBe(1);
// Close
await sharedDbModule.closeSharedDatabase();
expect(sharedDbModule.isSharedDatabaseInitialized()).toBe(false);
// Re-initialize
const state2 = await sharedDbModule.getSharedDatabase('/path/to/db');
expect(state2.refCount).toBe(1);
expect(sharedDbModule.isSharedDatabaseInitialized()).toBe(true);
// Should be a new state object
expect(state1).not.toBe(state2);
});
});
describe('isSharedDatabaseInitialized', () => {
it('should return false before initialization', () => {
expect(sharedDbModule.isSharedDatabaseInitialized()).toBe(false);
});
it('should return true after initialization', async () => {
await sharedDbModule.getSharedDatabase('/path/to/db');
expect(sharedDbModule.isSharedDatabaseInitialized()).toBe(true);
});
it('should return false after close', async () => {
await sharedDbModule.getSharedDatabase('/path/to/db');
await sharedDbModule.closeSharedDatabase();
expect(sharedDbModule.isSharedDatabaseInitialized()).toBe(false);
});
});
describe('getSharedDatabaseRefCount', () => {
it('should return 0 before initialization', () => {
expect(sharedDbModule.getSharedDatabaseRefCount()).toBe(0);
});
it('should return correct refCount after multiple operations', async () => {
const state = await sharedDbModule.getSharedDatabase('/path/to/db');
expect(sharedDbModule.getSharedDatabaseRefCount()).toBe(1);
await sharedDbModule.getSharedDatabase('/path/to/db');
expect(sharedDbModule.getSharedDatabaseRefCount()).toBe(2);
await sharedDbModule.getSharedDatabase('/path/to/db');
expect(sharedDbModule.getSharedDatabaseRefCount()).toBe(3);
sharedDbModule.releaseSharedDatabase(state);
expect(sharedDbModule.getSharedDatabaseRefCount()).toBe(2);
});
it('should return 0 after close', async () => {
await sharedDbModule.getSharedDatabase('/path/to/db');
await sharedDbModule.closeSharedDatabase();
expect(sharedDbModule.getSharedDatabaseRefCount()).toBe(0);
});
});
describe('SharedDatabaseState interface', () => {
it('should expose correct properties', async () => {
const state = await sharedDbModule.getSharedDatabase('/path/to/db');
expect(state).toHaveProperty('db');
expect(state).toHaveProperty('repository');
expect(state).toHaveProperty('templateService');
expect(state).toHaveProperty('dbPath');
expect(state).toHaveProperty('refCount');
expect(state).toHaveProperty('initialized');
});
});
});

View File

@@ -333,13 +333,14 @@ describe('HTTP Server Session Management', () => {
server = new SingleSessionHTTPServer();
// Mock expired sessions
// Note: Default session timeout is 5 minutes (configurable via SESSION_TIMEOUT_MINUTES)
const mockSessionMetadata = {
'session-1': {
lastAccess: new Date(Date.now() - 40 * 60 * 1000), // 40 minutes ago (expired)
'session-1': {
lastAccess: new Date(Date.now() - 10 * 60 * 1000), // 10 minutes ago (expired with 5 min timeout)
createdAt: new Date(Date.now() - 60 * 60 * 1000)
},
'session-2': {
lastAccess: new Date(Date.now() - 10 * 60 * 1000), // 10 minutes ago (not expired)
'session-2': {
lastAccess: new Date(Date.now() - 2 * 60 * 1000), // 2 minutes ago (not expired with 5 min timeout)
createdAt: new Date(Date.now() - 20 * 60 * 1000)
}
};
@@ -514,15 +515,16 @@ describe('HTTP Server Session Management', () => {
it('should get session metrics correctly', async () => {
server = new SingleSessionHTTPServer();
// Note: Default session timeout is 5 minutes (configurable via SESSION_TIMEOUT_MINUTES)
const now = Date.now();
(server as any).sessionMetadata = {
'active-session': {
lastAccess: new Date(now - 10 * 60 * 1000), // 10 minutes ago
lastAccess: new Date(now - 2 * 60 * 1000), // 2 minutes ago (not expired with 5 min timeout)
createdAt: new Date(now - 20 * 60 * 1000)
},
'expired-session': {
lastAccess: new Date(now - 40 * 60 * 1000), // 40 minutes ago (expired)
lastAccess: new Date(now - 10 * 60 * 1000), // 10 minutes ago (expired with 5 min timeout)
createdAt: new Date(now - 60 * 60 * 1000)
}
};
@@ -532,7 +534,7 @@ describe('HTTP Server Session Management', () => {
};
const metrics = (server as any).getSessionMetrics();
expect(metrics.totalSessions).toBe(2);
expect(metrics.activeSessions).toBe(2);
expect(metrics.expiredSessions).toBe(1);

View File

@@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest';
import { UI_APP_CONFIGS } from '@/mcp/ui/app-configs';
describe('UI_APP_CONFIGS', () => {
it('should have all required fields for every config', () => {
for (const config of UI_APP_CONFIGS) {
expect(config.id).toBeDefined();
expect(typeof config.id).toBe('string');
expect(config.id.length).toBeGreaterThan(0);
expect(config.displayName).toBeDefined();
expect(typeof config.displayName).toBe('string');
expect(config.displayName.length).toBeGreaterThan(0);
expect(config.description).toBeDefined();
expect(typeof config.description).toBe('string');
expect(config.description.length).toBeGreaterThan(0);
expect(config.uri).toBeDefined();
expect(typeof config.uri).toBe('string');
expect(config.mimeType).toBeDefined();
expect(typeof config.mimeType).toBe('string');
expect(config.toolPatterns).toBeDefined();
expect(Array.isArray(config.toolPatterns)).toBe(true);
}
});
it('should have URIs following ui://n8n-mcp/{id} pattern', () => {
for (const config of UI_APP_CONFIGS) {
expect(config.uri).toBe(`ui://n8n-mcp/${config.id}`);
}
});
it('should have unique IDs', () => {
const ids = UI_APP_CONFIGS.map(c => c.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it('should have non-empty toolPatterns arrays', () => {
for (const config of UI_APP_CONFIGS) {
expect(config.toolPatterns.length).toBeGreaterThan(0);
for (const pattern of config.toolPatterns) {
expect(typeof pattern).toBe('string');
expect(pattern.length).toBeGreaterThan(0);
}
}
});
it('should not have duplicate tool patterns across configs', () => {
const allPatterns: string[] = [];
for (const config of UI_APP_CONFIGS) {
allPatterns.push(...config.toolPatterns);
}
const uniquePatterns = new Set(allPatterns);
expect(uniquePatterns.size).toBe(allPatterns.length);
});
it('should not have duplicate tool patterns within a single config', () => {
for (const config of UI_APP_CONFIGS) {
const unique = new Set(config.toolPatterns);
expect(unique.size).toBe(config.toolPatterns.length);
}
});
it('should have consistent mimeType of text/html;profile=mcp-app', () => {
for (const config of UI_APP_CONFIGS) {
expect(config.mimeType).toBe('text/html;profile=mcp-app');
}
});
it('should have URIs that start with the ui://n8n-mcp/ scheme', () => {
for (const config of UI_APP_CONFIGS) {
expect(config.uri).toMatch(/^ui:\/\/n8n-mcp\//);
}
});
// Regression: verify expected configs are present
it('should contain the operation-result config', () => {
const config = UI_APP_CONFIGS.find(c => c.id === 'operation-result');
expect(config).toBeDefined();
expect(config!.displayName).toBe('Operation Result');
expect(config!.toolPatterns).toContain('n8n_create_workflow');
expect(config!.toolPatterns).toContain('n8n_update_full_workflow');
expect(config!.toolPatterns).toContain('n8n_delete_workflow');
expect(config!.toolPatterns).toContain('n8n_test_workflow');
expect(config!.toolPatterns).not.toContain('n8n_deploy_template');
});
it('should contain the validation-summary config', () => {
const config = UI_APP_CONFIGS.find(c => c.id === 'validation-summary');
expect(config).toBeDefined();
expect(config!.displayName).toBe('Validation Summary');
expect(config!.toolPatterns).toContain('validate_node');
expect(config!.toolPatterns).toContain('validate_workflow');
expect(config!.toolPatterns).toContain('n8n_validate_workflow');
});
it('should have exactly 2 configs', () => {
expect(UI_APP_CONFIGS.length).toBe(2);
});
it('should 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

@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UIAppRegistry } from '@/mcp/ui/registry';
vi.mock('fs', () => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
}));
import { existsSync, readFileSync } from 'fs';
const mockExistsSync = vi.mocked(existsSync);
const mockReadFileSync = vi.mocked(readFileSync);
describe('UI Meta Injection on Tool Definitions', () => {
beforeEach(() => {
vi.clearAllMocks();
UIAppRegistry.reset();
});
describe('when HTML is loaded', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>ui content</html>');
UIAppRegistry.load();
});
it('should add _meta.ui.resourceUri to matching tool definitions', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create workflow', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeDefined();
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
});
it('should add _meta.ui.resourceUri to validation tool definitions', () => {
const tools: any[] = [
{ name: 'validate_workflow', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeDefined();
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary');
});
it('should NOT add _meta to non-matching tool definitions', () => {
const tools: any[] = [
{ name: 'get_node_info', description: 'Get info', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeUndefined();
});
it('should inject _meta on matching tools and skip non-matching in a mixed list', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
{ name: 'get_node_info', description: 'Info', inputSchema: { type: 'object', properties: {} } },
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeDefined();
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
expect(tools[1]._meta).toBeUndefined();
expect(tools[2]._meta).toBeDefined();
expect(tools[2]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary');
});
it('should produce _meta with 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: {
resourceUri: 'ui://n8n-mcp/operation-result',
},
'ui/resourceUri': 'ui://n8n-mcp/operation-result',
});
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
expect(tools[0]._meta['ui/resourceUri']).toBe('ui://n8n-mcp/operation-result');
});
});
describe('when HTML is not loaded', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
});
it('should NOT add _meta even for matching tools', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeUndefined();
});
it('should NOT add _meta for validation tools without HTML', () => {
const tools: any[] = [
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeUndefined();
});
});
describe('when registry has not been loaded at all', () => {
it('should NOT add _meta because registry is not loaded', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeUndefined();
});
});
describe('empty tool list', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>ui</html>');
UIAppRegistry.load();
});
it('should handle an empty tools array without error', () => {
const tools: any[] = [];
UIAppRegistry.injectToolMeta(tools);
expect(tools.length).toBe(0);
});
});
});

View File

@@ -0,0 +1,377 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UIAppRegistry } from '@/mcp/ui/registry';
import { UI_APP_CONFIGS } from '@/mcp/ui/app-configs';
vi.mock('fs', () => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
}));
import { existsSync, readFileSync } from 'fs';
const mockExistsSync = vi.mocked(existsSync);
const mockReadFileSync = vi.mocked(readFileSync);
describe('UIAppRegistry', () => {
beforeEach(() => {
vi.clearAllMocks();
UIAppRegistry.reset();
});
describe('load()', () => {
it('should load HTML files when dist directory exists', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>test</html>');
UIAppRegistry.load();
const apps = UIAppRegistry.getAllApps();
expect(apps.length).toBe(UI_APP_CONFIGS.length);
for (const app of apps) {
expect(app.html).toBe('<html>test</html>');
}
});
it('should handle missing dist directory gracefully', () => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
const apps = UIAppRegistry.getAllApps();
expect(apps.length).toBe(UI_APP_CONFIGS.length);
for (const app of apps) {
expect(app.html).toBeNull();
}
});
it('should handle read errors gracefully', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockImplementation(() => {
throw new Error('Permission denied');
});
UIAppRegistry.load();
const apps = UIAppRegistry.getAllApps();
expect(apps.length).toBe(UI_APP_CONFIGS.length);
for (const app of apps) {
expect(app.html).toBeNull();
}
});
it('should set loaded flag so getters work', () => {
expect(UIAppRegistry.getAllApps()).toEqual([]);
expect(UIAppRegistry.getAppById('operation-result')).toBeNull();
expect(UIAppRegistry.getAppForTool('n8n_create_workflow')).toBeNull();
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
expect(UIAppRegistry.getAllApps().length).toBeGreaterThan(0);
});
it('should replace previous entries when called twice', () => {
// First load: files exist
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>first</html>');
UIAppRegistry.load();
expect(UIAppRegistry.getAppById('operation-result')!.html).toBe('<html>first</html>');
// Second load: files missing
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
expect(UIAppRegistry.getAppById('operation-result')!.html).toBeNull();
});
it('should handle empty HTML file content', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('');
UIAppRegistry.load();
const app = UIAppRegistry.getAppById('operation-result');
expect(app).not.toBeNull();
// Empty string is still a string, not null
expect(app!.html).toBe('');
});
it('should build the correct number of tool index entries', () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>app</html>');
UIAppRegistry.load();
// Every tool pattern from every config should be resolvable
for (const config of UI_APP_CONFIGS) {
for (const pattern of config.toolPatterns) {
const entry = UIAppRegistry.getAppForTool(pattern);
expect(entry).not.toBeNull();
expect(entry!.config.id).toBe(config.id);
}
}
});
it('should call existsSync for each config', () => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
expect(mockExistsSync).toHaveBeenCalledTimes(UI_APP_CONFIGS.length);
});
it('should only call readFileSync when existsSync returns true', () => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
expect(mockReadFileSync).not.toHaveBeenCalled();
});
});
describe('getAppForTool()', () => {
it('should return null before load() is called', () => {
const entry = UIAppRegistry.getAppForTool('n8n_create_workflow');
expect(entry).toBeNull();
});
describe('after loading', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>loaded</html>');
UIAppRegistry.load();
});
it('should return correct entry for known tool patterns', () => {
const entry = UIAppRegistry.getAppForTool('n8n_create_workflow');
expect(entry).not.toBeNull();
expect(entry!.config.id).toBe('operation-result');
});
it('should return correct entry for validation tools', () => {
const entry = UIAppRegistry.getAppForTool('validate_node');
expect(entry).not.toBeNull();
expect(entry!.config.id).toBe('validation-summary');
});
it('should return null for unknown tools', () => {
const entry = UIAppRegistry.getAppForTool('unknown_tool');
expect(entry).toBeNull();
});
it('should return null for empty string tool name', () => {
const entry = UIAppRegistry.getAppForTool('');
expect(entry).toBeNull();
});
// Regression: verify specific tools ARE mapped so config changes break the test
it('should map n8n_create_workflow to operation-result', () => {
expect(UIAppRegistry.getAppForTool('n8n_create_workflow')!.config.id).toBe('operation-result');
});
it('should map n8n_update_full_workflow to operation-result', () => {
expect(UIAppRegistry.getAppForTool('n8n_update_full_workflow')!.config.id).toBe('operation-result');
});
it('should map n8n_update_partial_workflow to operation-result', () => {
expect(UIAppRegistry.getAppForTool('n8n_update_partial_workflow')!.config.id).toBe('operation-result');
});
it('should map n8n_delete_workflow to operation-result', () => {
expect(UIAppRegistry.getAppForTool('n8n_delete_workflow')!.config.id).toBe('operation-result');
});
it('should map n8n_test_workflow to operation-result', () => {
expect(UIAppRegistry.getAppForTool('n8n_test_workflow')!.config.id).toBe('operation-result');
});
it('should map n8n_autofix_workflow to operation-result', () => {
expect(UIAppRegistry.getAppForTool('n8n_autofix_workflow')!.config.id).toBe('operation-result');
});
it('should 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', () => {
expect(UIAppRegistry.getAppForTool('validate_node')!.config.id).toBe('validation-summary');
});
it('should map validate_workflow to validation-summary', () => {
expect(UIAppRegistry.getAppForTool('validate_workflow')!.config.id).toBe('validation-summary');
});
it('should map n8n_validate_workflow to validation-summary', () => {
expect(UIAppRegistry.getAppForTool('n8n_validate_workflow')!.config.id).toBe('validation-summary');
});
});
});
describe('getAppById()', () => {
it('should return null before load() is called', () => {
const entry = UIAppRegistry.getAppById('operation-result');
expect(entry).toBeNull();
});
describe('after loading', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>app</html>');
UIAppRegistry.load();
});
it('should return correct entry for operation-result', () => {
const entry = UIAppRegistry.getAppById('operation-result');
expect(entry).not.toBeNull();
expect(entry!.config.displayName).toBe('Operation Result');
expect(entry!.html).toBe('<html>app</html>');
});
it('should return correct entry for validation-summary', () => {
const entry = UIAppRegistry.getAppById('validation-summary');
expect(entry).not.toBeNull();
expect(entry!.config.displayName).toBe('Validation Summary');
});
it('should return null for unknown id', () => {
const entry = UIAppRegistry.getAppById('nonexistent');
expect(entry).toBeNull();
});
it('should return null for empty string id', () => {
const entry = UIAppRegistry.getAppById('');
expect(entry).toBeNull();
});
});
});
describe('getAllApps()', () => {
it('should return empty array before load() is called', () => {
const apps = UIAppRegistry.getAllApps();
expect(apps).toEqual([]);
});
it('should return all entries after load', () => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
const apps = UIAppRegistry.getAllApps();
expect(apps.length).toBe(UI_APP_CONFIGS.length);
expect(apps.map(a => a.config.id)).toContain('operation-result');
expect(apps.map(a => a.config.id)).toContain('validation-summary');
});
it('should include entries with null html when dist is missing', () => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
const apps = UIAppRegistry.getAllApps();
for (const app of apps) {
expect(app.html).toBeNull();
}
// Entries are still present even with null html
expect(apps.length).toBe(UI_APP_CONFIGS.length);
});
it('should return entries with full config objects', () => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
for (const app of UIAppRegistry.getAllApps()) {
expect(app.config).toBeDefined();
expect(app.config.id).toBeDefined();
expect(app.config.displayName).toBeDefined();
expect(app.config.uri).toBeDefined();
expect(app.config.mimeType).toBeDefined();
expect(app.config.toolPatterns).toBeDefined();
expect(app.config.description).toBeDefined();
}
});
});
describe('injectToolMeta()', () => {
it('should not modify tools before load() is called', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeUndefined();
});
describe('after loading with HTML', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>loaded</html>');
UIAppRegistry.load();
});
it('should set _meta.ui.resourceUri on matching operation tools', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toEqual({ ui: { resourceUri: 'ui://n8n-mcp/operation-result' }, '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);
mockReadFileSync.mockReturnValue('<html>x</html>');
UIAppRegistry.load();
expect(UIAppRegistry.getAllApps().length).toBeGreaterThan(0);
UIAppRegistry.reset();
expect(UIAppRegistry.getAllApps()).toEqual([]);
expect(UIAppRegistry.getAppById('operation-result')).toBeNull();
expect(UIAppRegistry.getAppForTool('n8n_create_workflow')).toBeNull();
});
});
});

View File

@@ -58,9 +58,9 @@ describe('TypeStructureService', () => {
});
describe('getAllStructures', () => {
it('should return all 22 type structures', () => {
it('should return all 23 type structures', () => {
const structures = TypeStructureService.getAllStructures();
expect(Object.keys(structures)).toHaveLength(22);
expect(Object.keys(structures)).toHaveLength(23);
});
it('should return a copy not a reference', () => {

1911
ui-apps/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
ui-apps/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "n8n-mcp-ui-apps",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "APP_NAME=operation-result vite build && APP_NAME=validation-summary vite build && 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"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.8.0",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.0.0"
}
}

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

@@ -0,0 +1,337 @@
import React from 'react';
import '@shared/styles/theme.css';
import { Badge, Expandable } from '@shared/components';
import { useToolData } from '@shared/hooks/useToolData';
import type { OperationResultData, OperationType } from '@shared/types';
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',
};
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></>;
}
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 (
<>
{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>
)}
{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>
)}
</>
);
}
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;
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

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Operation Result</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

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,211 @@
import React, { useMemo } from 'react';
import '@shared/styles/theme.css';
import { Badge, Expandable } from '@shared/components';
import { useToolData } from '@shared/hooks/useToolData';
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: raw, error, isConnected } = useToolData<ValidationSummaryData>();
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: '12px' }}>
<Badge variant={valid ? 'success' : 'error'}>
{valid ? 'Valid' : 'Invalid'}
</Badge>
{displayName && (
<span style={{ fontSize: '14px', color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>{displayName}</span>
)}
</div>
<SeverityBar errorCount={errorCount} warningCount={warningCount} />
{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>
)}
</>
)}
{suggestions.length > 0 && (
<Expandable title="Suggestions" count={suggestions.length}>
<ul style={{ paddingLeft: '16px', fontSize: '12px' }}>
{suggestions.map((suggestion, i) => (
<li key={i} style={{ padding: '2px 0', color: 'var(--n8n-info)' }}> {suggestion}</li>
))}
</ul>
</Expandable>
)}
</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>Validation Summary</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,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,32 @@
import React from 'react';
type BadgeVariant = 'success' | 'warning' | 'error' | 'info';
interface BadgeProps {
variant: BadgeVariant;
children: React.ReactNode;
}
const variantStyles: Record<BadgeVariant, { bg: string; color: string }> = {
success: { bg: 'var(--n8n-success-light)', color: 'var(--n8n-success)' },
warning: { bg: 'var(--n8n-warning-light)', color: 'var(--n8n-warning)' },
error: { bg: 'var(--n8n-error-light)', color: 'var(--n8n-error)' },
info: { bg: 'var(--n8n-info-light)', color: 'var(--n8n-info)' },
};
export function Badge({ variant, children }: BadgeProps) {
const style = variantStyles[variant];
return (
<span style={{
display: 'inline-block',
padding: '2px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: 600,
background: style.bg,
color: style.color,
}}>
{children}
</span>
);
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
interface CardProps {
title?: string;
children: React.ReactNode;
}
export function Card({ title, children }: CardProps) {
return (
<div style={{
background: 'var(--n8n-bg-card)',
border: '1px solid var(--n8n-border)',
borderRadius: 'var(--n8n-radius)',
padding: '16px',
marginBottom: '12px',
}}>
{title && (
<h3 style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--n8n-text-muted)' }}>
{title}
</h3>
)}
{children}
</div>
);
}

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

@@ -0,0 +1,36 @@
import React from 'react';
interface ExpandableProps {
title: string;
count?: number;
defaultOpen?: boolean;
children: React.ReactNode;
}
export function Expandable({ title, count, defaultOpen = false, children }: ExpandableProps) {
return (
<details open={defaultOpen} style={{
marginBottom: '8px',
border: '1px solid var(--n8n-border)',
borderRadius: 'var(--n8n-radius)',
overflow: 'hidden',
}}>
<summary style={{
padding: '10px 14px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
background: 'var(--n8n-bg-card)',
userSelect: 'none',
}}>
{title}
{count !== undefined && (
<span style={{ marginLeft: '8px', color: 'var(--n8n-text-muted)' }}>({count})</span>
)}
</summary>
<div style={{ padding: '12px 14px' }}>
{children}
</div>
</details>
);
}

View File

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

View File

@@ -0,0 +1,50 @@
import { useState, useCallback } from 'react';
import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react';
import type { App } from '@modelcontextprotocol/ext-apps/react';
interface UseToolDataResult<T> {
data: T | null;
error: string | null;
isConnected: boolean;
app: App | null;
toolName: string | null;
}
export function useToolData<T>(): UseToolDataResult<T> {
const [data, setData] = useState<T | null>(null);
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);
}
}
}
};
}, []);
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

@@ -0,0 +1,3 @@
export { Card, Badge, Expandable } from './components';
export { useToolData } from './hooks/useToolData';
export type { OperationResultData, ValidationSummaryData, ValidationError, ValidationWarning } from './types';

View File

@@ -0,0 +1,50 @@
:root {
/* n8n brand colors */
--n8n-primary: #ff6d5a;
--n8n-primary-light: #ff8a7a;
/* Semantic colors */
--n8n-success: #17bf79;
--n8n-warning: #f59e0b;
--n8n-error: #ef4444;
--n8n-info: #3b82f6;
/* 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: 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;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--color-background-primary, var(--n8n-bg));
color: var(--color-text-primary, var(--n8n-text));
line-height: 1.5;
padding: 16px;
}

146
ui-apps/src/shared/types.ts Normal file
View File

@@ -0,0 +1,146 @@
// Matches the McpToolResponse format from handlers-n8n-manager.ts
export interface OperationResultData {
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;
property?: string;
message: string;
fix?: string;
node?: string;
details?: unknown;
}
export interface ValidationWarning {
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;
nodeType?: string;
displayName?: string;
errors: ValidationError[];
warnings: ValidationWarning[];
suggestions?: 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;
};
};
}

21
ui-apps/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@shared/*": ["src/shared/*"]
}
},
"include": ["src"]
}

21
ui-apps/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { viteSingleFile } from 'vite-plugin-singlefile';
import path from 'path';
// App name is passed via environment variable for per-app builds
const appName = process.env.APP_NAME || 'operation-result';
export default defineConfig({
plugins: [react(), viteSingleFile()],
resolve: {
alias: {
'@shared': path.resolve(__dirname, 'src/shared'),
},
},
root: path.resolve(__dirname, 'src/apps', appName),
build: {
outDir: path.resolve(__dirname, 'dist', appName),
emptyOutDir: true,
},
});