Compare commits

...

12 Commits

Author SHA1 Message Date
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
72 changed files with 12055 additions and 1315 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,166 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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

View File

@@ -5,7 +5,7 @@
[![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.4.4-orange.svg)](https://github.com/n8n-io/n8n)
[![n8n version](https://img.shields.io/badge/n8n-2.6.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)

Binary file not shown.

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

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

7384
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.3",
"version": "2.35.0",
"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.4.3",
"@n8n/n8n-nodes-langchain": "^2.6.2",
"@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.4.4",
"n8n-core": "^2.4.2",
"n8n-workflow": "^2.4.2",
"n8n": "^2.6.3",
"n8n-core": "^2.6.1",
"n8n-workflow": "^2.6.0",
"openai": "^4.77.0",
"sql.js": "^1.13.0",
"tslib": "^2.6.2",

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

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

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

@@ -0,0 +1,62 @@
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',
],
},
{
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',
],
},
{
id: 'workflow-list',
displayName: 'Workflow List',
description: 'Compact table of workflows with status, tags, and metadata',
uri: 'ui://n8n-mcp/workflow-list',
mimeType: 'text/html;profile=mcp-app',
toolPatterns: [
'n8n_list_workflows',
],
},
{
id: 'execution-history',
displayName: 'Execution History',
description: 'Execution history table with status summary bar',
uri: 'ui://n8n-mcp/execution-history',
mimeType: 'text/html;profile=mcp-app',
toolPatterns: [
'n8n_executions',
],
},
{
id: 'health-dashboard',
displayName: 'Health Dashboard',
description: 'Connection status, versions, and performance metrics',
uri: 'ui://n8n-mcp/health-dashboard',
mimeType: 'text/html;profile=mcp-app',
toolPatterns: [
'n8n_health_check',
],
},
];

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';

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

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

23
src/mcp/ui/types.ts Normal file
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

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

@@ -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,131 @@
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).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 5 configs', () => {
expect(UI_APP_CONFIGS.length).toBe(5);
});
it('should contain the workflow-list config', () => {
const config = UI_APP_CONFIGS.find(c => c.id === 'workflow-list');
expect(config).toBeDefined();
expect(config!.displayName).toBe('Workflow List');
expect(config!.toolPatterns).toContain('n8n_list_workflows');
});
it('should contain the execution-history config', () => {
const config = UI_APP_CONFIGS.find(c => c.id === 'execution-history');
expect(config).toBeDefined();
expect(config!.displayName).toBe('Execution History');
expect(config!.toolPatterns).toContain('n8n_executions');
});
it('should contain the health-dashboard config', () => {
const config = UI_APP_CONFIGS.find(c => c.id === 'health-dashboard');
expect(config).toBeDefined();
expect(config!.displayName).toBe('Health Dashboard');
expect(config!.toolPatterns).toContain('n8n_health_check');
});
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 exact shape { ui: { resourceUri: string } }', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toEqual({
ui: {
resourceUri: 'ui://n8n-mcp/operation-result',
},
});
expect(Object.keys(tools[0]._meta)).toEqual(['ui']);
expect(Object.keys(tools[0]._meta.ui)).toEqual(['resourceUri']);
expect(typeof tools[0]._meta.ui.resourceUri).toBe('string');
});
});
describe('when HTML is not loaded', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(false);
UIAppRegistry.load();
});
it('should NOT add _meta even for matching tools', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeUndefined();
});
it('should NOT add _meta for validation tools without HTML', () => {
const tools: any[] = [
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeUndefined();
});
});
describe('when registry has not been loaded at all', () => {
it('should NOT add _meta because registry is not loaded', () => {
const tools: any[] = [
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
];
UIAppRegistry.injectToolMeta(tools);
expect(tools[0]._meta).toBeUndefined();
});
});
describe('empty tool list', () => {
beforeEach(() => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('<html>ui</html>');
UIAppRegistry.load();
});
it('should handle an empty tools array without error', () => {
const tools: any[] = [];
UIAppRegistry.injectToolMeta(tools);
expect(tools.length).toBe(0);
});
});
});

View File

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

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

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