mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-27 23:23:07 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bad880f44 | ||
|
|
77048347b3 | ||
|
|
6f695be482 | ||
|
|
34159f4ece | ||
|
|
8217229e2f | ||
|
|
89146186d8 | ||
|
|
c601581714 | ||
|
|
020bc3d43d | ||
|
|
a57b400bd0 | ||
|
|
38aa70261a | ||
|
|
1b328d8168 | ||
|
|
23b90d01a6 | ||
|
|
1f45cc6dcc | ||
|
|
6814880410 | ||
|
|
c8c76e435d | ||
|
|
fad3437977 | ||
|
|
0f15b82f1e | ||
|
|
974a9fb349 | ||
|
|
a6dcbd2473 | ||
|
|
ec5340c7e4 |
12
.github/workflows/release.yml
vendored
12
.github/workflows/release.yml
vendored
@@ -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
4
.gitignore
vendored
@@ -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/
|
||||
|
||||
975
CHANGELOG.md
975
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ RUN --mount=type=cache,target=/root/.npm \
|
||||
echo '{}' > package.json && \
|
||||
npm install --no-save typescript@^5.8.3 @types/node@^22.15.30 @types/express@^5.0.3 \
|
||||
@modelcontextprotocol/sdk@1.20.1 dotenv@^16.5.0 express@^5.1.0 axios@^1.10.0 \
|
||||
n8n-workflow@^1.96.0 uuid@^11.0.5 @types/uuid@^10.0.0 \
|
||||
n8n-workflow@^2.4.2 uuid@^11.0.5 @types/uuid@^10.0.0 \
|
||||
openai@^4.77.0 zod@3.24.1 lru-cache@^11.2.1 @supabase/supabase-js@^2.57.4
|
||||
|
||||
# Copy source and build
|
||||
|
||||
@@ -18,21 +18,27 @@ npm run update:n8n:check
|
||||
# 4. Run update and skip tests (we'll test in CI)
|
||||
yes y | npm run update:n8n
|
||||
|
||||
# 5. Create feature branch
|
||||
# 5. Refresh community nodes (standard practice!)
|
||||
npm run fetch:community
|
||||
npm run generate:docs
|
||||
|
||||
# 6. Create feature branch
|
||||
git checkout -b update/n8n-X.X.X
|
||||
|
||||
# 6. Update version in package.json (must be HIGHER than latest release!)
|
||||
# 7. Update version in package.json (must be HIGHER than latest release!)
|
||||
# Edit: "version": "2.XX.X" (not the version from the release list!)
|
||||
|
||||
# 7. Update CHANGELOG.md
|
||||
# 8. Update CHANGELOG.md
|
||||
# - Change version number to match package.json
|
||||
# - Update date to today
|
||||
# - Update dependency versions
|
||||
# - Include community node refresh counts
|
||||
|
||||
# 8. Update README badge
|
||||
# 9. Update README badge and node counts
|
||||
# Edit line 8: Change n8n version badge to new n8n version
|
||||
# Update total node count in description (core + community)
|
||||
|
||||
# 9. Commit and push
|
||||
# 10. Commit and push
|
||||
git add -A
|
||||
git commit -m "chore: update n8n to X.X.X and bump version to 2.XX.X
|
||||
|
||||
@@ -41,7 +47,8 @@ git commit -m "chore: update n8n to X.X.X and bump version to 2.XX.X
|
||||
- Updated n8n-workflow from X.X.X to X.X.X
|
||||
- Updated @n8n/n8n-nodes-langchain from X.X.X to X.X.X
|
||||
- Rebuilt node database with XXX nodes (XXX from n8n-nodes-base, XXX from @n8n/n8n-nodes-langchain)
|
||||
- Updated README badge with new n8n version
|
||||
- Refreshed community nodes (XXX verified + XXX npm)
|
||||
- Updated README badge with new n8n version and node counts
|
||||
- Updated CHANGELOG with dependency changes
|
||||
|
||||
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
@@ -52,10 +59,10 @@ Co-Authored-By: Claude <noreply@anthropic.com>"
|
||||
|
||||
git push -u origin update/n8n-X.X.X
|
||||
|
||||
# 10. Create PR
|
||||
# 11. Create PR
|
||||
gh pr create --title "chore: update n8n to X.X.X" --body "Updates n8n and all related dependencies to the latest versions..."
|
||||
|
||||
# 11. After PR is merged, verify release triggered
|
||||
# 12. After PR is merged, verify release triggered
|
||||
gh release list | head -1
|
||||
# If the new version appears, you're done!
|
||||
# If not, the version might have already been released - bump version again and create new PR
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
[](https://www.npmjs.com/package/n8n-mcp)
|
||||
[](https://codecov.io/gh/czlonkowski/n8n-mcp)
|
||||
[](https://github.com/czlonkowski/n8n-mcp/actions)
|
||||
[](https://github.com/n8n-io/n8n)
|
||||
[](https://github.com/n8n-io/n8n)
|
||||
[](https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp)
|
||||
[](https://railway.com/deploy/n8n-mcp?referralCode=n8n-mcp)
|
||||
|
||||
A Model Context Protocol (MCP) server that provides AI assistants with comprehensive access to n8n node documentation, properties, and operations. Deploy in minutes to give Claude and other AI assistants deep knowledge about n8n's 1,084 workflow automation nodes (537 core + 547 community).
|
||||
A Model Context Protocol (MCP) server that provides AI assistants with comprehensive access to n8n node documentation, properties, and operations. Deploy in minutes to give Claude and other AI assistants deep knowledge about n8n's 1,236 workflow automation nodes (806 core + 430 community).
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
2
dist/constants/type-structures.d.ts.map
vendored
2
dist/constants/type-structures.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"type-structures.d.ts","sourceRoot":"","sources":["../../src/constants/type-structures.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAe9D,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,iBAAiB,EAAE,aAAa,CAilBpE,CAAC;AAUF,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4GjC,CAAC"}
|
||||
{"version":3,"file":"type-structures.d.ts","sourceRoot":"","sources":["../../src/constants/type-structures.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACtD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAe9D,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,iBAAiB,EAAE,aAAa,CAkmBpE,CAAC;AAUF,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4GjC,CAAC"}
|
||||
16
dist/constants/type-structures.js
vendored
16
dist/constants/type-structures.js
vendored
@@ -545,6 +545,22 @@ exports.TYPE_STRUCTURES = {
|
||||
'One-time import feature',
|
||||
],
|
||||
},
|
||||
icon: {
|
||||
type: 'primitive',
|
||||
jsType: 'string',
|
||||
description: 'Icon identifier for visual representation',
|
||||
example: 'fa:envelope',
|
||||
examples: ['fa:envelope', 'fa:user', 'fa:cog', 'file:slack.svg'],
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: false,
|
||||
},
|
||||
notes: [
|
||||
'References icon by name or file path',
|
||||
'Supports Font Awesome icons (fa:) and file paths (file:)',
|
||||
'Used for visual customization in UI',
|
||||
],
|
||||
},
|
||||
};
|
||||
exports.COMPLEX_TYPE_EXAMPLES = {
|
||||
collection: {
|
||||
|
||||
2
dist/constants/type-structures.js.map
vendored
2
dist/constants/type-structures.js.map
vendored
File diff suppressed because one or more lines are too long
20
dist/database/database-adapter.js
vendored
20
dist/database/database-adapter.js
vendored
@@ -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]();
|
||||
|
||||
2
dist/database/database-adapter.js.map
vendored
2
dist/database/database-adapter.js.map
vendored
File diff suppressed because one or more lines are too long
36
dist/database/node-repository.d.ts
vendored
36
dist/database/node-repository.d.ts
vendored
@@ -1,10 +1,20 @@
|
||||
import { DatabaseAdapter } from './database-adapter';
|
||||
import { ParsedNode } from '../parsers/node-parser';
|
||||
import { SQLiteStorageService } from '../services/sqlite-storage-service';
|
||||
export interface CommunityNodeFields {
|
||||
isCommunity: boolean;
|
||||
isVerified: boolean;
|
||||
authorName?: string;
|
||||
authorGithubUrl?: string;
|
||||
npmPackageName?: string;
|
||||
npmVersion?: string;
|
||||
npmDownloads?: number;
|
||||
communityFetchedAt?: string;
|
||||
}
|
||||
export declare class NodeRepository {
|
||||
private db;
|
||||
constructor(dbOrService: DatabaseAdapter | SQLiteStorageService);
|
||||
saveNode(node: ParsedNode): void;
|
||||
saveNode(node: ParsedNode & Partial<CommunityNodeFields>): void;
|
||||
getNode(nodeType: string): any;
|
||||
getAITools(): any[];
|
||||
private safeJsonParse;
|
||||
@@ -29,6 +39,30 @@ export declare class NodeRepository {
|
||||
getAllResources(): Map<string, any[]>;
|
||||
getNodePropertyDefaults(nodeType: string): Record<string, any>;
|
||||
getDefaultOperationForResource(nodeType: string, resource?: string): string | undefined;
|
||||
getCommunityNodes(options?: {
|
||||
verified?: boolean;
|
||||
limit?: number;
|
||||
orderBy?: 'downloads' | 'name' | 'updated';
|
||||
}): any[];
|
||||
getCommunityStats(): {
|
||||
total: number;
|
||||
verified: number;
|
||||
unverified: number;
|
||||
};
|
||||
hasNodeByNpmPackage(npmPackageName: string): boolean;
|
||||
getNodeByNpmPackage(npmPackageName: string): any | null;
|
||||
deleteCommunityNodes(): number;
|
||||
updateNodeReadme(nodeType: string, readme: string): void;
|
||||
updateNodeAISummary(nodeType: string, summary: object): void;
|
||||
getCommunityNodesWithoutReadme(): any[];
|
||||
getCommunityNodesWithoutAISummary(): any[];
|
||||
getDocumentationStats(): {
|
||||
total: number;
|
||||
withReadme: number;
|
||||
withAISummary: number;
|
||||
needingReadme: number;
|
||||
needingAISummary: number;
|
||||
};
|
||||
saveNodeVersion(versionData: {
|
||||
nodeType: string;
|
||||
version: string;
|
||||
|
||||
2
dist/database/node-repository.d.ts.map
vendored
2
dist/database/node-repository.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"node-repository.d.ts","sourceRoot":"","sources":["../../src/database/node-repository.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"node-repository.d.ts","sourceRoot":"","sources":["../../src/database/node-repository.ts"],"names":[],"mappings":"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"}
|
||||
123
dist/database/node-repository.js
vendored
123
dist/database/node-repository.js
vendored
@@ -19,10 +19,12 @@ class NodeRepository {
|
||||
is_webhook, is_versioned, is_tool_variant, tool_variant_of,
|
||||
has_tool_variant, version, documentation,
|
||||
properties_schema, operations, credentials_required,
|
||||
outputs, output_names
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
outputs, output_names,
|
||||
is_community, is_verified, author_name, author_github_url,
|
||||
npm_package_name, npm_version, npm_downloads, community_fetched_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(node.nodeType, node.packageName, node.displayName, node.description, node.category, node.style, node.isAITool ? 1 : 0, node.isTrigger ? 1 : 0, node.isWebhook ? 1 : 0, node.isVersioned ? 1 : 0, node.isToolVariant ? 1 : 0, node.toolVariantOf || null, node.hasToolVariant ? 1 : 0, node.version, node.documentation || null, JSON.stringify(node.properties, null, 2), JSON.stringify(node.operations, null, 2), JSON.stringify(node.credentials, null, 2), node.outputs ? JSON.stringify(node.outputs, null, 2) : null, node.outputNames ? JSON.stringify(node.outputNames, null, 2) : null);
|
||||
stmt.run(node.nodeType, node.packageName, node.displayName, node.description, node.category, node.style, node.isAITool ? 1 : 0, node.isTrigger ? 1 : 0, node.isWebhook ? 1 : 0, node.isVersioned ? 1 : 0, node.isToolVariant ? 1 : 0, node.toolVariantOf || null, node.hasToolVariant ? 1 : 0, node.version, node.documentation || null, JSON.stringify(node.properties, null, 2), JSON.stringify(node.operations, null, 2), JSON.stringify(node.credentials, null, 2), node.outputs ? JSON.stringify(node.outputs, null, 2) : null, node.outputNames ? JSON.stringify(node.outputNames, null, 2) : null, node.isCommunity ? 1 : 0, node.isVerified ? 1 : 0, node.authorName || null, node.authorGithubUrl || null, node.npmPackageName || null, node.npmVersion || null, node.npmDownloads || 0, node.communityFetchedAt || null);
|
||||
}
|
||||
getNode(nodeType) {
|
||||
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
@@ -37,6 +39,14 @@ class NodeRepository {
|
||||
return this.parseNodeRow(originalRow);
|
||||
}
|
||||
}
|
||||
if (!row) {
|
||||
const caseInsensitiveRow = this.db.prepare(`
|
||||
SELECT * FROM nodes WHERE LOWER(node_type) = LOWER(?)
|
||||
`).get(nodeType);
|
||||
if (caseInsensitiveRow) {
|
||||
return this.parseNodeRow(caseInsensitiveRow);
|
||||
}
|
||||
}
|
||||
if (!row)
|
||||
return null;
|
||||
return this.parseNodeRow(row);
|
||||
@@ -214,7 +224,20 @@ class NodeRepository {
|
||||
credentials: this.safeJsonParse(row.credentials_required, []),
|
||||
hasDocumentation: !!row.documentation,
|
||||
outputs: row.outputs ? this.safeJsonParse(row.outputs, null) : null,
|
||||
outputNames: row.output_names ? this.safeJsonParse(row.output_names, null) : null
|
||||
outputNames: row.output_names ? this.safeJsonParse(row.output_names, null) : null,
|
||||
isCommunity: Number(row.is_community) === 1,
|
||||
isVerified: Number(row.is_verified) === 1,
|
||||
authorName: row.author_name || null,
|
||||
authorGithubUrl: row.author_github_url || null,
|
||||
npmPackageName: row.npm_package_name || null,
|
||||
npmVersion: row.npm_version || null,
|
||||
npmDownloads: row.npm_downloads || 0,
|
||||
communityFetchedAt: row.community_fetched_at || null,
|
||||
npmReadme: row.npm_readme || null,
|
||||
aiDocumentationSummary: row.ai_documentation_summary
|
||||
? this.safeJsonParse(row.ai_documentation_summary, null)
|
||||
: null,
|
||||
aiSummaryGeneratedAt: row.ai_summary_generated_at || null,
|
||||
};
|
||||
}
|
||||
getNodeOperations(nodeType, resource) {
|
||||
@@ -360,6 +383,98 @@ class NodeRepository {
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
getCommunityNodes(options) {
|
||||
let sql = 'SELECT * FROM nodes WHERE is_community = 1';
|
||||
const params = [];
|
||||
if (options?.verified !== undefined) {
|
||||
sql += ' AND is_verified = ?';
|
||||
params.push(options.verified ? 1 : 0);
|
||||
}
|
||||
switch (options?.orderBy) {
|
||||
case 'downloads':
|
||||
sql += ' ORDER BY npm_downloads DESC';
|
||||
break;
|
||||
case 'updated':
|
||||
sql += ' ORDER BY community_fetched_at DESC';
|
||||
break;
|
||||
case 'name':
|
||||
default:
|
||||
sql += ' ORDER BY display_name';
|
||||
}
|
||||
if (options?.limit) {
|
||||
sql += ' LIMIT ?';
|
||||
params.push(options.limit);
|
||||
}
|
||||
const rows = this.db.prepare(sql).all(...params);
|
||||
return rows.map(row => this.parseNodeRow(row));
|
||||
}
|
||||
getCommunityStats() {
|
||||
const totalResult = this.db.prepare('SELECT COUNT(*) as count FROM nodes WHERE is_community = 1').get();
|
||||
const verifiedResult = this.db.prepare('SELECT COUNT(*) as count FROM nodes WHERE is_community = 1 AND is_verified = 1').get();
|
||||
return {
|
||||
total: totalResult.count,
|
||||
verified: verifiedResult.count,
|
||||
unverified: totalResult.count - verifiedResult.count
|
||||
};
|
||||
}
|
||||
hasNodeByNpmPackage(npmPackageName) {
|
||||
const result = this.db.prepare('SELECT 1 FROM nodes WHERE npm_package_name = ? LIMIT 1').get(npmPackageName);
|
||||
return !!result;
|
||||
}
|
||||
getNodeByNpmPackage(npmPackageName) {
|
||||
const row = this.db.prepare('SELECT * FROM nodes WHERE npm_package_name = ?').get(npmPackageName);
|
||||
if (!row)
|
||||
return null;
|
||||
return this.parseNodeRow(row);
|
||||
}
|
||||
deleteCommunityNodes() {
|
||||
const result = this.db.prepare('DELETE FROM nodes WHERE is_community = 1').run();
|
||||
return result.changes;
|
||||
}
|
||||
updateNodeReadme(nodeType, readme) {
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE nodes SET npm_readme = ? WHERE node_type = ?
|
||||
`);
|
||||
stmt.run(readme, nodeType);
|
||||
}
|
||||
updateNodeAISummary(nodeType, summary) {
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE nodes
|
||||
SET ai_documentation_summary = ?, ai_summary_generated_at = datetime('now')
|
||||
WHERE node_type = ?
|
||||
`);
|
||||
stmt.run(JSON.stringify(summary), nodeType);
|
||||
}
|
||||
getCommunityNodesWithoutReadme() {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM nodes
|
||||
WHERE is_community = 1 AND (npm_readme IS NULL OR npm_readme = '')
|
||||
ORDER BY npm_downloads DESC
|
||||
`).all();
|
||||
return rows.map(row => this.parseNodeRow(row));
|
||||
}
|
||||
getCommunityNodesWithoutAISummary() {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT * FROM nodes
|
||||
WHERE is_community = 1
|
||||
AND npm_readme IS NOT NULL AND npm_readme != ''
|
||||
AND (ai_documentation_summary IS NULL OR ai_documentation_summary = '')
|
||||
ORDER BY npm_downloads DESC
|
||||
`).all();
|
||||
return rows.map(row => this.parseNodeRow(row));
|
||||
}
|
||||
getDocumentationStats() {
|
||||
const total = this.db.prepare('SELECT COUNT(*) as count FROM nodes WHERE is_community = 1').get().count;
|
||||
const withReadme = this.db.prepare("SELECT COUNT(*) as count FROM nodes WHERE is_community = 1 AND npm_readme IS NOT NULL AND npm_readme != ''").get().count;
|
||||
const withAISummary = this.db.prepare("SELECT COUNT(*) as count FROM nodes WHERE is_community = 1 AND ai_documentation_summary IS NOT NULL AND ai_documentation_summary != ''").get().count;
|
||||
return {
|
||||
total,
|
||||
withReadme,
|
||||
withAISummary,
|
||||
needingReadme: total - withReadme,
|
||||
needingAISummary: withReadme - withAISummary
|
||||
};
|
||||
}
|
||||
saveNodeVersion(versionData) {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO node_versions (
|
||||
|
||||
2
dist/database/node-repository.js.map
vendored
2
dist/database/node-repository.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/http-server-single-session.d.ts.map
vendored
2
dist/http-server-single-session.d.ts.map
vendored
@@ -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"}
|
||||
44
dist/http-server-single-session.js
vendored
44
dist/http-server-single-session.js
vendored
@@ -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() {
|
||||
|
||||
2
dist/http-server-single-session.js.map
vendored
2
dist/http-server-single-session.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/http-server.d.ts.map
vendored
2
dist/http-server.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":";AA0CA,wBAAgB,aAAa,IAAI,MAAM,GAAG,IAAI,CAsB7C;AA+DD,wBAAsB,oBAAoB,kBA+dzC;AAGD,OAAO,QAAQ,cAAc,CAAC;IAC5B,UAAU,yBAAyB;QACjC,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;KACpD;CACF"}
|
||||
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":";AAiDA,wBAAgB,aAAa,IAAI,MAAM,GAAG,IAAI,CAsB7C;AAmED,wBAAsB,oBAAoB,kBAsezC;AAGD,OAAO,QAAQ,cAAc,CAAC;IAC5B,UAAU,yBAAyB;QACjC,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;KACpD;CACF"}
|
||||
3
dist/http-server.js
vendored
3
dist/http-server.js
vendored
@@ -85,6 +85,9 @@ async function shutdown() {
|
||||
}
|
||||
}
|
||||
async function startFixedHTTPServer() {
|
||||
logger_1.logger.warn('DEPRECATION: startFixedHTTPServer() is deprecated as of v2.31.8. ' +
|
||||
'Use SingleSessionHTTPServer which supports SSE streaming. ' +
|
||||
'See: https://github.com/czlonkowski/n8n-mcp/issues/524');
|
||||
validateEnvironment();
|
||||
const app = (0, express_1.default)();
|
||||
const trustProxy = process.env.TRUST_PROXY ? Number(process.env.TRUST_PROXY) : 0;
|
||||
|
||||
2
dist/http-server.js.map
vendored
2
dist/http-server.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/index.d.ts
vendored
2
dist/index.d.ts
vendored
@@ -5,6 +5,8 @@ export { N8NDocumentationMCPServer } from './mcp/server';
|
||||
export type { InstanceContext } from './types/instance-context';
|
||||
export { 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
2
dist/index.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACzD,OAAO,EAAE,yBAAyB,EAAE,MAAM,cAAc,CAAC;AAGzD,YAAY,EACV,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,uBAAuB,EACvB,iBAAiB,EAClB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACV,YAAY,EACb,MAAM,uBAAuB,CAAC;AAG/B,YAAY,EACV,IAAI,EACJ,cAAc,EACd,eAAe,EAChB,MAAM,oCAAoC,CAAC;AAG5C,OAAO,YAAY,MAAM,cAAc,CAAC;AACxC,eAAe,YAAY,CAAC"}
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACzD,OAAO,EAAE,yBAAyB,EAAE,MAAM,cAAc,CAAC;AAGzD,YAAY,EACV,eAAe,EAChB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,uBAAuB,EACvB,iBAAiB,EAClB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACV,YAAY,EACb,MAAM,uBAAuB,CAAC;AAG/B,YAAY,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAC9D,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAGtD,YAAY,EACV,IAAI,EACJ,cAAc,EACd,eAAe,EAChB,MAAM,oCAAoC,CAAC;AAG5C,OAAO,YAAY,MAAM,cAAc,CAAC;AACxC,eAAe,YAAY,CAAC"}
|
||||
4
dist/index.js
vendored
4
dist/index.js
vendored
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
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
2
dist/index.js.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;AAOA,2CAAyE;AAAhE,0GAAA,YAAY,OAAA;AACrB,2EAAuE;AAA9D,qIAAA,uBAAuB,OAAA;AAChC,2DAAyD;AAAhD,iHAAA,cAAc,OAAA;AACvB,uCAAyD;AAAhD,mHAAA,yBAAyB,OAAA;AAMlC,6DAGkC;AAFhC,2HAAA,uBAAuB,OAAA;AACvB,qHAAA,iBAAiB,OAAA;AAcnB,8DAAwC;AACxC,kBAAe,oBAAY,CAAC"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;AAOA,2CAAyE;AAAhE,0GAAA,YAAY,OAAA;AACrB,2EAAuE;AAA9D,qIAAA,uBAAuB,OAAA;AAChC,2DAAyD;AAAhD,iHAAA,cAAc,OAAA;AACvB,uCAAyD;AAAhD,mHAAA,yBAAyB,OAAA;AAMlC,6DAGkC;AAFhC,2HAAA,uBAAuB,OAAA;AACvB,qHAAA,iBAAiB,OAAA;AAQnB,oDAAsD;AAA7C,6GAAA,cAAc,OAAA;AAUvB,8DAAwC;AACxC,kBAAe,oBAAY,CAAC"}
|
||||
2
dist/loaders/node-loader.d.ts
vendored
2
dist/loaders/node-loader.d.ts
vendored
@@ -6,6 +6,8 @@ export interface LoadedNode {
|
||||
export declare class N8nNodeLoader {
|
||||
private readonly CORE_PACKAGES;
|
||||
loadAllNodes(): Promise<LoadedNode[]>;
|
||||
private resolvePackageDir;
|
||||
private loadNodeModule;
|
||||
private loadPackageNodes;
|
||||
}
|
||||
//# sourceMappingURL=node-loader.d.ts.map
|
||||
2
dist/loaders/node-loader.d.ts.map
vendored
2
dist/loaders/node-loader.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"node-loader.d.ts","sourceRoot":"","sources":["../../src/loaders/node-loader.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,GAAG,CAAC;CAChB;AAED,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAG5B;IAEI,YAAY,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC;YAmB7B,gBAAgB;CAqD/B"}
|
||||
{"version":3,"file":"node-loader.d.ts","sourceRoot":"","sources":["../../src/loaders/node-loader.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,GAAG,CAAC;CAChB;AAED,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,aAAa,CAG5B;IAEI,YAAY,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC;IAuB3C,OAAO,CAAC,iBAAiB;IAUzB,OAAO,CAAC,cAAc;YAIR,gBAAgB;CAuD/B"}
|
||||
16
dist/loaders/node-loader.js
vendored
16
dist/loaders/node-loader.js
vendored
@@ -28,15 +28,23 @@ class N8nNodeLoader {
|
||||
}
|
||||
return results;
|
||||
}
|
||||
resolvePackageDir(packagePath) {
|
||||
const pkgJsonPath = require.resolve(`${packagePath}/package.json`);
|
||||
return path_1.default.dirname(pkgJsonPath);
|
||||
}
|
||||
loadNodeModule(absolutePath) {
|
||||
return require(absolutePath);
|
||||
}
|
||||
async loadPackageNodes(packageName, packagePath, packageJson) {
|
||||
const n8nConfig = packageJson.n8n || {};
|
||||
const nodes = [];
|
||||
const packageDir = this.resolvePackageDir(packagePath);
|
||||
const nodesList = n8nConfig.nodes || [];
|
||||
if (Array.isArray(nodesList)) {
|
||||
for (const nodePath of nodesList) {
|
||||
try {
|
||||
const fullPath = require.resolve(`${packagePath}/${nodePath}`);
|
||||
const nodeModule = require(fullPath);
|
||||
const fullPath = path_1.default.join(packageDir, nodePath);
|
||||
const nodeModule = this.loadNodeModule(fullPath);
|
||||
const nodeNameMatch = nodePath.match(/\/([^\/]+)\.node\.(js|ts)$/);
|
||||
const nodeName = nodeNameMatch ? nodeNameMatch[1] : path_1.default.basename(nodePath, '.node.js');
|
||||
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule)[0];
|
||||
@@ -56,8 +64,8 @@ class N8nNodeLoader {
|
||||
else {
|
||||
for (const [nodeName, nodePath] of Object.entries(nodesList)) {
|
||||
try {
|
||||
const fullPath = require.resolve(`${packagePath}/${nodePath}`);
|
||||
const nodeModule = require(fullPath);
|
||||
const fullPath = path_1.default.join(packageDir, nodePath);
|
||||
const nodeModule = this.loadNodeModule(fullPath);
|
||||
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule)[0];
|
||||
if (NodeClass) {
|
||||
nodes.push({ packageName, nodeName, NodeClass });
|
||||
|
||||
2
dist/loaders/node-loader.js.map
vendored
2
dist/loaders/node-loader.js.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"node-loader.js","sourceRoot":"","sources":["../../src/loaders/node-loader.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"node-loader.js","sourceRoot":"","sources":["../../src/loaders/node-loader.ts"],"names":[],"mappings":"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"}
|
||||
9
dist/mcp/index.js
vendored
9
dist/mcp/index.js
vendored
@@ -124,6 +124,15 @@ Learn more: https://github.com/czlonkowski/n8n-mcp/blob/main/PRIVACY.md
|
||||
checkpoints.push(startup_checkpoints_1.STARTUP_CHECKPOINTS.MCP_HANDSHAKE_STARTING);
|
||||
if (mode === 'http') {
|
||||
if (process.env.USE_FIXED_HTTP === 'true') {
|
||||
logger_1.logger.warn('DEPRECATION WARNING: USE_FIXED_HTTP=true is deprecated as of v2.31.8. ' +
|
||||
'The fixed HTTP implementation does not support SSE streaming required by clients like OpenAI Codex. ' +
|
||||
'Please unset USE_FIXED_HTTP to use the modern SingleSessionHTTPServer which supports both JSON-RPC and SSE. ' +
|
||||
'This option will be removed in a future version. See: https://github.com/czlonkowski/n8n-mcp/issues/524');
|
||||
console.warn('\n⚠️ DEPRECATION WARNING ⚠️');
|
||||
console.warn('USE_FIXED_HTTP=true is deprecated as of v2.31.8.');
|
||||
console.warn('The fixed HTTP implementation does not support SSE streaming.');
|
||||
console.warn('Please unset USE_FIXED_HTTP to use SingleSessionHTTPServer.');
|
||||
console.warn('See: https://github.com/czlonkowski/n8n-mcp/issues/524\n');
|
||||
const { startFixedHTTPServer } = await Promise.resolve().then(() => __importStar(require('../http-server')));
|
||||
await startFixedHTTPServer();
|
||||
}
|
||||
|
||||
2
dist/mcp/index.js.map
vendored
2
dist/mcp/index.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/mcp/server.d.ts
vendored
4
dist/mcp/server.d.ts
vendored
@@ -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;
|
||||
@@ -40,6 +43,7 @@ export declare class N8NDocumentationMCPServer {
|
||||
private rankSearchResults;
|
||||
private listAITools;
|
||||
private getNodeDocumentation;
|
||||
private safeJsonParse;
|
||||
private getDatabaseStatistics;
|
||||
private getNodeEssentials;
|
||||
private getNode;
|
||||
|
||||
2
dist/mcp/server.d.ts.map
vendored
2
dist/mcp/server.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AAsCA,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAE5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAgGnE,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;YAgTf,SAAS;YA2DT,WAAW;YAkFX,WAAW;YAyCX,cAAc;YAyKd,gBAAgB;IAqD9B,OAAO,CAAC,mBAAmB;IAwE3B,OAAO,CAAC,eAAe;YAsBT,eAAe;IAqI7B,OAAO,CAAC,kBAAkB;IAQ1B,OAAO,CAAC,uBAAuB;IA0D/B,OAAO,CAAC,iBAAiB;YAqFX,WAAW;YAgCX,oBAAoB;YA2EpB,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"}
|
||||
276
dist/mcp/server.js
vendored
276
dist/mcp/server.js
vendored
@@ -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') {
|
||||
@@ -750,7 +815,11 @@ class N8NDocumentationMCPServer {
|
||||
case 'search_nodes':
|
||||
this.validateToolParams(name, args, ['query']);
|
||||
const limit = args.limit !== undefined ? Number(args.limit) || 20 : 20;
|
||||
return this.searchNodes(args.query, limit, { mode: args.mode, includeExamples: args.includeExamples });
|
||||
return this.searchNodes(args.query, limit, {
|
||||
mode: args.mode,
|
||||
includeExamples: args.includeExamples,
|
||||
source: args.source
|
||||
});
|
||||
case 'get_node':
|
||||
this.validateToolParams(name, args, ['nodeType']);
|
||||
if (args.mode === 'docs') {
|
||||
@@ -1089,6 +1158,19 @@ class N8NDocumentationMCPServer {
|
||||
}
|
||||
}
|
||||
try {
|
||||
let sourceFilter = '';
|
||||
const sourceValue = options?.source || 'all';
|
||||
switch (sourceValue) {
|
||||
case 'core':
|
||||
sourceFilter = 'AND n.is_community = 0';
|
||||
break;
|
||||
case 'community':
|
||||
sourceFilter = 'AND n.is_community = 1';
|
||||
break;
|
||||
case 'verified':
|
||||
sourceFilter = 'AND n.is_community = 1 AND n.is_verified = 1';
|
||||
break;
|
||||
}
|
||||
const nodes = this.db.prepare(`
|
||||
SELECT
|
||||
n.*,
|
||||
@@ -1096,6 +1178,7 @@ class N8NDocumentationMCPServer {
|
||||
FROM nodes n
|
||||
JOIN nodes_fts ON n.rowid = nodes_fts.rowid
|
||||
WHERE nodes_fts MATCH ?
|
||||
${sourceFilter}
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN LOWER(n.display_name) = LOWER(?) THEN 0
|
||||
@@ -1128,15 +1211,28 @@ class N8NDocumentationMCPServer {
|
||||
}
|
||||
const result = {
|
||||
query,
|
||||
results: scoredNodes.map(node => ({
|
||||
nodeType: node.node_type,
|
||||
workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package_name, node.node_type),
|
||||
displayName: node.display_name,
|
||||
description: node.description,
|
||||
category: node.category,
|
||||
package: node.package_name,
|
||||
relevance: this.calculateRelevance(node, cleanedQuery)
|
||||
})),
|
||||
results: scoredNodes.map(node => {
|
||||
const nodeResult = {
|
||||
nodeType: node.node_type,
|
||||
workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package_name, node.node_type),
|
||||
displayName: node.display_name,
|
||||
description: node.description,
|
||||
category: node.category,
|
||||
package: node.package_name,
|
||||
relevance: this.calculateRelevance(node, cleanedQuery)
|
||||
};
|
||||
if (node.is_community === 1) {
|
||||
nodeResult.isCommunity = true;
|
||||
nodeResult.isVerified = node.is_verified === 1;
|
||||
if (node.author_name) {
|
||||
nodeResult.authorName = node.author_name;
|
||||
}
|
||||
if (node.npm_downloads) {
|
||||
nodeResult.npmDownloads = node.npm_downloads;
|
||||
}
|
||||
}
|
||||
return nodeResult;
|
||||
}),
|
||||
totalCount: scoredNodes.length
|
||||
};
|
||||
if (mode !== 'OR') {
|
||||
@@ -1298,24 +1394,51 @@ class N8NDocumentationMCPServer {
|
||||
async searchNodesLIKE(query, limit, options) {
|
||||
if (!this.db)
|
||||
throw new Error('Database not initialized');
|
||||
let sourceFilter = '';
|
||||
const sourceValue = options?.source || 'all';
|
||||
switch (sourceValue) {
|
||||
case 'core':
|
||||
sourceFilter = 'AND is_community = 0';
|
||||
break;
|
||||
case 'community':
|
||||
sourceFilter = 'AND is_community = 1';
|
||||
break;
|
||||
case 'verified':
|
||||
sourceFilter = 'AND is_community = 1 AND is_verified = 1';
|
||||
break;
|
||||
}
|
||||
if (query.startsWith('"') && query.endsWith('"')) {
|
||||
const exactPhrase = query.slice(1, -1);
|
||||
const nodes = this.db.prepare(`
|
||||
SELECT * FROM nodes
|
||||
WHERE node_type LIKE ? OR display_name LIKE ? OR description LIKE ?
|
||||
WHERE (node_type LIKE ? OR display_name LIKE ? OR description LIKE ?)
|
||||
${sourceFilter}
|
||||
LIMIT ?
|
||||
`).all(`%${exactPhrase}%`, `%${exactPhrase}%`, `%${exactPhrase}%`, limit * 3);
|
||||
const rankedNodes = this.rankSearchResults(nodes, exactPhrase, limit);
|
||||
const result = {
|
||||
query,
|
||||
results: rankedNodes.map(node => ({
|
||||
nodeType: node.node_type,
|
||||
workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package_name, node.node_type),
|
||||
displayName: node.display_name,
|
||||
description: node.description,
|
||||
category: node.category,
|
||||
package: node.package_name
|
||||
})),
|
||||
results: rankedNodes.map(node => {
|
||||
const nodeResult = {
|
||||
nodeType: node.node_type,
|
||||
workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package_name, node.node_type),
|
||||
displayName: node.display_name,
|
||||
description: node.description,
|
||||
category: node.category,
|
||||
package: node.package_name
|
||||
};
|
||||
if (node.is_community === 1) {
|
||||
nodeResult.isCommunity = true;
|
||||
nodeResult.isVerified = node.is_verified === 1;
|
||||
if (node.author_name) {
|
||||
nodeResult.authorName = node.author_name;
|
||||
}
|
||||
if (node.npm_downloads) {
|
||||
nodeResult.npmDownloads = node.npm_downloads;
|
||||
}
|
||||
}
|
||||
return nodeResult;
|
||||
}),
|
||||
totalCount: rankedNodes.length
|
||||
};
|
||||
if (options?.includeExamples) {
|
||||
@@ -1354,21 +1477,35 @@ class N8NDocumentationMCPServer {
|
||||
const params = words.flatMap(w => [`%${w}%`, `%${w}%`, `%${w}%`]);
|
||||
params.push(limit * 3);
|
||||
const nodes = this.db.prepare(`
|
||||
SELECT DISTINCT * FROM nodes
|
||||
WHERE ${conditions}
|
||||
SELECT DISTINCT * FROM nodes
|
||||
WHERE (${conditions})
|
||||
${sourceFilter}
|
||||
LIMIT ?
|
||||
`).all(...params);
|
||||
const rankedNodes = this.rankSearchResults(nodes, query, limit);
|
||||
const result = {
|
||||
query,
|
||||
results: rankedNodes.map(node => ({
|
||||
nodeType: node.node_type,
|
||||
workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package_name, node.node_type),
|
||||
displayName: node.display_name,
|
||||
description: node.description,
|
||||
category: node.category,
|
||||
package: node.package_name
|
||||
})),
|
||||
results: rankedNodes.map(node => {
|
||||
const nodeResult = {
|
||||
nodeType: node.node_type,
|
||||
workflowNodeType: (0, node_utils_1.getWorkflowNodeType)(node.package_name, node.node_type),
|
||||
displayName: node.display_name,
|
||||
description: node.description,
|
||||
category: node.category,
|
||||
package: node.package_name
|
||||
};
|
||||
if (node.is_community === 1) {
|
||||
nodeResult.isCommunity = true;
|
||||
nodeResult.isVerified = node.is_verified === 1;
|
||||
if (node.author_name) {
|
||||
nodeResult.authorName = node.author_name;
|
||||
}
|
||||
if (node.npm_downloads) {
|
||||
nodeResult.npmDownloads = node.npm_downloads;
|
||||
}
|
||||
}
|
||||
return nodeResult;
|
||||
}),
|
||||
totalCount: rankedNodes.length
|
||||
};
|
||||
if (options?.includeExamples) {
|
||||
@@ -1545,14 +1682,16 @@ class N8NDocumentationMCPServer {
|
||||
throw new Error('Database not initialized');
|
||||
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
let node = this.db.prepare(`
|
||||
SELECT node_type, display_name, documentation, description
|
||||
FROM nodes
|
||||
SELECT node_type, display_name, documentation, description,
|
||||
ai_documentation_summary, ai_summary_generated_at
|
||||
FROM nodes
|
||||
WHERE node_type = ?
|
||||
`).get(normalizedType);
|
||||
if (!node && normalizedType !== nodeType) {
|
||||
node = this.db.prepare(`
|
||||
SELECT node_type, display_name, documentation, description
|
||||
FROM nodes
|
||||
SELECT node_type, display_name, documentation, description,
|
||||
ai_documentation_summary, ai_summary_generated_at
|
||||
FROM nodes
|
||||
WHERE node_type = ?
|
||||
`).get(nodeType);
|
||||
}
|
||||
@@ -1560,8 +1699,9 @@ class N8NDocumentationMCPServer {
|
||||
const alternatives = (0, node_utils_1.getNodeTypeAlternatives)(normalizedType);
|
||||
for (const alt of alternatives) {
|
||||
node = this.db.prepare(`
|
||||
SELECT node_type, display_name, documentation, description
|
||||
FROM nodes
|
||||
SELECT node_type, display_name, documentation, description,
|
||||
ai_documentation_summary, ai_summary_generated_at
|
||||
FROM nodes
|
||||
WHERE node_type = ?
|
||||
`).get(alt);
|
||||
if (node)
|
||||
@@ -1571,6 +1711,9 @@ class N8NDocumentationMCPServer {
|
||||
if (!node) {
|
||||
throw new Error(`Node ${nodeType} not found`);
|
||||
}
|
||||
const aiDocSummary = node.ai_documentation_summary
|
||||
? this.safeJsonParse(node.ai_documentation_summary, null)
|
||||
: null;
|
||||
if (!node.documentation) {
|
||||
const essentials = await this.getNodeEssentials(nodeType);
|
||||
return {
|
||||
@@ -1590,7 +1733,9 @@ ${essentials?.commonProperties?.length > 0 ?
|
||||
## Note
|
||||
Full documentation is being prepared. For now, use get_node_essentials for configuration help.
|
||||
`,
|
||||
hasDocumentation: false
|
||||
hasDocumentation: false,
|
||||
aiDocumentationSummary: aiDocSummary,
|
||||
aiSummaryGeneratedAt: node.ai_summary_generated_at || null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -1598,8 +1743,18 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
displayName: node.display_name || 'Unknown Node',
|
||||
documentation: node.documentation,
|
||||
hasDocumentation: true,
|
||||
aiDocumentationSummary: aiDocSummary,
|
||||
aiSummaryGeneratedAt: node.ai_summary_generated_at || null,
|
||||
};
|
||||
}
|
||||
safeJsonParse(json, defaultValue = null) {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
}
|
||||
catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
async getDatabaseStatistics() {
|
||||
await this.ensureInitialized();
|
||||
if (!this.db)
|
||||
@@ -2799,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();
|
||||
@@ -2809,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;
|
||||
|
||||
2
dist/mcp/server.js.map
vendored
2
dist/mcp/server.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"version":3,"file":"search-nodes.d.ts","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/discovery/search-nodes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAE7C,eAAO,MAAM,cAAc,EAAE,iBAmD5B,CAAC"}
|
||||
{"version":3,"file":"search-nodes.d.ts","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/discovery/search-nodes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAE7C,eAAO,MAAM,cAAc,EAAE,iBAiE5B,CAAC"}
|
||||
34
dist/mcp/tool-docs/discovery/search-nodes.js
vendored
34
dist/mcp/tool-docs/discovery/search-nodes.js
vendored
@@ -5,50 +5,64 @@ exports.searchNodesDoc = {
|
||||
name: 'search_nodes',
|
||||
category: 'discovery',
|
||||
essentials: {
|
||||
description: 'Text search across node names and descriptions. Returns most relevant nodes first, with frequently-used nodes (HTTP Request, Webhook, Set, Code, Slack) prioritized in results. Searches all 500+ nodes in the database.',
|
||||
keyParameters: ['query', 'mode', 'limit'],
|
||||
description: 'Text search across node names and descriptions. Returns most relevant nodes first, with frequently-used nodes (HTTP Request, Webhook, Set, Code, Slack) prioritized in results. Searches all 800+ nodes including 300+ verified community nodes.',
|
||||
keyParameters: ['query', 'mode', 'limit', 'source', 'includeExamples'],
|
||||
example: 'search_nodes({query: "webhook"})',
|
||||
performance: '<20ms even for complex queries',
|
||||
tips: [
|
||||
'OR mode (default): Matches any search word',
|
||||
'AND mode: Requires all words present',
|
||||
'FUZZY mode: Handles typos and spelling errors',
|
||||
'Use quotes for exact phrases: "google sheets"'
|
||||
'Use quotes for exact phrases: "google sheets"',
|
||||
'Use source="community" to search only community nodes',
|
||||
'Use source="verified" for verified community nodes only'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: 'Full-text search engine for n8n nodes using SQLite FTS5. Searches across node names, descriptions, and aliases. Results are ranked by relevance with commonly-used nodes given priority. Common nodes include: HTTP Request, Webhook, Set, Code, IF, Switch, Merge, SplitInBatches, Slack, Google Sheets.',
|
||||
description: 'Full-text search engine for n8n nodes using SQLite FTS5. Searches across node names, descriptions, and aliases. Results are ranked by relevance with commonly-used nodes given priority. Includes 500+ core nodes and 300+ community nodes. Common core nodes include: HTTP Request, Webhook, Set, Code, IF, Switch, Merge, SplitInBatches, Slack, Google Sheets. Community nodes include verified integrations like BrightData, ScrapingBee, CraftMyPDF, and more.',
|
||||
parameters: {
|
||||
query: { type: 'string', description: 'Search keywords. Use quotes for exact phrases like "google sheets"', required: true },
|
||||
limit: { type: 'number', description: 'Maximum results to return. Default: 20, Max: 100', required: false },
|
||||
mode: { type: 'string', description: 'Search mode: "OR" (any word matches, default), "AND" (all words required), "FUZZY" (typo-tolerant)', required: false }
|
||||
mode: { type: 'string', description: 'Search mode: "OR" (any word matches, default), "AND" (all words required), "FUZZY" (typo-tolerant)', required: false },
|
||||
source: { type: 'string', description: 'Filter by node source: "all" (default, everything), "core" (n8n base nodes only), "community" (community nodes only), "verified" (verified community nodes only)', required: false },
|
||||
includeExamples: { type: 'boolean', description: 'Include top 2 real-world configuration examples from popular templates for each node. Default: false. Adds ~200-400 tokens per node.', required: false }
|
||||
},
|
||||
returns: 'Array of node objects sorted by relevance score. Each object contains: nodeType, displayName, description, category, relevance score. Common nodes appear first when relevance is similar.',
|
||||
returns: 'Array of node objects sorted by relevance score. Each object contains: nodeType, displayName, description, category, relevance score. For community nodes, also includes: isCommunity (boolean), isVerified (boolean), authorName (string), npmDownloads (number). Common nodes appear first when relevance is similar.',
|
||||
examples: [
|
||||
'search_nodes({query: "webhook"}) - Returns Webhook node as top result',
|
||||
'search_nodes({query: "database"}) - Returns MySQL, Postgres, MongoDB, Redis, etc.',
|
||||
'search_nodes({query: "google sheets", mode: "AND"}) - Requires both words',
|
||||
'search_nodes({query: "slak", mode: "FUZZY"}) - Finds Slack despite typo',
|
||||
'search_nodes({query: "http api"}) - Finds HTTP Request, GraphQL, REST nodes',
|
||||
'search_nodes({query: "transform data"}) - Finds Set, Code, Function, Item Lists nodes'
|
||||
'search_nodes({query: "transform data"}) - Finds Set, Code, Function, Item Lists nodes',
|
||||
'search_nodes({query: "scraping", source: "community"}) - Find community scraping nodes',
|
||||
'search_nodes({query: "pdf", source: "verified"}) - Find verified community PDF nodes',
|
||||
'search_nodes({query: "brightdata"}) - Find BrightData community node',
|
||||
'search_nodes({query: "slack", includeExamples: true}) - Get Slack with template examples'
|
||||
],
|
||||
useCases: [
|
||||
'Finding nodes when you know partial names',
|
||||
'Discovering nodes by functionality (e.g., "email", "database", "transform")',
|
||||
'Handling user typos in node names',
|
||||
'Finding all nodes related to a service (e.g., "google", "aws", "microsoft")'
|
||||
'Finding all nodes related to a service (e.g., "google", "aws", "microsoft")',
|
||||
'Discovering community integrations for specific services',
|
||||
'Finding verified community nodes for enhanced trust'
|
||||
],
|
||||
performance: '<20ms for simple queries, <50ms for complex FUZZY searches. Uses FTS5 index for speed',
|
||||
bestPractices: [
|
||||
'Start with single keywords for broadest results',
|
||||
'Use FUZZY mode when users might misspell node names',
|
||||
'AND mode works best for 2-3 word searches',
|
||||
'Combine with get_node after finding the right node'
|
||||
'Combine with get_node after finding the right node',
|
||||
'Use source="verified" when recommending community nodes for production',
|
||||
'Check isVerified flag to ensure community node quality'
|
||||
],
|
||||
pitfalls: [
|
||||
'AND mode searches all fields (name, description) not just node names',
|
||||
'FUZZY mode with very short queries (1-2 chars) may return unexpected results',
|
||||
'Exact matches in quotes are case-sensitive'
|
||||
'Exact matches in quotes are case-sensitive',
|
||||
'Community nodes require npm installation (n8n npm install <package-name>)',
|
||||
'Unverified community nodes (isVerified: false) may have limited support'
|
||||
],
|
||||
relatedTools: ['get_node to configure found nodes', 'search_templates to find workflow examples', 'validate_node to check configurations']
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"search-nodes.js","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/discovery/search-nodes.ts"],"names":[],"mappings":";;;AAEa,QAAA,cAAc,GAAsB;IAC/C,IAAI,EAAE,cAAc;IACpB,QAAQ,EAAE,WAAW;IACrB,UAAU,EAAE;QACV,WAAW,EAAE,0NAA0N;QACvO,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC;QACzC,OAAO,EAAE,kCAAkC;QAC3C,WAAW,EAAE,gCAAgC;QAC7C,IAAI,EAAE;YACJ,4CAA4C;YAC5C,sCAAsC;YACtC,+CAA+C;YAC/C,+CAA+C;SAChD;KACF;IACD,IAAI,EAAE;QACJ,WAAW,EAAE,2SAA2S;QACxT,UAAU,EAAE;YACV,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,oEAAoE,EAAE,QAAQ,EAAE,IAAI,EAAE;YAC5H,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,kDAAkD,EAAE,QAAQ,EAAE,KAAK,EAAE;YAC3G,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,oGAAoG,EAAE,QAAQ,EAAE,KAAK,EAAE;SAC7J;QACD,OAAO,EAAE,4LAA4L;QACrM,QAAQ,EAAE;YACR,uEAAuE;YACvE,mFAAmF;YACnF,2EAA2E;YAC3E,yEAAyE;YACzE,6EAA6E;YAC7E,uFAAuF;SACxF;QACD,QAAQ,EAAE;YACR,2CAA2C;YAC3C,6EAA6E;YAC7E,mCAAmC;YACnC,6EAA6E;SAC9E;QACD,WAAW,EAAE,uFAAuF;QACpG,aAAa,EAAE;YACb,iDAAiD;YACjD,qDAAqD;YACrD,2CAA2C;YAC3C,oDAAoD;SACrD;QACD,QAAQ,EAAE;YACR,sEAAsE;YACtE,8EAA8E;YAC9E,4CAA4C;SAC7C;QACD,YAAY,EAAE,CAAC,mCAAmC,EAAE,4CAA4C,EAAE,uCAAuC,CAAC;KAC3I;CACF,CAAC"}
|
||||
{"version":3,"file":"search-nodes.js","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/discovery/search-nodes.ts"],"names":[],"mappings":";;;AAEa,QAAA,cAAc,GAAsB;IAC/C,IAAI,EAAE,cAAc;IACpB,QAAQ,EAAE,WAAW;IACrB,UAAU,EAAE;QACV,WAAW,EAAE,kPAAkP;QAC/P,aAAa,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,iBAAiB,CAAC;QACtE,OAAO,EAAE,kCAAkC;QAC3C,WAAW,EAAE,gCAAgC;QAC7C,IAAI,EAAE;YACJ,4CAA4C;YAC5C,sCAAsC;YACtC,+CAA+C;YAC/C,+CAA+C;YAC/C,uDAAuD;YACvD,yDAAyD;SAC1D;KACF;IACD,IAAI,EAAE;QACJ,WAAW,EAAE,qcAAqc;QACld,UAAU,EAAE;YACV,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,oEAAoE,EAAE,QAAQ,EAAE,IAAI,EAAE;YAC5H,KAAK,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,kDAAkD,EAAE,QAAQ,EAAE,KAAK,EAAE;YAC3G,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,oGAAoG,EAAE,QAAQ,EAAE,KAAK,EAAE;YAC5J,MAAM,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,kKAAkK,EAAE,QAAQ,EAAE,KAAK,EAAE;YAC5N,eAAe,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,sIAAsI,EAAE,QAAQ,EAAE,KAAK,EAAE;SAC3M;QACD,OAAO,EAAE,yTAAyT;QAClU,QAAQ,EAAE;YACR,uEAAuE;YACvE,mFAAmF;YACnF,2EAA2E;YAC3E,yEAAyE;YACzE,6EAA6E;YAC7E,uFAAuF;YACvF,wFAAwF;YACxF,sFAAsF;YACtF,sEAAsE;YACtE,0FAA0F;SAC3F;QACD,QAAQ,EAAE;YACR,2CAA2C;YAC3C,6EAA6E;YAC7E,mCAAmC;YACnC,6EAA6E;YAC7E,0DAA0D;YAC1D,qDAAqD;SACtD;QACD,WAAW,EAAE,uFAAuF;QACpG,aAAa,EAAE;YACb,iDAAiD;YACjD,qDAAqD;YACrD,2CAA2C;YAC3C,oDAAoD;YACpD,wEAAwE;YACxE,wDAAwD;SACzD;QACD,QAAQ,EAAE;YACR,sEAAsE;YACtE,8EAA8E;YAC9E,4CAA4C;YAC5C,2EAA2E;YAC3E,yEAAyE;SAC1E;QACD,YAAY,EAAE,CAAC,mCAAmC,EAAE,4CAA4C,EAAE,uCAAuC,CAAC;KAC3I;CACF,CAAC"}
|
||||
2
dist/mcp/tools.d.ts.map
vendored
2
dist/mcp/tools.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../../src/mcp/tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAQ1C,eAAO,MAAM,0BAA0B,EAAE,cAAc,EAkatD,CAAC"}
|
||||
{"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../../src/mcp/tools.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAQ1C,eAAO,MAAM,0BAA0B,EAAE,cAAc,EAwatD,CAAC"}
|
||||
6
dist/mcp/tools.js
vendored
6
dist/mcp/tools.js
vendored
@@ -52,6 +52,12 @@ exports.n8nDocumentationToolsFinal = [
|
||||
description: 'Include top 2 real-world configuration examples from popular templates (default: false)',
|
||||
default: false,
|
||||
},
|
||||
source: {
|
||||
type: 'string',
|
||||
enum: ['all', 'core', 'community', 'verified'],
|
||||
description: 'Filter by node source: all=everything (default), core=n8n base nodes, community=community nodes, verified=verified community nodes only',
|
||||
default: 'all',
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
|
||||
2
dist/mcp/tools.js.map
vendored
2
dist/mcp/tools.js.map
vendored
File diff suppressed because one or more lines are too long
24
dist/services/n8n-validation.d.ts
vendored
24
dist/services/n8n-validation.d.ts
vendored
@@ -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;
|
||||
@@ -155,6 +155,11 @@ export declare const workflowConnectionSchema: z.ZodRecord<z.ZodString, z.ZodObj
|
||||
node: string;
|
||||
index: number;
|
||||
}[][] | undefined;
|
||||
ai_tool?: {
|
||||
type: string;
|
||||
node: string;
|
||||
index: number;
|
||||
}[][] | undefined;
|
||||
ai_languageModel?: {
|
||||
type: string;
|
||||
node: string;
|
||||
@@ -165,11 +170,6 @@ export declare const workflowConnectionSchema: z.ZodRecord<z.ZodString, z.ZodObj
|
||||
node: string;
|
||||
index: number;
|
||||
}[][] | undefined;
|
||||
ai_tool?: {
|
||||
type: string;
|
||||
node: string;
|
||||
index: number;
|
||||
}[][] | undefined;
|
||||
ai_embedding?: {
|
||||
type: string;
|
||||
node: string;
|
||||
@@ -191,6 +191,11 @@ export declare const workflowConnectionSchema: z.ZodRecord<z.ZodString, z.ZodObj
|
||||
node: string;
|
||||
index: number;
|
||||
}[][] | undefined;
|
||||
ai_tool?: {
|
||||
type: string;
|
||||
node: string;
|
||||
index: number;
|
||||
}[][] | undefined;
|
||||
ai_languageModel?: {
|
||||
type: string;
|
||||
node: string;
|
||||
@@ -201,11 +206,6 @@ export declare const workflowConnectionSchema: z.ZodRecord<z.ZodString, z.ZodObj
|
||||
node: string;
|
||||
index: number;
|
||||
}[][] | undefined;
|
||||
ai_tool?: {
|
||||
type: string;
|
||||
node: string;
|
||||
index: number;
|
||||
}[][] | undefined;
|
||||
ai_embedding?: {
|
||||
type: string;
|
||||
node: string;
|
||||
|
||||
2
dist/telemetry/batch-processor.d.ts
vendored
2
dist/telemetry/batch-processor.d.ts
vendored
@@ -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;
|
||||
|
||||
2
dist/telemetry/batch-processor.d.ts.map
vendored
2
dist/telemetry/batch-processor.d.ts.map
vendored
@@ -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"}
|
||||
31
dist/telemetry/batch-processor.js
vendored
31
dist/telemetry/batch-processor.js
vendored
@@ -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) {
|
||||
|
||||
2
dist/telemetry/batch-processor.js.map
vendored
2
dist/telemetry/batch-processor.js.map
vendored
File diff suppressed because one or more lines are too long
5
dist/types/index.d.ts
vendored
5
dist/types/index.d.ts
vendored
@@ -30,6 +30,11 @@ export interface ToolDefinition {
|
||||
additionalProperties?: boolean | Record<string, any>;
|
||||
};
|
||||
annotations?: ToolAnnotations;
|
||||
_meta?: {
|
||||
ui?: {
|
||||
resourceUri?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
export interface ResourceDefinition {
|
||||
uri: string;
|
||||
|
||||
2
dist/types/index.d.ts.map
vendored
2
dist/types/index.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AACA,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,iBAAiB,CAAC;AAEhC,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD,MAAM,WAAW,eAAe;IAE9B,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B,cAAc,CAAC,EAAE,OAAO,CAAC;IAEzB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE;QACX,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAChC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,oBAAoB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;KACtD,CAAC;IACF,YAAY,CAAC,EAAE;QACb,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAChC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,oBAAoB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;KACtD,CAAC;IAEF,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B;AAED,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,KAAK,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB,CAAC,CAAC;CACJ"}
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AACA,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,iBAAiB,CAAC;AAEhC,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAMD,MAAM,WAAW,eAAe;IAE9B,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,YAAY,CAAC,EAAE,OAAO,CAAC;IAEvB,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B,cAAc,CAAC,EAAE,OAAO,CAAC;IAEzB,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE;QACX,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAChC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,oBAAoB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;KACtD,CAAC;IACF,YAAY,CAAC,EAAE;QACb,IAAI,EAAE,MAAM,CAAC;QACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QAChC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;QACpB,oBAAoB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;KACtD,CAAC;IAEF,WAAW,CAAC,EAAE,eAAe,CAAC;IAC9B,KAAK,CAAC,EAAE;QACN,EAAE,CAAC,EAAE;YACH,WAAW,CAAC,EAAE,MAAM,CAAC;SACtB,CAAC;KACH,CAAC;CACH;AAED,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,KAAK,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;QACb,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;KACpB,CAAC,CAAC;CACJ"}
|
||||
1662
docs/CHANGELOG.md
1662
docs/CHANGELOG.md
File diff suppressed because it is too large
Load Diff
18676
package-lock.json
generated
18676
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.33.1",
|
||||
"version": "2.35.4",
|
||||
"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.2.2",
|
||||
"@n8n/n8n-nodes-langchain": "^2.8.1",
|
||||
"@supabase/supabase-js": "^2.57.4",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"form-data": "^4.0.5",
|
||||
"lru-cache": "^11.2.1",
|
||||
"n8n": "^2.2.3",
|
||||
"n8n-core": "^2.2.2",
|
||||
"n8n-workflow": "^2.2.2",
|
||||
"n8n": "^2.8.3",
|
||||
"n8n-core": "^2.8.1",
|
||||
"n8n-workflow": "^2.8.0",
|
||||
"openai": "^4.77.0",
|
||||
"sql.js": "^1.13.0",
|
||||
"tslib": "^2.6.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp-runtime",
|
||||
"version": "2.33.1",
|
||||
"version": "2.33.2",
|
||||
"description": "n8n MCP Server Runtime Dependencies Only",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -57,12 +57,14 @@ export interface DocumentationGeneratorConfig {
|
||||
timeout?: number;
|
||||
/** Max tokens for response (default: 2000) */
|
||||
maxTokens?: number;
|
||||
/** Temperature for generation (default: 0.3, set to undefined to omit) */
|
||||
temperature?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<Omit<DocumentationGeneratorConfig, 'baseUrl'>> = {
|
||||
const DEFAULT_CONFIG: Required<Omit<DocumentationGeneratorConfig, 'baseUrl' | 'temperature'>> = {
|
||||
model: 'qwen3-4b-thinking-2507',
|
||||
apiKey: 'not-needed',
|
||||
timeout: 60000,
|
||||
@@ -78,6 +80,7 @@ export class DocumentationGenerator {
|
||||
private model: string;
|
||||
private maxTokens: number;
|
||||
private timeout: number;
|
||||
private temperature?: number;
|
||||
|
||||
constructor(config: DocumentationGeneratorConfig) {
|
||||
const fullConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
@@ -90,6 +93,7 @@ export class DocumentationGenerator {
|
||||
this.model = fullConfig.model;
|
||||
this.maxTokens = fullConfig.maxTokens;
|
||||
this.timeout = fullConfig.timeout;
|
||||
this.temperature = fullConfig.temperature;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,8 +105,8 @@ export class DocumentationGenerator {
|
||||
|
||||
const completion = await this.client.chat.completions.create({
|
||||
model: this.model,
|
||||
max_tokens: this.maxTokens,
|
||||
temperature: 0.3, // Lower temperature for more consistent output
|
||||
max_completion_tokens: this.maxTokens,
|
||||
...(this.temperature !== undefined ? { temperature: this.temperature } : {}),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
@@ -321,7 +325,7 @@ Guidelines:
|
||||
try {
|
||||
const completion = await this.client.chat.completions.create({
|
||||
model: this.model,
|
||||
max_tokens: 10,
|
||||
max_completion_tokens: 200,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
@@ -353,10 +357,15 @@ export function createDocumentationGenerator(): DocumentationGenerator {
|
||||
const baseUrl = process.env.N8N_MCP_LLM_BASE_URL || 'http://localhost:1234/v1';
|
||||
const model = process.env.N8N_MCP_LLM_MODEL || 'qwen3-4b-thinking-2507';
|
||||
const timeout = parseInt(process.env.N8N_MCP_LLM_TIMEOUT || '60000', 10);
|
||||
const apiKey = process.env.N8N_MCP_LLM_API_KEY || process.env.OPENAI_API_KEY;
|
||||
// Only set temperature for local LLM servers; cloud APIs like OpenAI may not support custom values
|
||||
const isLocalServer = !baseUrl.includes('openai.com') && !baseUrl.includes('anthropic.com');
|
||||
|
||||
return new DocumentationGenerator({
|
||||
baseUrl,
|
||||
model,
|
||||
timeout,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(isLocalServer ? { temperature: 0.3 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* These structures define the expected data format, JavaScript type,
|
||||
* validation rules, and examples for each property type.
|
||||
*
|
||||
* Based on n8n-workflow v1.120.3 NodePropertyTypes
|
||||
* Based on n8n-workflow v2.4.2 NodePropertyTypes
|
||||
*
|
||||
* @module constants/type-structures
|
||||
* @since 2.23.0
|
||||
@@ -15,7 +15,7 @@ import type { NodePropertyTypes } from 'n8n-workflow';
|
||||
import type { TypeStructure } from '../types/type-structures';
|
||||
|
||||
/**
|
||||
* Complete type structure definitions for all 22 NodePropertyTypes
|
||||
* Complete type structure definitions for all 23 NodePropertyTypes
|
||||
*
|
||||
* Each entry defines:
|
||||
* - type: Category (primitive/object/collection/special)
|
||||
@@ -620,6 +620,23 @@ export const TYPE_STRUCTURES: Record<NodePropertyTypes, TypeStructure> = {
|
||||
'One-time import feature',
|
||||
],
|
||||
},
|
||||
|
||||
icon: {
|
||||
type: 'primitive',
|
||||
jsType: 'string',
|
||||
description: 'Icon identifier for visual representation',
|
||||
example: 'fa:envelope',
|
||||
examples: ['fa:envelope', 'fa:user', 'fa:cog', 'file:slack.svg'],
|
||||
validation: {
|
||||
allowEmpty: false,
|
||||
allowExpressions: false,
|
||||
},
|
||||
notes: [
|
||||
'References icon by name or file path',
|
||||
'Supports Font Awesome icons (fa:) and file paths (file:)',
|
||||
'Used for visual customization in UI',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
197
src/database/shared-database.ts
Normal file
197
src/database/shared-database.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,24 +31,44 @@ export class N8nNodeLoader {
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute directory of an installed package.
|
||||
* Uses require.resolve on package.json (always exported) and strips the filename.
|
||||
*/
|
||||
private resolvePackageDir(packagePath: string): string {
|
||||
const pkgJsonPath = require.resolve(`${packagePath}/package.json`);
|
||||
return path.dirname(pkgJsonPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a node module by absolute file path, bypassing package.json "exports".
|
||||
* Some packages (e.g. @n8n/n8n-nodes-langchain >=2.9) restrict exports but
|
||||
* still list node files in the n8n.nodes array — we need direct filesystem access.
|
||||
*/
|
||||
private loadNodeModule(absolutePath: string): any {
|
||||
return require(absolutePath);
|
||||
}
|
||||
|
||||
private async loadPackageNodes(packageName: string, packagePath: string, packageJson: any): Promise<LoadedNode[]> {
|
||||
const n8nConfig = packageJson.n8n || {};
|
||||
const nodes: LoadedNode[] = [];
|
||||
|
||||
const packageDir = this.resolvePackageDir(packagePath);
|
||||
|
||||
// Check if nodes is an array or object
|
||||
const nodesList = n8nConfig.nodes || [];
|
||||
|
||||
|
||||
if (Array.isArray(nodesList)) {
|
||||
// Handle array format (n8n-nodes-base uses this)
|
||||
for (const nodePath of nodesList) {
|
||||
try {
|
||||
const fullPath = require.resolve(`${packagePath}/${nodePath}`);
|
||||
const nodeModule = require(fullPath);
|
||||
|
||||
// Resolve absolute path directly to bypass package exports restrictions
|
||||
const fullPath = path.join(packageDir, nodePath);
|
||||
const nodeModule = this.loadNodeModule(fullPath);
|
||||
|
||||
// Extract node name from path (e.g., "dist/nodes/Slack/Slack.node.js" -> "Slack")
|
||||
const nodeNameMatch = nodePath.match(/\/([^\/]+)\.node\.(js|ts)$/);
|
||||
const nodeName = nodeNameMatch ? nodeNameMatch[1] : path.basename(nodePath, '.node.js');
|
||||
|
||||
|
||||
// Handle default export and various export patterns
|
||||
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule)[0];
|
||||
if (NodeClass) {
|
||||
@@ -65,9 +85,9 @@ export class N8nNodeLoader {
|
||||
// Handle object format (for other packages)
|
||||
for (const [nodeName, nodePath] of Object.entries(nodesList)) {
|
||||
try {
|
||||
const fullPath = require.resolve(`${packagePath}/${nodePath as string}`);
|
||||
const nodeModule = require(fullPath);
|
||||
|
||||
const fullPath = path.join(packageDir, nodePath as string);
|
||||
const nodeModule = this.loadNodeModule(fullPath);
|
||||
|
||||
// Handle default export and various export patterns
|
||||
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule)[0];
|
||||
if (NodeClass) {
|
||||
@@ -81,7 +101,7 @@ export class N8nNodeLoader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -678,7 +720,12 @@ export class N8NDocumentationMCPServer {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Workaround for Claude Desktop 1.1.3189 string serialization bug.
|
||||
// The MCP client serializes object/array parameters as JSON strings.
|
||||
// Use the tool's inputSchema to detect and parse them back.
|
||||
processedArgs = this.coerceStringifiedJsonParams(name, processedArgs);
|
||||
|
||||
try {
|
||||
logger.debug(`Executing tool: ${name}`, { args: processedArgs });
|
||||
const startTime = Date.now();
|
||||
@@ -739,7 +786,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 +838,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,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1043,6 +1130,50 @@ export class N8NDocumentationMCPServer {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce stringified JSON parameters back to objects/arrays.
|
||||
* Workaround for Claude Desktop 1.1.3189 which serializes object/array
|
||||
* params as JSON strings before sending them to MCP servers.
|
||||
*/
|
||||
private coerceStringifiedJsonParams(
|
||||
toolName: string,
|
||||
args: Record<string, any> | undefined
|
||||
): Record<string, any> | undefined {
|
||||
if (!args || typeof args !== 'object') return args;
|
||||
|
||||
const allTools = [...n8nDocumentationToolsFinal, ...n8nManagementTools];
|
||||
const tool = allTools.find(t => t.name === toolName);
|
||||
if (!tool?.inputSchema?.properties) return args;
|
||||
|
||||
const properties = tool.inputSchema.properties;
|
||||
const coerced = { ...args };
|
||||
|
||||
for (const [key, value] of Object.entries(coerced)) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const expectedType = (properties as any)[key]?.type;
|
||||
if (expectedType !== 'object' && expectedType !== 'array') continue;
|
||||
|
||||
const trimmed = value.trim();
|
||||
const validPrefix = (expectedType === 'object' && trimmed.startsWith('{'))
|
||||
|| (expectedType === 'array' && trimmed.startsWith('['));
|
||||
if (!validPrefix) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
const isArray = Array.isArray(parsed);
|
||||
if ((expectedType === 'object' && typeof parsed === 'object' && !isArray)
|
||||
|| (expectedType === 'array' && isArray)) {
|
||||
coerced[key] = parsed;
|
||||
logger.warn(`Coerced stringified ${expectedType} param "${key}" for tool "${toolName}"`);
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON — keep original string, downstream validation will report the error
|
||||
}
|
||||
}
|
||||
|
||||
return coerced;
|
||||
}
|
||||
|
||||
async executeTool(name: string, args: any): Promise<any> {
|
||||
// Ensure args is an object and validate it
|
||||
args = args || {};
|
||||
@@ -3910,8 +4041,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 +4077,31 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
logger.error('Error cleaning up cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Close database connection if it exists
|
||||
if (this.db) {
|
||||
|
||||
// Handle database cleanup based on whether it's shared or dedicated
|
||||
// For shared databases, we only release the reference (decrement refCount)
|
||||
// For dedicated databases (in-memory for tests), we close the connection
|
||||
if (this.useSharedDatabase && this.sharedDbState) {
|
||||
try {
|
||||
await this.db.close();
|
||||
releaseSharedDatabase(this.sharedDbState);
|
||||
logger.info('Released shared database reference');
|
||||
} catch (error) {
|
||||
logger.error('Error releasing shared database:', error);
|
||||
}
|
||||
} else if (this.db) {
|
||||
try {
|
||||
this.db.close();
|
||||
logger.info('Database connection closed');
|
||||
} catch (error) {
|
||||
logger.error('Error closing database:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Null out references to help garbage collection
|
||||
this.db = null;
|
||||
this.repository = null;
|
||||
this.templateService = null;
|
||||
this.earlyLogger = null;
|
||||
this.sharedDbState = null;
|
||||
}
|
||||
}
|
||||
36
src/mcp/ui/app-configs.ts
Normal file
36
src/mcp/ui/app-configs.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { UIAppConfig } from './types';
|
||||
|
||||
export const UI_APP_CONFIGS: UIAppConfig[] = [
|
||||
{
|
||||
id: 'operation-result',
|
||||
displayName: 'Operation Result',
|
||||
description: 'Visual summary of workflow operations (create, update, delete, test)',
|
||||
uri: 'ui://n8n-mcp/operation-result',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
toolPatterns: [
|
||||
'n8n_create_workflow',
|
||||
'n8n_update_full_workflow',
|
||||
'n8n_update_partial_workflow',
|
||||
'n8n_delete_workflow',
|
||||
'n8n_test_workflow',
|
||||
'n8n_autofix_workflow',
|
||||
// n8n_deploy_template disabled: Claude.ai renders blank content for this tool
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'validation-summary',
|
||||
displayName: 'Validation Summary',
|
||||
description: 'Visual summary of node and workflow validation results',
|
||||
uri: 'ui://n8n-mcp/validation-summary',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
toolPatterns: [
|
||||
'validate_node',
|
||||
'validate_workflow',
|
||||
'n8n_validate_workflow',
|
||||
],
|
||||
},
|
||||
// workflow-list, execution-history, health-dashboard disabled:
|
||||
// Claude.ai does not render these apps (shows collapsed accordions).
|
||||
// The server sets _meta correctly on the wire but the host ignores it.
|
||||
// Re-enable once the host-side issue is resolved.
|
||||
];
|
||||
3
src/mcp/ui/index.ts
Normal file
3
src/mcp/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { UIAppConfig, UIMetadata, UIAppEntry } from './types';
|
||||
export { UI_APP_CONFIGS } from './app-configs';
|
||||
export { UIAppRegistry } from './registry';
|
||||
90
src/mcp/ui/registry.ts
Normal file
90
src/mcp/ui/registry.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type { UIAppConfig, UIAppEntry } from './types';
|
||||
import { UI_APP_CONFIGS } from './app-configs';
|
||||
|
||||
export class UIAppRegistry {
|
||||
private static entries: Map<string, UIAppEntry> = new Map();
|
||||
private static toolIndex: Map<string, UIAppEntry> = new Map();
|
||||
private static loaded = false;
|
||||
|
||||
static load(): void {
|
||||
// Resolve dist directory relative to package root
|
||||
// In production: package-root/ui-apps/dist/
|
||||
// __dirname will be src/mcp/ui or dist/mcp/ui
|
||||
const packageRoot = path.resolve(__dirname, '..', '..', '..');
|
||||
const distDir = path.join(packageRoot, 'ui-apps', 'dist');
|
||||
|
||||
this.entries.clear();
|
||||
this.toolIndex.clear();
|
||||
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
let html: string | null = null;
|
||||
const htmlPath = path.join(distDir, config.id, 'index.html');
|
||||
|
||||
if (existsSync(htmlPath)) {
|
||||
try {
|
||||
html = readFileSync(htmlPath, 'utf-8');
|
||||
logger.info(`Loaded UI app: ${config.id}`);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to read UI app HTML: ${config.id}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const entry: UIAppEntry = { config, html };
|
||||
this.entries.set(config.id, entry);
|
||||
|
||||
// Build tool -> entry index
|
||||
for (const pattern of config.toolPatterns) {
|
||||
this.toolIndex.set(pattern, entry);
|
||||
}
|
||||
}
|
||||
|
||||
this.loaded = true;
|
||||
logger.info(`UI App Registry loaded: ${this.entries.size} apps, ${this.toolIndex.size} tool mappings`);
|
||||
}
|
||||
|
||||
static getAppForTool(toolName: string): UIAppEntry | null {
|
||||
if (!this.loaded) return null;
|
||||
return this.toolIndex.get(toolName) ?? null;
|
||||
}
|
||||
|
||||
static getAppById(id: string): UIAppEntry | null {
|
||||
if (!this.loaded) return null;
|
||||
return this.entries.get(id) ?? null;
|
||||
}
|
||||
|
||||
static getAllApps(): UIAppEntry[] {
|
||||
if (!this.loaded) return [];
|
||||
return Array.from(this.entries.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich tool definitions with _meta.ui.resourceUri for tools that have
|
||||
* a matching UI app. Per MCP ext-apps spec, this goes on the tool
|
||||
* definition (tools/list), not the tool call response.
|
||||
*
|
||||
* Sets both nested (_meta.ui.resourceUri) and flat (_meta["ui/resourceUri"])
|
||||
* keys for compatibility with hosts that read either format.
|
||||
*/
|
||||
static injectToolMeta(tools: Array<{ name: string; [key: string]: any }>): void {
|
||||
if (!this.loaded) return;
|
||||
for (const tool of tools) {
|
||||
const entry = this.toolIndex.get(tool.name);
|
||||
if (entry && entry.html) {
|
||||
tool._meta = {
|
||||
ui: { resourceUri: entry.config.uri },
|
||||
'ui/resourceUri': entry.config.uri,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset registry state. Intended for testing only. */
|
||||
static reset(): void {
|
||||
this.entries.clear();
|
||||
this.toolIndex.clear();
|
||||
this.loaded = false;
|
||||
}
|
||||
}
|
||||
23
src/mcp/ui/types.ts
Normal file
23
src/mcp/ui/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* MCP Apps UI type definitions
|
||||
*/
|
||||
|
||||
export interface UIAppConfig {
|
||||
id: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
toolPatterns: string[];
|
||||
}
|
||||
|
||||
export interface UIMetadata {
|
||||
ui: {
|
||||
resourceUri: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UIAppEntry {
|
||||
config: UIAppConfig;
|
||||
html: string | null;
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
* Environment variables:
|
||||
* N8N_MCP_LLM_BASE_URL - LLM server URL (default: http://localhost:1234/v1)
|
||||
* N8N_MCP_LLM_MODEL - LLM model name (default: qwen3-4b-thinking-2507)
|
||||
* N8N_MCP_LLM_API_KEY - LLM API key (falls back to OPENAI_API_KEY; default: 'not-needed')
|
||||
* N8N_MCP_LLM_TIMEOUT - Request timeout in ms (default: 60000)
|
||||
* N8N_MCP_DB_PATH - Database path (default: ./data/nodes.db)
|
||||
*/
|
||||
@@ -81,6 +82,7 @@ Options:
|
||||
Environment Variables:
|
||||
N8N_MCP_LLM_BASE_URL LLM server URL (default: http://localhost:1234/v1)
|
||||
N8N_MCP_LLM_MODEL LLM model name (default: qwen3-4b-thinking-2507)
|
||||
N8N_MCP_LLM_API_KEY LLM API key (falls back to OPENAI_API_KEY; default: 'not-needed')
|
||||
N8N_MCP_LLM_TIMEOUT Request timeout in ms (default: 60000)
|
||||
N8N_MCP_DB_PATH Database path (default: ./data/nodes.db)
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export class TypeStructureService {
|
||||
/**
|
||||
* Get all type structure definitions
|
||||
*
|
||||
* Returns a record of all 22 NodePropertyTypes with their structures.
|
||||
* Returns a record of all 23 NodePropertyTypes with their structures.
|
||||
* Useful for documentation, validation setup, or UI generation.
|
||||
*
|
||||
* @returns Record mapping all types to their structures
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,11 @@ export interface ToolDefinition {
|
||||
};
|
||||
/** Tool behavior hints for AI assistants */
|
||||
annotations?: ToolAnnotations;
|
||||
_meta?: {
|
||||
ui?: {
|
||||
resourceUri?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResourceDefinition {
|
||||
|
||||
@@ -41,6 +41,7 @@ describe('DocumentationGenerator', () => {
|
||||
apiKey: 'test-key',
|
||||
timeout: 30000,
|
||||
maxTokens: 1000,
|
||||
temperature: 0.3,
|
||||
};
|
||||
|
||||
const validSummary = {
|
||||
@@ -163,7 +164,7 @@ describe('DocumentationGenerator', () => {
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
model: 'test-model',
|
||||
max_tokens: 1000,
|
||||
max_completion_tokens: 1000,
|
||||
temperature: 0.3,
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({ role: 'system' }),
|
||||
@@ -680,7 +681,7 @@ describe('DocumentationGenerator', () => {
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
max_tokens: 10,
|
||||
max_completion_tokens: 200,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
|
||||
@@ -11,7 +11,7 @@ import { isTypeStructure } from '@/types/type-structures';
|
||||
import type { NodePropertyTypes } from 'n8n-workflow';
|
||||
|
||||
describe('TYPE_STRUCTURES', () => {
|
||||
// All 22 NodePropertyTypes from n8n-workflow
|
||||
// All 23 NodePropertyTypes from n8n-workflow
|
||||
const ALL_PROPERTY_TYPES: NodePropertyTypes[] = [
|
||||
'boolean',
|
||||
'button',
|
||||
@@ -20,6 +20,7 @@ describe('TYPE_STRUCTURES', () => {
|
||||
'dateTime',
|
||||
'fixedCollection',
|
||||
'hidden',
|
||||
'icon',
|
||||
'json',
|
||||
'callout',
|
||||
'notice',
|
||||
@@ -38,16 +39,16 @@ describe('TYPE_STRUCTURES', () => {
|
||||
];
|
||||
|
||||
describe('Completeness', () => {
|
||||
it('should define all 22 NodePropertyTypes', () => {
|
||||
it('should define all 23 NodePropertyTypes', () => {
|
||||
const definedTypes = Object.keys(TYPE_STRUCTURES);
|
||||
expect(definedTypes).toHaveLength(22);
|
||||
expect(definedTypes).toHaveLength(23);
|
||||
|
||||
for (const type of ALL_PROPERTY_TYPES) {
|
||||
expect(TYPE_STRUCTURES).toHaveProperty(type);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not have extra types beyond the 22 standard types', () => {
|
||||
it('should not have extra types beyond the 23 standard types', () => {
|
||||
const definedTypes = Object.keys(TYPE_STRUCTURES);
|
||||
const extraTypes = definedTypes.filter((type) => !ALL_PROPERTY_TYPES.includes(type as NodePropertyTypes));
|
||||
|
||||
|
||||
302
tests/unit/database/shared-database.test.ts
Normal file
302
tests/unit/database/shared-database.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
207
tests/unit/mcp/coerce-stringified-params.test.ts
Normal file
207
tests/unit/mcp/coerce-stringified-params.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { N8NDocumentationMCPServer } from '../../../src/mcp/server';
|
||||
|
||||
// Mock the database and dependencies
|
||||
vi.mock('../../../src/database/database-adapter');
|
||||
vi.mock('../../../src/database/node-repository');
|
||||
vi.mock('../../../src/templates/template-service');
|
||||
vi.mock('../../../src/utils/logger');
|
||||
|
||||
class TestableN8NMCPServer extends N8NDocumentationMCPServer {
|
||||
public testCoerceStringifiedJsonParams(
|
||||
toolName: string,
|
||||
args: Record<string, any>
|
||||
): Record<string, any> {
|
||||
return (this as any).coerceStringifiedJsonParams(toolName, args);
|
||||
}
|
||||
}
|
||||
|
||||
describe('coerceStringifiedJsonParams', () => {
|
||||
let server: TestableN8NMCPServer;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.NODE_DB_PATH = ':memory:';
|
||||
server = new TestableN8NMCPServer();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NODE_DB_PATH;
|
||||
});
|
||||
|
||||
describe('Object coercion', () => {
|
||||
it('should coerce stringified object for validate_node config', () => {
|
||||
const args = {
|
||||
nodeType: 'nodes-base.slack',
|
||||
config: '{"resource":"channel","operation":"create"}'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_node', args);
|
||||
expect(result.config).toEqual({ resource: 'channel', operation: 'create' });
|
||||
expect(result.nodeType).toBe('nodes-base.slack');
|
||||
});
|
||||
|
||||
it('should coerce stringified object for n8n_create_workflow connections', () => {
|
||||
const connections = { 'Webhook': { main: [[{ node: 'Slack', type: 'main', index: 0 }]] } };
|
||||
const args = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook' }],
|
||||
connections: JSON.stringify(connections)
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_create_workflow', args);
|
||||
expect(result.connections).toEqual(connections);
|
||||
});
|
||||
|
||||
it('should coerce stringified object for validate_workflow workflow param', () => {
|
||||
const workflow = { nodes: [], connections: {} };
|
||||
const args = {
|
||||
workflow: JSON.stringify(workflow)
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_workflow', args);
|
||||
expect(result.workflow).toEqual(workflow);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Array coercion', () => {
|
||||
it('should coerce stringified array for n8n_update_partial_workflow operations', () => {
|
||||
const operations = [
|
||||
{ type: 'addNode', node: { id: '1', name: 'Test', type: 'n8n-nodes-base.noOp' } }
|
||||
];
|
||||
const args = {
|
||||
id: '123',
|
||||
operations: JSON.stringify(operations)
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_update_partial_workflow', args);
|
||||
expect(result.operations).toEqual(operations);
|
||||
expect(result.id).toBe('123');
|
||||
});
|
||||
|
||||
it('should coerce stringified array for n8n_autofix_workflow fixTypes', () => {
|
||||
const fixTypes = ['expression-format', 'typeversion-correction'];
|
||||
const args = {
|
||||
id: '456',
|
||||
fixTypes: JSON.stringify(fixTypes)
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_autofix_workflow', args);
|
||||
expect(result.fixTypes).toEqual(fixTypes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('No-op cases', () => {
|
||||
it('should not modify object params that are already objects', () => {
|
||||
const config = { resource: 'channel', operation: 'create' };
|
||||
const args = {
|
||||
nodeType: 'nodes-base.slack',
|
||||
config
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_node', args);
|
||||
expect(result.config).toEqual(config);
|
||||
expect(result.config).toBe(config); // same reference
|
||||
});
|
||||
|
||||
it('should not modify string params even if they contain JSON', () => {
|
||||
const args = {
|
||||
query: '{"some":"json"}',
|
||||
limit: 10
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||
expect(result.query).toBe('{"some":"json"}');
|
||||
});
|
||||
|
||||
it('should not modify args for tools with no object/array params', () => {
|
||||
const args = {
|
||||
query: 'webhook',
|
||||
limit: 20,
|
||||
mode: 'OR'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||
expect(result).toEqual(args);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Safety cases', () => {
|
||||
it('should keep original string for invalid JSON', () => {
|
||||
const args = {
|
||||
nodeType: 'nodes-base.slack',
|
||||
config: '{invalid json here}'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_node', args);
|
||||
expect(result.config).toBe('{invalid json here}');
|
||||
});
|
||||
|
||||
it('should not attempt parse when object param starts with [', () => {
|
||||
const args = {
|
||||
nodeType: 'nodes-base.slack',
|
||||
config: '[1, 2, 3]'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_node', args);
|
||||
expect(result.config).toBe('[1, 2, 3]');
|
||||
});
|
||||
|
||||
it('should not attempt parse when array param starts with {', () => {
|
||||
const args = {
|
||||
id: '123',
|
||||
operations: '{"not":"an array"}'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_update_partial_workflow', args);
|
||||
expect(result.operations).toBe('{"not":"an array"}');
|
||||
});
|
||||
|
||||
it('should handle null args gracefully', () => {
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_node', null as any);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined args gracefully', () => {
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_node', undefined as any);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return args unchanged for unknown tool', () => {
|
||||
const args = { config: '{"key":"value"}' };
|
||||
const result = server.testCoerceStringifiedJsonParams('nonexistent_tool', args);
|
||||
expect(result).toEqual(args);
|
||||
expect(result.config).toBe('{"key":"value"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-to-end Claude Desktop scenario', () => {
|
||||
it('should coerce all stringified params for n8n_create_workflow', () => {
|
||||
const nodes = [
|
||||
{
|
||||
id: 'webhook_1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [250, 300],
|
||||
parameters: { httpMethod: 'POST', path: 'slack-notify' }
|
||||
},
|
||||
{
|
||||
id: 'slack_1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 1,
|
||||
position: [450, 300],
|
||||
parameters: { resource: 'message', operation: 'post', channel: '#general' }
|
||||
}
|
||||
];
|
||||
const connections = {
|
||||
'Webhook': { main: [[{ node: 'Slack', type: 'main', index: 0 }]] }
|
||||
};
|
||||
const settings = { executionOrder: 'v1', timezone: 'America/New_York' };
|
||||
|
||||
// Simulate Claude Desktop sending all object/array params as strings
|
||||
const args = {
|
||||
name: 'Webhook to Slack',
|
||||
nodes: JSON.stringify(nodes),
|
||||
connections: JSON.stringify(connections),
|
||||
settings: JSON.stringify(settings)
|
||||
};
|
||||
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_create_workflow', args);
|
||||
|
||||
expect(result.name).toBe('Webhook to Slack');
|
||||
expect(result.nodes).toEqual(nodes);
|
||||
expect(result.connections).toEqual(connections);
|
||||
expect(result.settings).toEqual(settings);
|
||||
});
|
||||
});
|
||||
});
|
||||
116
tests/unit/mcp/ui/app-configs.test.ts
Normal file
116
tests/unit/mcp/ui/app-configs.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { UI_APP_CONFIGS } from '@/mcp/ui/app-configs';
|
||||
|
||||
describe('UI_APP_CONFIGS', () => {
|
||||
it('should have all required fields for every config', () => {
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
expect(config.id).toBeDefined();
|
||||
expect(typeof config.id).toBe('string');
|
||||
expect(config.id.length).toBeGreaterThan(0);
|
||||
|
||||
expect(config.displayName).toBeDefined();
|
||||
expect(typeof config.displayName).toBe('string');
|
||||
expect(config.displayName.length).toBeGreaterThan(0);
|
||||
|
||||
expect(config.description).toBeDefined();
|
||||
expect(typeof config.description).toBe('string');
|
||||
expect(config.description.length).toBeGreaterThan(0);
|
||||
|
||||
expect(config.uri).toBeDefined();
|
||||
expect(typeof config.uri).toBe('string');
|
||||
|
||||
expect(config.mimeType).toBeDefined();
|
||||
expect(typeof config.mimeType).toBe('string');
|
||||
|
||||
expect(config.toolPatterns).toBeDefined();
|
||||
expect(Array.isArray(config.toolPatterns)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have URIs following ui://n8n-mcp/{id} pattern', () => {
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
expect(config.uri).toBe(`ui://n8n-mcp/${config.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have unique IDs', () => {
|
||||
const ids = UI_APP_CONFIGS.map(c => c.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('should have non-empty toolPatterns arrays', () => {
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
expect(config.toolPatterns.length).toBeGreaterThan(0);
|
||||
for (const pattern of config.toolPatterns) {
|
||||
expect(typeof pattern).toBe('string');
|
||||
expect(pattern.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should not have duplicate tool patterns across configs', () => {
|
||||
const allPatterns: string[] = [];
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
allPatterns.push(...config.toolPatterns);
|
||||
}
|
||||
const uniquePatterns = new Set(allPatterns);
|
||||
expect(uniquePatterns.size).toBe(allPatterns.length);
|
||||
});
|
||||
|
||||
it('should not have duplicate tool patterns within a single config', () => {
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
const unique = new Set(config.toolPatterns);
|
||||
expect(unique.size).toBe(config.toolPatterns.length);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have consistent mimeType of text/html;profile=mcp-app', () => {
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
expect(config.mimeType).toBe('text/html;profile=mcp-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have URIs that start with the ui://n8n-mcp/ scheme', () => {
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
expect(config.uri).toMatch(/^ui:\/\/n8n-mcp\//);
|
||||
}
|
||||
});
|
||||
|
||||
// Regression: verify expected configs are present
|
||||
it('should contain the operation-result config', () => {
|
||||
const config = UI_APP_CONFIGS.find(c => c.id === 'operation-result');
|
||||
expect(config).toBeDefined();
|
||||
expect(config!.displayName).toBe('Operation Result');
|
||||
expect(config!.toolPatterns).toContain('n8n_create_workflow');
|
||||
expect(config!.toolPatterns).toContain('n8n_update_full_workflow');
|
||||
expect(config!.toolPatterns).toContain('n8n_delete_workflow');
|
||||
expect(config!.toolPatterns).toContain('n8n_test_workflow');
|
||||
expect(config!.toolPatterns).not.toContain('n8n_deploy_template');
|
||||
});
|
||||
|
||||
it('should contain the validation-summary config', () => {
|
||||
const config = UI_APP_CONFIGS.find(c => c.id === 'validation-summary');
|
||||
expect(config).toBeDefined();
|
||||
expect(config!.displayName).toBe('Validation Summary');
|
||||
expect(config!.toolPatterns).toContain('validate_node');
|
||||
expect(config!.toolPatterns).toContain('validate_workflow');
|
||||
expect(config!.toolPatterns).toContain('n8n_validate_workflow');
|
||||
});
|
||||
|
||||
it('should have exactly 2 configs', () => {
|
||||
expect(UI_APP_CONFIGS.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should not contain disabled apps', () => {
|
||||
expect(UI_APP_CONFIGS.find(c => c.id === 'workflow-list')).toBeUndefined();
|
||||
expect(UI_APP_CONFIGS.find(c => c.id === 'execution-history')).toBeUndefined();
|
||||
expect(UI_APP_CONFIGS.find(c => c.id === 'health-dashboard')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should have IDs that are valid URI path segments (no spaces or special chars)', () => {
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
expect(config.id).toMatch(/^[a-z0-9-]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
145
tests/unit/mcp/ui/meta-injection.test.ts
Normal file
145
tests/unit/mcp/ui/meta-injection.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { UIAppRegistry } from '@/mcp/ui/registry';
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
|
||||
const mockExistsSync = vi.mocked(existsSync);
|
||||
const mockReadFileSync = vi.mocked(readFileSync);
|
||||
|
||||
describe('UI Meta Injection on Tool Definitions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
UIAppRegistry.reset();
|
||||
});
|
||||
|
||||
describe('when HTML is loaded', () => {
|
||||
beforeEach(() => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('<html>ui content</html>');
|
||||
UIAppRegistry.load();
|
||||
});
|
||||
|
||||
it('should add _meta.ui.resourceUri to matching tool definitions', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create workflow', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
expect(tools[0]._meta).toBeDefined();
|
||||
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
|
||||
});
|
||||
|
||||
it('should add _meta.ui.resourceUri to validation tool definitions', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'validate_workflow', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
expect(tools[0]._meta).toBeDefined();
|
||||
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary');
|
||||
});
|
||||
|
||||
it('should NOT add _meta to non-matching tool definitions', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'get_node_info', description: 'Get info', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
expect(tools[0]._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should inject _meta on matching tools and skip non-matching in a mixed list', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'get_node_info', description: 'Info', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
expect(tools[0]._meta).toBeDefined();
|
||||
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
|
||||
expect(tools[1]._meta).toBeUndefined();
|
||||
expect(tools[2]._meta).toBeDefined();
|
||||
expect(tools[2]._meta.ui.resourceUri).toBe('ui://n8n-mcp/validation-summary');
|
||||
});
|
||||
|
||||
it('should produce _meta with both nested and flat resourceUri keys', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
expect(tools[0]._meta).toEqual({
|
||||
ui: {
|
||||
resourceUri: 'ui://n8n-mcp/operation-result',
|
||||
},
|
||||
'ui/resourceUri': 'ui://n8n-mcp/operation-result',
|
||||
});
|
||||
expect(tools[0]._meta.ui.resourceUri).toBe('ui://n8n-mcp/operation-result');
|
||||
expect(tools[0]._meta['ui/resourceUri']).toBe('ui://n8n-mcp/operation-result');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when HTML is not loaded', () => {
|
||||
beforeEach(() => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
UIAppRegistry.load();
|
||||
});
|
||||
|
||||
it('should NOT add _meta even for matching tools', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
expect(tools[0]._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should NOT add _meta for validation tools without HTML', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
expect(tools[0]._meta).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when registry has not been loaded at all', () => {
|
||||
it('should NOT add _meta because registry is not loaded', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
|
||||
expect(tools[0]._meta).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty tool list', () => {
|
||||
beforeEach(() => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('<html>ui</html>');
|
||||
UIAppRegistry.load();
|
||||
});
|
||||
|
||||
it('should handle an empty tools array without error', () => {
|
||||
const tools: any[] = [];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
377
tests/unit/mcp/ui/registry.test.ts
Normal file
377
tests/unit/mcp/ui/registry.test.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { UIAppRegistry } from '@/mcp/ui/registry';
|
||||
import { UI_APP_CONFIGS } from '@/mcp/ui/app-configs';
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
|
||||
const mockExistsSync = vi.mocked(existsSync);
|
||||
const mockReadFileSync = vi.mocked(readFileSync);
|
||||
|
||||
describe('UIAppRegistry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
UIAppRegistry.reset();
|
||||
});
|
||||
|
||||
describe('load()', () => {
|
||||
it('should load HTML files when dist directory exists', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('<html>test</html>');
|
||||
|
||||
UIAppRegistry.load();
|
||||
|
||||
const apps = UIAppRegistry.getAllApps();
|
||||
expect(apps.length).toBe(UI_APP_CONFIGS.length);
|
||||
for (const app of apps) {
|
||||
expect(app.html).toBe('<html>test</html>');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle missing dist directory gracefully', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
UIAppRegistry.load();
|
||||
|
||||
const apps = UIAppRegistry.getAllApps();
|
||||
expect(apps.length).toBe(UI_APP_CONFIGS.length);
|
||||
for (const app of apps) {
|
||||
expect(app.html).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle read errors gracefully', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
UIAppRegistry.load();
|
||||
|
||||
const apps = UIAppRegistry.getAllApps();
|
||||
expect(apps.length).toBe(UI_APP_CONFIGS.length);
|
||||
for (const app of apps) {
|
||||
expect(app.html).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('should set loaded flag so getters work', () => {
|
||||
expect(UIAppRegistry.getAllApps()).toEqual([]);
|
||||
expect(UIAppRegistry.getAppById('operation-result')).toBeNull();
|
||||
expect(UIAppRegistry.getAppForTool('n8n_create_workflow')).toBeNull();
|
||||
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
UIAppRegistry.load();
|
||||
|
||||
expect(UIAppRegistry.getAllApps().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should replace previous entries when called twice', () => {
|
||||
// First load: files exist
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('<html>first</html>');
|
||||
UIAppRegistry.load();
|
||||
|
||||
expect(UIAppRegistry.getAppById('operation-result')!.html).toBe('<html>first</html>');
|
||||
|
||||
// Second load: files missing
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
UIAppRegistry.load();
|
||||
|
||||
expect(UIAppRegistry.getAppById('operation-result')!.html).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty HTML file content', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('');
|
||||
|
||||
UIAppRegistry.load();
|
||||
|
||||
const app = UIAppRegistry.getAppById('operation-result');
|
||||
expect(app).not.toBeNull();
|
||||
// Empty string is still a string, not null
|
||||
expect(app!.html).toBe('');
|
||||
});
|
||||
|
||||
it('should build the correct number of tool index entries', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('<html>app</html>');
|
||||
UIAppRegistry.load();
|
||||
|
||||
// Every tool pattern from every config should be resolvable
|
||||
for (const config of UI_APP_CONFIGS) {
|
||||
for (const pattern of config.toolPatterns) {
|
||||
const entry = UIAppRegistry.getAppForTool(pattern);
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.config.id).toBe(config.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should call existsSync for each config', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
UIAppRegistry.load();
|
||||
|
||||
expect(mockExistsSync).toHaveBeenCalledTimes(UI_APP_CONFIGS.length);
|
||||
});
|
||||
|
||||
it('should only call readFileSync when existsSync returns true', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
UIAppRegistry.load();
|
||||
|
||||
expect(mockReadFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppForTool()', () => {
|
||||
it('should return null before load() is called', () => {
|
||||
const entry = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
||||
expect(entry).toBeNull();
|
||||
});
|
||||
|
||||
describe('after loading', () => {
|
||||
beforeEach(() => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('<html>loaded</html>');
|
||||
UIAppRegistry.load();
|
||||
});
|
||||
|
||||
it('should return correct entry for known tool patterns', () => {
|
||||
const entry = UIAppRegistry.getAppForTool('n8n_create_workflow');
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.config.id).toBe('operation-result');
|
||||
});
|
||||
|
||||
it('should return correct entry for validation tools', () => {
|
||||
const entry = UIAppRegistry.getAppForTool('validate_node');
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.config.id).toBe('validation-summary');
|
||||
});
|
||||
|
||||
it('should return null for unknown tools', () => {
|
||||
const entry = UIAppRegistry.getAppForTool('unknown_tool');
|
||||
expect(entry).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty string tool name', () => {
|
||||
const entry = UIAppRegistry.getAppForTool('');
|
||||
expect(entry).toBeNull();
|
||||
});
|
||||
|
||||
// Regression: verify specific tools ARE mapped so config changes break the test
|
||||
it('should map n8n_create_workflow to operation-result', () => {
|
||||
expect(UIAppRegistry.getAppForTool('n8n_create_workflow')!.config.id).toBe('operation-result');
|
||||
});
|
||||
|
||||
it('should map n8n_update_full_workflow to operation-result', () => {
|
||||
expect(UIAppRegistry.getAppForTool('n8n_update_full_workflow')!.config.id).toBe('operation-result');
|
||||
});
|
||||
|
||||
it('should map n8n_update_partial_workflow to operation-result', () => {
|
||||
expect(UIAppRegistry.getAppForTool('n8n_update_partial_workflow')!.config.id).toBe('operation-result');
|
||||
});
|
||||
|
||||
it('should map n8n_delete_workflow to operation-result', () => {
|
||||
expect(UIAppRegistry.getAppForTool('n8n_delete_workflow')!.config.id).toBe('operation-result');
|
||||
});
|
||||
|
||||
it('should map n8n_test_workflow to operation-result', () => {
|
||||
expect(UIAppRegistry.getAppForTool('n8n_test_workflow')!.config.id).toBe('operation-result');
|
||||
});
|
||||
|
||||
it('should map n8n_autofix_workflow to operation-result', () => {
|
||||
expect(UIAppRegistry.getAppForTool('n8n_autofix_workflow')!.config.id).toBe('operation-result');
|
||||
});
|
||||
|
||||
it('should not map disabled tools', () => {
|
||||
expect(UIAppRegistry.getAppForTool('n8n_deploy_template')).toBeNull();
|
||||
expect(UIAppRegistry.getAppForTool('n8n_list_workflows')).toBeNull();
|
||||
expect(UIAppRegistry.getAppForTool('n8n_executions')).toBeNull();
|
||||
expect(UIAppRegistry.getAppForTool('n8n_health_check')).toBeNull();
|
||||
});
|
||||
|
||||
it('should map validate_node to validation-summary', () => {
|
||||
expect(UIAppRegistry.getAppForTool('validate_node')!.config.id).toBe('validation-summary');
|
||||
});
|
||||
|
||||
it('should map validate_workflow to validation-summary', () => {
|
||||
expect(UIAppRegistry.getAppForTool('validate_workflow')!.config.id).toBe('validation-summary');
|
||||
});
|
||||
|
||||
it('should map n8n_validate_workflow to validation-summary', () => {
|
||||
expect(UIAppRegistry.getAppForTool('n8n_validate_workflow')!.config.id).toBe('validation-summary');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAppById()', () => {
|
||||
it('should return null before load() is called', () => {
|
||||
const entry = UIAppRegistry.getAppById('operation-result');
|
||||
expect(entry).toBeNull();
|
||||
});
|
||||
|
||||
describe('after loading', () => {
|
||||
beforeEach(() => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('<html>app</html>');
|
||||
UIAppRegistry.load();
|
||||
});
|
||||
|
||||
it('should return correct entry for operation-result', () => {
|
||||
const entry = UIAppRegistry.getAppById('operation-result');
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.config.displayName).toBe('Operation Result');
|
||||
expect(entry!.html).toBe('<html>app</html>');
|
||||
});
|
||||
|
||||
it('should return correct entry for validation-summary', () => {
|
||||
const entry = UIAppRegistry.getAppById('validation-summary');
|
||||
expect(entry).not.toBeNull();
|
||||
expect(entry!.config.displayName).toBe('Validation Summary');
|
||||
});
|
||||
|
||||
it('should return null for unknown id', () => {
|
||||
const entry = UIAppRegistry.getAppById('nonexistent');
|
||||
expect(entry).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty string id', () => {
|
||||
const entry = UIAppRegistry.getAppById('');
|
||||
expect(entry).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllApps()', () => {
|
||||
it('should return empty array before load() is called', () => {
|
||||
const apps = UIAppRegistry.getAllApps();
|
||||
expect(apps).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return all entries after load', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
UIAppRegistry.load();
|
||||
|
||||
const apps = UIAppRegistry.getAllApps();
|
||||
expect(apps.length).toBe(UI_APP_CONFIGS.length);
|
||||
expect(apps.map(a => a.config.id)).toContain('operation-result');
|
||||
expect(apps.map(a => a.config.id)).toContain('validation-summary');
|
||||
});
|
||||
|
||||
it('should include entries with null html when dist is missing', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
UIAppRegistry.load();
|
||||
|
||||
const apps = UIAppRegistry.getAllApps();
|
||||
for (const app of apps) {
|
||||
expect(app.html).toBeNull();
|
||||
}
|
||||
// Entries are still present even with null html
|
||||
expect(apps.length).toBe(UI_APP_CONFIGS.length);
|
||||
});
|
||||
|
||||
it('should return entries with full config objects', () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
UIAppRegistry.load();
|
||||
|
||||
for (const app of UIAppRegistry.getAllApps()) {
|
||||
expect(app.config).toBeDefined();
|
||||
expect(app.config.id).toBeDefined();
|
||||
expect(app.config.displayName).toBeDefined();
|
||||
expect(app.config.uri).toBeDefined();
|
||||
expect(app.config.mimeType).toBeDefined();
|
||||
expect(app.config.toolPatterns).toBeDefined();
|
||||
expect(app.config.description).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectToolMeta()', () => {
|
||||
it('should not modify tools before load() is called', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools[0]._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('after loading with HTML', () => {
|
||||
beforeEach(() => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('<html>loaded</html>');
|
||||
UIAppRegistry.load();
|
||||
});
|
||||
|
||||
it('should set _meta.ui.resourceUri on matching operation tools', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools[0]._meta).toEqual({ ui: { resourceUri: 'ui://n8n-mcp/operation-result' }, 'ui/resourceUri': 'ui://n8n-mcp/operation-result' });
|
||||
});
|
||||
|
||||
it('should set _meta.ui.resourceUri on matching validation tools', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'validate_node', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools[0]._meta).toEqual({ ui: { resourceUri: 'ui://n8n-mcp/validation-summary' }, 'ui/resourceUri': 'ui://n8n-mcp/validation-summary' });
|
||||
});
|
||||
|
||||
it('should not set _meta on tools without a matching UI app', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'search_nodes', description: 'Search', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools[0]._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle a mix of matching and non-matching tools', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_delete_workflow', description: 'Delete', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'get_node_essentials', description: 'Essentials', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'validate_workflow', description: 'Validate', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools[0]._meta?.ui?.resourceUri).toBe('ui://n8n-mcp/operation-result');
|
||||
expect(tools[1]._meta).toBeUndefined();
|
||||
expect(tools[2]._meta?.ui?.resourceUri).toBe('ui://n8n-mcp/validation-summary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('after loading without HTML', () => {
|
||||
beforeEach(() => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
UIAppRegistry.load();
|
||||
});
|
||||
|
||||
it('should not set _meta when HTML is not available', () => {
|
||||
const tools: any[] = [
|
||||
{ name: 'n8n_create_workflow', description: 'Create', inputSchema: { type: 'object', properties: {} } },
|
||||
];
|
||||
UIAppRegistry.injectToolMeta(tools);
|
||||
expect(tools[0]._meta).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset()', () => {
|
||||
it('should clear loaded state so getters return defaults', () => {
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReadFileSync.mockReturnValue('<html>x</html>');
|
||||
UIAppRegistry.load();
|
||||
|
||||
expect(UIAppRegistry.getAllApps().length).toBeGreaterThan(0);
|
||||
|
||||
UIAppRegistry.reset();
|
||||
|
||||
expect(UIAppRegistry.getAllApps()).toEqual([]);
|
||||
expect(UIAppRegistry.getAppById('operation-result')).toBeNull();
|
||||
expect(UIAppRegistry.getAppForTool('n8n_create_workflow')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,9 +58,9 @@ describe('TypeStructureService', () => {
|
||||
});
|
||||
|
||||
describe('getAllStructures', () => {
|
||||
it('should return all 22 type structures', () => {
|
||||
it('should return all 23 type structures', () => {
|
||||
const structures = TypeStructureService.getAllStructures();
|
||||
expect(Object.keys(structures)).toHaveLength(22);
|
||||
expect(Object.keys(structures)).toHaveLength(23);
|
||||
});
|
||||
|
||||
it('should return a copy not a reference', () => {
|
||||
|
||||
1911
ui-apps/package-lock.json
generated
Normal file
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
29
ui-apps/package.json
Normal 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
357
ui-apps/preview.html
Normal file
@@ -0,0 +1,357 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>MCP App Preview</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 24px; transition: background 0.3s, color 0.3s; }
|
||||
body.dark { background: #111; color: #e0e0e0; }
|
||||
body.light { background: #f5f5f5; color: #1f2937; }
|
||||
h1 { font-size: 18px; margin-bottom: 8px; }
|
||||
.theme-toggle { margin-bottom: 16px; }
|
||||
.theme-toggle button { padding: 6px 14px; border: 1px solid #666; border-radius: 6px; cursor: pointer; font-size: 12px; margin-right: 8px; }
|
||||
body.dark .theme-toggle button { background: #252540; color: #e0e0e0; border-color: #444; }
|
||||
body.light .theme-toggle button { background: #fff; color: #1f2937; border-color: #ccc; }
|
||||
.theme-toggle button.active { border-color: #ff6d5a; color: #ff6d5a; }
|
||||
.section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin: 16px 0 8px; opacity: 0.6; }
|
||||
.controls { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
button { padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 12px; }
|
||||
body.dark button { border: 1px solid #444; background: #252540; color: #e0e0e0; }
|
||||
body.light button { border: 1px solid #d1d5db; background: #fff; color: #1f2937; }
|
||||
button:hover { opacity: 0.85; }
|
||||
button.active { border-color: #ff6d5a; color: #ff6d5a; }
|
||||
.preview-frame { border-radius: 8px; overflow: hidden; max-width: 520px; transition: background 0.3s, border-color 0.3s; }
|
||||
body.dark .preview-frame { border: 1px solid #333; background: #1a1a2e; }
|
||||
body.light .preview-frame { border: 1px solid #e5e7eb; background: #ffffff; }
|
||||
iframe { border: none; width: 100%; height: 600px; }
|
||||
.info { font-size: 12px; opacity: 0.4; margin-top: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="dark">
|
||||
<h1>MCP App Local Preview</h1>
|
||||
<div class="theme-toggle">
|
||||
<button onclick="setTheme('dark')" class="active" id="btn-dark">Dark</button>
|
||||
<button onclick="setTheme('light')" id="btn-light">Light</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Operation Result</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('operation-result', mockCreateSuccess, 'n8n_create_workflow', this)" class="active">Create (success)</button>
|
||||
<button onclick="load('operation-result', mockCreateError, 'n8n_create_workflow', this)">Create (error)</button>
|
||||
<button onclick="load('operation-result', mockDelete, 'n8n_delete_workflow', this)">Delete</button>
|
||||
<button onclick="load('operation-result', mockPartialUpdate, 'n8n_update_partial_workflow', this)">Partial Update</button>
|
||||
<button onclick="load('operation-result', mockFullUpdate, 'n8n_update_full_workflow', this)">Full Update</button>
|
||||
<button onclick="load('operation-result', mockAutofix, 'n8n_autofix_workflow', this)">Autofix</button>
|
||||
<button onclick="load('operation-result', mockAutofixPreview, 'n8n_autofix_workflow', this)">Autofix (preview)</button>
|
||||
<button onclick="load('operation-result', mockDeploy, 'n8n_deploy_template', this)">Deploy Template</button>
|
||||
<button onclick="load('operation-result', mockTest, 'n8n_test_workflow', this)">Test Workflow</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Validation Summary</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('validation-summary', mockValidValid, 'validate_node', this)">Valid Node</button>
|
||||
<button onclick="load('validation-summary', mockValidInvalid, 'validate_node', this)">Invalid Node</button>
|
||||
<button onclick="load('validation-summary', mockN8nValidate, 'n8n_validate_workflow', this)">Workflow (multi-node)</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Workflow List</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('workflow-list', mockWorkflowList, 'n8n_list_workflows', this)">Workflow List</button>
|
||||
<button onclick="load('workflow-list', mockWorkflowListEmpty, 'n8n_list_workflows', this)">Empty List</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Execution History</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('execution-history', mockExecutions, 'n8n_executions', this)">Executions</button>
|
||||
<button onclick="load('execution-history', mockExecutionsEmpty, 'n8n_executions', this)">Empty</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Health Dashboard</div>
|
||||
<div class="controls">
|
||||
<button onclick="load('health-dashboard', mockHealthOk, 'n8n_health_check', this)">Healthy</button>
|
||||
<button onclick="load('health-dashboard', mockHealthOutdated, 'n8n_health_check', this)">Outdated</button>
|
||||
<button onclick="load('health-dashboard', mockHealthError, 'n8n_health_check', this)">Error</button>
|
||||
</div>
|
||||
|
||||
<div class="preview-frame">
|
||||
<iframe id="app"></iframe>
|
||||
</div>
|
||||
<div class="info">This preview simulates the Claude host postMessage protocol to push mock tool result data into the MCP App iframe.</div>
|
||||
|
||||
<script>
|
||||
const iframe = document.getElementById('app');
|
||||
let pendingData = null;
|
||||
let pendingToolName = null;
|
||||
let currentTheme = 'dark';
|
||||
|
||||
// --- Theme ---
|
||||
function setTheme(theme) {
|
||||
currentTheme = theme;
|
||||
document.body.className = theme;
|
||||
document.getElementById('btn-dark').classList.toggle('active', theme === 'dark');
|
||||
document.getElementById('btn-light').classList.toggle('active', theme === 'light');
|
||||
// Reload current iframe to re-initialize with new theme
|
||||
if (iframe.src) iframe.src = iframe.src;
|
||||
}
|
||||
|
||||
// --- Mock Data: Operation Result ---
|
||||
const mockCreateSuccess = {
|
||||
success: true,
|
||||
data: { id: 'abc123XYZ', name: 'Webhook with Set Node', active: false, nodeCount: 2 },
|
||||
message: 'Workflow "Webhook with Set Node" created successfully.'
|
||||
};
|
||||
|
||||
const mockCreateError = {
|
||||
success: false,
|
||||
error: 'Node type format error: n8n API requires FULL form node types',
|
||||
details: { errors: ['Node 0 ("HTTP Request") uses SHORT form "nodes-base.httpRequest". Change to "n8n-nodes-base.httpRequest"'] }
|
||||
};
|
||||
|
||||
const mockDelete = {
|
||||
success: true,
|
||||
data: { id: 'wf_456', name: 'Old Workflow', deleted: true },
|
||||
message: 'Workflow "Old Workflow" deleted successfully.'
|
||||
};
|
||||
|
||||
const mockPartialUpdate = {
|
||||
success: true,
|
||||
data: { id: 'wf_789', name: 'My API Workflow', active: true, nodeCount: 5, operationsApplied: 3 },
|
||||
message: 'Workflow "My API Workflow" updated successfully.',
|
||||
details: {
|
||||
applied: ['add_node:Set', 'modify_node:HTTP Request', 'add_connection:Set->HTTP Request'],
|
||||
failed: [],
|
||||
warnings: ['Node "Set" has deprecated property "keepOnlySet"']
|
||||
}
|
||||
};
|
||||
|
||||
const mockFullUpdate = {
|
||||
success: true,
|
||||
data: { id: 'wf_101', name: 'Updated Workflow', active: true, nodeCount: 8 },
|
||||
message: 'Workflow updated.'
|
||||
};
|
||||
|
||||
const mockAutofix = {
|
||||
success: true,
|
||||
data: {
|
||||
id: 'wf_auto', name: 'Fixed Workflow', nodeCount: 4, fixesApplied: 3,
|
||||
fixes: [
|
||||
{ description: 'Changed node type from short to full form', confidence: 'HIGH' },
|
||||
{ description: 'Added missing authentication parameter', confidence: 'HIGH' },
|
||||
{ description: 'Replaced deprecated property channelId with channel', confidence: 'MEDIUM' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const mockAutofixPreview = {
|
||||
success: true,
|
||||
data: {
|
||||
id: 'wf_preview', name: 'Preview Workflow', nodeCount: 3, fixesApplied: 2, preview: true,
|
||||
fixes: [
|
||||
{ description: 'Would change node type from short to full form', confidence: 'HIGH' },
|
||||
{ description: 'Would add missing required field "resource"', confidence: 'MEDIUM' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const mockDeploy = {
|
||||
success: true,
|
||||
data: {
|
||||
id: 'wf_deployed', name: 'Email Notification Flow', active: false, nodeCount: 5,
|
||||
templateId: 1234, triggerType: 'webhook',
|
||||
requiredCredentials: ['gmailOAuth2Api (Gmail node)', 'slackOAuth2Api (Slack node)'],
|
||||
autoFixStatus: 'success'
|
||||
}
|
||||
};
|
||||
|
||||
const mockTest = {
|
||||
success: true,
|
||||
data: {
|
||||
id: 'wf_test', name: 'Test Workflow', executionId: 'exec_98765',
|
||||
triggerType: 'manual'
|
||||
}
|
||||
};
|
||||
|
||||
// --- Mock Data: Validation Summary ---
|
||||
const mockValidValid = {
|
||||
nodeType: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
suggestions: ['Consider adding error handling with an Error Trigger node'],
|
||||
summary: { hasErrors: false, errorCount: 0, warningCount: 0, suggestionCount: 1 }
|
||||
};
|
||||
|
||||
const mockValidInvalid = {
|
||||
nodeType: 'n8n-nodes-base.slack',
|
||||
displayName: 'Slack',
|
||||
valid: false,
|
||||
errors: [
|
||||
{ type: 'missing_required', property: 'authentication', message: 'Required field "authentication" is missing', fix: 'Set authentication to "oAuth2" or "accessToken"' },
|
||||
{ type: 'missing_required', property: 'resource', message: 'Required field "resource" is missing', fix: 'Set resource to "channel", "message", "user", etc.' }
|
||||
],
|
||||
warnings: [
|
||||
{ type: 'deprecated_property', property: 'channelId', message: 'Property "channelId" is deprecated, use "channel" instead' }
|
||||
],
|
||||
suggestions: ['Use OAuth2 authentication for production workflows', 'Add error handling for rate limits'],
|
||||
summary: { hasErrors: true, errorCount: 2, warningCount: 1, suggestionCount: 2 }
|
||||
};
|
||||
|
||||
const mockN8nValidate = {
|
||||
success: true,
|
||||
data: {
|
||||
valid: false,
|
||||
workflowId: 'wf_abc',
|
||||
workflowName: 'My Production Workflow',
|
||||
errors: [
|
||||
{ node: 'HTTP Request', message: 'Missing URL parameter', property: 'url', fix: 'Set the url field' },
|
||||
{ node: 'HTTP Request', message: 'Invalid method "PATCH" for this endpoint', property: 'method' },
|
||||
{ node: 'Slack', message: 'Missing authentication configuration', property: 'authentication', fix: 'Set authentication to oAuth2' }
|
||||
],
|
||||
warnings: [
|
||||
{ node: 'Set', message: 'Unused output field "oldField"', property: 'oldField' },
|
||||
{ node: 'HTTP Request', message: 'Consider using retry on failure', property: 'options.retry' }
|
||||
],
|
||||
suggestions: ['Add error handling between HTTP Request and Set nodes'],
|
||||
summary: { errorCount: 3, warningCount: 2, totalNodes: 4, enabledNodes: 4 }
|
||||
}
|
||||
};
|
||||
|
||||
// --- Mock Data: Workflow List ---
|
||||
const mockWorkflowList = {
|
||||
success: true,
|
||||
data: {
|
||||
workflows: [
|
||||
{ id: 'wf_001', name: 'Customer Onboarding', active: true, nodeCount: 12, tags: ['production', 'crm'], updatedAt: '2026-02-07T14:30:00Z' },
|
||||
{ id: 'wf_002', name: 'Slack Notifications', active: true, nodeCount: 5, tags: ['notifications'], updatedAt: '2026-02-06T09:15:00Z' },
|
||||
{ id: 'wf_003', name: 'Data Backup (weekly)', active: false, nodeCount: 8, tags: ['maintenance', 'backup'], updatedAt: '2026-01-28T22:00:00Z' },
|
||||
{ id: 'wf_004', name: 'Invoice Processing', active: true, nodeCount: 15, tags: ['finance', 'production', 'critical'], updatedAt: '2026-02-08T01:00:00Z' },
|
||||
{ id: 'wf_005', name: 'Old Integration Test', active: false, isArchived: true, nodeCount: 3, tags: [], updatedAt: '2025-11-01T10:00:00Z' },
|
||||
{ id: 'wf_006', name: 'Email Campaign Drip', active: true, nodeCount: 9, tags: ['marketing'], updatedAt: '2026-02-05T16:45:00Z' },
|
||||
],
|
||||
returned: 6,
|
||||
hasMore: true,
|
||||
nextCursor: 'cursor_abc'
|
||||
}
|
||||
};
|
||||
|
||||
const mockWorkflowListEmpty = {
|
||||
success: true,
|
||||
data: { workflows: [], returned: 0, hasMore: false }
|
||||
};
|
||||
|
||||
// --- Mock Data: Execution History ---
|
||||
const mockExecutions = {
|
||||
success: true,
|
||||
data: {
|
||||
executions: [
|
||||
{ id: 'exec_001', workflowName: 'Customer Onboarding', status: 'success', startedAt: '2026-02-08T10:30:00Z', stoppedAt: '2026-02-08T10:30:02Z', mode: 'webhook' },
|
||||
{ id: 'exec_002', workflowName: 'Slack Notifications', status: 'success', startedAt: '2026-02-08T10:28:00Z', stoppedAt: '2026-02-08T10:28:01Z', mode: 'trigger' },
|
||||
{ id: 'exec_003', workflowName: 'Invoice Processing', status: 'error', startedAt: '2026-02-08T10:25:00Z', stoppedAt: '2026-02-08T10:25:15Z', mode: 'webhook' },
|
||||
{ id: 'exec_004', workflowName: 'Customer Onboarding', status: 'success', startedAt: '2026-02-08T10:20:00Z', stoppedAt: '2026-02-08T10:20:03Z', mode: 'webhook' },
|
||||
{ id: 'exec_005', workflowName: 'Email Campaign Drip', status: 'waiting', startedAt: '2026-02-08T10:15:00Z', mode: 'manual' },
|
||||
{ id: 'exec_006', workflowName: 'Data Backup', status: 'success', startedAt: '2026-02-08T09:00:00Z', stoppedAt: '2026-02-08T09:02:30Z', mode: 'cron' },
|
||||
{ id: 'exec_007', workflowName: 'Invoice Processing', status: 'error', startedAt: '2026-02-07T23:00:00Z', stoppedAt: '2026-02-07T23:00:08Z', mode: 'webhook' },
|
||||
],
|
||||
returned: 7,
|
||||
hasMore: true
|
||||
}
|
||||
};
|
||||
|
||||
const mockExecutionsEmpty = {
|
||||
success: true,
|
||||
data: { executions: [], returned: 0, hasMore: false }
|
||||
};
|
||||
|
||||
// --- Mock Data: Health Dashboard ---
|
||||
const mockHealthOk = {
|
||||
success: true,
|
||||
data: {
|
||||
status: 'connected',
|
||||
instanceId: 'inst_abc123',
|
||||
n8nVersion: '1.72.1',
|
||||
mcpVersion: '2.24.1',
|
||||
apiUrl: 'https://n8n.example.com/api/v1',
|
||||
versionCheck: { current: '1.72.1', latest: '1.72.1', upToDate: true },
|
||||
performance: { responseTimeMs: 142, cacheHitRate: 0.87 },
|
||||
nextSteps: ['Your n8n instance is up to date', 'Try creating a workflow with n8n_create_workflow']
|
||||
}
|
||||
};
|
||||
|
||||
const mockHealthOutdated = {
|
||||
success: true,
|
||||
data: {
|
||||
status: 'connected',
|
||||
instanceId: 'inst_xyz789',
|
||||
n8nVersion: '1.68.0',
|
||||
mcpVersion: '2.24.1',
|
||||
apiUrl: 'https://n8n.company.io/api/v1',
|
||||
versionCheck: { current: '1.68.0', latest: '1.72.1', upToDate: false, updateCommand: 'npm update n8n -g' },
|
||||
performance: { responseTimeMs: 1850, cacheHitRate: 0.45 },
|
||||
nextSteps: ['Update n8n to version 1.72.1 for latest features and fixes', 'Cache hit rate is low - consider warming the cache']
|
||||
}
|
||||
};
|
||||
|
||||
const mockHealthError = {
|
||||
success: false,
|
||||
error: 'Connection failed: Unable to reach n8n instance at https://n8n.offline.com/api/v1'
|
||||
};
|
||||
|
||||
// --- Host Protocol ---
|
||||
window.addEventListener('message', (event) => {
|
||||
if (!event.data || typeof event.data !== 'object') return;
|
||||
const msg = event.data;
|
||||
if (!msg.jsonrpc) return;
|
||||
|
||||
if (msg.jsonrpc === '2.0' && msg.method === 'ui/initialize' && msg.id != null) {
|
||||
iframe.contentWindow.postMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: {
|
||||
protocolVersion: '2026-01-26',
|
||||
hostCapabilities: {},
|
||||
hostInfo: { name: 'Local Preview', version: '1.0.0' },
|
||||
hostContext: {
|
||||
theme: currentTheme,
|
||||
toolInfo: pendingToolName ? { tool: { name: pendingToolName, inputSchema: { type: 'object', properties: {} } } } : undefined,
|
||||
}
|
||||
}
|
||||
}, '*');
|
||||
|
||||
setTimeout(() => {
|
||||
if (pendingData) {
|
||||
iframe.contentWindow.postMessage({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/tool-result',
|
||||
params: {
|
||||
content: [{ type: 'text', text: JSON.stringify(pendingData) }]
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
// Respond to any other JSON-RPC request (with id) with empty result
|
||||
else if (msg.jsonrpc === '2.0' && msg.id != null && msg.method) {
|
||||
iframe.contentWindow.postMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: {}
|
||||
}, '*');
|
||||
}
|
||||
});
|
||||
|
||||
function load(appName, data, toolName, btn) {
|
||||
pendingData = data;
|
||||
pendingToolName = toolName || null;
|
||||
document.querySelectorAll('.controls button').forEach(b => b.classList.remove('active'));
|
||||
if (btn) btn.classList.add('active');
|
||||
iframe.src = `dist/${appName}/index.html`;
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
load('operation-result', mockCreateSuccess, 'n8n_create_workflow', document.querySelector('.controls button'));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
201
ui-apps/src/apps/execution-history/App.tsx
Normal file
201
ui-apps/src/apps/execution-history/App.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import '@shared/styles/theme.css';
|
||||
import { Badge } from '@shared/components';
|
||||
import { useToolData } from '@shared/hooks/useToolData';
|
||||
import type { ExecutionHistoryData } from '@shared/types';
|
||||
|
||||
type ExecStatus = 'success' | 'error' | 'waiting' | 'running' | 'unknown';
|
||||
|
||||
function getStatusInfo(status?: string): { variant: 'success' | 'error' | 'warning' | 'info'; label: string } {
|
||||
switch (status) {
|
||||
case 'success': return { variant: 'success', label: 'Success' };
|
||||
case 'error': case 'failed': case 'crashed': return { variant: 'error', label: 'Error' };
|
||||
case 'waiting': return { variant: 'warning', label: 'Waiting' };
|
||||
case 'running': return { variant: 'info', label: 'Running' };
|
||||
default: return { variant: 'info', label: status ?? 'Unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(startedAt?: string, stoppedAt?: string): string {
|
||||
if (!startedAt || !stoppedAt) return '–';
|
||||
try {
|
||||
const ms = new Date(stoppedAt).getTime() - new Date(startedAt).getTime();
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
||||
} catch {
|
||||
return '–';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(dateStr?: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function classifyStatus(status?: string): ExecStatus {
|
||||
switch (status) {
|
||||
case 'success': return 'success';
|
||||
case 'error': case 'failed': case 'crashed': return 'error';
|
||||
case 'waiting': return 'waiting';
|
||||
case 'running': return 'running';
|
||||
default: return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { data, error, isConnected } = useToolData<ExecutionHistoryData>();
|
||||
|
||||
const executions = data?.data?.executions ?? [];
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const counts: Record<ExecStatus, number> = { success: 0, error: 0, waiting: 0, running: 0, unknown: 0 };
|
||||
for (const ex of executions) {
|
||||
counts[classifyStatus(ex.status)]++;
|
||||
}
|
||||
return counts;
|
||||
}, [executions]);
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: '16px', color: '#ef4444' }}>Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Connecting...</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Waiting for data...</div>;
|
||||
}
|
||||
|
||||
if (!data.success && data.error) {
|
||||
return (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
<Badge variant="error">Error</Badge>
|
||||
<div style={{ marginTop: '8px', fontSize: '13px', color: 'var(--n8n-error)' }}>{data.error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const total = executions.length;
|
||||
const barSegments: { color: string; pct: number }[] = [];
|
||||
if (total > 0) {
|
||||
if (summary.success > 0) barSegments.push({ color: 'var(--n8n-success)', pct: (summary.success / total) * 100 });
|
||||
if (summary.error > 0) barSegments.push({ color: 'var(--n8n-error)', pct: (summary.error / total) * 100 });
|
||||
if (summary.waiting > 0) barSegments.push({ color: 'var(--n8n-warning)', pct: (summary.waiting / total) * 100 });
|
||||
if (summary.running > 0) barSegments.push({ color: 'var(--n8n-info)', pct: (summary.running / total) * 100 });
|
||||
if (summary.unknown > 0) barSegments.push({ color: 'var(--n8n-border)', pct: (summary.unknown / total) * 100 });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
{/* Summary bar */}
|
||||
{total > 0 && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
height: '6px',
|
||||
borderRadius: '3px',
|
||||
background: 'var(--n8n-border)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
}}>
|
||||
{barSegments.map((seg, i) => (
|
||||
<div key={i} style={{ width: `${seg.pct}%`, background: seg.color, minWidth: '3px' }} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-secondary, var(--n8n-text-muted))', marginTop: '6px' }}>
|
||||
{summary.success > 0 && <><span style={{ color: 'var(--n8n-success)', fontWeight: 500 }}>{summary.success}</span> succeeded</>}
|
||||
{summary.error > 0 && <>{summary.success > 0 && ', '}<span style={{ color: 'var(--n8n-error)', fontWeight: 500 }}>{summary.error}</span> failed</>}
|
||||
{summary.waiting > 0 && <>{(summary.success > 0 || summary.error > 0) && ', '}<span style={{ color: 'var(--n8n-warning)', fontWeight: 500 }}>{summary.waiting}</span> waiting</>}
|
||||
{summary.running > 0 && <>{(summary.success > 0 || summary.error > 0 || summary.waiting > 0) && ', '}<span style={{ color: 'var(--n8n-info)', fontWeight: 500 }}>{summary.running}</span> running</>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div style={{
|
||||
border: '1px solid var(--n8n-border)',
|
||||
borderRadius: 'var(--n8n-radius)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '70px 1fr 70px 90px 60px',
|
||||
gap: '6px',
|
||||
padding: '8px 10px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.03em',
|
||||
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
|
||||
background: 'var(--n8n-bg-card)',
|
||||
borderBottom: '1px solid var(--n8n-border)',
|
||||
}}>
|
||||
<span>ID</span>
|
||||
<span>Workflow</span>
|
||||
<span>Status</span>
|
||||
<span>Started</span>
|
||||
<span>Duration</span>
|
||||
</div>
|
||||
|
||||
{executions.length === 0 && (
|
||||
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--n8n-text-muted)', fontSize: '13px' }}>
|
||||
No executions found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{executions.map((ex) => {
|
||||
const statusInfo = getStatusInfo(ex.status);
|
||||
return (
|
||||
<div
|
||||
key={ex.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '70px 1fr 70px 90px 60px',
|
||||
gap: '6px',
|
||||
padding: '6px 10px',
|
||||
fontSize: '12px',
|
||||
borderBottom: '1px solid var(--n8n-border)',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontSize: '11px' }}>
|
||||
{ex.id.length > 8 ? ex.id.slice(0, 8) + '…' : ex.id}
|
||||
</span>
|
||||
<span style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
}}>
|
||||
{ex.workflowName || ex.workflowId || '–'}
|
||||
</span>
|
||||
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary, var(--n8n-text-muted))', whiteSpace: 'nowrap' as const }}>
|
||||
{formatTime(ex.startedAt)}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>
|
||||
{formatDuration(ex.startedAt, ex.stoppedAt)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{data.data?.hasMore && (
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
|
||||
marginTop: '6px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
More executions available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
ui-apps/src/apps/execution-history/index.html
Normal file
12
ui-apps/src/apps/execution-history/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Execution History</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
ui-apps/src/apps/execution-history/main.tsx
Normal file
8
ui-apps/src/apps/execution-history/main.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (root) {
|
||||
createRoot(root).render(<App />);
|
||||
}
|
||||
141
ui-apps/src/apps/health-dashboard/App.tsx
Normal file
141
ui-apps/src/apps/health-dashboard/App.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import '@shared/styles/theme.css';
|
||||
import { Badge, Card } from '@shared/components';
|
||||
import { useToolData } from '@shared/hooks/useToolData';
|
||||
import type { HealthDashboardData } from '@shared/types';
|
||||
|
||||
export default function App() {
|
||||
const { data, error, isConnected } = useToolData<HealthDashboardData>();
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: '16px', color: '#ef4444' }}>Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Connecting...</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Waiting for data...</div>;
|
||||
}
|
||||
|
||||
if (!data.success && data.error) {
|
||||
return (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
||||
<Badge variant="error">Disconnected</Badge>
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--n8n-error)' }}>{data.error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const d = data.data;
|
||||
const isConnectedStatus = d?.status === 'connected' || d?.status === 'ok' || data.success;
|
||||
const vc = d?.versionCheck;
|
||||
const perf = d?.performance;
|
||||
const nextSteps = d?.nextSteps ?? [];
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
{/* Connection status */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '16px' }}>
|
||||
<Badge variant={isConnectedStatus ? 'success' : 'error'}>
|
||||
{isConnectedStatus ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
{d?.apiUrl && (
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
|
||||
}}>
|
||||
{d.apiUrl}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Version info */}
|
||||
{(d?.n8nVersion || d?.mcpVersion) && (
|
||||
<Card>
|
||||
<div style={{ fontSize: '13px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>n8n</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontWeight: 500 }}>
|
||||
{d?.n8nVersion ?? '–'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>MCP Server</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontWeight: 500 }}>
|
||||
{d?.mcpVersion ?? '–'}
|
||||
</span>
|
||||
</div>
|
||||
{vc && !vc.upToDate && (
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
padding: '6px 10px',
|
||||
background: 'var(--n8n-warning-light)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--n8n-warning)',
|
||||
}}>
|
||||
Update available: {vc.current} → {vc.latest}
|
||||
{vc.updateCommand && (
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '11px',
|
||||
marginTop: '4px',
|
||||
opacity: 0.9,
|
||||
}}>
|
||||
{vc.updateCommand}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Performance */}
|
||||
{perf && (
|
||||
<Card>
|
||||
<div style={{ fontSize: '13px' }}>
|
||||
{perf.responseTimeMs !== undefined && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>Response time</span>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontWeight: 500,
|
||||
color: perf.responseTimeMs < 500 ? 'var(--n8n-success)' : perf.responseTimeMs < 2000 ? 'var(--n8n-warning)' : 'var(--n8n-error)',
|
||||
}}>
|
||||
{perf.responseTimeMs}ms
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{perf.cacheHitRate !== undefined && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>Cache hit rate</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono, monospace)', fontWeight: 500 }}>
|
||||
{typeof perf.cacheHitRate === 'number' && perf.cacheHitRate <= 1
|
||||
? `${(perf.cacheHitRate * 100).toFixed(0)}%`
|
||||
: `${perf.cacheHitRate}%`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Next steps */}
|
||||
{nextSteps.length > 0 && (
|
||||
<Card title="Next Steps">
|
||||
<ul style={{ paddingLeft: '16px', fontSize: '12px' }}>
|
||||
{nextSteps.map((step, i) => (
|
||||
<li key={i} style={{ padding: '2px 0' }}>{step}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
ui-apps/src/apps/health-dashboard/index.html
Normal file
12
ui-apps/src/apps/health-dashboard/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Health Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
ui-apps/src/apps/health-dashboard/main.tsx
Normal file
8
ui-apps/src/apps/health-dashboard/main.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (root) {
|
||||
createRoot(root).render(<App />);
|
||||
}
|
||||
337
ui-apps/src/apps/operation-result/App.tsx
Normal file
337
ui-apps/src/apps/operation-result/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
ui-apps/src/apps/operation-result/index.html
Normal file
12
ui-apps/src/apps/operation-result/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Operation Result</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
ui-apps/src/apps/operation-result/main.tsx
Normal file
8
ui-apps/src/apps/operation-result/main.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (root) {
|
||||
createRoot(root).render(<App />);
|
||||
}
|
||||
211
ui-apps/src/apps/validation-summary/App.tsx
Normal file
211
ui-apps/src/apps/validation-summary/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
ui-apps/src/apps/validation-summary/index.html
Normal file
12
ui-apps/src/apps/validation-summary/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Validation Summary</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
ui-apps/src/apps/validation-summary/main.tsx
Normal file
8
ui-apps/src/apps/validation-summary/main.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (root) {
|
||||
createRoot(root).render(<App />);
|
||||
}
|
||||
145
ui-apps/src/apps/workflow-list/App.tsx
Normal file
145
ui-apps/src/apps/workflow-list/App.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import '@shared/styles/theme.css';
|
||||
import { Badge } from '@shared/components';
|
||||
import { useToolData } from '@shared/hooks/useToolData';
|
||||
import type { WorkflowListData } from '@shared/types';
|
||||
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { data, error, isConnected } = useToolData<WorkflowListData>();
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: '16px', color: '#ef4444' }}>Error: {error}</div>;
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Connecting...</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div style={{ padding: '16px', color: 'var(--n8n-text-muted)' }}>Waiting for data...</div>;
|
||||
}
|
||||
|
||||
if (!data.success && data.error) {
|
||||
return (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
<Badge variant="error">Error</Badge>
|
||||
<div style={{ marginTop: '8px', fontSize: '13px', color: 'var(--n8n-error)' }}>{data.error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const workflows = data.data?.workflows ?? [];
|
||||
const returned = data.data?.returned ?? workflows.length;
|
||||
const hasMore = data.data?.hasMore;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '480px' }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
Showing {returned} workflow{returned !== 1 ? 's' : ''}
|
||||
{hasMore && ' (more available)'}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '1px solid var(--n8n-border)',
|
||||
borderRadius: 'var(--n8n-radius)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header row */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 60px 50px auto',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.03em',
|
||||
color: 'var(--color-text-secondary, var(--n8n-text-muted))',
|
||||
background: 'var(--n8n-bg-card)',
|
||||
borderBottom: '1px solid var(--n8n-border)',
|
||||
}}>
|
||||
<span>Name</span>
|
||||
<span>Status</span>
|
||||
<span>Nodes</span>
|
||||
<span>Updated</span>
|
||||
</div>
|
||||
|
||||
{workflows.length === 0 && (
|
||||
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--n8n-text-muted)', fontSize: '13px' }}>
|
||||
No workflows found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workflows.map((wf) => (
|
||||
<div
|
||||
key={wf.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 60px 50px auto',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
borderBottom: '1px solid var(--n8n-border)',
|
||||
opacity: wf.isArchived ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' as const }}>
|
||||
<span style={{ fontWeight: 500 }}>{wf.name}</span>
|
||||
{wf.tags && wf.tags.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '4px', marginTop: '2px', flexWrap: 'wrap' }}>
|
||||
{wf.tags.slice(0, 3).map((tag, i) => (
|
||||
<span key={i} style={{
|
||||
fontSize: '10px',
|
||||
padding: '1px 6px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--n8n-info-light)',
|
||||
color: 'var(--n8n-info)',
|
||||
}}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{wf.tags.length > 3 && (
|
||||
<span style={{ fontSize: '10px', color: 'var(--n8n-text-muted)' }}>+{wf.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: wf.active ? 'var(--n8n-success)' : 'var(--n8n-border)',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>
|
||||
{wf.isArchived ? 'Archived' : wf.active ? 'Active' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ color: 'var(--color-text-secondary, var(--n8n-text-muted))' }}>
|
||||
{wf.nodeCount ?? '–'}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary, var(--n8n-text-muted))', whiteSpace: 'nowrap' as const }}>
|
||||
{formatDate(wf.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
ui-apps/src/apps/workflow-list/index.html
Normal file
12
ui-apps/src/apps/workflow-list/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Workflow List</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
ui-apps/src/apps/workflow-list/main.tsx
Normal file
8
ui-apps/src/apps/workflow-list/main.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (root) {
|
||||
createRoot(root).render(<App />);
|
||||
}
|
||||
32
ui-apps/src/shared/components/Badge.tsx
Normal file
32
ui-apps/src/shared/components/Badge.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
type BadgeVariant = 'success' | 'warning' | 'error' | 'info';
|
||||
|
||||
interface BadgeProps {
|
||||
variant: BadgeVariant;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, { bg: string; color: string }> = {
|
||||
success: { bg: 'var(--n8n-success-light)', color: 'var(--n8n-success)' },
|
||||
warning: { bg: 'var(--n8n-warning-light)', color: 'var(--n8n-warning)' },
|
||||
error: { bg: 'var(--n8n-error-light)', color: 'var(--n8n-error)' },
|
||||
info: { bg: 'var(--n8n-info-light)', color: 'var(--n8n-info)' },
|
||||
};
|
||||
|
||||
export function Badge({ variant, children }: BadgeProps) {
|
||||
const style = variantStyles[variant];
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 10px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
background: style.bg,
|
||||
color: style.color,
|
||||
}}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
25
ui-apps/src/shared/components/Card.tsx
Normal file
25
ui-apps/src/shared/components/Card.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Card({ title, children }: CardProps) {
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--n8n-bg-card)',
|
||||
border: '1px solid var(--n8n-border)',
|
||||
borderRadius: 'var(--n8n-radius)',
|
||||
padding: '16px',
|
||||
marginBottom: '12px',
|
||||
}}>
|
||||
{title && (
|
||||
<h3 style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--n8n-text-muted)' }}>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
ui-apps/src/shared/components/CopyButton.tsx
Normal file
54
ui-apps/src/shared/components/CopyButton.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function CopyButton({ text }: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for sandboxed environments
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
padding: 0,
|
||||
border: '1px solid var(--n8n-border)',
|
||||
borderRadius: '4px',
|
||||
background: 'transparent',
|
||||
color: copied ? 'var(--n8n-success)' : 'var(--n8n-text-muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Copy"
|
||||
>
|
||||
{copied ? '✓' : '⎘'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
36
ui-apps/src/shared/components/Expandable.tsx
Normal file
36
ui-apps/src/shared/components/Expandable.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ExpandableProps {
|
||||
title: string;
|
||||
count?: number;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Expandable({ title, count, defaultOpen = false, children }: ExpandableProps) {
|
||||
return (
|
||||
<details open={defaultOpen} style={{
|
||||
marginBottom: '8px',
|
||||
border: '1px solid var(--n8n-border)',
|
||||
borderRadius: 'var(--n8n-radius)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<summary style={{
|
||||
padding: '10px 14px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
background: 'var(--n8n-bg-card)',
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
{title}
|
||||
{count !== undefined && (
|
||||
<span style={{ marginLeft: '8px', color: 'var(--n8n-text-muted)' }}>({count})</span>
|
||||
)}
|
||||
</summary>
|
||||
<div style={{ padding: '12px 14px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user