chore: import upstream snapshot with attribution
Build and Push Docker Images / Build Docker Image (push) Has been cancelled
Build and Push Docker Images / Build Railway Docker Image (push) Has been cancelled
Build and Publish n8n Docker Image / test-image (push) Has been cancelled
Dependency Compatibility Check / Fresh Install Dependency Check (push) Has been cancelled
Build and Publish n8n Docker Image / build-and-push (push) Has been cancelled
Build and Publish n8n Docker Image / create-release (push) Has been cancelled
Automated Release / Detect Version Change (push) Has been cancelled
Automated Release / Generate Release Notes (push) Has been cancelled
Automated Release / Create GitHub Release (push) Has been cancelled
Automated Release / Package MCPB Bundle (push) Has been cancelled
Automated Release / Build and Verify (push) Has been cancelled
Automated Release / Publish to NPM (push) Has been cancelled
Automated Release / Build and Push Docker Images (push) Has been cancelled
Automated Release / Update Documentation (push) Has been cancelled
Automated Release / Notify Release Completion (push) Has been cancelled
Secret Scan / secretlint (push) Has been cancelled
Test Suite / test (push) Has been cancelled
Test Suite / cjs-runtime (push) Has been cancelled
Test Suite / publish-results (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:41:06 +08:00
commit 409e92d6ae
848 changed files with 347605 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# Analyze potential optimization savings
echo "🔍 Analyzing Docker Optimization Potential"
echo "=========================================="
# Check current database size
if [ -f data/nodes.db ]; then
DB_SIZE=$(du -h data/nodes.db | cut -f1)
echo "Current database size: $DB_SIZE"
fi
# Check node_modules size
if [ -d node_modules ]; then
echo -e "\n📦 Package sizes:"
echo "Total node_modules: $(du -sh node_modules | cut -f1)"
echo "n8n packages:"
for pkg in n8n n8n-core n8n-workflow @n8n/n8n-nodes-langchain; do
if [ -d "node_modules/$pkg" ]; then
SIZE=$(du -sh "node_modules/$pkg" 2>/dev/null | cut -f1 || echo "N/A")
echo " - $pkg: $SIZE"
fi
done
fi
# Check runtime dependencies
echo -e "\n🎯 Runtime-only dependencies:"
RUNTIME_DEPS="@modelcontextprotocol/sdk better-sqlite3 sql.js express dotenv"
RUNTIME_SIZE=0
for dep in $RUNTIME_DEPS; do
if [ -d "node_modules/$dep" ]; then
SIZE=$(du -sh "node_modules/$dep" 2>/dev/null | cut -f1 || echo "0")
echo " - $dep: $SIZE"
fi
done
# Estimate savings
echo -e "\n💡 Optimization potential:"
echo "- Current image: 2.61GB"
echo "- Estimated optimized: ~200MB"
echo "- Savings: ~92%"
# Show what would be removed
echo -e "\n🗑️ Would remove in optimization:"
echo "- n8n packages (>2GB)"
echo "- Build dependencies"
echo "- Documentation files"
echo "- Test files"
echo "- Source maps"
# Check if optimized database exists
if [ -f data/nodes-optimized.db ]; then
OPT_SIZE=$(du -h data/nodes-optimized.db | cut -f1)
echo -e "\n✅ Optimized database exists: $OPT_SIZE"
fi
+78
View File
@@ -0,0 +1,78 @@
/**
* Database Schema Coverage Audit Script
*
* Audits the database to determine how many nodes have complete schema information
* for resourceLocator mode validation. This helps assess the coverage of our
* schema-driven validation approach.
*/
import Database from 'better-sqlite3';
import path from 'path';
const dbPath = path.join(__dirname, '../data/nodes.db');
const db = new Database(dbPath, { readonly: true });
console.log('=== Schema Coverage Audit ===\n');
// Query 1: How many nodes have resourceLocator properties?
const totalResourceLocator = db.prepare(`
SELECT COUNT(*) as count FROM nodes
WHERE properties_schema LIKE '%resourceLocator%'
`).get() as { count: number };
console.log(`Nodes with resourceLocator properties: ${totalResourceLocator.count}`);
// Query 2: Of those, how many have modes defined?
const withModes = db.prepare(`
SELECT COUNT(*) as count FROM nodes
WHERE properties_schema LIKE '%resourceLocator%'
AND properties_schema LIKE '%modes%'
`).get() as { count: number };
console.log(`Nodes with modes defined: ${withModes.count}`);
// Query 3: Which nodes have resourceLocator but NO modes?
const withoutModes = db.prepare(`
SELECT node_type, display_name
FROM nodes
WHERE properties_schema LIKE '%resourceLocator%'
AND properties_schema NOT LIKE '%modes%'
LIMIT 10
`).all() as Array<{ node_type: string; display_name: string }>;
console.log(`\nSample nodes WITHOUT modes (showing 10):`);
withoutModes.forEach(node => {
console.log(` - ${node.display_name} (${node.node_type})`);
});
// Calculate coverage percentage
const coverage = totalResourceLocator.count > 0
? (withModes.count / totalResourceLocator.count) * 100
: 0;
console.log(`\nSchema coverage: ${coverage.toFixed(1)}% of resourceLocator nodes have modes defined`);
// Query 4: Get some examples of nodes WITH modes for verification
console.log('\nSample nodes WITH modes (showing 5):');
const withModesExamples = db.prepare(`
SELECT node_type, display_name
FROM nodes
WHERE properties_schema LIKE '%resourceLocator%'
AND properties_schema LIKE '%modes%'
LIMIT 5
`).all() as Array<{ node_type: string; display_name: string }>;
withModesExamples.forEach(node => {
console.log(` - ${node.display_name} (${node.node_type})`);
});
// Summary
console.log('\n=== Summary ===');
console.log(`Total nodes in database: ${db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any as { count: number }.count}`);
console.log(`Nodes with resourceLocator: ${totalResourceLocator.count}`);
console.log(`Nodes with complete mode schemas: ${withModes.count}`);
console.log(`Nodes without mode schemas: ${totalResourceLocator.count - withModes.count}`);
console.log(`\nImplication: Schema-driven validation will apply to ${withModes.count} nodes.`);
console.log(`For the remaining ${totalResourceLocator.count - withModes.count} nodes, validation will be skipped (graceful degradation).`);
db.close();
+192
View File
@@ -0,0 +1,192 @@
/**
* Backfill script to populate structural hashes for existing workflow mutations
*
* Purpose: Generates workflow_structure_hash_before and workflow_structure_hash_after
* for all existing mutations to enable cross-referencing with telemetry_workflows
*
* Usage: npx tsx scripts/backfill-mutation-hashes.ts
*
* Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
*/
import { WorkflowSanitizer } from '../src/telemetry/workflow-sanitizer.js';
import { createClient } from '@supabase/supabase-js';
// Initialize Supabase client
const supabaseUrl = process.env.SUPABASE_URL || '';
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || '';
if (!supabaseUrl || !supabaseKey) {
console.error('Error: SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables are required');
process.exit(1);
}
const supabase = createClient(supabaseUrl, supabaseKey);
interface MutationRecord {
id: string;
workflow_before: any;
workflow_after: any;
workflow_structure_hash_before: string | null;
workflow_structure_hash_after: string | null;
}
/**
* Fetch all mutations that need structural hashes
*/
async function fetchMutationsToBackfill(): Promise<MutationRecord[]> {
console.log('Fetching mutations without structural hashes...');
const { data, error } = await supabase
.from('workflow_mutations')
.select('id, workflow_before, workflow_after, workflow_structure_hash_before, workflow_structure_hash_after')
.is('workflow_structure_hash_before', null);
if (error) {
throw new Error(`Failed to fetch mutations: ${error.message}`);
}
console.log(`Found ${data?.length || 0} mutations to backfill`);
return data || [];
}
/**
* Generate structural hash for a workflow
*/
function generateStructuralHash(workflow: any): string {
try {
return WorkflowSanitizer.generateWorkflowHash(workflow);
} catch (error) {
console.error('Error generating hash:', error);
return '';
}
}
/**
* Update a single mutation with structural hashes
*/
async function updateMutation(id: string, structureHashBefore: string, structureHashAfter: string): Promise<boolean> {
const { error } = await supabase
.from('workflow_mutations')
.update({
workflow_structure_hash_before: structureHashBefore,
workflow_structure_hash_after: structureHashAfter,
})
.eq('id', id);
if (error) {
console.error(`Failed to update mutation ${id}:`, error.message);
return false;
}
return true;
}
/**
* Process mutations in batches
*/
async function backfillMutations() {
const startTime = Date.now();
console.log('Starting backfill process...\n');
// Fetch mutations
const mutations = await fetchMutationsToBackfill();
if (mutations.length === 0) {
console.log('No mutations need backfilling. All done!');
return;
}
let processedCount = 0;
let successCount = 0;
let errorCount = 0;
const errors: Array<{ id: string; error: string }> = [];
// Process each mutation
for (const mutation of mutations) {
try {
// Generate structural hashes
const structureHashBefore = generateStructuralHash(mutation.workflow_before);
const structureHashAfter = generateStructuralHash(mutation.workflow_after);
if (!structureHashBefore || !structureHashAfter) {
console.warn(`Skipping mutation ${mutation.id}: Failed to generate hashes`);
errors.push({ id: mutation.id, error: 'Failed to generate hashes' });
errorCount++;
continue;
}
// Update database
const success = await updateMutation(mutation.id, structureHashBefore, structureHashAfter);
if (success) {
successCount++;
} else {
errorCount++;
errors.push({ id: mutation.id, error: 'Database update failed' });
}
processedCount++;
// Progress update every 100 mutations
if (processedCount % 100 === 0) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
const rate = (processedCount / (Date.now() - startTime) * 1000).toFixed(1);
console.log(
`Progress: ${processedCount}/${mutations.length} (${((processedCount / mutations.length) * 100).toFixed(1)}%) | ` +
`Success: ${successCount} | Errors: ${errorCount} | Rate: ${rate}/s | Elapsed: ${elapsed}s`
);
}
} catch (error) {
console.error(`Unexpected error processing mutation ${mutation.id}:`, error);
errors.push({ id: mutation.id, error: String(error) });
errorCount++;
}
}
// Final summary
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
console.log('\n' + '='.repeat(80));
console.log('BACKFILL COMPLETE');
console.log('='.repeat(80));
console.log(`Total mutations processed: ${processedCount}`);
console.log(`Successfully updated: ${successCount}`);
console.log(`Errors: ${errorCount}`);
console.log(`Duration: ${duration}s`);
console.log(`Average rate: ${(processedCount / (Date.now() - startTime) * 1000).toFixed(1)} mutations/s`);
if (errors.length > 0) {
console.log('\nErrors encountered:');
errors.slice(0, 10).forEach(({ id, error }) => {
console.log(` - ${id}: ${error}`);
});
if (errors.length > 10) {
console.log(` ... and ${errors.length - 10} more errors`);
}
}
// Verify cross-reference matches
console.log('\n' + '='.repeat(80));
console.log('VERIFYING CROSS-REFERENCE MATCHES');
console.log('='.repeat(80));
const { data: statsData, error: statsError } = await supabase.rpc('get_mutation_crossref_stats');
if (statsError) {
console.error('Failed to get cross-reference stats:', statsError.message);
} else if (statsData && statsData.length > 0) {
const stats = statsData[0];
console.log(`Total mutations: ${stats.total_mutations}`);
console.log(`Before matches: ${stats.before_matches} (${stats.before_match_rate}%)`);
console.log(`After matches: ${stats.after_matches} (${stats.after_match_rate}%)`);
console.log(`Both matches: ${stats.both_matches}`);
}
console.log('\nBackfill process completed successfully! ✓');
}
// Run the backfill
backfillMutations().catch((error) => {
console.error('Fatal error during backfill:', error);
process.exit(1);
});
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# Optimized Docker build script - no n8n dependencies!
set -e
# Enable BuildKit
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
echo "🚀 Building n8n-mcp (runtime-only, no n8n deps)..."
echo "💡 This build assumes database is pre-built"
# Check if nodes.db exists
if [ ! -f "data/nodes.db" ]; then
echo "⚠️ Warning: data/nodes.db not found!"
echo " Run 'npm run rebuild' first to create the database"
exit 1
fi
# Build with BuildKit
echo "📦 Building Docker image..."
docker build \
--progress=plain \
--cache-from type=gha \
--cache-from type=registry,ref=ghcr.io/czlonkowski/n8n-mcp:buildcache \
--build-arg BUILDKIT_INLINE_CACHE=1 \
-t "n8n-mcp:latest" \
.
# Show image size
echo ""
echo "📊 Image size:"
docker images n8n-mcp:latest --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
# Test the build
echo ""
echo "🧪 Testing build..."
docker run --rm n8n-mcp:latest node -e "console.log('Build OK - Runtime dependencies only!')"
# Estimate size savings
echo ""
echo "💰 Size comparison:"
echo " Old approach (with n8n deps): ~1.5GB"
echo " New approach (runtime only): ~280MB"
echo " Savings: ~82% smaller!"
echo ""
echo "✅ Build complete!"
echo ""
echo "🎯 Next steps:"
echo " - Use 'docker run -p 3000:3000 -e AUTH_TOKEN=your-token n8n-mcp:latest' to run"
echo " - Use 'docker-compose up' for production deployment"
echo " - Remember to rebuild database locally before pushing!"
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# Demonstrate the optimization concept
echo "🎯 Demonstrating Docker Optimization"
echo "===================================="
# Create a demo directory structure
DEMO_DIR="optimization-demo"
rm -rf $DEMO_DIR
mkdir -p $DEMO_DIR
# Copy only runtime files
echo -e "\n📦 Creating minimal runtime package..."
cat > $DEMO_DIR/package.json << 'EOF'
{
"name": "n8n-mcp-optimized",
"version": "1.0.0",
"private": true,
"main": "dist/mcp/index.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"better-sqlite3": "^11.10.0",
"sql.js": "^1.13.0",
"express": "^5.1.0",
"dotenv": "^16.5.0"
}
}
EOF
# Copy built files
echo "📁 Copying built application..."
cp -r dist $DEMO_DIR/
cp -r data $DEMO_DIR/
mkdir -p $DEMO_DIR/src/database
cp src/database/schema*.sql $DEMO_DIR/src/database/
# Calculate sizes
echo -e "\n📊 Size comparison:"
echo "Original project: $(du -sh . | cut -f1)"
echo "Optimized runtime: $(du -sh $DEMO_DIR | cut -f1)"
# Show what's included
echo -e "\n✅ Optimized package includes:"
echo "- Pre-built SQLite database with all node info"
echo "- Compiled JavaScript (dist/)"
echo "- Minimal runtime dependencies"
echo "- No n8n packages needed!"
# Create a simple test
echo -e "\n🧪 Testing database content..."
if command -v sqlite3 &> /dev/null; then
NODE_COUNT=$(sqlite3 data/nodes.db "SELECT COUNT(*) FROM nodes;" 2>/dev/null || echo "0")
AI_COUNT=$(sqlite3 data/nodes.db "SELECT COUNT(*) FROM nodes WHERE is_ai_tool = 1;" 2>/dev/null || echo "0")
echo "- Total nodes in database: $NODE_COUNT"
echo "- AI-capable nodes: $AI_COUNT"
else
echo "- SQLite CLI not installed, skipping count"
fi
echo -e "\n💡 This demonstrates that we can run n8n-MCP with:"
echo "- ~50MB of runtime dependencies (vs 1.6GB)"
echo "- Pre-built database (11MB)"
echo "- No n8n packages at runtime"
echo "- Total optimized size: ~200MB (vs 2.6GB)"
# Cleanup
echo -e "\n🧹 Cleaning up demo..."
rm -rf $DEMO_DIR
echo -e "\n✨ Optimization concept demonstrated!"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# Simple deployment script for n8n-MCP HTTP server
# For private, single-user deployments only
set -e
echo "n8n-MCP HTTP Deployment Script"
echo "=============================="
echo ""
# Check if .env exists
if [ ! -f .env ]; then
echo "Creating .env file..."
cp .env.example .env
echo ""
echo "⚠️ Please edit .env file and set:"
echo " - AUTH_TOKEN (generate with: openssl rand -base64 32)"
echo " - MCP_MODE=http"
echo " - PORT (default 3000)"
echo ""
exit 1
fi
# Check if AUTH_TOKEN is set
if ! grep -q "AUTH_TOKEN=.*[a-zA-Z0-9]" .env; then
echo "ERROR: AUTH_TOKEN not set in .env file"
echo "Generate one with: openssl rand -base64 32"
exit 1
fi
# Build and start
echo "Building project..."
npm run build
echo ""
echo "Starting HTTP server..."
echo "Use Ctrl+C to stop"
echo ""
# Start with production settings
NODE_ENV=production npm run start:http
+151
View File
@@ -0,0 +1,151 @@
#!/bin/bash
# Deployment script for n8n Documentation MCP Server
# Target: n8ndocumentation.aiservices.pl
set -e
echo "🚀 n8n Documentation MCP Server - VM Deployment"
echo "=============================================="
# Configuration
SERVER_USER=${SERVER_USER:-root}
SERVER_HOST=${SERVER_HOST:-n8ndocumentation.aiservices.pl}
APP_DIR="/opt/n8n-mcp"
SERVICE_NAME="n8n-docs-mcp"
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Check if .env exists
if [ ! -f .env ]; then
echo -e "${RED}❌ .env file not found. Please create it from .env.example${NC}"
exit 1
fi
# Check required environment variables
source .env
if [ "$MCP_DOMAIN" != "n8ndocumentation.aiservices.pl" ]; then
echo -e "${YELLOW}⚠️ Warning: MCP_DOMAIN is not set to n8ndocumentation.aiservices.pl${NC}"
read -p "Continue anyway? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
if [ -z "$MCP_AUTH_TOKEN" ] || [ "$MCP_AUTH_TOKEN" == "your-secure-auth-token-here" ]; then
echo -e "${RED}❌ MCP_AUTH_TOKEN not set or using default value${NC}"
echo "Generate a secure token with: openssl rand -hex 32"
exit 1
fi
echo -e "${GREEN}✅ Configuration validated${NC}"
# Build the project locally
echo -e "\n${YELLOW}Building project...${NC}"
npm run build
# Create deployment package
echo -e "\n${YELLOW}Creating deployment package...${NC}"
rm -rf deploy-package
mkdir -p deploy-package
# Copy necessary files
cp -r dist deploy-package/
cp -r data deploy-package/
cp package*.json deploy-package/
cp .env deploy-package/
cp ecosystem.config.js deploy-package/ 2>/dev/null || true
# Create tarball
tar -czf deploy-package.tar.gz deploy-package
echo -e "${GREEN}✅ Deployment package created${NC}"
# Upload to server
echo -e "\n${YELLOW}Uploading to server...${NC}"
scp deploy-package.tar.gz $SERVER_USER@$SERVER_HOST:/tmp/
# Deploy on server
echo -e "\n${YELLOW}Deploying on server...${NC}"
ssh $SERVER_USER@$SERVER_HOST << 'ENDSSH'
set -e
# Create app directory
mkdir -p /opt/n8n-mcp
cd /opt/n8n-mcp
# Stop existing service if running
pm2 stop n8n-docs-mcp 2>/dev/null || true
# Extract deployment package
tar -xzf /tmp/deploy-package.tar.gz --strip-components=1
rm /tmp/deploy-package.tar.gz
# Install production dependencies
npm ci --only=production
# Create PM2 ecosystem file if not exists
if [ ! -f ecosystem.config.js ]; then
cat > ecosystem.config.js << 'EOF'
module.exports = {
apps: [{
name: 'n8n-docs-mcp',
script: './dist/index-http.js',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production'
},
error_file: './logs/error.log',
out_file: './logs/out.log',
log_file: './logs/combined.log',
time: true
}]
};
EOF
fi
# Create logs directory
mkdir -p logs
# Start with PM2
pm2 start ecosystem.config.js
pm2 save
echo "✅ Deployment complete!"
echo ""
echo "Service status:"
pm2 status n8n-docs-mcp
ENDSSH
# Clean up local files
rm -rf deploy-package deploy-package.tar.gz
echo -e "\n${GREEN}🎉 Deployment successful!${NC}"
echo -e "\nServer endpoints:"
echo -e " Health: https://$SERVER_HOST/health"
echo -e " Stats: https://$SERVER_HOST/stats"
echo -e " MCP: https://$SERVER_HOST/mcp"
echo -e "\nClaude Desktop configuration:"
echo -e " {
\"mcpServers\": {
\"n8n-nodes-remote\": {
\"command\": \"npx\",
\"args\": [
\"-y\",
\"@modelcontextprotocol/client-http\",
\"https://$SERVER_HOST/mcp\"
],
\"env\": {
\"MCP_AUTH_TOKEN\": \"$MCP_AUTH_TOKEN\"
}
}
}
}"
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env tsx
/**
* Export Webhook Workflow JSONs
*
* Generates the 4 webhook workflow JSON files needed for integration testing.
* These workflows must be imported into n8n and activated manually.
*/
import { writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { exportAllWebhookWorkflows } from '../tests/integration/n8n-api/utils/webhook-workflows';
const OUTPUT_DIR = join(process.cwd(), 'workflows-for-import');
// Create output directory
mkdirSync(OUTPUT_DIR, { recursive: true });
// Generate all workflow JSONs
const workflows = exportAllWebhookWorkflows();
// Write each workflow to a separate file
Object.entries(workflows).forEach(([method, workflow]) => {
const filename = `webhook-${method.toLowerCase()}.json`;
const filepath = join(OUTPUT_DIR, filename);
writeFileSync(filepath, JSON.stringify(workflow, null, 2), 'utf-8');
console.log(`✓ Generated: ${filename}`);
});
console.log(`\n✓ All workflow JSONs written to: ${OUTPUT_DIR}`);
console.log('\nNext steps:');
console.log('1. Import each JSON file into your n8n instance');
console.log('2. Activate each workflow in the n8n UI');
console.log('3. Copy the webhook URLs from each workflow (open workflow → Webhook node → copy URL)');
console.log('4. Add them to your .env file:');
console.log(' N8N_TEST_WEBHOOK_GET_URL=https://your-n8n.com/webhook/mcp-test-get');
console.log(' N8N_TEST_WEBHOOK_POST_URL=https://your-n8n.com/webhook/mcp-test-post');
console.log(' N8N_TEST_WEBHOOK_PUT_URL=https://your-n8n.com/webhook/mcp-test-put');
console.log(' N8N_TEST_WEBHOOK_DELETE_URL=https://your-n8n.com/webhook/mcp-test-delete');
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
/**
* Extract changelog content for a specific version
* Used by GitHub Actions to extract release notes
*/
const fs = require('fs');
const path = require('path');
function extractChangelog(version, changelogPath) {
try {
if (!fs.existsSync(changelogPath)) {
console.error(`Changelog file not found at ${changelogPath}`);
process.exit(1);
}
const content = fs.readFileSync(changelogPath, 'utf8');
const lines = content.split('\n');
// Find the start of this version's section
const versionHeaderRegex = new RegExp(`^## \\[${version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`);
let startIndex = -1;
let endIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (versionHeaderRegex.test(lines[i])) {
startIndex = i;
break;
}
}
if (startIndex === -1) {
console.error(`No changelog entries found for version ${version}`);
process.exit(1);
}
// Find the end of this version's section (next version or end of file)
for (let i = startIndex + 1; i < lines.length; i++) {
if (lines[i].startsWith('## [') && !lines[i].includes('Unreleased')) {
endIndex = i;
break;
}
}
if (endIndex === -1) {
endIndex = lines.length;
}
// Extract the section content
const sectionLines = lines.slice(startIndex, endIndex);
// Remove the version header and any trailing empty lines
let contentLines = sectionLines.slice(1);
while (contentLines.length > 0 && contentLines[contentLines.length - 1].trim() === '') {
contentLines.pop();
}
if (contentLines.length === 0) {
console.error(`No content found for version ${version}`);
process.exit(1);
}
const releaseNotes = contentLines.join('\n').trim();
// Write to stdout for GitHub Actions
console.log(releaseNotes);
} catch (error) {
console.error(`Error extracting changelog: ${error.message}`);
process.exit(1);
}
}
// Parse command line arguments
const version = process.argv[2];
const changelogPath = process.argv[3];
if (!version || !changelogPath) {
console.error('Usage: extract-changelog.js <version> <changelog-path>');
process.exit(1);
}
extractChangelog(version, changelogPath);
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env node
const dotenv = require('dotenv');
const { NodeDocumentationService } = require('../dist/services/node-documentation-service');
const { NodeSourceExtractor } = require('../dist/utils/node-source-extractor');
const { logger } = require('../dist/utils/logger');
const fs = require('fs').promises;
const path = require('path');
// Load environment variables
dotenv.config();
async function extractNodesFromDocker() {
logger.info('🐳 Starting Docker-based node extraction...');
// Add Docker volume paths to environment for NodeSourceExtractor
const dockerVolumePaths = [
process.env.N8N_MODULES_PATH || '/n8n-modules',
process.env.N8N_CUSTOM_PATH || '/n8n-custom',
];
logger.info(`Docker volume paths: ${dockerVolumePaths.join(', ')}`);
// Check if volumes are mounted
for (const volumePath of dockerVolumePaths) {
try {
await fs.access(volumePath);
logger.info(`✅ Volume mounted: ${volumePath}`);
// List what's in the volume
const entries = await fs.readdir(volumePath);
logger.info(`Contents of ${volumePath}: ${entries.slice(0, 10).join(', ')}${entries.length > 10 ? '...' : ''}`);
} catch (error) {
logger.warn(`❌ Volume not accessible: ${volumePath}`);
}
}
// Initialize services
const docService = new NodeDocumentationService();
const extractor = new NodeSourceExtractor();
// Extend the extractor's search paths with Docker volumes
extractor.n8nBasePaths.unshift(...dockerVolumePaths);
// Clear existing nodes to ensure we only have latest versions
logger.info('🧹 Clearing existing nodes...');
const db = docService.db;
db.prepare('DELETE FROM nodes').run();
logger.info('🔍 Searching for n8n nodes in Docker volumes...');
// Known n8n packages to extract
const n8nPackages = [
'n8n-nodes-base',
'@n8n/n8n-nodes-langchain',
'n8n-nodes-extras',
];
let totalExtracted = 0;
let ifNodeVersion = null;
for (const packageName of n8nPackages) {
logger.info(`\n📦 Processing package: ${packageName}`);
try {
// Find package in Docker volumes
let packagePath = null;
for (const volumePath of dockerVolumePaths) {
const possiblePaths = [
path.join(volumePath, packageName),
path.join(volumePath, '.pnpm', `${packageName}@*`, 'node_modules', packageName),
];
for (const testPath of possiblePaths) {
try {
// Use glob pattern to find pnpm packages
if (testPath.includes('*')) {
const baseDir = path.dirname(testPath.split('*')[0]);
const entries = await fs.readdir(baseDir);
for (const entry of entries) {
if (entry.includes(packageName.replace('/', '+'))) {
const fullPath = path.join(baseDir, entry, 'node_modules', packageName);
try {
await fs.access(fullPath);
packagePath = fullPath;
break;
} catch {}
}
}
} else {
await fs.access(testPath);
packagePath = testPath;
break;
}
} catch {}
}
if (packagePath) break;
}
if (!packagePath) {
logger.warn(`Package ${packageName} not found in Docker volumes`);
continue;
}
logger.info(`Found package at: ${packagePath}`);
// Check package version
try {
const packageJsonPath = path.join(packagePath, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8'));
logger.info(`Package version: ${packageJson.version}`);
} catch {}
// Find nodes directory
const nodesPath = path.join(packagePath, 'dist', 'nodes');
try {
await fs.access(nodesPath);
logger.info(`Scanning nodes directory: ${nodesPath}`);
// Extract all nodes from this package
const nodeEntries = await scanForNodes(nodesPath);
logger.info(`Found ${nodeEntries.length} nodes in ${packageName}`);
for (const nodeEntry of nodeEntries) {
try {
const nodeName = nodeEntry.name.replace('.node.js', '');
const nodeType = `${packageName}.${nodeName}`;
logger.info(`Extracting: ${nodeType}`);
// Extract source info
const sourceInfo = await extractor.extractNodeSource(nodeType);
// Check if this is the If node
if (nodeName === 'If') {
// Look for version in the source code
const versionMatch = sourceInfo.sourceCode.match(/version:\s*(\d+)/);
if (versionMatch) {
ifNodeVersion = versionMatch[1];
logger.info(`📍 Found If node version: ${ifNodeVersion}`);
}
}
// Store in database
await docService.storeNode({
nodeType: nodeType,
name: nodeName,
displayName: nodeName,
description: `${nodeName} node from ${packageName}`,
sourceCode: sourceInfo.sourceCode,
credentialCode: sourceInfo.credentialCode,
packageName: packageName,
version: ifNodeVersion || '1',
hasCredentials: !!sourceInfo.credentialCode,
isTrigger: sourceInfo.sourceCode.includes('trigger: true') || nodeName.toLowerCase().includes('trigger'),
isWebhook: sourceInfo.sourceCode.includes('webhook: true') || nodeName.toLowerCase().includes('webhook'),
});
totalExtracted++;
} catch (error) {
logger.error(`Failed to extract ${nodeEntry.name}: ${error}`);
}
}
} catch (error) {
logger.error(`Failed to scan nodes directory: ${error}`);
}
} catch (error) {
logger.error(`Failed to process package ${packageName}: ${error}`);
}
}
logger.info(`\n✅ Extraction complete!`);
logger.info(`📊 Total nodes extracted: ${totalExtracted}`);
if (ifNodeVersion) {
logger.info(`📍 If node version: ${ifNodeVersion}`);
if (ifNodeVersion === '2' || ifNodeVersion === '2.2') {
logger.info('✅ Successfully extracted latest If node (v2+)!');
} else {
logger.warn(`⚠️ If node version is ${ifNodeVersion}, expected v2 or higher`);
}
}
// Close database
docService.close();
}
async function scanForNodes(dirPath) {
const nodes = [];
async function scan(currentPath) {
try {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
if (entry.isFile() && entry.name.endsWith('.node.js')) {
nodes.push({ name: entry.name, path: fullPath });
} else if (entry.isDirectory() && entry.name !== 'node_modules') {
await scan(fullPath);
}
}
} catch (error) {
logger.debug(`Failed to scan directory ${currentPath}: ${error}`);
}
}
await scan(dirPath);
return nodes;
}
// Run extraction
extractNodesFromDocker().catch(error => {
logger.error('Extraction failed:', error);
process.exit(1);
});
+116
View File
@@ -0,0 +1,116 @@
#!/bin/bash
set -e
echo "🐳 n8n Node Extraction via Docker"
echo "================================="
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${GREEN}[$(date +'%H:%M:%S')]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[$(date +'%H:%M:%S')]${NC} ⚠️ $1"
}
print_error() {
echo -e "${RED}[$(date +'%H:%M:%S')]${NC}$1"
}
# Check if Docker is running
if ! docker info > /dev/null 2>&1; then
print_error "Docker is not running. Please start Docker and try again."
exit 1
fi
print_status "Docker is running ✅"
# Clean up any existing containers
print_status "Cleaning up existing containers..."
docker-compose -f docker-compose.extract.yml down -v 2>/dev/null || true
# Build the project first
print_status "Building the project..."
npm run build
# Start the extraction process
print_status "Starting n8n container to extract latest nodes..."
docker-compose -f docker-compose.extract.yml up -d n8n-latest
# Wait for n8n container to be healthy
print_status "Waiting for n8n container to initialize..."
ATTEMPTS=0
MAX_ATTEMPTS=60
while [ $ATTEMPTS -lt $MAX_ATTEMPTS ]; do
if docker-compose -f docker-compose.extract.yml ps | grep -q "healthy"; then
print_status "n8n container is ready ✅"
break
fi
ATTEMPTS=$((ATTEMPTS + 1))
echo -n "."
sleep 2
done
if [ $ATTEMPTS -eq $MAX_ATTEMPTS ]; then
print_error "n8n container failed to become healthy"
docker-compose -f docker-compose.extract.yml logs n8n-latest
docker-compose -f docker-compose.extract.yml down -v
exit 1
fi
# Run the extraction
print_status "Running node extraction..."
docker-compose -f docker-compose.extract.yml run --rm node-extractor
# Check the results
print_status "Checking extraction results..."
if [ -f "./data/nodes-fresh.db" ]; then
NODE_COUNT=$(sqlite3 ./data/nodes-fresh.db "SELECT COUNT(*) FROM nodes;" 2>/dev/null || echo "0")
IF_VERSION=$(sqlite3 ./data/nodes-fresh.db "SELECT version FROM nodes WHERE name='n8n-nodes-base.If' LIMIT 1;" 2>/dev/null || echo "not found")
print_status "Extracted $NODE_COUNT nodes"
print_status "If node version: $IF_VERSION"
# Check if we got the If node source code and look for version
IF_SOURCE=$(sqlite3 ./data/nodes-fresh.db "SELECT source_code FROM nodes WHERE name='n8n-nodes-base.If' LIMIT 1;" 2>/dev/null || echo "")
if [[ $IF_SOURCE =~ version:[[:space:]]*([0-9]+) ]]; then
IF_CODE_VERSION="${BASH_REMATCH[1]}"
print_status "If node version from source code: v$IF_CODE_VERSION"
if [ "$IF_CODE_VERSION" -ge "2" ]; then
print_status "✅ Successfully extracted latest If node (v$IF_CODE_VERSION)!"
else
print_warning "If node is still v$IF_CODE_VERSION, expected v2 or higher"
fi
fi
else
print_error "Database file not found after extraction"
fi
# Clean up
print_status "Cleaning up Docker containers..."
docker-compose -f docker-compose.extract.yml down -v
print_status "✨ Extraction complete!"
# Offer to restart the MCP server
echo ""
read -p "Would you like to restart the MCP server with the new nodes? (y/n) " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
print_status "Restarting MCP server..."
# Kill any existing server process
pkill -f "node.*dist/index.js" || true
# Start the server
npm start &
print_status "MCP server restarted with fresh node database"
fi
+108
View File
@@ -0,0 +1,108 @@
#!/bin/bash
set -e
echo "🐳 Simple n8n Node Extraction via Docker"
echo "======================================="
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${GREEN}[$(date +'%H:%M:%S')]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[$(date +'%H:%M:%S')]${NC} ⚠️ $1"
}
print_error() {
echo -e "${RED}[$(date +'%H:%M:%S')]${NC}$1"
}
# Check if Docker is running
if ! docker info > /dev/null 2>&1; then
print_error "Docker is not running. Please start Docker and try again."
exit 1
fi
print_status "Docker is running ✅"
# Build the project first
print_status "Building the project..."
npm run build
# Create a temporary directory for extraction
TEMP_DIR=$(mktemp -d)
print_status "Created temporary directory: $TEMP_DIR"
# Run Docker container to copy node files
print_status "Running n8n container to extract nodes..."
docker run --rm -d --name n8n-temp n8nio/n8n:latest sleep 300
# Wait a bit for container to start
sleep 5
# Copy n8n modules from container
print_status "Copying n8n modules from container..."
docker cp n8n-temp:/usr/local/lib/node_modules/n8n/node_modules "$TEMP_DIR/node_modules" || {
print_error "Failed to copy node_modules"
docker stop n8n-temp
rm -rf "$TEMP_DIR"
exit 1
}
# Stop the container
docker stop n8n-temp
# Run our extraction script locally
print_status "Running extraction script..."
NODE_ENV=development \
NODE_DB_PATH=./data/nodes-fresh.db \
N8N_MODULES_PATH="$TEMP_DIR/node_modules" \
node scripts/extract-from-docker.js
# Clean up
print_status "Cleaning up temporary files..."
rm -rf "$TEMP_DIR"
# Check the results
print_status "Checking extraction results..."
if [ -f "./data/nodes-fresh.db" ]; then
NODE_COUNT=$(sqlite3 ./data/nodes-fresh.db "SELECT COUNT(*) FROM nodes;" 2>/dev/null || echo "0")
print_status "Extracted $NODE_COUNT nodes"
# Check if we got the If node source code and look for version
IF_SOURCE=$(sqlite3 ./data/nodes-fresh.db "SELECT source_code FROM nodes WHERE node_type='n8n-nodes-base.If' LIMIT 1;" 2>/dev/null || echo "")
if [[ $IF_SOURCE =~ version:[[:space:]]*([0-9]+) ]]; then
IF_CODE_VERSION="${BASH_REMATCH[1]}"
print_status "If node version from source code: v$IF_CODE_VERSION"
if [ "$IF_CODE_VERSION" -ge "2" ]; then
print_status "✅ Successfully extracted latest If node (v$IF_CODE_VERSION)!"
else
print_warning "If node is still v$IF_CODE_VERSION, expected v2 or higher"
fi
fi
else
print_error "Database file not found after extraction"
fi
print_status "✨ Extraction complete!"
# Offer to restart the MCP server
echo ""
read -p "Would you like to restart the MCP server with the new nodes? (y/n) " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
print_status "Restarting MCP server..."
# Kill any existing server process
pkill -f "node.*dist/index.js" || true
# Start the server
npm start &
print_status "MCP server restarted with fresh node database"
fi
+675
View File
@@ -0,0 +1,675 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { resolve, dirname } from 'path';
/**
* Generate detailed test reports in multiple formats
*/
class TestReportGenerator {
constructor() {
this.results = {
tests: null,
coverage: null,
benchmarks: null,
metadata: {
timestamp: new Date().toISOString(),
repository: process.env.GITHUB_REPOSITORY || 'n8n-mcp',
sha: process.env.GITHUB_SHA || 'unknown',
branch: process.env.GITHUB_REF || 'unknown',
runId: process.env.GITHUB_RUN_ID || 'local',
runNumber: process.env.GITHUB_RUN_NUMBER || '0',
}
};
}
loadTestResults() {
const testResultPath = resolve(process.cwd(), 'test-results/results.json');
if (existsSync(testResultPath)) {
try {
const data = JSON.parse(readFileSync(testResultPath, 'utf-8'));
this.results.tests = this.processTestResults(data);
} catch (error) {
console.error('Error loading test results:', error);
}
}
}
processTestResults(data) {
const processedResults = {
summary: {
total: data.numTotalTests || 0,
passed: data.numPassedTests || 0,
failed: data.numFailedTests || 0,
skipped: data.numSkippedTests || 0,
duration: data.duration || 0,
success: (data.numFailedTests || 0) === 0
},
testSuites: [],
failedTests: []
};
// Process test suites
if (data.testResults) {
for (const suite of data.testResults) {
const suiteInfo = {
name: suite.name,
duration: suite.duration || 0,
tests: {
total: suite.numPassingTests + suite.numFailingTests + suite.numPendingTests,
passed: suite.numPassingTests || 0,
failed: suite.numFailingTests || 0,
skipped: suite.numPendingTests || 0
},
status: suite.numFailingTests === 0 ? 'passed' : 'failed'
};
processedResults.testSuites.push(suiteInfo);
// Collect failed tests
if (suite.testResults) {
for (const test of suite.testResults) {
if (test.status === 'failed') {
processedResults.failedTests.push({
suite: suite.name,
test: test.title,
duration: test.duration || 0,
error: test.failureMessages ? test.failureMessages.join('\n') : 'Unknown error'
});
}
}
}
}
}
return processedResults;
}
loadCoverageResults() {
const coveragePath = resolve(process.cwd(), 'coverage/coverage-summary.json');
if (existsSync(coveragePath)) {
try {
const data = JSON.parse(readFileSync(coveragePath, 'utf-8'));
this.results.coverage = this.processCoverageResults(data);
} catch (error) {
console.error('Error loading coverage results:', error);
}
}
}
processCoverageResults(data) {
const coverage = {
summary: {
lines: data.total.lines.pct,
statements: data.total.statements.pct,
functions: data.total.functions.pct,
branches: data.total.branches.pct,
average: 0
},
files: []
};
// Calculate average
coverage.summary.average = (
coverage.summary.lines +
coverage.summary.statements +
coverage.summary.functions +
coverage.summary.branches
) / 4;
// Process file coverage
for (const [filePath, fileData] of Object.entries(data)) {
if (filePath !== 'total') {
coverage.files.push({
path: filePath,
lines: fileData.lines.pct,
statements: fileData.statements.pct,
functions: fileData.functions.pct,
branches: fileData.branches.pct,
uncoveredLines: fileData.lines.total - fileData.lines.covered
});
}
}
// Sort files by coverage (lowest first)
coverage.files.sort((a, b) => a.lines - b.lines);
return coverage;
}
loadBenchmarkResults() {
const benchmarkPath = resolve(process.cwd(), 'benchmark-results.json');
if (existsSync(benchmarkPath)) {
try {
const data = JSON.parse(readFileSync(benchmarkPath, 'utf-8'));
this.results.benchmarks = this.processBenchmarkResults(data);
} catch (error) {
console.error('Error loading benchmark results:', error);
}
}
}
processBenchmarkResults(data) {
const benchmarks = {
timestamp: data.timestamp,
results: []
};
for (const file of data.files || []) {
for (const group of file.groups || []) {
for (const benchmark of group.benchmarks || []) {
benchmarks.results.push({
file: file.filepath,
group: group.name,
name: benchmark.name,
ops: benchmark.result.hz,
mean: benchmark.result.mean,
min: benchmark.result.min,
max: benchmark.result.max,
p75: benchmark.result.p75,
p99: benchmark.result.p99,
samples: benchmark.result.samples
});
}
}
}
// Sort by ops/sec (highest first)
benchmarks.results.sort((a, b) => b.ops - a.ops);
return benchmarks;
}
generateMarkdownReport() {
let report = '# n8n-mcp Test Report\n\n';
report += `Generated: ${this.results.metadata.timestamp}\n\n`;
// Metadata
report += '## Build Information\n\n';
report += `- **Repository**: ${this.results.metadata.repository}\n`;
report += `- **Commit**: ${this.results.metadata.sha.substring(0, 7)}\n`;
report += `- **Branch**: ${this.results.metadata.branch}\n`;
report += `- **Run**: #${this.results.metadata.runNumber}\n\n`;
// Test Results
if (this.results.tests) {
const { summary, testSuites, failedTests } = this.results.tests;
const emoji = summary.success ? '✅' : '❌';
report += `## ${emoji} Test Results\n\n`;
report += `### Summary\n\n`;
report += `- **Total Tests**: ${summary.total}\n`;
report += `- **Passed**: ${summary.passed} (${((summary.passed / summary.total) * 100).toFixed(1)}%)\n`;
report += `- **Failed**: ${summary.failed}\n`;
report += `- **Skipped**: ${summary.skipped}\n`;
report += `- **Duration**: ${(summary.duration / 1000).toFixed(2)}s\n\n`;
// Test Suites
if (testSuites.length > 0) {
report += '### Test Suites\n\n';
report += '| Suite | Status | Tests | Duration |\n';
report += '|-------|--------|-------|----------|\n';
for (const suite of testSuites) {
const status = suite.status === 'passed' ? '✅' : '❌';
const tests = `${suite.tests.passed}/${suite.tests.total}`;
const duration = `${(suite.duration / 1000).toFixed(2)}s`;
report += `| ${suite.name} | ${status} | ${tests} | ${duration} |\n`;
}
report += '\n';
}
// Failed Tests
if (failedTests.length > 0) {
report += '### Failed Tests\n\n';
for (const failed of failedTests) {
report += `#### ${failed.suite} > ${failed.test}\n\n`;
report += '```\n';
report += failed.error;
report += '\n```\n\n';
}
}
}
// Coverage Results
if (this.results.coverage) {
const { summary, files } = this.results.coverage;
const emoji = summary.average >= 80 ? '✅' : summary.average >= 60 ? '⚠️' : '❌';
report += `## ${emoji} Coverage Report\n\n`;
report += '### Summary\n\n';
report += `- **Lines**: ${summary.lines.toFixed(2)}%\n`;
report += `- **Statements**: ${summary.statements.toFixed(2)}%\n`;
report += `- **Functions**: ${summary.functions.toFixed(2)}%\n`;
report += `- **Branches**: ${summary.branches.toFixed(2)}%\n`;
report += `- **Average**: ${summary.average.toFixed(2)}%\n\n`;
// Files with low coverage
const lowCoverageFiles = files.filter(f => f.lines < 80).slice(0, 10);
if (lowCoverageFiles.length > 0) {
report += '### Files with Low Coverage\n\n';
report += '| File | Lines | Uncovered Lines |\n';
report += '|------|-------|----------------|\n';
for (const file of lowCoverageFiles) {
const fileName = file.path.split('/').pop();
report += `| ${fileName} | ${file.lines.toFixed(1)}% | ${file.uncoveredLines} |\n`;
}
report += '\n';
}
}
// Benchmark Results
if (this.results.benchmarks && this.results.benchmarks.results.length > 0) {
report += '## ⚡ Benchmark Results\n\n';
report += '### Top Performers\n\n';
report += '| Benchmark | Ops/sec | Mean (ms) | Samples |\n';
report += '|-----------|---------|-----------|----------|\n';
for (const bench of this.results.benchmarks.results.slice(0, 10)) {
const opsFormatted = bench.ops.toLocaleString('en-US', { maximumFractionDigits: 0 });
const meanFormatted = (bench.mean * 1000).toFixed(3);
report += `| ${bench.name} | ${opsFormatted} | ${meanFormatted} | ${bench.samples} |\n`;
}
report += '\n';
}
return report;
}
generateJsonReport() {
return JSON.stringify(this.results, null, 2);
}
generateHtmlReport() {
const htmlTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>n8n-mcp Test Report</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 10px;
margin-bottom: 30px;
}
.header h1 {
margin: 0 0 10px 0;
font-size: 2.5em;
}
.metadata {
opacity: 0.9;
font-size: 0.9em;
}
.section {
background: white;
padding: 25px;
margin-bottom: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.section h2 {
margin-top: 0;
color: #333;
border-bottom: 2px solid #eee;
padding-bottom: 10px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin: 20px 0;
}
.stat-card {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
text-align: center;
border: 1px solid #e9ecef;
}
.stat-card .value {
font-size: 2em;
font-weight: bold;
color: #667eea;
}
.stat-card .label {
color: #666;
font-size: 0.9em;
margin-top: 5px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
}
tr:hover {
background-color: #f8f9fa;
}
.success { color: #28a745; }
.warning { color: #ffc107; }
.danger { color: #dc3545; }
.failed-test {
background-color: #fff5f5;
border: 1px solid #feb2b2;
border-radius: 5px;
padding: 15px;
margin: 10px 0;
}
.failed-test h4 {
margin: 0 0 10px 0;
color: #c53030;
}
.error-message {
background-color: #1a202c;
color: #e2e8f0;
padding: 15px;
border-radius: 5px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
overflow-x: auto;
}
.progress-bar {
width: 100%;
height: 20px;
background-color: #e9ecef;
border-radius: 10px;
overflow: hidden;
margin: 10px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #28a745 0%, #20c997 100%);
transition: width 0.3s ease;
}
.coverage-low { background: linear-gradient(90deg, #dc3545 0%, #f86734 100%); }
.coverage-medium { background: linear-gradient(90deg, #ffc107 0%, #ffb347 100%); }
</style>
</head>
<body>
<div class="header">
<h1>n8n-mcp Test Report</h1>
<div class="metadata">
<div>Repository: ${this.results.metadata.repository}</div>
<div>Commit: ${this.results.metadata.sha.substring(0, 7)}</div>
<div>Run: #${this.results.metadata.runNumber}</div>
<div>Generated: ${new Date(this.results.metadata.timestamp).toLocaleString()}</div>
</div>
</div>
${this.generateTestResultsHtml()}
${this.generateCoverageHtml()}
${this.generateBenchmarkHtml()}
</body>
</html>`;
return htmlTemplate;
}
generateTestResultsHtml() {
if (!this.results.tests) return '';
const { summary, testSuites, failedTests } = this.results.tests;
const successRate = ((summary.passed / summary.total) * 100).toFixed(1);
const statusClass = summary.success ? 'success' : 'danger';
const statusIcon = summary.success ? '✅' : '❌';
let html = `
<div class="section">
<h2>${statusIcon} Test Results</h2>
<div class="stats">
<div class="stat-card">
<div class="value">${summary.total}</div>
<div class="label">Total Tests</div>
</div>
<div class="stat-card">
<div class="value ${statusClass}">${summary.passed}</div>
<div class="label">Passed</div>
</div>
<div class="stat-card">
<div class="value ${summary.failed > 0 ? 'danger' : ''}">${summary.failed}</div>
<div class="label">Failed</div>
</div>
<div class="stat-card">
<div class="value">${successRate}%</div>
<div class="label">Success Rate</div>
</div>
<div class="stat-card">
<div class="value">${(summary.duration / 1000).toFixed(1)}s</div>
<div class="label">Duration</div>
</div>
</div>`;
if (testSuites.length > 0) {
html += `
<h3>Test Suites</h3>
<table>
<thead>
<tr>
<th>Suite</th>
<th>Status</th>
<th>Tests</th>
<th>Duration</th>
</tr>
</thead>
<tbody>`;
for (const suite of testSuites) {
const status = suite.status === 'passed' ? '✅' : '❌';
const statusClass = suite.status === 'passed' ? 'success' : 'danger';
html += `
<tr>
<td>${suite.name}</td>
<td class="${statusClass}">${status}</td>
<td>${suite.tests.passed}/${suite.tests.total}</td>
<td>${(suite.duration / 1000).toFixed(2)}s</td>
</tr>`;
}
html += `
</tbody>
</table>`;
}
if (failedTests.length > 0) {
html += `
<h3>Failed Tests</h3>`;
for (const failed of failedTests) {
html += `
<div class="failed-test">
<h4>${failed.suite} > ${failed.test}</h4>
<div class="error-message">${this.escapeHtml(failed.error)}</div>
</div>`;
}
}
html += `</div>`;
return html;
}
generateCoverageHtml() {
if (!this.results.coverage) return '';
const { summary, files } = this.results.coverage;
const coverageClass = summary.average >= 80 ? 'success' : summary.average >= 60 ? 'warning' : 'danger';
const progressClass = summary.average >= 80 ? '' : summary.average >= 60 ? 'coverage-medium' : 'coverage-low';
let html = `
<div class="section">
<h2>📊 Coverage Report</h2>
<div class="stats">
<div class="stat-card">
<div class="value ${coverageClass}">${summary.average.toFixed(1)}%</div>
<div class="label">Average Coverage</div>
</div>
<div class="stat-card">
<div class="value">${summary.lines.toFixed(1)}%</div>
<div class="label">Lines</div>
</div>
<div class="stat-card">
<div class="value">${summary.statements.toFixed(1)}%</div>
<div class="label">Statements</div>
</div>
<div class="stat-card">
<div class="value">${summary.functions.toFixed(1)}%</div>
<div class="label">Functions</div>
</div>
<div class="stat-card">
<div class="value">${summary.branches.toFixed(1)}%</div>
<div class="label">Branches</div>
</div>
</div>
<div class="progress-bar">
<div class="progress-fill ${progressClass}" style="width: ${summary.average}%"></div>
</div>`;
const lowCoverageFiles = files.filter(f => f.lines < 80).slice(0, 10);
if (lowCoverageFiles.length > 0) {
html += `
<h3>Files with Low Coverage</h3>
<table>
<thead>
<tr>
<th>File</th>
<th>Lines</th>
<th>Statements</th>
<th>Functions</th>
<th>Branches</th>
</tr>
</thead>
<tbody>`;
for (const file of lowCoverageFiles) {
const fileName = file.path.split('/').pop();
html += `
<tr>
<td>${fileName}</td>
<td class="${file.lines < 50 ? 'danger' : file.lines < 80 ? 'warning' : ''}">${file.lines.toFixed(1)}%</td>
<td>${file.statements.toFixed(1)}%</td>
<td>${file.functions.toFixed(1)}%</td>
<td>${file.branches.toFixed(1)}%</td>
</tr>`;
}
html += `
</tbody>
</table>`;
}
html += `</div>`;
return html;
}
generateBenchmarkHtml() {
if (!this.results.benchmarks || this.results.benchmarks.results.length === 0) return '';
let html = `
<div class="section">
<h2>⚡ Benchmark Results</h2>
<table>
<thead>
<tr>
<th>Benchmark</th>
<th>Operations/sec</th>
<th>Mean Time (ms)</th>
<th>Min (ms)</th>
<th>Max (ms)</th>
<th>Samples</th>
</tr>
</thead>
<tbody>`;
for (const bench of this.results.benchmarks.results.slice(0, 20)) {
const opsFormatted = bench.ops.toLocaleString('en-US', { maximumFractionDigits: 0 });
const meanFormatted = (bench.mean * 1000).toFixed(3);
const minFormatted = (bench.min * 1000).toFixed(3);
const maxFormatted = (bench.max * 1000).toFixed(3);
html += `
<tr>
<td>${bench.name}</td>
<td><strong>${opsFormatted}</strong></td>
<td>${meanFormatted}</td>
<td>${minFormatted}</td>
<td>${maxFormatted}</td>
<td>${bench.samples}</td>
</tr>`;
}
html += `
</tbody>
</table>`;
if (this.results.benchmarks.results.length > 20) {
html += `<p><em>Showing top 20 of ${this.results.benchmarks.results.length} benchmarks</em></p>`;
}
html += `</div>`;
return html;
}
escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
async generate() {
// Load all results
this.loadTestResults();
this.loadCoverageResults();
this.loadBenchmarkResults();
// Ensure output directory exists
const outputDir = resolve(process.cwd(), 'test-reports');
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
// Generate reports in different formats
const markdownReport = this.generateMarkdownReport();
const jsonReport = this.generateJsonReport();
const htmlReport = this.generateHtmlReport();
// Write reports
writeFileSync(resolve(outputDir, 'report.md'), markdownReport);
writeFileSync(resolve(outputDir, 'report.json'), jsonReport);
writeFileSync(resolve(outputDir, 'report.html'), htmlReport);
console.log('Test reports generated successfully:');
console.log('- test-reports/report.md');
console.log('- test-reports/report.json');
console.log('- test-reports/report.html');
}
}
// Run the generator
const generator = new TestReportGenerator();
generator.generate().catch(console.error);
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env node
/**
* Generate release notes for the initial release
* Used by GitHub Actions when no previous tag exists
*/
const { execSync } = require('child_process');
function generateInitialReleaseNotes(version) {
try {
// Get total commit count
const commitCount = execSync('git rev-list --count HEAD', { encoding: 'utf8' }).trim();
// Generate release notes
const releaseNotes = [
'### 🎉 Initial Release',
'',
`This is the initial release of n8n-mcp v${version}.`,
'',
'---',
'',
'**Release Statistics:**',
`- Commit count: ${commitCount}`,
'- First release setup'
];
return releaseNotes.join('\n');
} catch (error) {
console.error(`Error generating initial release notes: ${error.message}`);
return `Failed to generate initial release notes: ${error.message}`;
}
}
// Parse command line arguments
const version = process.argv[2];
if (!version) {
console.error('Usage: generate-initial-release-notes.js <version>');
process.exit(1);
}
const releaseNotes = generateInitialReleaseNotes(version);
console.log(releaseNotes);
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env node
/**
* generate-mcpb-manifest.js
*
* Generates manifest.json for the MCPB (MCP Bundle) used for one-click install
* in Claude Desktop and other MCP hosts.
*
* The version and the advertised tool list are derived from the single sources
* of truth (package.json and the live MCP tool registry) so the bundle metadata
* can never drift from the server, the way the hand-maintained manifest did.
*
* The project must be built first: the tool list is read from dist/mcp/*.js.
*
* Usage:
* node scripts/generate-mcpb-manifest.js # write manifest.json
* node scripts/generate-mcpb-manifest.js --check # exit 1 if manifest.json is stale
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const MANIFEST_PATH = path.join(ROOT, 'manifest.json');
/**
* Load the full tool registry from the built output. Both arrays are plain
* data (their only import is a TypeScript type), so requiring them has no
* side effects.
*/
function loadTools() {
const docToolsPath = path.join(ROOT, 'dist/mcp/tools.js');
const mgmtToolsPath = path.join(ROOT, 'dist/mcp/tools-n8n-manager.js');
for (const p of [docToolsPath, mgmtToolsPath]) {
if (!fs.existsSync(p)) {
console.error(`${path.relative(ROOT, p)} not found. Run "npm run build" first.`);
process.exit(1);
}
}
const docTools = require(docToolsPath).n8nDocumentationToolsFinal;
const mgmtTools = require(mgmtToolsPath).n8nManagementTools;
return [...docTools, ...mgmtTools];
}
/** Reduce a (possibly multi-line, multi-sentence) tool description to one tidy line. */
function shortDescription(description) {
const oneLine = String(description).replace(/\s+/g, ' ').trim();
const match = oneLine.match(/^(.*?[.!?])(\s|$)/);
let sentence = match ? match[1] : oneLine;
if (sentence.length > 160) {
sentence = sentence.slice(0, 157).trimEnd() + '...';
}
return sentence;
}
function buildManifest() {
const pkg = require(path.join(ROOT, 'package.json'));
const tools = loadTools().map(t => ({
name: t.name,
description: shortDescription(t.description),
}));
return {
manifest_version: '0.3',
name: 'n8n-mcp',
display_name: 'n8n-MCP',
version: pkg.version,
description:
'MCP server providing AI assistants with comprehensive access to n8n node ' +
'documentation and workflow management capabilities',
author: {
name: 'Romuald Członkowski',
url: 'https://www.aiadvisors.pl/en',
},
repository: {
type: 'git',
url: 'https://github.com/czlonkowski/n8n-mcp',
},
homepage: 'https://www.n8n-mcp.com/',
documentation: 'https://github.com/czlonkowski/n8n-mcp#readme',
support: 'https://github.com/czlonkowski/n8n-mcp/issues',
license: 'MIT',
keywords: ['n8n', 'mcp', 'workflow', 'automation', 'ai', 'documentation', 'model-context-protocol'],
privacy_policies: ['https://n8n.io/legal/privacy/'],
server: {
type: 'node',
// Required by the v0.3 schema. This bundle launches via npx (see mcp_config
// below), so entry_point is documentation-only; it must still resolve on disk
// at pack time, which is why the bundle is packed after "npm run build".
entry_point: 'dist/mcp/index.js',
mcp_config: {
command: 'npx',
args: ['-y', 'n8n-mcp'],
env: {
MCP_MODE: 'stdio',
LOG_LEVEL: 'error',
DISABLE_CONSOLE_OUTPUT: 'true',
N8N_API_URL: '${user_config.n8n_api_url}',
N8N_API_KEY: '${user_config.n8n_api_key}',
N8N_MCP_TELEMETRY_DISABLED: '${user_config.n8n_mcp_telemetry_disabled}',
},
},
},
user_config: {
n8n_api_url: {
type: 'string',
title: 'n8n Instance URL',
description:
'URL of your n8n instance (e.g., https://your-n8n.com or http://localhost:5678). ' +
'Leave empty for documentation-only mode.',
required: false,
},
n8n_api_key: {
type: 'string',
title: 'n8n API Key',
description: 'API key from your n8n instance Settings > API. Required for workflow management features.',
required: false,
sensitive: true,
},
n8n_mcp_telemetry_disabled: {
type: 'boolean',
title: 'Disable Telemetry',
description: 'Enable to turn off anonymous usage telemetry. Telemetry is on by default.',
required: false,
default: false,
},
},
compatibility: {
claude_desktop: '>=0.10.0',
platforms: ['darwin', 'win32', 'linux'],
runtimes: {
// npx needs a host Node install; declaring it lets hosts fail fast with a
// clear message instead of npx erroring at launch.
node: '>=20',
},
},
tools_generated: false,
tools,
};
}
function serialize(manifest) {
return JSON.stringify(manifest, null, 2) + '\n';
}
function main() {
const check = process.argv.includes('--check');
const output = serialize(buildManifest());
if (check) {
const existing = fs.existsSync(MANIFEST_PATH) ? fs.readFileSync(MANIFEST_PATH, 'utf8') : '';
if (existing !== output) {
console.error('✖ manifest.json is out of date. Run: npm run generate:mcpb-manifest');
process.exit(1);
}
console.log('✓ manifest.json is up to date');
return;
}
fs.writeFileSync(MANIFEST_PATH, output);
const manifest = buildManifest();
console.log(`✓ Wrote manifest.json (version ${manifest.version}, ${manifest.tools.length} tools)`);
}
main();
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* Generate release notes from commit messages between two tags
* Used by GitHub Actions to create automated release notes
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
function generateReleaseNotes(previousTag, currentTag) {
try {
console.log(`Generating release notes from ${previousTag} to ${currentTag}`);
// Get commits between tags
const gitLogCommand = `git log --pretty=format:"%H|%s|%an|%ae|%ad" --date=short --no-merges ${previousTag}..${currentTag}`;
const commitsOutput = execSync(gitLogCommand, { encoding: 'utf8' });
if (!commitsOutput.trim()) {
console.log('No commits found between tags');
return 'No changes in this release.';
}
const commits = commitsOutput.trim().split('\n').map(line => {
const [hash, subject, author, email, date] = line.split('|');
return { hash, subject, author, email, date };
});
// Categorize commits
const categories = {
'feat': { title: '✨ Features', commits: [] },
'fix': { title: '🐛 Bug Fixes', commits: [] },
'docs': { title: '📚 Documentation', commits: [] },
'refactor': { title: '♻️ Refactoring', commits: [] },
'test': { title: '🧪 Testing', commits: [] },
'perf': { title: '⚡ Performance', commits: [] },
'style': { title: '💅 Styling', commits: [] },
'ci': { title: '🔧 CI/CD', commits: [] },
'build': { title: '📦 Build', commits: [] },
'chore': { title: '🔧 Maintenance', commits: [] },
'other': { title: '📝 Other Changes', commits: [] }
};
commits.forEach(commit => {
const subject = commit.subject.toLowerCase();
let categorized = false;
// Check for conventional commit prefixes
for (const [prefix, category] of Object.entries(categories)) {
if (prefix !== 'other' && subject.startsWith(`${prefix}:`)) {
category.commits.push(commit);
categorized = true;
break;
}
}
// If not categorized, put in other
if (!categorized) {
categories.other.commits.push(commit);
}
});
// Generate release notes
const releaseNotes = [];
for (const [key, category] of Object.entries(categories)) {
if (category.commits.length > 0) {
releaseNotes.push(`### ${category.title}`);
releaseNotes.push('');
category.commits.forEach(commit => {
// Clean up the subject by removing the prefix if it exists
let cleanSubject = commit.subject;
const colonIndex = cleanSubject.indexOf(':');
if (colonIndex !== -1 && cleanSubject.substring(0, colonIndex).match(/^(feat|fix|docs|refactor|test|perf|style|ci|build|chore)$/)) {
cleanSubject = cleanSubject.substring(colonIndex + 1).trim();
// Capitalize first letter
cleanSubject = cleanSubject.charAt(0).toUpperCase() + cleanSubject.slice(1);
}
releaseNotes.push(`- ${cleanSubject} (${commit.hash.substring(0, 7)})`);
});
releaseNotes.push('');
}
}
// Add commit statistics
const totalCommits = commits.length;
const contributors = [...new Set(commits.map(c => c.author))];
releaseNotes.push('---');
releaseNotes.push('');
releaseNotes.push(`**Release Statistics:**`);
releaseNotes.push(`- ${totalCommits} commit${totalCommits !== 1 ? 's' : ''}`);
releaseNotes.push(`- ${contributors.length} contributor${contributors.length !== 1 ? 's' : ''}`);
if (contributors.length <= 5) {
releaseNotes.push(`- Contributors: ${contributors.join(', ')}`);
}
return releaseNotes.join('\n');
} catch (error) {
console.error(`Error generating release notes: ${error.message}`);
return `Failed to generate release notes: ${error.message}`;
}
}
// Parse command line arguments
const previousTag = process.argv[2];
const currentTag = process.argv[3];
if (!previousTag || !currentTag) {
console.error('Usage: generate-release-notes.js <previous-tag> <current-tag>');
process.exit(1);
}
const releaseNotes = generateReleaseNotes(previousTag, currentTag);
console.log(releaseNotes);
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env node
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
/**
* Generate a markdown summary of test results for PR comments
*/
function generateTestSummary() {
const results = {
tests: null,
coverage: null,
benchmarks: null,
timestamp: new Date().toISOString()
};
// Read test results
const testResultPath = resolve(process.cwd(), 'test-results/results.json');
if (existsSync(testResultPath)) {
try {
const testData = JSON.parse(readFileSync(testResultPath, 'utf-8'));
const totalTests = testData.numTotalTests || 0;
const passedTests = testData.numPassedTests || 0;
const failedTests = testData.numFailedTests || 0;
const skippedTests = testData.numSkippedTests || 0;
const duration = testData.duration || 0;
results.tests = {
total: totalTests,
passed: passedTests,
failed: failedTests,
skipped: skippedTests,
duration: duration,
success: failedTests === 0
};
} catch (error) {
console.error('Error reading test results:', error);
}
}
// Read coverage results
const coveragePath = resolve(process.cwd(), 'coverage/coverage-summary.json');
if (existsSync(coveragePath)) {
try {
const coverageData = JSON.parse(readFileSync(coveragePath, 'utf-8'));
const total = coverageData.total;
results.coverage = {
lines: total.lines.pct,
statements: total.statements.pct,
functions: total.functions.pct,
branches: total.branches.pct
};
} catch (error) {
console.error('Error reading coverage results:', error);
}
}
// Read benchmark results
const benchmarkPath = resolve(process.cwd(), 'benchmark-results.json');
if (existsSync(benchmarkPath)) {
try {
const benchmarkData = JSON.parse(readFileSync(benchmarkPath, 'utf-8'));
const benchmarks = [];
for (const file of benchmarkData.files || []) {
for (const group of file.groups || []) {
for (const benchmark of group.benchmarks || []) {
benchmarks.push({
name: `${group.name} - ${benchmark.name}`,
mean: benchmark.result.mean,
ops: benchmark.result.hz
});
}
}
}
results.benchmarks = benchmarks;
} catch (error) {
console.error('Error reading benchmark results:', error);
}
}
// Generate markdown summary
let summary = '## Test Results Summary\n\n';
// Test results
if (results.tests) {
const { total, passed, failed, skipped, duration, success } = results.tests;
const emoji = success ? '✅' : '❌';
const status = success ? 'PASSED' : 'FAILED';
summary += `### ${emoji} Tests ${status}\n\n`;
summary += `| Metric | Value |\n`;
summary += `|--------|-------|\n`;
summary += `| Total Tests | ${total} |\n`;
summary += `| Passed | ${passed} |\n`;
summary += `| Failed | ${failed} |\n`;
summary += `| Skipped | ${skipped} |\n`;
summary += `| Duration | ${(duration / 1000).toFixed(2)}s |\n\n`;
}
// Coverage results
if (results.coverage) {
const { lines, statements, functions, branches } = results.coverage;
const avgCoverage = (lines + statements + functions + branches) / 4;
const emoji = avgCoverage >= 80 ? '✅' : avgCoverage >= 60 ? '⚠️' : '❌';
summary += `### ${emoji} Coverage Report\n\n`;
summary += `| Type | Coverage |\n`;
summary += `|------|----------|\n`;
summary += `| Lines | ${lines.toFixed(2)}% |\n`;
summary += `| Statements | ${statements.toFixed(2)}% |\n`;
summary += `| Functions | ${functions.toFixed(2)}% |\n`;
summary += `| Branches | ${branches.toFixed(2)}% |\n`;
summary += `| **Average** | **${avgCoverage.toFixed(2)}%** |\n\n`;
}
// Benchmark results
if (results.benchmarks && results.benchmarks.length > 0) {
summary += `### ⚡ Benchmark Results\n\n`;
summary += `| Benchmark | Ops/sec | Mean (ms) |\n`;
summary += `|-----------|---------|------------|\n`;
for (const bench of results.benchmarks.slice(0, 10)) { // Show top 10
const opsFormatted = bench.ops.toLocaleString('en-US', { maximumFractionDigits: 0 });
const meanFormatted = (bench.mean * 1000).toFixed(3);
summary += `| ${bench.name} | ${opsFormatted} | ${meanFormatted} |\n`;
}
if (results.benchmarks.length > 10) {
summary += `\n*...and ${results.benchmarks.length - 10} more benchmarks*\n`;
}
summary += '\n';
}
// Links to artifacts
const runId = process.env.GITHUB_RUN_ID;
const runNumber = process.env.GITHUB_RUN_NUMBER;
const sha = process.env.GITHUB_SHA;
if (runId) {
summary += `### 📊 Artifacts\n\n`;
summary += `- 📄 [Test Results](https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${runId})\n`;
summary += `- 📊 [Coverage Report](https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${runId})\n`;
summary += `- ⚡ [Benchmark Results](https://github.com/${process.env.GITHUB_REPOSITORY}/actions/runs/${runId})\n\n`;
}
// Metadata
summary += `---\n`;
summary += `*Generated at ${new Date().toUTCString()}*\n`;
if (sha) {
summary += `*Commit: ${sha.substring(0, 7)}*\n`;
}
if (runNumber) {
summary += `*Run: #${runNumber}*\n`;
}
return summary;
}
// Generate and output summary
const summary = generateTestSummary();
console.log(summary);
// Also write to file for artifact
import { writeFileSync } from 'fs';
writeFileSync('test-summary.md', summary);
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
/**
* HTTP-to-stdio bridge for n8n-MCP
* Connects to n8n-MCP HTTP server and bridges stdio communication
*/
const http = require('http');
const readline = require('readline');
// Use MCP_URL from environment or construct from HOST/PORT if available
const defaultHost = process.env.HOST || 'localhost';
const defaultPort = process.env.PORT || '3000';
const MCP_URL = process.env.MCP_URL || `http://${defaultHost}:${defaultPort}/mcp`;
const AUTH_TOKEN = process.env.AUTH_TOKEN || process.argv[2];
if (!AUTH_TOKEN) {
console.error('Error: AUTH_TOKEN environment variable or first argument required');
process.exit(1);
}
// Parse URL
const url = new URL(MCP_URL);
// Create readline interface for stdio
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
// Buffer for incomplete JSON messages
let buffer = '';
// Handle incoming stdio messages
rl.on('line', async (line) => {
try {
const message = JSON.parse(line);
// Forward to HTTP server
const options = {
hostname: url.hostname,
port: url.port || 80,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${AUTH_TOKEN}`,
'Accept': 'application/json, text/event-stream'
}
};
const req = http.request(options, (res) => {
let responseData = '';
res.on('data', (chunk) => {
responseData += chunk;
});
res.on('end', () => {
try {
// Try to parse as JSON
const response = JSON.parse(responseData);
console.log(JSON.stringify(response));
} catch (e) {
// Handle SSE format
const lines = responseData.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6));
console.log(JSON.stringify(data));
} catch (e) {
// Ignore parse errors
}
}
}
}
});
});
req.on('error', (error) => {
console.error(JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32000,
message: `HTTP request failed: ${error.message}`
},
id: message.id || null
}));
});
req.write(JSON.stringify(message));
req.end();
} catch (error) {
// Not valid JSON, ignore
}
});
// Handle process termination
process.on('SIGTERM', () => {
process.exit(0);
});
process.on('SIGINT', () => {
process.exit(0);
});
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Minimal MCP HTTP Client for Node.js v16 compatibility
* This bypasses mcp-remote and its TransformStream dependency
*/
const http = require('http');
const https = require('https');
const readline = require('readline');
// Get configuration from command line arguments
const url = process.argv[2];
const authToken = process.env.MCP_AUTH_TOKEN;
if (!url) {
console.error('Usage: node mcp-http-client.js <server-url>');
process.exit(1);
}
if (!authToken) {
console.error('Error: MCP_AUTH_TOKEN environment variable is required');
process.exit(1);
}
// Parse URL
const parsedUrl = new URL(url);
const isHttps = parsedUrl.protocol === 'https:';
const httpModule = isHttps ? https : http;
// Create readline interface for stdio
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
// Buffer for incomplete JSON messages
let buffer = '';
// Function to send JSON-RPC request
function sendRequest(request) {
const requestBody = JSON.stringify(request);
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (isHttps ? 443 : 80),
path: parsedUrl.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(requestBody),
'Authorization': `Bearer ${authToken}`
}
};
const req = httpModule.request(options, (res) => {
let responseData = '';
res.on('data', (chunk) => {
responseData += chunk;
});
res.on('end', () => {
try {
const response = JSON.parse(responseData);
// Ensure the response has the correct structure
if (response.jsonrpc && (response.result !== undefined || response.error !== undefined)) {
console.log(JSON.stringify(response));
} else {
// Wrap non-JSON-RPC responses
console.log(JSON.stringify({
jsonrpc: '2.0',
id: request.id || null,
error: {
code: -32603,
message: 'Internal error',
data: response
}
}));
}
} catch (err) {
console.log(JSON.stringify({
jsonrpc: '2.0',
id: request.id || null,
error: {
code: -32700,
message: 'Parse error',
data: err.message
}
}));
}
});
});
req.on('error', (err) => {
console.log(JSON.stringify({
jsonrpc: '2.0',
id: request.id || null,
error: {
code: -32000,
message: 'Transport error',
data: err.message
}
}));
});
req.write(requestBody);
req.end();
}
// Process incoming JSON-RPC messages from stdin
rl.on('line', (line) => {
// Try to parse each line as a complete JSON-RPC message
try {
const request = JSON.parse(line);
// Forward the request to the HTTP server
sendRequest(request);
} catch (err) {
// Log parse errors to stdout in JSON-RPC format
console.log(JSON.stringify({
jsonrpc: '2.0',
id: null,
error: {
code: -32700,
message: 'Parse error',
data: err.message
}
}));
}
});
// Handle process termination
process.on('SIGINT', () => {
process.exit(0);
});
process.on('SIGTERM', () => {
process.exit(0);
});
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env node
import * as path from 'path';
import { createDatabaseAdapter } from '../src/database/database-adapter';
import { logger } from '../src/utils/logger';
/**
* Migrate existing database to add FTS5 support for nodes
*/
async function migrateNodesFTS() {
logger.info('Starting nodes FTS5 migration...');
const dbPath = path.join(process.cwd(), 'data', 'nodes.db');
const db = await createDatabaseAdapter(dbPath);
try {
// Check if nodes_fts already exists
const tableExists = db.prepare(`
SELECT name FROM sqlite_master
WHERE type='table' AND name='nodes_fts'
`).get();
if (tableExists) {
logger.info('nodes_fts table already exists, skipping migration');
return;
}
logger.info('Creating nodes_fts virtual table...');
// Create the FTS5 virtual table
db.prepare(`
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
node_type,
display_name,
description,
documentation,
operations,
content=nodes,
content_rowid=rowid,
tokenize='porter'
)
`).run();
// Populate the FTS table with existing data
logger.info('Populating nodes_fts with existing data...');
const nodes = db.prepare('SELECT rowid, * FROM nodes').all() as any[];
logger.info(`Migrating ${nodes.length} nodes to FTS index...`);
const insertStmt = db.prepare(`
INSERT INTO nodes_fts(rowid, node_type, display_name, description, documentation, operations)
VALUES (?, ?, ?, ?, ?, ?)
`);
for (const node of nodes) {
insertStmt.run(
node.rowid,
node.node_type,
node.display_name,
node.description || '',
node.documentation || '',
node.operations || ''
);
}
// Create triggers to keep FTS in sync
logger.info('Creating synchronization triggers...');
db.prepare(`
CREATE TRIGGER IF NOT EXISTS nodes_fts_insert AFTER INSERT ON nodes
BEGIN
INSERT INTO nodes_fts(rowid, node_type, display_name, description, documentation, operations)
VALUES (new.rowid, new.node_type, new.display_name, new.description, new.documentation, new.operations);
END
`).run();
db.prepare(`
CREATE TRIGGER IF NOT EXISTS nodes_fts_update AFTER UPDATE ON nodes
BEGIN
UPDATE nodes_fts
SET node_type = new.node_type,
display_name = new.display_name,
description = new.description,
documentation = new.documentation,
operations = new.operations
WHERE rowid = new.rowid;
END
`).run();
db.prepare(`
CREATE TRIGGER IF NOT EXISTS nodes_fts_delete AFTER DELETE ON nodes
BEGIN
DELETE FROM nodes_fts WHERE rowid = old.rowid;
END
`).run();
// Test the FTS search
logger.info('Testing FTS search...');
const testResults = db.prepare(`
SELECT n.* FROM nodes n
JOIN nodes_fts ON n.rowid = nodes_fts.rowid
WHERE nodes_fts MATCH 'webhook'
ORDER BY rank
LIMIT 5
`).all();
logger.info(`FTS test search found ${testResults.length} results for 'webhook'`);
// Persist if using sql.js
if ('persist' in db) {
logger.info('Persisting database changes...');
(db as any).persist();
}
logger.info('✅ FTS5 migration completed successfully!');
} catch (error) {
logger.error('Migration failed:', error);
throw error;
} finally {
db.close();
}
}
// Run migration
migrateNodesFTS().catch(error => {
logger.error('Migration error:', error);
process.exit(1);
});
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env tsx
import * as fs from 'fs';
import * as path from 'path';
// This is a helper script to migrate tool documentation to the new structure
// It creates a template file for each tool that needs to be migrated
const toolsByCategory = {
discovery: [
'search_nodes',
'list_nodes',
'list_ai_tools',
'get_database_statistics'
],
configuration: [
'get_node_info',
'get_node_essentials',
'get_node_documentation',
'search_node_properties',
'get_node_as_tool_info',
'get_property_dependencies'
],
validation: [
'validate_node_minimal',
'validate_node_operation',
'validate_workflow',
'validate_workflow_connections',
'validate_workflow_expressions'
],
templates: [
'get_node_for_task',
'list_tasks',
'list_node_templates',
'get_template',
'search_templates',
'get_templates_for_task'
],
workflow_management: [
'n8n_create_workflow',
'n8n_get_workflow',
'n8n_get_workflow_details',
'n8n_get_workflow_structure',
'n8n_get_workflow_minimal',
'n8n_update_full_workflow',
'n8n_update_partial_workflow',
'n8n_delete_workflow',
'n8n_list_workflows',
'n8n_validate_workflow',
'n8n_trigger_webhook_workflow',
'n8n_get_execution',
'n8n_list_executions',
'n8n_delete_execution'
],
system: [
'tools_documentation',
'n8n_diagnostic',
'n8n_health_check',
'n8n_list_available_tools'
],
special: [
'code_node_guide'
]
};
const template = (toolName: string, category: string) => `import { ToolDocumentation } from '../types';
export const ${toCamelCase(toolName)}Doc: ToolDocumentation = {
name: '${toolName}',
category: '${category}',
essentials: {
description: 'TODO: Add description from old file',
keyParameters: ['TODO'],
example: '${toolName}({TODO})',
performance: 'TODO',
tips: [
'TODO: Add tips'
]
},
full: {
description: 'TODO: Add full description',
parameters: {
// TODO: Add parameters
},
returns: 'TODO: Add return description',
examples: [
'${toolName}({TODO}) - TODO'
],
useCases: [
'TODO: Add use cases'
],
performance: 'TODO: Add performance description',
bestPractices: [
'TODO: Add best practices'
],
pitfalls: [
'TODO: Add pitfalls'
],
relatedTools: ['TODO']
}
};`;
function toCamelCase(str: string): string {
return str.split('_').map((part, index) =>
index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)
).join('');
}
function toKebabCase(str: string): string {
return str.replace(/_/g, '-');
}
// Create template files for tools that don't exist yet
Object.entries(toolsByCategory).forEach(([category, tools]) => {
tools.forEach(toolName => {
const fileName = toKebabCase(toolName) + '.ts';
const filePath = path.join('src/mcp/tool-docs', category, fileName);
// Skip if file already exists
if (fs.existsSync(filePath)) {
console.log(`${filePath} already exists`);
return;
}
// Create the file with template
fs.writeFileSync(filePath, template(toolName, category));
console.log(`✨ Created ${filePath}`);
});
// Create index file for the category
const indexPath = path.join('src/mcp/tool-docs', category, 'index.ts');
if (!fs.existsSync(indexPath)) {
const indexContent = tools.map(toolName =>
`export { ${toCamelCase(toolName)}Doc } from './${toKebabCase(toolName)}';`
).join('\n');
fs.writeFileSync(indexPath, indexContent);
console.log(`✨ Created ${indexPath}`);
}
});
console.log('\n📝 Migration templates created!');
console.log('Next steps:');
console.log('1. Copy documentation from the old tools-documentation.ts file');
console.log('2. Update each template file with the actual documentation');
console.log('3. Update src/mcp/tool-docs/index.ts to import all tools');
console.log('4. Replace the old tools-documentation.ts with the new one');
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=n8n Documentation MCP Server
After=network.target
[Service]
Type=simple
User=nodejs
WorkingDirectory=/opt/n8n-mcp
ExecStart=/usr/bin/node /opt/n8n-mcp/dist/index-http.js
Restart=always
RestartSec=10
# Environment
Environment="NODE_ENV=production"
EnvironmentFile=/opt/n8n-mcp/.env
# Security
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/n8n-mcp/data /opt/n8n-mcp/logs /opt/n8n-mcp/temp
# Logging
StandardOutput=append:/opt/n8n-mcp/logs/service.log
StandardError=append:/opt/n8n-mcp/logs/service-error.log
[Install]
WantedBy=multi-user.target
+75
View File
@@ -0,0 +1,75 @@
server {
listen 80;
server_name n8ndocumentation.aiservices.pl;
# Redirect HTTP to HTTPS
location / {
return 301 https://$server_name$request_uri;
}
}
server {
listen 443 ssl http2;
server_name n8ndocumentation.aiservices.pl;
# SSL configuration (managed by Certbot)
ssl_certificate /etc/letsencrypt/live/n8ndocumentation.aiservices.pl/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/n8ndocumentation.aiservices.pl/privkey.pem;
# SSL security settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# Security headers
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Logging
access_log /var/log/nginx/n8n-mcp-access.log;
error_log /var/log/nginx/n8n-mcp-error.log;
# Proxy settings
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Timeouts for MCP operations
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Increase buffer sizes for large responses
proxy_buffer_size 16k;
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
}
# Rate limiting for API endpoints
location /mcp {
limit_req zone=mcp_limit burst=10 nodelay;
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Larger timeouts for MCP
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
}
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=mcp_limit:10m rate=10r/s;
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env npx tsx
/**
* Pre-build FTS5 indexes for the database
* This ensures FTS5 tables are created before the database is deployed to Docker
*/
import { createDatabaseAdapter } from '../src/database/database-adapter';
import { logger } from '../src/utils/logger';
import * as fs from 'fs';
async function prebuildFTS5() {
console.log('🔍 Pre-building FTS5 indexes...\n');
const dbPath = './data/nodes.db';
if (!fs.existsSync(dbPath)) {
console.error('❌ Database not found at', dbPath);
console.error(' Please run npm run rebuild first');
process.exit(1);
}
const db = await createDatabaseAdapter(dbPath);
// Check FTS5 support
const hasFTS5 = db.checkFTS5Support();
if (!hasFTS5) {
console.log('️ FTS5 not supported in this SQLite build');
console.log(' Skipping FTS5 pre-build');
db.close();
return;
}
console.log('✅ FTS5 is supported');
try {
// Create FTS5 virtual table for templates
console.log('\n📋 Creating FTS5 table for templates...');
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS templates_fts USING fts5(
name, description, content=templates
);
`);
// Create triggers to keep FTS5 in sync
console.log('🔗 Creating synchronization triggers...');
db.exec(`
CREATE TRIGGER IF NOT EXISTS templates_ai AFTER INSERT ON templates BEGIN
INSERT INTO templates_fts(rowid, name, description)
VALUES (new.id, new.name, new.description);
END;
`);
db.exec(`
CREATE TRIGGER IF NOT EXISTS templates_au AFTER UPDATE ON templates BEGIN
UPDATE templates_fts SET name = new.name, description = new.description
WHERE rowid = new.id;
END;
`);
db.exec(`
CREATE TRIGGER IF NOT EXISTS templates_ad AFTER DELETE ON templates BEGIN
DELETE FROM templates_fts WHERE rowid = old.id;
END;
`);
// Rebuild FTS5 index from existing data
console.log('🔄 Rebuilding FTS5 index from existing templates...');
// Clear existing FTS data
db.exec('DELETE FROM templates_fts');
// Repopulate from templates table
db.exec(`
INSERT INTO templates_fts(rowid, name, description)
SELECT id, name, description FROM templates
`);
// Get counts
const templateCount = db.prepare('SELECT COUNT(*) as count FROM templates').get() as { count: number };
const ftsCount = db.prepare('SELECT COUNT(*) as count FROM templates_fts').get() as { count: number };
console.log(`\n✅ FTS5 pre-build complete!`);
console.log(` Templates: ${templateCount.count}`);
console.log(` FTS5 entries: ${ftsCount.count}`);
// Test FTS5 search
console.log('\n🧪 Testing FTS5 search...');
const testResults = db.prepare(`
SELECT COUNT(*) as count FROM templates t
JOIN templates_fts ON t.id = templates_fts.rowid
WHERE templates_fts MATCH 'webhook'
`).get() as { count: number };
console.log(` Found ${testResults.count} templates matching "webhook"`);
} catch (error) {
console.error('❌ Error pre-building FTS5:', error);
process.exit(1);
}
db.close();
console.log('\n✅ Database is ready for Docker deployment!');
}
// Run if called directly
if (require.main === module) {
prebuildFTS5().catch(console.error);
}
export { prebuildFTS5 };
+400
View File
@@ -0,0 +1,400 @@
#!/usr/bin/env node
/**
* Pre-release preparation script
* Validates and prepares everything needed for a successful release
*/
const fs = require('fs');
const path = require('path');
const { execSync, spawnSync } = require('child_process');
const readline = require('readline');
// Color codes
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m'
};
function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
function success(message) {
log(`${message}`, 'green');
}
function warning(message) {
log(`⚠️ ${message}`, 'yellow');
}
function error(message) {
log(`${message}`, 'red');
}
function info(message) {
log(`${message}`, 'blue');
}
function header(title) {
log(`\n${'='.repeat(60)}`, 'cyan');
log(`🚀 ${title}`, 'cyan');
log(`${'='.repeat(60)}`, 'cyan');
}
class ReleasePreparation {
constructor() {
this.rootDir = path.resolve(__dirname, '..');
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
}
async askQuestion(question) {
return new Promise((resolve) => {
this.rl.question(question, resolve);
});
}
/**
* Get current version and ask for new version
*/
async getVersionInfo() {
const packageJson = require(path.join(this.rootDir, 'package.json'));
const currentVersion = packageJson.version;
log(`\nCurrent version: ${currentVersion}`, 'blue');
const newVersion = await this.askQuestion('\nEnter new version (e.g., 2.10.0): ');
if (!newVersion || !this.isValidSemver(newVersion)) {
error('Invalid semantic version format');
throw new Error('Invalid version');
}
if (this.compareVersions(newVersion, currentVersion) <= 0) {
error('New version must be greater than current version');
throw new Error('Version not incremented');
}
return { currentVersion, newVersion };
}
/**
* Validate semantic version format (strict semver compliance)
*/
isValidSemver(version) {
// Strict semantic versioning regex
const semverRegex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
return semverRegex.test(version);
}
/**
* Compare two semantic versions
*/
compareVersions(v1, v2) {
const parseVersion = (v) => v.split('-')[0].split('.').map(Number);
const [v1Parts, v2Parts] = [parseVersion(v1), parseVersion(v2)];
for (let i = 0; i < 3; i++) {
if (v1Parts[i] > v2Parts[i]) return 1;
if (v1Parts[i] < v2Parts[i]) return -1;
}
return 0;
}
/**
* Update version in package files
*/
updateVersions(newVersion) {
log('\n📝 Updating version in package files...', 'blue');
// Update package.json
const packageJsonPath = path.join(this.rootDir, 'package.json');
const packageJson = require(packageJsonPath);
packageJson.version = newVersion;
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
success('Updated package.json');
// Sync to runtime package
try {
execSync('npm run sync:runtime-version', { cwd: this.rootDir, stdio: 'pipe' });
success('Synced package.runtime.json');
} catch (err) {
warning('Could not sync runtime version automatically');
// Manual sync
const runtimeJsonPath = path.join(this.rootDir, 'package.runtime.json');
if (fs.existsSync(runtimeJsonPath)) {
const runtimeJson = require(runtimeJsonPath);
runtimeJson.version = newVersion;
fs.writeFileSync(runtimeJsonPath, JSON.stringify(runtimeJson, null, 2) + '\n');
success('Manually synced package.runtime.json');
}
}
}
/**
* Update changelog
*/
async updateChangelog(newVersion) {
const changelogPath = path.join(this.rootDir, 'docs/CHANGELOG.md');
if (!fs.existsSync(changelogPath)) {
warning('Changelog file not found, skipping update');
return;
}
log('\n📋 Updating changelog...', 'blue');
const content = fs.readFileSync(changelogPath, 'utf8');
const today = new Date().toISOString().split('T')[0];
// Check if version already exists in changelog
const versionRegex = new RegExp(`^## \\[${newVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm');
if (versionRegex.test(content)) {
info(`Version ${newVersion} already exists in changelog`);
return;
}
// Find the Unreleased section
const unreleasedMatch = content.match(/^## \[Unreleased\]\s*\n([\s\S]*?)(?=\n## \[|$)/m);
if (unreleasedMatch) {
const unreleasedContent = unreleasedMatch[1].trim();
if (unreleasedContent) {
log('\nFound content in Unreleased section:', 'blue');
log(unreleasedContent.substring(0, 200) + '...', 'yellow');
const moveContent = await this.askQuestion('\nMove this content to the new version? (y/n): ');
if (moveContent.toLowerCase() === 'y') {
// Move unreleased content to new version
const newVersionSection = `## [${newVersion}] - ${today}\n\n${unreleasedContent}\n\n`;
const updatedContent = content.replace(
/^## \[Unreleased\]\s*\n[\s\S]*?(?=\n## \[)/m,
`## [Unreleased]\n\n${newVersionSection}## [`
);
fs.writeFileSync(changelogPath, updatedContent);
success(`Moved unreleased content to version ${newVersion}`);
} else {
// Just add empty version section
const newVersionSection = `## [${newVersion}] - ${today}\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n\n`;
const updatedContent = content.replace(
/^## \[Unreleased\]\s*\n/m,
`## [Unreleased]\n\n${newVersionSection}`
);
fs.writeFileSync(changelogPath, updatedContent);
warning(`Added empty version section for ${newVersion} - please fill in the changes`);
}
} else {
// Add empty version section
const newVersionSection = `## [${newVersion}] - ${today}\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n\n`;
const updatedContent = content.replace(
/^## \[Unreleased\]\s*\n/m,
`## [Unreleased]\n\n${newVersionSection}`
);
fs.writeFileSync(changelogPath, updatedContent);
warning(`Added empty version section for ${newVersion} - please fill in the changes`);
}
} else {
warning('Could not find Unreleased section in changelog');
}
info('Please review and edit the changelog before committing');
}
/**
* Run tests and build
*/
async runChecks() {
log('\n🧪 Running pre-release checks...', 'blue');
try {
// Run tests
log('Running tests...', 'blue');
execSync('npm test', { cwd: this.rootDir, stdio: 'inherit' });
success('All tests passed');
// Run build
log('Building project...', 'blue');
execSync('npm run build', { cwd: this.rootDir, stdio: 'inherit' });
success('Build completed');
// Rebuild database
log('Rebuilding database...', 'blue');
execSync('npm run rebuild', { cwd: this.rootDir, stdio: 'inherit' });
success('Database rebuilt');
// Run type checking
log('Type checking...', 'blue');
execSync('npm run typecheck', { cwd: this.rootDir, stdio: 'inherit' });
success('Type checking passed');
} catch (err) {
error('Pre-release checks failed');
throw err;
}
}
/**
* Create git commit
*/
async createCommit(newVersion) {
log('\n📝 Creating git commit...', 'blue');
try {
// Check git status
const status = execSync('git status --porcelain', {
cwd: this.rootDir,
encoding: 'utf8'
});
if (!status.trim()) {
info('No changes to commit');
return;
}
// Show what will be committed
log('\nFiles to be committed:', 'blue');
execSync('git diff --name-only', { cwd: this.rootDir, stdio: 'inherit' });
const commit = await this.askQuestion('\nCreate commit for release? (y/n): ');
if (commit.toLowerCase() === 'y') {
// Add files
execSync('git add package.json package.runtime.json docs/CHANGELOG.md', {
cwd: this.rootDir,
stdio: 'pipe'
});
// Create commit
const commitMessage = `chore: release v${newVersion}
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>`;
const result = spawnSync('git', ['commit', '-m', commitMessage], {
cwd: this.rootDir,
stdio: 'pipe',
encoding: 'utf8'
});
if (result.error || result.status !== 0) {
throw new Error(`Git commit failed: ${result.stderr || result.error?.message}`);
}
success(`Created commit for v${newVersion}`);
const push = await this.askQuestion('\nPush to trigger release workflow? (y/n): ');
if (push.toLowerCase() === 'y') {
// Add confirmation for destructive operation
warning('\n⚠️ DESTRUCTIVE OPERATION WARNING ⚠️');
warning('This will trigger a PUBLIC RELEASE that cannot be undone!');
warning('The following will happen automatically:');
warning('• Create GitHub release with tag');
warning('• Publish package to NPM registry');
warning('• Build and push Docker images');
warning('• Update documentation');
const confirmation = await this.askQuestion('\nType "RELEASE" (all caps) to confirm: ');
if (confirmation === 'RELEASE') {
execSync('git push', { cwd: this.rootDir, stdio: 'inherit' });
success('Pushed to remote repository');
log('\n🎉 Release workflow will be triggered automatically!', 'green');
log('Monitor progress at: https://github.com/czlonkowski/n8n-mcp/actions', 'blue');
} else {
warning('Release cancelled. Commit created but not pushed.');
info('You can push manually later to trigger the release.');
}
} else {
info('Commit created but not pushed. Push manually to trigger release.');
}
}
} catch (err) {
error(`Git operations failed: ${err.message}`);
throw err;
}
}
/**
* Display final instructions
*/
displayInstructions(newVersion) {
header('Release Preparation Complete');
log('📋 What happens next:', 'blue');
log(`1. The GitHub Actions workflow will detect the version change to v${newVersion}`, 'green');
log('2. It will automatically:', 'green');
log(' • Create a GitHub release with changelog content', 'green');
log(' • Publish the npm package', 'green');
log(' • Build and push Docker images', 'green');
log(' • Update documentation badges', 'green');
log('\n🔍 Monitor the release at:', 'blue');
log(' • GitHub Actions: https://github.com/czlonkowski/n8n-mcp/actions', 'blue');
log(' • NPM Package: https://www.npmjs.com/package/n8n-mcp', 'blue');
log(' • Docker Images: https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp', 'blue');
log('\n✅ Release preparation completed successfully!', 'green');
}
/**
* Main execution flow
*/
async run() {
try {
header('n8n-MCP Release Preparation');
// Get version information
const { currentVersion, newVersion } = await this.getVersionInfo();
log(`\n🔄 Preparing release: ${currentVersion}${newVersion}`, 'magenta');
// Update versions
this.updateVersions(newVersion);
// Update changelog
await this.updateChangelog(newVersion);
// Run pre-release checks
await this.runChecks();
// Create git commit
await this.createCommit(newVersion);
// Display final instructions
this.displayInstructions(newVersion);
} catch (err) {
error(`Release preparation failed: ${err.message}`);
process.exit(1);
} finally {
this.rl.close();
}
}
}
// Run the script
if (require.main === module) {
const preparation = new ReleasePreparation();
preparation.run().catch(err => {
console.error('Release preparation failed:', err);
process.exit(1);
});
}
module.exports = ReleasePreparation;
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env ts-node
import * as fs from 'fs';
import * as path from 'path';
import { createDatabaseAdapter } from '../src/database/database-adapter';
interface BatchResponse {
id: string;
custom_id: string;
response: {
status_code: number;
body: {
choices: Array<{
message: {
content: string;
};
}>;
};
};
error: any;
}
async function processBatchMetadata(batchFile: string) {
console.log(`📥 Processing batch file: ${batchFile}`);
// Read the JSONL file
const content = fs.readFileSync(batchFile, 'utf-8');
const lines = content.trim().split('\n');
console.log(`📊 Found ${lines.length} batch responses`);
// Initialize database
const db = await createDatabaseAdapter('./data/nodes.db');
let updated = 0;
let skipped = 0;
let errors = 0;
for (const line of lines) {
try {
const response: BatchResponse = JSON.parse(line);
// Extract template ID from custom_id (format: "template-9100")
const templateId = parseInt(response.custom_id.replace('template-', ''));
// Check for errors
if (response.error || response.response.status_code !== 200) {
console.warn(`⚠️ Template ${templateId}: API error`, response.error);
errors++;
continue;
}
// Extract metadata from response
const metadataJson = response.response.body.choices[0].message.content;
// Validate it's valid JSON
JSON.parse(metadataJson); // Will throw if invalid
// Update database
const stmt = db.prepare(`
UPDATE templates
SET metadata_json = ?
WHERE id = ?
`);
stmt.run(metadataJson, templateId);
updated++;
console.log(`✅ Template ${templateId}: Updated metadata`);
} catch (error: any) {
console.error(`❌ Error processing line:`, error.message);
errors++;
}
}
// Close database
if ('close' in db && typeof db.close === 'function') {
db.close();
}
console.log(`\n📈 Summary:`);
console.log(` - Updated: ${updated}`);
console.log(` - Skipped: ${skipped}`);
console.log(` - Errors: ${errors}`);
console.log(` - Total: ${lines.length}`);
}
// Main
const batchFile = process.argv[2] || '/Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp/docs/batch_68fff7242850819091cfed64f10fb6b4_output.jsonl';
processBatchMetadata(batchFile)
.then(() => {
console.log('\n✅ Batch processing complete!');
process.exit(0);
})
.catch((error) => {
console.error('\n❌ Batch processing failed:', error);
process.exit(1);
});
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
# Quick publish script that skips tests
set -e
# Color codes
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo "🚀 Preparing n8n-mcp for npm publish (quick mode)..."
# Sync version
echo "🔄 Syncing version to package.runtime.json..."
npm run sync:runtime-version
VERSION=$(node -e "console.log(require('./package.json').version)")
echo -e "${GREEN}📌 Version: $VERSION${NC}"
# Prepare publish directory
PUBLISH_DIR="npm-publish-temp"
rm -rf $PUBLISH_DIR
mkdir -p $PUBLISH_DIR
echo "📦 Copying files..."
cp -r dist $PUBLISH_DIR/
cp -r data $PUBLISH_DIR/
cp README.md LICENSE .env.example $PUBLISH_DIR/
cp .npmignore $PUBLISH_DIR/ 2>/dev/null || true
cp package.runtime.json $PUBLISH_DIR/package.json
cd $PUBLISH_DIR
# Configure package.json
node -e "
const pkg = require('./package.json');
pkg.name = 'n8n-mcp';
pkg.description = 'Integration between n8n workflow automation and Model Context Protocol (MCP)';
pkg.bin = { 'n8n-mcp': './dist/mcp/stdio-wrapper.js' };
pkg.repository = { type: 'git', url: 'git+https://github.com/czlonkowski/n8n-mcp.git' };
pkg.keywords = ['n8n', 'mcp', 'model-context-protocol', 'ai', 'workflow', 'automation'];
pkg.author = 'Romuald Czlonkowski @ www.aiadvisors.pl/en';
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'];
delete pkg.private;
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));
"
echo ""
echo "📋 Package details:"
echo -e "${GREEN}Name:${NC} $(node -e "console.log(require('./package.json').name)")"
echo -e "${GREEN}Version:${NC} $(node -e "console.log(require('./package.json').version)")"
echo -e "${GREEN}Size:${NC} ~50MB"
echo ""
echo "✅ Ready to publish!"
echo ""
echo -e "${YELLOW}⚠️ Note: Tests were skipped in quick mode${NC}"
echo ""
echo "To publish, run:"
echo -e " ${GREEN}cd $PUBLISH_DIR${NC}"
echo -e " ${GREEN}npm publish --otp=YOUR_OTP_CODE${NC}"
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
# Script to publish n8n-mcp with runtime-only dependencies
set -e
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "🚀 Preparing n8n-mcp for npm publish..."
# Skip tests - they already run in CI before merge/publish
echo "⏭️ Skipping tests (already verified in CI)"
# Sync version to runtime package first
echo "🔄 Syncing version to package.runtime.json..."
npm run sync:runtime-version
# Get version from main package.json
VERSION=$(node -e "console.log(require('./package.json').version)")
echo -e "${GREEN}📌 Version: $VERSION${NC}"
# Check if dist directory exists
if [ ! -d "dist" ]; then
echo -e "${RED}❌ Error: dist directory not found. Run 'npm run build' first.${NC}"
exit 1
fi
# Check if database exists
if [ ! -f "data/nodes.db" ]; then
echo -e "${RED}❌ Error: data/nodes.db not found. Run 'npm run rebuild' first.${NC}"
exit 1
fi
# Create a temporary publish directory
PUBLISH_DIR="npm-publish-temp"
rm -rf $PUBLISH_DIR
mkdir -p $PUBLISH_DIR
# Copy necessary files
echo "📦 Copying files..."
cp -r dist $PUBLISH_DIR/
cp -r data $PUBLISH_DIR/
cp README.md $PUBLISH_DIR/
cp LICENSE $PUBLISH_DIR/
cp .env.example $PUBLISH_DIR/
cp .npmignore $PUBLISH_DIR/ 2>/dev/null || true
# Use runtime package.json (already has correct version from sync)
echo "📋 Using runtime-only dependencies..."
cp package.runtime.json $PUBLISH_DIR/package.json
cd $PUBLISH_DIR
# Add required fields from main package.json
node -e "
const pkg = require('./package.json');
pkg.name = 'n8n-mcp';
pkg.description = 'Integration between n8n workflow automation and Model Context Protocol (MCP)';
pkg.main = 'dist/index.js';
pkg.types = 'dist/index.d.ts';
pkg.exports = {
'.': {
types: './dist/index.d.ts',
require: './dist/index.js',
import: './dist/index.js'
}
};
pkg.bin = { 'n8n-mcp': './dist/mcp/stdio-wrapper.js' };
pkg.repository = { type: 'git', url: 'git+https://github.com/czlonkowski/n8n-mcp.git' };
pkg.keywords = ['n8n', 'mcp', 'model-context-protocol', 'ai', 'workflow', 'automation'];
pkg.author = 'Romuald Czlonkowski @ www.aiadvisors.pl/en';
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'];
// Note: node_modules are automatically included for dependencies
delete pkg.private; // Remove private field so we can publish
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));
"
echo ""
echo "📋 Package details:"
echo -e "${GREEN}Name:${NC} $(node -e "console.log(require('./package.json').name)")"
echo -e "${GREEN}Version:${NC} $(node -e "console.log(require('./package.json').version)")"
echo -e "${GREEN}Size:${NC} ~50MB (vs 1GB+ with dev dependencies)"
echo -e "${GREEN}Runtime deps:${NC} 8 packages"
echo ""
echo "✅ Ready to publish!"
echo ""
echo -e "${YELLOW}⚠️ Important: npm publishing requires OTP authentication${NC}"
echo ""
echo "To publish, run:"
echo -e " ${GREEN}cd $PUBLISH_DIR${NC}"
echo -e " ${GREEN}npm publish --otp=YOUR_OTP_CODE${NC}"
echo ""
echo "After publishing, clean up with:"
echo -e " ${GREEN}cd ..${NC}"
echo -e " ${GREEN}rm -rf $PUBLISH_DIR${NC}"
echo ""
echo "📝 Notes:"
echo " - Get your OTP from your authenticator app"
echo " - The package will be available at https://www.npmjs.com/package/n8n-mcp"
echo " - Users can run 'npx n8n-mcp' immediately after publish"
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env ts-node
/**
* Quick test script to validate the essentials implementation
*/
import { spawn } from 'child_process';
import { join } from 'path';
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m'
};
function log(message: string, color: string = colors.reset) {
console.log(`${color}${message}${colors.reset}`);
}
async function runMCPCommand(toolName: string, args: any): Promise<any> {
return new Promise((resolve, reject) => {
const request = {
jsonrpc: '2.0',
method: 'tools/call',
params: {
name: toolName,
arguments: args
},
id: 1
};
const mcp = spawn('npm', ['start'], {
cwd: join(__dirname, '..'),
stdio: ['pipe', 'pipe', 'pipe']
});
let output = '';
let error = '';
mcp.stdout.on('data', (data) => {
output += data.toString();
});
mcp.stderr.on('data', (data) => {
error += data.toString();
});
mcp.on('close', (code) => {
if (code !== 0) {
reject(new Error(`Process exited with code ${code}: ${error}`));
return;
}
try {
// Parse JSON-RPC response
const lines = output.split('\n');
for (const line of lines) {
if (line.trim() && line.includes('"jsonrpc"')) {
const response = JSON.parse(line);
if (response.result) {
resolve(JSON.parse(response.result.content[0].text));
return;
} else if (response.error) {
reject(new Error(response.error.message));
return;
}
}
}
reject(new Error('No valid response found'));
} catch (err) {
reject(err);
}
});
// Send request
mcp.stdin.write(JSON.stringify(request) + '\n');
mcp.stdin.end();
});
}
async function quickTest() {
log('\n🚀 Quick Test - n8n MCP Essentials', colors.bright + colors.cyan);
try {
// Test 1: Get essentials for HTTP Request
log('\n1️⃣ Testing get_node_essentials for HTTP Request...', colors.yellow);
const essentials = await runMCPCommand('get_node_essentials', {
nodeType: 'nodes-base.httpRequest'
});
log('✅ Success! Got essentials:', colors.green);
log(` Required properties: ${essentials.requiredProperties?.map((p: any) => p.name).join(', ') || 'None'}`);
log(` Common properties: ${essentials.commonProperties?.map((p: any) => p.name).join(', ') || 'None'}`);
log(` Examples: ${Object.keys(essentials.examples || {}).join(', ')}`);
log(` Response size: ${JSON.stringify(essentials).length} bytes`, colors.green);
// Test 2: Search properties
log('\n2️⃣ Testing search_node_properties...', colors.yellow);
const searchResults = await runMCPCommand('search_node_properties', {
nodeType: 'nodes-base.httpRequest',
query: 'auth'
});
log('✅ Success! Found properties:', colors.green);
log(` Matches: ${searchResults.totalMatches}`);
searchResults.matches?.slice(0, 3).forEach((match: any) => {
log(` - ${match.name}: ${match.description}`);
});
// Test 3: Compare sizes
log('\n3️⃣ Comparing response sizes...', colors.yellow);
const fullInfo = await runMCPCommand('get_node_info', {
nodeType: 'nodes-base.httpRequest'
});
const fullSize = JSON.stringify(fullInfo).length;
const essentialSize = JSON.stringify(essentials).length;
const reduction = ((fullSize - essentialSize) / fullSize * 100).toFixed(1);
log(`✅ Size comparison:`, colors.green);
log(` Full response: ${(fullSize / 1024).toFixed(1)} KB`);
log(` Essential response: ${(essentialSize / 1024).toFixed(1)} KB`);
log(` Size reduction: ${reduction}% 🎉`, colors.bright + colors.green);
log('\n✨ All tests passed!', colors.bright + colors.green);
} catch (error) {
log(`\n❌ Test failed: ${error}`, colors.red);
process.exit(1);
}
}
// Run if called directly
if (require.main === module) {
quickTest().catch(console.error);
}
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
'use strict';
/**
* CommonJS runtime smoke test — regression guard for #864.
*
* The shipped artifact is compiled to CommonJS and `require()`s its dependencies. If a
* dependency is ESM-only (no `require` export condition), `require()` throws
* ERR_REQUIRE_ESM and the server crashes at startup before any config is read — exactly
* how `uuid@14` broke v2.59.12.59.3.
*
* Node >= 20.19 / >= 22.12 enable `require(ESM)` by default, which silently masks the
* mismatch — so just requiring the artifact on a modern Node would NOT catch it. We force
* the strict (pre-`require(ESM)`) loader with `--no-experimental-require-module` so the
* mismatch surfaces regardless of the runner's Node version.
*
* That flag does not exist on older Node (added in v22.0.0, backported to v20.19.0;
* absent in 18.x and 20.020.18). On those versions the strict loader is already the
* default, so the flag is unnecessary — and passing it would error with `bad option`. We
* probe for flag support rather than hard-coding the version matrix, so the guard is
* strict on every supported Node (>=18) instead of depending on which Node happens to run
* it (the meta-mistake that produced #864).
*/
const { spawnSync } = require('node:child_process');
const path = require('node:path');
const FLAG = '--no-experimental-require-module';
const entry = path.resolve(__dirname, '..', 'dist', 'index.js');
const program =
`require(${JSON.stringify(entry)}); ` +
`console.log('CJS runtime load OK (node ' + process.versions.node + ')');`;
// Probe: does this Node recognize the strict-loader flag? Run an empty program with it.
const flagSupported =
spawnSync(process.execPath, [FLAG, '-e', ''], { stdio: 'ignore' }).status === 0;
const args = flagSupported ? [FLAG, '-e', program] : ['-e', program];
const result = spawnSync(process.execPath, args, { stdio: 'inherit' });
if (result.status !== 0) {
console.error(
`\nCJS runtime smoke test FAILED (node ${process.versions.node}, strict loader forced: ${flagSupported}).`
);
console.error(
'The compiled dist/ could not be require()d under the CommonJS loader — a shipped ' +
'dependency is likely ESM-only. See #864.'
);
process.exit(result.status === null ? 1 : result.status);
}
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env node
/**
* Sync version from package.json to package.runtime.json and README.md
* This ensures all files always have the same version
*/
const fs = require('fs');
const path = require('path');
const packageJsonPath = path.join(__dirname, '..', 'package.json');
const packageRuntimePath = path.join(__dirname, '..', 'package.runtime.json');
const readmePath = path.join(__dirname, '..', 'README.md');
try {
// Read package.json
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
const version = packageJson.version;
// Read package.runtime.json
const packageRuntime = JSON.parse(fs.readFileSync(packageRuntimePath, 'utf-8'));
// Update version if different
if (packageRuntime.version !== version) {
packageRuntime.version = version;
// Write back with proper formatting
fs.writeFileSync(
packageRuntimePath,
JSON.stringify(packageRuntime, null, 2) + '\n',
'utf-8'
);
console.log(`✅ Updated package.runtime.json version to ${version}`);
} else {
console.log(`✓ package.runtime.json already at version ${version}`);
}
// Update README.md version badge
let readmeContent = fs.readFileSync(readmePath, 'utf-8');
const versionBadgeRegex = /(\[!\[Version\]\(https:\/\/img\.shields\.io\/badge\/version-)[^-]+(-.+?\)\])/;
const newVersionBadge = `$1${version}$2`;
const updatedReadmeContent = readmeContent.replace(versionBadgeRegex, newVersionBadge);
if (updatedReadmeContent !== readmeContent) {
fs.writeFileSync(readmePath, updatedReadmeContent);
console.log(`✅ Updated README.md version badge to ${version}`);
} else {
console.log(`✓ README.md already has version badge ${version}`);
}
} catch (error) {
console.error('❌ Error syncing version:', error.message);
process.exit(1);
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env npx tsx
/**
* Copy markdown skill files from the sibling n8n-skills repo into
* data/skills/ so they ship inside the n8n-mcp npm/Docker artifacts.
*
* Source defaults to ../n8n-skills/skills relative to this repo root.
* Override with N8N_SKILLS_SOURCE.
*/
import { promises as fs } from 'fs';
import { existsSync } from 'fs';
import path from 'path';
const REPO_ROOT = path.resolve(__dirname, '..');
const CANDIDATE_SOURCES = [
path.resolve(REPO_ROOT, '..', 'n8n-skills', 'skills'),
path.resolve(REPO_ROOT, '..', '..', 'n8n-skills', 'skills'),
];
const SOURCE = process.env.N8N_SKILLS_SOURCE
? path.resolve(process.env.N8N_SKILLS_SOURCE)
: CANDIDATE_SOURCES.find((p) => existsSync(p)) ?? CANDIDATE_SOURCES[0];
const DEST = path.join(REPO_ROOT, 'data', 'skills');
async function copyMarkdownTree(src: string, dst: string): Promise<number> {
const entries = await fs.readdir(src, { withFileTypes: true });
let copied = 0;
await fs.mkdir(dst, { recursive: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const dstPath = path.join(dst, entry.name);
if (entry.isDirectory()) {
// Skill-creator eval workspaces (skills/*-workspace/) are local debris,
// untracked in n8n-skills and excluded from its dist builds — skip them.
if (entry.name.endsWith('-workspace')) continue;
copied += await copyMarkdownTree(srcPath, dstPath);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
await fs.copyFile(srcPath, dstPath);
copied++;
}
}
return copied;
}
async function clearDestination(dir: string): Promise<void> {
if (!existsSync(dir)) return;
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
await fs.rm(path.join(dir, entry.name), { recursive: true, force: true });
}
}
}
async function main(): Promise<void> {
console.log(`Syncing skills from: ${SOURCE}`);
console.log(` into: ${DEST}`);
if (!existsSync(SOURCE)) {
if (existsSync(DEST)) {
console.warn(`Source not found, keeping existing ${DEST} unchanged.`);
return;
}
console.error(`Source directory not found: ${SOURCE}`);
console.error('Set N8N_SKILLS_SOURCE or clone n8n-skills next to n8n-mcp.');
process.exit(1);
}
await clearDestination(DEST);
const count = await copyMarkdownTree(SOURCE, DEST);
console.log(`Synced ${count} markdown files.`);
}
main().catch((err) => {
console.error('sync-skills failed:', err);
process.exit(1);
});
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env node
/**
* Debug test for AI validation issues
* Reproduces the bugs found by n8n-mcp-tester
*/
import { validateAISpecificNodes, buildReverseConnectionMap } from '../src/services/ai-node-validator';
import type { WorkflowJson } from '../src/services/ai-tool-validators';
import { NodeTypeNormalizer } from '../src/utils/node-type-normalizer';
console.log('=== AI Validation Debug Tests ===\n');
// Test 1: AI Agent with NO language model connection
console.log('Test 1: Missing Language Model Detection');
const workflow1: WorkflowJson = {
name: 'Test Missing LM',
nodes: [
{
id: 'ai-agent-1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
position: [500, 300],
parameters: {
promptType: 'define',
text: 'You are a helpful assistant'
},
typeVersion: 1.7
}
],
connections: {
// NO connections - AI Agent is isolated
}
};
console.log('Workflow:', JSON.stringify(workflow1, null, 2));
const reverseMap1 = buildReverseConnectionMap(workflow1);
console.log('\nReverse connection map for AI Agent:');
console.log('Entries:', Array.from(reverseMap1.entries()));
console.log('AI Agent connections:', reverseMap1.get('AI Agent'));
// Check node normalization
const normalizedType1 = NodeTypeNormalizer.normalizeToFullForm(workflow1.nodes[0].type);
console.log(`\nNode type: ${workflow1.nodes[0].type}`);
console.log(`Normalized type: ${normalizedType1}`);
console.log(`Match check: ${normalizedType1 === '@n8n/n8n-nodes-langchain.agent'}`);
const issues1 = validateAISpecificNodes(workflow1);
console.log('\nValidation issues:');
console.log(JSON.stringify(issues1, null, 2));
const hasMissingLMError = issues1.some(
i => i.severity === 'error' && i.code === 'MISSING_LANGUAGE_MODEL'
);
console.log(`\n✓ Has MISSING_LANGUAGE_MODEL error: ${hasMissingLMError}`);
console.log(`✗ Expected: true, Got: ${hasMissingLMError}`);
// Test 2: AI Agent WITH language model connection
console.log('\n\n' + '='.repeat(60));
console.log('Test 2: AI Agent WITH Language Model (Should be valid)');
const workflow2: WorkflowJson = {
name: 'Test With LM',
nodes: [
{
id: 'openai-1',
name: 'OpenAI Chat Model',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
position: [200, 300],
parameters: {
modelName: 'gpt-4'
},
typeVersion: 1
},
{
id: 'ai-agent-1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
position: [500, 300],
parameters: {
promptType: 'define',
text: 'You are a helpful assistant'
},
typeVersion: 1.7
}
],
connections: {
'OpenAI Chat Model': {
ai_languageModel: [
[
{
node: 'AI Agent',
type: 'ai_languageModel',
index: 0
}
]
]
}
}
};
console.log('\nConnections:', JSON.stringify(workflow2.connections, null, 2));
const reverseMap2 = buildReverseConnectionMap(workflow2);
console.log('\nReverse connection map for AI Agent:');
console.log('AI Agent connections:', reverseMap2.get('AI Agent'));
const issues2 = validateAISpecificNodes(workflow2);
console.log('\nValidation issues:');
console.log(JSON.stringify(issues2, null, 2));
const hasMissingLMError2 = issues2.some(
i => i.severity === 'error' && i.code === 'MISSING_LANGUAGE_MODEL'
);
console.log(`\n✓ Should NOT have MISSING_LANGUAGE_MODEL error: ${!hasMissingLMError2}`);
console.log(`Expected: false, Got: ${hasMissingLMError2}`);
// Test 3: AI Agent with tools but no language model
console.log('\n\n' + '='.repeat(60));
console.log('Test 3: AI Agent with Tools but NO Language Model');
const workflow3: WorkflowJson = {
name: 'Test Tools No LM',
nodes: [
{
id: 'http-tool-1',
name: 'HTTP Request Tool',
type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
position: [200, 300],
parameters: {
toolDescription: 'Calls an API',
url: 'https://api.example.com'
},
typeVersion: 1.1
},
{
id: 'ai-agent-1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
position: [500, 300],
parameters: {
promptType: 'define',
text: 'You are a helpful assistant'
},
typeVersion: 1.7
}
],
connections: {
'HTTP Request Tool': {
ai_tool: [
[
{
node: 'AI Agent',
type: 'ai_tool',
index: 0
}
]
]
}
}
};
console.log('\nConnections:', JSON.stringify(workflow3.connections, null, 2));
const reverseMap3 = buildReverseConnectionMap(workflow3);
console.log('\nReverse connection map for AI Agent:');
const aiAgentConns = reverseMap3.get('AI Agent');
console.log('AI Agent connections:', aiAgentConns);
console.log('Connection types:', aiAgentConns?.map(c => c.type));
const issues3 = validateAISpecificNodes(workflow3);
console.log('\nValidation issues:');
console.log(JSON.stringify(issues3, null, 2));
const hasMissingLMError3 = issues3.some(
i => i.severity === 'error' && i.code === 'MISSING_LANGUAGE_MODEL'
);
const hasNoToolsInfo3 = issues3.some(
i => i.severity === 'info' && i.message.includes('no ai_tool connections')
);
console.log(`\n✓ Should have MISSING_LANGUAGE_MODEL error: ${hasMissingLMError3}`);
console.log(`Expected: true, Got: ${hasMissingLMError3}`);
console.log(`✗ Should NOT have "no tools" info: ${!hasNoToolsInfo3}`);
console.log(`Expected: false, Got: ${hasNoToolsInfo3}`);
console.log('\n' + '='.repeat(60));
console.log('Summary:');
console.log(`Test 1 (No LM): ${hasMissingLMError ? 'PASS ✓' : 'FAIL ✗'}`);
console.log(`Test 2 (With LM): ${!hasMissingLMError2 ? 'PASS ✓' : 'FAIL ✗'}`);
console.log(`Test 3 (Tools, No LM): ${hasMissingLMError3 && !hasNoToolsInfo3 ? 'PASS ✓' : 'FAIL ✗'}`);
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env npx tsx
/**
* Test script for Code node enhancements
* Tests:
* 1. Code node documentation in tools_documentation
* 2. Enhanced validation for Code nodes
* 3. Code node examples
* 4. Code node task templates
*/
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator.js';
import { ExampleGenerator } from '../src/services/example-generator.js';
import { TaskTemplates } from '../src/services/task-templates.js';
import { getToolDocumentation } from '../src/mcp/tools-documentation.js';
console.log('🧪 Testing Code Node Enhancements\n');
// Test 1: Code node documentation
console.log('1️⃣ Testing Code Node Documentation');
console.log('=====================================');
const codeNodeDocs = getToolDocumentation('code_node_guide', 'essentials');
console.log('✅ Code node documentation available');
console.log('First 500 chars:', codeNodeDocs.substring(0, 500) + '...\n');
// Test 2: Code node validation
console.log('2️⃣ Testing Code Node Validation');
console.log('=====================================');
// Test cases
const validationTests = [
{
name: 'Empty code',
config: {
language: 'javaScript',
jsCode: ''
}
},
{
name: 'No return statement',
config: {
language: 'javaScript',
jsCode: 'const data = items;'
}
},
{
name: 'Invalid return format',
config: {
language: 'javaScript',
jsCode: 'return "hello";'
}
},
{
name: 'Valid code',
config: {
language: 'javaScript',
jsCode: 'return [{json: {result: "success"}}];'
}
},
{
name: 'Python with external library',
config: {
language: 'python',
pythonCode: 'import pandas as pd\nreturn [{"json": {"result": "fail"}}]'
}
},
{
name: 'Code with $json in wrong mode',
config: {
language: 'javaScript',
jsCode: 'const value = $json.field;\nreturn [{json: {value}}];'
}
},
{
name: 'Code with security issue',
config: {
language: 'javaScript',
jsCode: 'const result = eval(item.json.code);\nreturn [{json: {result}}];'
}
}
];
for (const test of validationTests) {
console.log(`\nTest: ${test.name}`);
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.code',
test.config,
[
{ name: 'language', type: 'options', options: ['javaScript', 'python'] },
{ name: 'jsCode', type: 'string' },
{ name: 'pythonCode', type: 'string' },
{ name: 'mode', type: 'options', options: ['runOnceForAllItems', 'runOnceForEachItem'] }
],
'operation',
'ai-friendly'
);
console.log(` Valid: ${result.valid}`);
if (result.errors.length > 0) {
console.log(` Errors: ${result.errors.map(e => e.message).join(', ')}`);
}
if (result.warnings.length > 0) {
console.log(` Warnings: ${result.warnings.map(w => w.message).join(', ')}`);
}
if (result.suggestions.length > 0) {
console.log(` Suggestions: ${result.suggestions.join(', ')}`);
}
}
// Test 3: Code node examples
console.log('\n\n3️⃣ Testing Code Node Examples');
console.log('=====================================');
const codeExamples = ExampleGenerator.getExamples('nodes-base.code');
console.log('Available examples:', Object.keys(codeExamples));
console.log('\nMinimal example:');
console.log(JSON.stringify(codeExamples.minimal, null, 2));
console.log('\nCommon example preview:');
console.log(codeExamples.common?.jsCode?.substring(0, 200) + '...');
// Test 4: Code node task templates
console.log('\n\n4️⃣ Testing Code Node Task Templates');
console.log('=====================================');
const codeNodeTasks = [
'transform_data',
'custom_ai_tool',
'aggregate_data',
'batch_process_with_api',
'error_safe_transform',
'async_data_processing',
'python_data_analysis'
];
for (const taskName of codeNodeTasks) {
const template = TaskTemplates.getTemplate(taskName);
if (template) {
console.log(`\n✅ ${taskName}:`);
console.log(` Description: ${template.description}`);
console.log(` Language: ${template.configuration.language || 'javaScript'}`);
console.log(` Code preview: ${template.configuration.jsCode?.substring(0, 100) || template.configuration.pythonCode?.substring(0, 100)}...`);
} else {
console.log(`\n❌ ${taskName}: Template not found`);
}
}
// Test 5: Validate a complex Code node configuration
console.log('\n\n5️⃣ Testing Complex Code Node Validation');
console.log('==========================================');
const complexCode = {
language: 'javaScript',
mode: 'runOnceForEachItem',
jsCode: `// Complex validation test
try {
const email = $json.email;
const response = await $helpers.httpRequest({
method: 'POST',
url: 'https://api.example.com/validate',
body: { email }
});
return [{
json: {
...response,
validated: true
}
}];
} catch (error) {
return [{
json: {
error: error.message,
validated: false
}
}];
}`,
onError: 'continueRegularOutput',
retryOnFail: true,
maxTries: 3
};
const complexResult = EnhancedConfigValidator.validateWithMode(
'nodes-base.code',
complexCode,
[
{ name: 'language', type: 'options', options: ['javaScript', 'python'] },
{ name: 'jsCode', type: 'string' },
{ name: 'mode', type: 'options', options: ['runOnceForAllItems', 'runOnceForEachItem'] },
{ name: 'onError', type: 'options' },
{ name: 'retryOnFail', type: 'boolean' },
{ name: 'maxTries', type: 'number' }
],
'operation',
'strict'
);
console.log('Complex code validation:');
console.log(` Valid: ${complexResult.valid}`);
console.log(` Errors: ${complexResult.errors.length}`);
console.log(` Warnings: ${complexResult.warnings.length}`);
console.log(` Suggestions: ${complexResult.suggestions.length}`);
console.log('\n✅ All Code node enhancement tests completed!');
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env ts-node
/**
* Test script to verify Code node documentation fixes
*/
import { createDatabaseAdapter } from '../src/database/database-adapter';
import { NodeDocumentationService } from '../src/services/node-documentation-service';
import { getToolDocumentation } from '../src/mcp/tools-documentation';
import { ExampleGenerator } from '../src/services/example-generator';
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
const dbPath = process.env.NODE_DB_PATH || './data/nodes.db';
async function main() {
console.log('🧪 Testing Code Node Documentation Fixes\n');
const db = await createDatabaseAdapter(dbPath);
const service = new NodeDocumentationService(dbPath);
// Test 1: Check JMESPath documentation
console.log('1️⃣ Testing JMESPath Documentation Fix');
console.log('=====================================');
const codeNodeGuide = getToolDocumentation('code_node_guide', 'full');
// Check for correct JMESPath syntax
if (codeNodeGuide.includes('$jmespath(') && !codeNodeGuide.includes('jmespath.search(')) {
console.log('✅ JMESPath documentation correctly shows $jmespath() syntax');
} else {
console.log('❌ JMESPath documentation still shows incorrect syntax');
}
// Check for Python JMESPath
if (codeNodeGuide.includes('_jmespath(')) {
console.log('✅ Python JMESPath with underscore prefix documented');
} else {
console.log('❌ Python JMESPath not properly documented');
}
// Test 2: Check $node documentation
console.log('\n2️⃣ Testing $node Documentation Fix');
console.log('===================================');
if (codeNodeGuide.includes("$('Previous Node')") && !codeNodeGuide.includes('$node.name')) {
console.log('✅ Node access correctly shows $("Node Name") syntax');
} else {
console.log('❌ Node access documentation still incorrect');
}
// Test 3: Check Python item.json documentation
console.log('\n3️⃣ Testing Python item.json Documentation Fix');
console.log('==============================================');
if (codeNodeGuide.includes('item.json.to_py()') && codeNodeGuide.includes('JsProxy')) {
console.log('✅ Python item.json correctly documented with to_py() method');
} else {
console.log('❌ Python item.json documentation incomplete');
}
// Test 4: Check Python examples
console.log('\n4️⃣ Testing Python Examples');
console.log('===========================');
const pythonExample = ExampleGenerator.getExamples('nodes-base.code.pythonExample');
if (pythonExample?.minimal?.pythonCode?.includes('_input.all()') &&
pythonExample?.minimal?.pythonCode?.includes('to_py()')) {
console.log('✅ Python examples use correct _input.all() and to_py()');
} else {
console.log('❌ Python examples not updated correctly');
}
// Test 5: Validate Code node without visibility warnings
console.log('\n5️⃣ Testing Code Node Validation (No Visibility Warnings)');
console.log('=========================================================');
const codeNodeInfo = await service.getNodeInfo('n8n-nodes-base.code');
if (!codeNodeInfo) {
console.log('❌ Could not find Code node info');
return;
}
const testConfig = {
language: 'javaScript',
jsCode: 'return items.map(item => ({json: {...item.json, processed: true}}))',
mode: 'runOnceForAllItems',
onError: 'continueRegularOutput'
};
const nodeProperties = (codeNodeInfo as any).properties || [];
const validationResult = EnhancedConfigValidator.validateWithMode(
'nodes-base.code',
testConfig,
nodeProperties,
'full',
'ai-friendly'
);
// Check if there are any visibility warnings
const visibilityWarnings = validationResult.warnings.filter(w =>
w.message.includes("won't be used due to current settings")
);
if (visibilityWarnings.length === 0) {
console.log('✅ No false positive visibility warnings for Code node');
} else {
console.log(`❌ Still getting ${visibilityWarnings.length} visibility warnings:`);
visibilityWarnings.forEach(w => console.log(` - ${w.property}: ${w.message}`));
}
// Test 6: Check Python underscore variables in documentation
console.log('\n6️⃣ Testing Python Underscore Variables');
console.log('========================================');
const pythonVarsDocumented = codeNodeGuide.includes('Variables use underscore prefix') &&
codeNodeGuide.includes('_input') &&
codeNodeGuide.includes('_json') &&
codeNodeGuide.includes('_jmespath');
if (pythonVarsDocumented) {
console.log('✅ Python underscore variables properly documented');
} else {
console.log('❌ Python underscore variables not fully documented');
}
// Summary
console.log('\n📊 Test Summary');
console.log('===============');
console.log('All critical documentation fixes have been verified!');
db.close();
}
main().catch(console.error);
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Script to run Docker config tests
# Usage: ./scripts/test-docker-config.sh [unit|integration|all]
set -e
MODE=${1:-all}
echo "Running Docker config tests in mode: $MODE"
case $MODE in
unit)
echo "Running unit tests..."
npm test -- tests/unit/docker/
;;
integration)
echo "Running integration tests (requires Docker)..."
RUN_DOCKER_TESTS=true npm run test:integration -- tests/integration/docker/
;;
all)
echo "Running all Docker config tests..."
npm test -- tests/unit/docker/
if command -v docker &> /dev/null; then
echo "Docker found, running integration tests..."
RUN_DOCKER_TESTS=true npm run test:integration -- tests/integration/docker/
else
echo "Docker not found, skipping integration tests"
fi
;;
coverage)
echo "Running Docker config tests with coverage..."
npm run test:coverage -- tests/unit/docker/
;;
security)
echo "Running security-focused tests..."
npm test -- tests/unit/docker/config-security.test.ts tests/unit/docker/parse-config.test.ts
;;
*)
echo "Usage: $0 [unit|integration|all|coverage|security]"
exit 1
;;
esac
echo "Docker config tests completed!"
+169
View File
@@ -0,0 +1,169 @@
/**
* Test Docker Host Fingerprinting
* Verifies that host machine characteristics are stable across container recreations
*/
import { existsSync, readFileSync } from 'fs';
import { platform, arch } from 'os';
import { createHash } from 'crypto';
console.log('=== Docker Host Fingerprinting Test ===\n');
function generateHostFingerprint(): string {
try {
const signals: string[] = [];
console.log('Collecting host signals...\n');
// CPU info (stable across container recreations)
if (existsSync('/proc/cpuinfo')) {
const cpuinfo = readFileSync('/proc/cpuinfo', 'utf-8');
const modelMatch = cpuinfo.match(/model name\s*:\s*(.+)/);
const coresMatch = cpuinfo.match(/processor\s*:/g);
if (modelMatch) {
const cpuModel = modelMatch[1].trim();
signals.push(cpuModel);
console.log('✓ CPU Model:', cpuModel);
}
if (coresMatch) {
const cores = `cores:${coresMatch.length}`;
signals.push(cores);
console.log('✓ CPU Cores:', coresMatch.length);
}
} else {
console.log('✗ /proc/cpuinfo not available (Windows/Mac Docker)');
}
// Memory (stable)
if (existsSync('/proc/meminfo')) {
const meminfo = readFileSync('/proc/meminfo', 'utf-8');
const totalMatch = meminfo.match(/MemTotal:\s+(\d+)/);
if (totalMatch) {
const memory = `mem:${totalMatch[1]}`;
signals.push(memory);
console.log('✓ Total Memory:', totalMatch[1], 'kB');
}
} else {
console.log('✗ /proc/meminfo not available (Windows/Mac Docker)');
}
// Docker network subnet
const networkInfo = getDockerNetworkInfo();
if (networkInfo) {
signals.push(networkInfo);
console.log('✓ Network Info:', networkInfo);
} else {
console.log('✗ Network info not available');
}
// Platform basics (stable)
signals.push(platform(), arch());
console.log('✓ Platform:', platform());
console.log('✓ Architecture:', arch());
// Generate stable ID from all signals
console.log('\nCombined signals:', signals.join(' | '));
const fingerprint = signals.join('-');
const userId = createHash('sha256').update(fingerprint).digest('hex').substring(0, 16);
return userId;
} catch (error) {
console.error('Error generating fingerprint:', error);
// Fallback
return createHash('sha256')
.update(`${platform()}-${arch()}-docker`)
.digest('hex')
.substring(0, 16);
}
}
function getDockerNetworkInfo(): string | null {
try {
// Read routing table to get bridge network
if (existsSync('/proc/net/route')) {
const routes = readFileSync('/proc/net/route', 'utf-8');
const lines = routes.split('\n');
for (const line of lines) {
if (line.includes('eth0')) {
const parts = line.split(/\s+/);
if (parts[2]) {
const gateway = parseInt(parts[2], 16).toString(16);
return `net:${gateway}`;
}
}
}
}
} catch {
// Ignore errors
}
return null;
}
// Test environment detection
console.log('\n=== Environment Detection ===\n');
const isDocker = process.env.IS_DOCKER === 'true';
const isCloudEnvironment = !!(
process.env.RAILWAY_ENVIRONMENT ||
process.env.RENDER ||
process.env.FLY_APP_NAME ||
process.env.HEROKU_APP_NAME ||
process.env.AWS_EXECUTION_ENV ||
process.env.KUBERNETES_SERVICE_HOST
);
console.log('IS_DOCKER env:', process.env.IS_DOCKER);
console.log('Docker detected:', isDocker);
console.log('Cloud environment:', isCloudEnvironment);
// Generate fingerprints
console.log('\n=== Fingerprint Generation ===\n');
const fingerprint1 = generateHostFingerprint();
const fingerprint2 = generateHostFingerprint();
const fingerprint3 = generateHostFingerprint();
console.log('\nFingerprint 1:', fingerprint1);
console.log('Fingerprint 2:', fingerprint2);
console.log('Fingerprint 3:', fingerprint3);
const consistent = fingerprint1 === fingerprint2 && fingerprint2 === fingerprint3;
console.log('\nConsistent:', consistent ? '✓ YES' : '✗ NO');
// Test explicit ID override
console.log('\n=== Environment Variable Override Test ===\n');
if (process.env.N8N_MCP_USER_ID) {
// Intentionally log nothing derived from the env value — not even a
// masked prefix, because CodeQL's taint tracking follows string
// slicing and any substring still counts as "sensitive data in
// clear text". The only signal the developer needs here is "an
// override is set", which is what this line conveys.
// Addresses CodeQL js/clear-text-logging.
console.log('Explicit user ID is set (value redacted)');
console.log('This would override the fingerprint');
} else {
console.log('No explicit user ID set');
console.log('To test: N8N_MCP_USER_ID=my-custom-id npx tsx ' + process.argv[1]);
}
// Stability estimate
console.log('\n=== Stability Analysis ===\n');
const hasStableSignals = existsSync('/proc/cpuinfo') || existsSync('/proc/meminfo');
if (hasStableSignals) {
console.log('✓ Host-based signals available');
console.log('✓ Fingerprint should be stable across container recreations');
console.log('✓ Different fingerprints on different physical hosts');
} else {
console.log('⚠️ Limited host signals (Windows/Mac Docker Desktop)');
console.log('⚠️ Fingerprint may not be fully stable');
console.log('💡 Recommendation: Use N8N_MCP_USER_ID env var for stability');
}
console.log('\n');
+81
View File
@@ -0,0 +1,81 @@
#!/bin/bash
# Test script to verify Docker optimization (no n8n deps)
set -e
echo "🧪 Testing Docker optimization..."
echo ""
# Check if nodes.db exists
if [ ! -f "data/nodes.db" ]; then
echo "❌ ERROR: data/nodes.db not found!"
echo " Run 'npm run rebuild' first to create the database"
exit 1
fi
# Build the image
echo "📦 Building Docker image..."
DOCKER_BUILDKIT=1 docker build -t n8n-mcp:test . > /dev/null 2>&1
# Check image size
echo "📊 Checking image size..."
SIZE=$(docker images n8n-mcp:test --format "{{.Size}}")
echo " Image size: $SIZE"
# Test that n8n is NOT in the image
echo ""
echo "🔍 Verifying no n8n dependencies..."
if docker run --rm n8n-mcp:test sh -c "ls node_modules | grep -E '^n8n$|^n8n-|^@n8n'" 2>/dev/null; then
echo "❌ ERROR: Found n8n dependencies in runtime image!"
exit 1
else
echo "✅ No n8n dependencies found (as expected)"
fi
# Test that runtime dependencies ARE present
echo ""
echo "🔍 Verifying runtime dependencies..."
EXPECTED_DEPS=("@modelcontextprotocol" "better-sqlite3" "express" "dotenv")
for dep in "${EXPECTED_DEPS[@]}"; do
if docker run --rm n8n-mcp:test sh -c "ls node_modules | grep -q '$dep'" 2>/dev/null; then
echo "✅ Found: $dep"
else
echo "❌ Missing: $dep"
exit 1
fi
done
# Test that the server starts
echo ""
echo "🚀 Testing server startup..."
docker run --rm -d \
--name n8n-mcp-test \
-e MCP_MODE=http \
-e AUTH_TOKEN=test-token \
-e LOG_LEVEL=error \
n8n-mcp:test > /dev/null 2>&1
# Wait for startup
sleep 3
# Check if running
if docker ps | grep -q n8n-mcp-test; then
echo "✅ Server started successfully"
docker stop n8n-mcp-test > /dev/null 2>&1
else
echo "❌ Server failed to start"
docker logs n8n-mcp-test 2>&1
exit 1
fi
# Clean up
docker rmi n8n-mcp:test > /dev/null 2>&1
echo ""
echo "🎉 All tests passed! Docker optimization is working correctly."
echo ""
echo "📈 Benefits:"
echo " - No n8n dependencies in runtime image"
echo " - Image size: ~200MB (vs ~1.5GB with n8n)"
echo " - Build time: ~1-2 minutes (vs ~12 minutes)"
echo " - No version conflicts at runtime"
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# scripts/test-docker.sh
echo "🧪 Testing n8n-MCP Docker Deployment"
# Test 1: Build simple image
echo "1. Building simple Docker image..."
docker build -t n8n-mcp:test .
# Test 2: Test stdio mode
echo "2. Testing stdio mode..."
echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | \
docker run --rm -i -e MCP_MODE=stdio n8n-mcp:test
# Test 3: Test HTTP mode
echo "3. Testing HTTP mode..."
docker run -d --name test-http \
-e MCP_MODE=http \
-e AUTH_TOKEN=test-token \
-p 3001:3000 \
n8n-mcp:test
sleep 5
# Check health
curl -f http://localhost:3001/health || echo "Health check failed"
# Test auth
curl -H "Authorization: Bearer test-token" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
http://localhost:3001/mcp
docker stop test-http && docker rm test-http
# Test 4: Volume persistence
echo "4. Testing volume persistence..."
docker volume create test-data
docker run -d --name test-persist \
-v test-data:/app/data \
-e MCP_MODE=http \
-e AUTH_TOKEN=test \
-p 3002:3000 \
n8n-mcp:test
sleep 10
docker exec test-persist ls -la /app/data/nodes.db
docker stop test-persist && docker rm test-persist
docker volume rm test-data
echo "✅ Docker tests completed!"
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env tsx
/**
* Test script for empty connection validation
* Tests the improvements to prevent broken workflows like the one in the logs
*/
import { WorkflowValidator } from '../src/services/workflow-validator';
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
import { NodeRepository } from '../src/database/node-repository';
import { createDatabaseAdapter } from '../src/database/database-adapter';
import { validateWorkflowStructure, getWorkflowFixSuggestions, getWorkflowStructureExample } from '../src/services/n8n-validation';
import { Logger } from '../src/utils/logger';
const logger = new Logger({ prefix: '[TestEmptyConnectionValidation]' });
async function testValidation() {
const adapter = await createDatabaseAdapter('./data/nodes.db');
const repository = new NodeRepository(adapter);
const validator = new WorkflowValidator(repository, EnhancedConfigValidator);
logger.info('Testing empty connection validation...\n');
// Test 1: The broken workflow from the logs
const brokenWorkflow = {
"nodes": [
{
"parameters": {},
"id": "webhook_node",
"name": "Webhook",
"type": "nodes-base.webhook",
"typeVersion": 2,
"position": [260, 300] as [number, number]
}
],
"connections": {},
"pinData": {},
"meta": {
"instanceId": "74e11c77e266f2c77f6408eb6c88e3fec63c9a5d8c4a3a2ea4c135c542012d6b"
}
};
logger.info('Test 1: Broken single-node workflow with empty connections');
const result1 = await validator.validateWorkflow(brokenWorkflow as any);
logger.info('Validation result:');
logger.info(`Valid: ${result1.valid}`);
logger.info(`Errors: ${result1.errors.length}`);
result1.errors.forEach(err => {
if (typeof err === 'string') {
logger.error(` - ${err}`);
} else if (err && typeof err === 'object' && 'message' in err) {
logger.error(` - ${err.message}`);
} else {
logger.error(` - ${JSON.stringify(err)}`);
}
});
logger.info(`Warnings: ${result1.warnings.length}`);
result1.warnings.forEach(warn => logger.warn(` - ${warn.message || JSON.stringify(warn)}`));
logger.info(`Suggestions: ${result1.suggestions.length}`);
result1.suggestions.forEach(sug => logger.info(` - ${sug}`));
// Test 2: Multi-node workflow with no connections
const multiNodeNoConnections = {
"name": "Test Workflow",
"nodes": [
{
"id": "manual-1",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [250, 300] as [number, number],
"parameters": {}
},
{
"id": "set-1",
"name": "Set",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [450, 300] as [number, number],
"parameters": {}
}
],
"connections": {}
};
logger.info('\nTest 2: Multi-node workflow with empty connections');
const result2 = await validator.validateWorkflow(multiNodeNoConnections as any);
logger.info('Validation result:');
logger.info(`Valid: ${result2.valid}`);
logger.info(`Errors: ${result2.errors.length}`);
result2.errors.forEach(err => logger.error(` - ${err.message || JSON.stringify(err)}`));
logger.info(`Suggestions: ${result2.suggestions.length}`);
result2.suggestions.forEach(sug => logger.info(` - ${sug}`));
// Test 3: Using n8n-validation functions
logger.info('\nTest 3: Testing n8n-validation.ts functions');
const errors = validateWorkflowStructure(brokenWorkflow as any);
logger.info('Validation errors:');
errors.forEach(err => logger.error(` - ${err}`));
const suggestions = getWorkflowFixSuggestions(errors);
logger.info('Fix suggestions:');
suggestions.forEach(sug => logger.info(` - ${sug}`));
logger.info('\nExample of proper workflow structure:');
logger.info(getWorkflowStructureExample());
// Test 4: Workflow using IDs instead of names in connections
const workflowWithIdConnections = {
"name": "Test Workflow",
"nodes": [
{
"id": "manual-1",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [250, 300] as [number, number],
"parameters": {}
},
{
"id": "set-1",
"name": "Set Data",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [450, 300] as [number, number],
"parameters": {}
}
],
"connections": {
"manual-1": { // Using ID instead of name!
"main": [[{
"node": "set-1", // Using ID instead of name!
"type": "main",
"index": 0
}]]
}
}
};
logger.info('\nTest 4: Workflow using IDs instead of names in connections');
const result4 = await validator.validateWorkflow(workflowWithIdConnections as any);
logger.info('Validation result:');
logger.info(`Valid: ${result4.valid}`);
logger.info(`Errors: ${result4.errors.length}`);
result4.errors.forEach(err => logger.error(` - ${err.message || JSON.stringify(err)}`));
adapter.close();
}
testValidation().catch(err => {
logger.error('Test failed:', err);
process.exit(1);
});
+58
View File
@@ -0,0 +1,58 @@
/**
* Test script to verify error message tracking is working
*/
import { telemetry } from '../src/telemetry';
async function testErrorTracking() {
console.log('=== Testing Error Message Tracking ===\n');
// Track session first
console.log('1. Starting session...');
telemetry.trackSessionStart();
// Track an error WITH a message
console.log('\n2. Tracking error WITH message:');
const testErrorMessage = 'This is a test error message with sensitive data: password=secret123 and test@example.com';
telemetry.trackError(
'TypeError',
'tool_execution',
'test_tool',
testErrorMessage
);
console.log(` Original message: "${testErrorMessage}"`);
// Track an error WITHOUT a message
console.log('\n3. Tracking error WITHOUT message:');
telemetry.trackError(
'Error',
'tool_execution',
'test_tool2'
);
// Check the event queue
const metrics = telemetry.getMetrics();
console.log('\n4. Telemetry metrics:');
console.log(' Status:', metrics.status);
console.log(' Events queued:', metrics.tracking.eventsQueued);
// Get raw event queue to inspect
const eventTracker = (telemetry as any).eventTracker;
const queue = eventTracker.getEventQueue();
console.log('\n5. Event queue contents:');
queue.forEach((event, i) => {
console.log(`\n Event ${i + 1}:`);
console.log(` - Type: ${event.event}`);
console.log(` - Properties:`, JSON.stringify(event.properties, null, 6));
});
// Flush to database
console.log('\n6. Flushing to database...');
await telemetry.flush();
console.log('\n7. Done! Check Supabase for error events with "error" field.');
console.log(' Query: SELECT * FROM telemetry_events WHERE event = \'error_occurred\' ORDER BY created_at DESC LIMIT 5;');
}
testErrorTracking().catch(console.error);
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env npx tsx
/**
* Test script for error output validation improvements
* Tests both incorrect and correct error output configurations
*/
import { WorkflowValidator } from '../dist/services/workflow-validator.js';
import { NodeRepository } from '../dist/database/node-repository.js';
import { EnhancedConfigValidator } from '../dist/services/enhanced-config-validator.js';
import { DatabaseAdapter } from '../dist/database/database-adapter.js';
import { Logger } from '../dist/utils/logger.js';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const logger = new Logger({ prefix: '[TestErrorValidation]' });
async function runTests() {
// Initialize database
const dbPath = path.join(__dirname, '..', 'data', 'n8n-nodes.db');
const adapter = new DatabaseAdapter();
adapter.initialize({
type: 'better-sqlite3',
filename: dbPath
});
const db = adapter.getDatabase();
const nodeRepository = new NodeRepository(db);
const validator = new WorkflowValidator(nodeRepository, EnhancedConfigValidator);
console.log('\n🧪 Testing Error Output Validation Improvements\n');
console.log('=' .repeat(60));
// Test 1: Incorrect configuration - multiple nodes in same array
console.log('\n📝 Test 1: INCORRECT - Multiple nodes in main[0]');
console.log('-'.repeat(40));
const incorrectWorkflow = {
nodes: [
{
id: '132ef0dc-87af-41de-a95d-cabe3a0a5342',
name: 'Validate Input',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [-400, 64] as [number, number],
parameters: {}
},
{
id: '5dedf217-63f9-409f-b34e-7780b22e199a',
name: 'Filter URLs',
type: 'n8n-nodes-base.filter',
typeVersion: 2.2,
position: [-176, 64] as [number, number],
parameters: {}
},
{
id: '9d5407cc-ca5a-4966-b4b7-0e5dfbf54ad3',
name: 'Error Response1',
type: 'n8n-nodes-base.respondToWebhook',
typeVersion: 1.5,
position: [-160, 240] as [number, number],
parameters: {}
}
],
connections: {
'Validate Input': {
main: [
[
{ node: 'Filter URLs', type: 'main', index: 0 },
{ node: 'Error Response1', type: 'main', index: 0 } // WRONG!
]
]
}
}
};
const result1 = await validator.validateWorkflow(incorrectWorkflow);
if (result1.errors.length > 0) {
console.log('❌ ERROR DETECTED (as expected):');
const errorMessage = result1.errors.find(e =>
e.message.includes('Incorrect error output configuration')
);
if (errorMessage) {
console.log('\n' + errorMessage.message);
}
} else {
console.log('✅ No errors found (but should have detected the issue!)');
}
// Test 2: Correct configuration - separate arrays
console.log('\n📝 Test 2: CORRECT - Separate main[0] and main[1]');
console.log('-'.repeat(40));
const correctWorkflow = {
nodes: [
{
id: '132ef0dc-87af-41de-a95d-cabe3a0a5342',
name: 'Validate Input',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [-400, 64] as [number, number],
parameters: {},
onError: 'continueErrorOutput' as const
},
{
id: '5dedf217-63f9-409f-b34e-7780b22e199a',
name: 'Filter URLs',
type: 'n8n-nodes-base.filter',
typeVersion: 2.2,
position: [-176, 64] as [number, number],
parameters: {}
},
{
id: '9d5407cc-ca5a-4966-b4b7-0e5dfbf54ad3',
name: 'Error Response1',
type: 'n8n-nodes-base.respondToWebhook',
typeVersion: 1.5,
position: [-160, 240] as [number, number],
parameters: {}
}
],
connections: {
'Validate Input': {
main: [
[
{ node: 'Filter URLs', type: 'main', index: 0 }
],
[
{ node: 'Error Response1', type: 'main', index: 0 } // CORRECT!
]
]
}
}
};
const result2 = await validator.validateWorkflow(correctWorkflow);
const hasIncorrectError = result2.errors.some(e =>
e.message.includes('Incorrect error output configuration')
);
if (!hasIncorrectError) {
console.log('✅ No error output configuration issues (correct!)');
} else {
console.log('❌ Unexpected error found');
}
// Test 3: onError without error connections
console.log('\n📝 Test 3: onError without error connections');
console.log('-'.repeat(40));
const mismatchWorkflow = {
nodes: [
{
id: '1',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4,
position: [100, 100] as [number, number],
parameters: {},
onError: 'continueErrorOutput' as const
},
{
id: '2',
name: 'Process Data',
type: 'n8n-nodes-base.set',
typeVersion: 2,
position: [300, 100] as [number, number],
parameters: {}
}
],
connections: {
'HTTP Request': {
main: [
[
{ node: 'Process Data', type: 'main', index: 0 }
]
// No main[1] for error output
]
}
}
};
const result3 = await validator.validateWorkflow(mismatchWorkflow);
const mismatchError = result3.errors.find(e =>
e.message.includes("has onError: 'continueErrorOutput' but no error output connections")
);
if (mismatchError) {
console.log('❌ ERROR DETECTED (as expected):');
console.log(`Node: ${mismatchError.nodeName}`);
console.log(`Message: ${mismatchError.message}`);
} else {
console.log('✅ No mismatch detected (but should have!)');
}
// Test 4: Error connections without onError
console.log('\n📝 Test 4: Error connections without onError property');
console.log('-'.repeat(40));
const missingOnErrorWorkflow = {
nodes: [
{
id: '1',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4,
position: [100, 100] as [number, number],
parameters: {}
// Missing onError property
},
{
id: '2',
name: 'Process Data',
type: 'n8n-nodes-base.set',
position: [300, 100] as [number, number],
parameters: {}
},
{
id: '3',
name: 'Error Handler',
type: 'n8n-nodes-base.set',
position: [300, 300] as [number, number],
parameters: {}
}
],
connections: {
'HTTP Request': {
main: [
[
{ node: 'Process Data', type: 'main', index: 0 }
],
[
{ node: 'Error Handler', type: 'main', index: 0 }
]
]
}
}
};
const result4 = await validator.validateWorkflow(missingOnErrorWorkflow);
const missingOnErrorWarning = result4.warnings.find(w =>
w.message.includes('error output connections in main[1] but missing onError')
);
if (missingOnErrorWarning) {
console.log('⚠️ WARNING DETECTED (as expected):');
console.log(`Node: ${missingOnErrorWarning.nodeName}`);
console.log(`Message: ${missingOnErrorWarning.message}`);
} else {
console.log('✅ No warning (but should have warned!)');
}
console.log('\n' + '='.repeat(60));
console.log('\n📊 Summary:');
console.log('- Error output validation is working correctly');
console.log('- Detects incorrect configurations (multiple nodes in main[0])');
console.log('- Validates onError property matches connections');
console.log('- Provides clear error messages with fix examples');
// Close database
adapter.close();
}
runTests().catch(error => {
console.error('Test failed:', error);
process.exit(1);
});
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
/**
* Test script for error output validation improvements
*/
const { WorkflowValidator } = require('../dist/services/workflow-validator.js');
const { NodeRepository } = require('../dist/database/node-repository.js');
const { EnhancedConfigValidator } = require('../dist/services/enhanced-config-validator.js');
const Database = require('better-sqlite3');
const path = require('path');
async function runTests() {
// Initialize database
const dbPath = path.join(__dirname, '..', 'data', 'nodes.db');
const db = new Database(dbPath, { readonly: true });
const nodeRepository = new NodeRepository(db);
const validator = new WorkflowValidator(nodeRepository, EnhancedConfigValidator);
console.log('\n🧪 Testing Error Output Validation Improvements\n');
console.log('=' .repeat(60));
// Test 1: Incorrect configuration - multiple nodes in same array
console.log('\n📝 Test 1: INCORRECT - Multiple nodes in main[0]');
console.log('-'.repeat(40));
const incorrectWorkflow = {
nodes: [
{
id: '132ef0dc-87af-41de-a95d-cabe3a0a5342',
name: 'Validate Input',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [-400, 64],
parameters: {}
},
{
id: '5dedf217-63f9-409f-b34e-7780b22e199a',
name: 'Filter URLs',
type: 'n8n-nodes-base.filter',
typeVersion: 2.2,
position: [-176, 64],
parameters: {}
},
{
id: '9d5407cc-ca5a-4966-b4b7-0e5dfbf54ad3',
name: 'Error Response1',
type: 'n8n-nodes-base.respondToWebhook',
typeVersion: 1.5,
position: [-160, 240],
parameters: {}
}
],
connections: {
'Validate Input': {
main: [
[
{ node: 'Filter URLs', type: 'main', index: 0 },
{ node: 'Error Response1', type: 'main', index: 0 } // WRONG!
]
]
}
}
};
const result1 = await validator.validateWorkflow(incorrectWorkflow);
if (result1.errors.length > 0) {
console.log('❌ ERROR DETECTED (as expected):');
const errorMessage = result1.errors.find(e =>
e.message.includes('Incorrect error output configuration')
);
if (errorMessage) {
console.log('\nError Summary:');
console.log(`Node: ${errorMessage.nodeName || 'Validate Input'}`);
console.log('\nFull Error Message:');
console.log(errorMessage.message);
} else {
console.log('Other errors found:', result1.errors.map(e => e.message));
}
} else {
console.log('⚠️ No errors found - validation may not be working correctly');
}
// Test 2: Correct configuration - separate arrays
console.log('\n📝 Test 2: CORRECT - Separate main[0] and main[1]');
console.log('-'.repeat(40));
const correctWorkflow = {
nodes: [
{
id: '132ef0dc-87af-41de-a95d-cabe3a0a5342',
name: 'Validate Input',
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
position: [-400, 64],
parameters: {},
onError: 'continueErrorOutput'
},
{
id: '5dedf217-63f9-409f-b34e-7780b22e199a',
name: 'Filter URLs',
type: 'n8n-nodes-base.filter',
typeVersion: 2.2,
position: [-176, 64],
parameters: {}
},
{
id: '9d5407cc-ca5a-4966-b4b7-0e5dfbf54ad3',
name: 'Error Response1',
type: 'n8n-nodes-base.respondToWebhook',
typeVersion: 1.5,
position: [-160, 240],
parameters: {}
}
],
connections: {
'Validate Input': {
main: [
[
{ node: 'Filter URLs', type: 'main', index: 0 }
],
[
{ node: 'Error Response1', type: 'main', index: 0 } // CORRECT!
]
]
}
}
};
const result2 = await validator.validateWorkflow(correctWorkflow);
const hasIncorrectError = result2.errors.some(e =>
e.message.includes('Incorrect error output configuration')
);
if (!hasIncorrectError) {
console.log('✅ No error output configuration issues (correct!)');
} else {
console.log('❌ Unexpected error found');
}
console.log('\n' + '='.repeat(60));
console.log('\n✨ Error output validation is working correctly!');
console.log('The validator now properly detects:');
console.log(' 1. Multiple nodes incorrectly placed in main[0]');
console.log(' 2. Provides clear JSON examples for fixing issues');
console.log(' 3. Validates onError property matches connections');
// Close database
db.close();
}
runTests().catch(error => {
console.error('Test failed:', error);
process.exit(1);
});
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env ts-node
/**
* Test script for validating the get_node_essentials tool
*
* This script:
* 1. Compares get_node_essentials vs get_node_info response sizes
* 2. Validates that essential properties are correctly extracted
* 3. Checks that examples are properly generated
* 4. Tests the property search functionality
*/
import { N8NDocumentationMCPServer } from '../src/mcp/server';
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
// Color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m'
};
function log(message: string, color: string = colors.reset) {
console.log(`${color}${message}${colors.reset}`);
}
function logSection(title: string) {
console.log('\n' + '='.repeat(60));
log(title, colors.bright + colors.cyan);
console.log('='.repeat(60));
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
const kb = bytes / 1024;
if (kb < 1024) return kb.toFixed(1) + ' KB';
const mb = kb / 1024;
return mb.toFixed(2) + ' MB';
}
async function testNodeEssentials(server: N8NDocumentationMCPServer, nodeType: string) {
logSection(`Testing ${nodeType}`);
try {
// Get full node info
const startFull = Date.now();
const fullInfo = await server.executeTool('get_node_info', { nodeType });
const fullTime = Date.now() - startFull;
const fullSize = JSON.stringify(fullInfo).length;
// Get essential info
const startEssential = Date.now();
const essentialInfo = await server.executeTool('get_node_essentials', { nodeType });
const essentialTime = Date.now() - startEssential;
const essentialSize = JSON.stringify(essentialInfo).length;
// Calculate metrics
const sizeReduction = ((fullSize - essentialSize) / fullSize * 100).toFixed(1);
const speedImprovement = ((fullTime - essentialTime) / fullTime * 100).toFixed(1);
// Display results
log(`\n📊 Size Comparison:`, colors.bright);
log(` Full response: ${formatBytes(fullSize)}`, colors.yellow);
log(` Essential response: ${formatBytes(essentialSize)}`, colors.green);
log(` Size reduction: ${sizeReduction}% ✨`, colors.bright + colors.green);
log(`\n⚡ Performance:`, colors.bright);
log(` Full response time: ${fullTime}ms`);
log(` Essential response time: ${essentialTime}ms`);
log(` Speed improvement: ${speedImprovement}%`, colors.green);
log(`\n📋 Property Count:`, colors.bright);
const fullPropCount = fullInfo.properties?.length || 0;
const essentialPropCount = (essentialInfo.requiredProperties?.length || 0) +
(essentialInfo.commonProperties?.length || 0);
log(` Full properties: ${fullPropCount}`);
log(` Essential properties: ${essentialPropCount}`);
log(` Properties removed: ${fullPropCount - essentialPropCount} (${((fullPropCount - essentialPropCount) / fullPropCount * 100).toFixed(1)}%)`, colors.green);
log(`\n🔧 Essential Properties:`, colors.bright);
log(` Required: ${essentialInfo.requiredProperties?.map((p: any) => p.name).join(', ') || 'None'}`);
log(` Common: ${essentialInfo.commonProperties?.map((p: any) => p.name).join(', ') || 'None'}`);
log(`\n📚 Examples:`, colors.bright);
const examples = Object.keys(essentialInfo.examples || {});
log(` Available examples: ${examples.join(', ') || 'None'}`);
if (essentialInfo.examples?.minimal) {
log(` Minimal example properties: ${Object.keys(essentialInfo.examples.minimal).join(', ')}`);
}
log(`\n📊 Metadata:`, colors.bright);
log(` Total properties available: ${essentialInfo.metadata?.totalProperties || 0}`);
log(` Is AI Tool: ${essentialInfo.metadata?.isAITool ? 'Yes' : 'No'}`);
log(` Is Trigger: ${essentialInfo.metadata?.isTrigger ? 'Yes' : 'No'}`);
log(` Has Credentials: ${essentialInfo.metadata?.hasCredentials ? 'Yes' : 'No'}`);
// Test property search
const searchTerms = ['auth', 'header', 'body', 'json'];
log(`\n🔍 Property Search Test:`, colors.bright);
for (const term of searchTerms) {
try {
const searchResult = await server.executeTool('search_node_properties', {
nodeType,
query: term,
maxResults: 5
});
log(` "${term}": Found ${searchResult.totalMatches} properties`);
} catch (error) {
log(` "${term}": Search failed`, colors.red);
}
}
return {
nodeType,
fullSize,
essentialSize,
sizeReduction: parseFloat(sizeReduction),
fullPropCount,
essentialPropCount,
success: true
};
} catch (error) {
log(`❌ Error testing ${nodeType}: ${error}`, colors.red);
return {
nodeType,
fullSize: 0,
essentialSize: 0,
sizeReduction: 0,
fullPropCount: 0,
essentialPropCount: 0,
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
async function main() {
logSection('n8n MCP Essentials Tool Test Suite');
try {
// Initialize server
log('\n🚀 Initializing MCP server...', colors.cyan);
const server = new N8NDocumentationMCPServer();
// Wait for initialization
await new Promise(resolve => setTimeout(resolve, 1000));
// Test nodes
const testNodes = [
'nodes-base.httpRequest',
'nodes-base.webhook',
'nodes-base.code',
'nodes-base.set',
'nodes-base.if',
'nodes-base.postgres',
'nodes-base.openAi',
'nodes-base.googleSheets',
'nodes-base.slack',
'nodes-base.merge'
];
const results = [];
for (const nodeType of testNodes) {
const result = await testNodeEssentials(server, nodeType);
results.push(result);
}
// Summary
logSection('Test Summary');
const successful = results.filter(r => r.success);
const totalFullSize = successful.reduce((sum, r) => sum + r.fullSize, 0);
const totalEssentialSize = successful.reduce((sum, r) => sum + r.essentialSize, 0);
const avgReduction = successful.reduce((sum, r) => sum + r.sizeReduction, 0) / successful.length;
log(`\n✅ Successful tests: ${successful.length}/${results.length}`, colors.green);
if (successful.length > 0) {
log(`\n📊 Overall Statistics:`, colors.bright);
log(` Total full size: ${formatBytes(totalFullSize)}`);
log(` Total essential size: ${formatBytes(totalEssentialSize)}`);
log(` Average reduction: ${avgReduction.toFixed(1)}%`, colors.bright + colors.green);
log(`\n🏆 Best Performers:`, colors.bright);
const sorted = successful.sort((a, b) => b.sizeReduction - a.sizeReduction);
sorted.slice(0, 3).forEach((r, i) => {
log(` ${i + 1}. ${r.nodeType}: ${r.sizeReduction}% reduction (${formatBytes(r.fullSize)}${formatBytes(r.essentialSize)})`);
});
}
const failed = results.filter(r => !r.success);
if (failed.length > 0) {
log(`\n❌ Failed tests:`, colors.red);
failed.forEach(r => {
log(` - ${r.nodeType}: ${r.error}`, colors.red);
});
}
// Save detailed results
const reportPath = join(process.cwd(), 'test-results-essentials.json');
writeFileSync(reportPath, JSON.stringify({
timestamp: new Date().toISOString(),
summary: {
totalTests: results.length,
successful: successful.length,
failed: failed.length,
averageReduction: avgReduction,
totalFullSize,
totalEssentialSize
},
results
}, null, 2));
log(`\n📄 Detailed results saved to: ${reportPath}`, colors.cyan);
// Recommendations
logSection('Recommendations');
if (avgReduction > 90) {
log('✨ Excellent! The essentials tool is achieving >90% size reduction.', colors.green);
} else if (avgReduction > 80) {
log('👍 Good! The essentials tool is achieving 80-90% size reduction.', colors.yellow);
log(' Consider reviewing nodes with lower reduction rates.');
} else {
log('⚠️ The average size reduction is below 80%.', colors.yellow);
log(' Review the essential property lists for optimization.');
}
// Test specific functionality
logSection('Testing Advanced Features');
// Test error handling
log('\n🧪 Testing error handling...', colors.cyan);
try {
await server.executeTool('get_node_essentials', { nodeType: 'non-existent-node' });
log(' ❌ Error handling failed - should have thrown error', colors.red);
} catch (error) {
log(' ✅ Error handling works correctly', colors.green);
}
// Test alternative node type formats
log('\n🧪 Testing alternative node type formats...', colors.cyan);
const alternativeFormats = [
{ input: 'httpRequest', expected: 'nodes-base.httpRequest' },
{ input: 'nodes-base.httpRequest', expected: 'nodes-base.httpRequest' },
{ input: 'HTTPREQUEST', expected: 'nodes-base.httpRequest' }
];
for (const format of alternativeFormats) {
try {
const result = await server.executeTool('get_node_essentials', { nodeType: format.input });
if (result.nodeType === format.expected) {
log(` ✅ "${format.input}" → "${format.expected}"`, colors.green);
} else {
log(` ❌ "${format.input}" → "${result.nodeType}" (expected "${format.expected}")`, colors.red);
}
} catch (error) {
log(` ❌ "${format.input}" → Error: ${error}`, colors.red);
}
}
log('\n✨ Test suite completed!', colors.bright + colors.green);
} catch (error) {
log(`\n❌ Fatal error: ${error}`, colors.red);
process.exit(1);
}
}
// Run the test
main().catch(error => {
console.error('Unhandled error:', error);
process.exit(1);
});
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env npx tsx
/**
* Test script for Expression vs Code Node validation
* Tests that we properly detect and warn about expression syntax in Code nodes
*/
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator.js';
console.log('🧪 Testing Expression vs Code Node Validation\n');
// Test cases with expression syntax that shouldn't work in Code nodes
const testCases = [
{
name: 'Expression syntax in Code node',
config: {
language: 'javaScript',
jsCode: `// Using expression syntax
const value = {{$json.field}};
return [{json: {value}}];`
},
expectedError: 'Expression syntax {{...}} is not valid in Code nodes'
},
{
name: 'Wrong $node syntax',
config: {
language: 'javaScript',
jsCode: `// Using expression $node syntax
const data = $node['Previous Node'].json;
return [{json: data}];`
},
expectedWarning: 'Use $(\'Node Name\') instead of $node[\'Node Name\'] in Code nodes'
},
{
name: 'Expression-only functions',
config: {
language: 'javaScript',
jsCode: `// Using expression functions
const now = $now();
const unique = items.unique();
return [{json: {now, unique}}];`
},
expectedWarning: '$now() is an expression-only function'
},
{
name: 'Wrong JMESPath parameter order',
config: {
language: 'javaScript',
jsCode: `// Wrong parameter order
const result = $jmespath("users[*].name", data);
return [{json: {result}}];`
},
expectedWarning: 'Code node $jmespath has reversed parameter order'
},
{
name: 'Correct Code node syntax',
config: {
language: 'javaScript',
jsCode: `// Correct syntax
const prevData = $('Previous Node').first();
const now = DateTime.now();
const result = $jmespath(data, "users[*].name");
return [{json: {prevData, now, result}}];`
},
shouldBeValid: true
}
];
// Basic node properties for Code node
const codeNodeProperties = [
{ name: 'language', type: 'options', options: ['javaScript', 'python'] },
{ name: 'jsCode', type: 'string' },
{ name: 'pythonCode', type: 'string' },
{ name: 'mode', type: 'options', options: ['runOnceForAllItems', 'runOnceForEachItem'] }
];
console.log('Running validation tests...\n');
testCases.forEach((test, index) => {
console.log(`Test ${index + 1}: ${test.name}`);
console.log('─'.repeat(50));
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.code',
test.config,
codeNodeProperties,
'operation',
'ai-friendly'
);
console.log(`Valid: ${result.valid}`);
console.log(`Errors: ${result.errors.length}`);
console.log(`Warnings: ${result.warnings.length}`);
if (test.expectedError) {
const hasExpectedError = result.errors.some(e =>
e.message.includes(test.expectedError)
);
console.log(`✅ Expected error found: ${hasExpectedError}`);
if (!hasExpectedError) {
console.log('❌ Missing expected error:', test.expectedError);
console.log('Actual errors:', result.errors.map(e => e.message));
}
}
if (test.expectedWarning) {
const hasExpectedWarning = result.warnings.some(w =>
w.message.includes(test.expectedWarning)
);
console.log(`✅ Expected warning found: ${hasExpectedWarning}`);
if (!hasExpectedWarning) {
console.log('❌ Missing expected warning:', test.expectedWarning);
console.log('Actual warnings:', result.warnings.map(w => w.message));
}
}
if (test.shouldBeValid) {
console.log(`✅ Should be valid: ${result.valid && result.errors.length === 0}`);
if (!result.valid || result.errors.length > 0) {
console.log('❌ Unexpected errors:', result.errors);
}
}
// Show actual messages
if (result.errors.length > 0) {
console.log('\nErrors:');
result.errors.forEach(e => console.log(` - ${e.message}`));
}
if (result.warnings.length > 0) {
console.log('\nWarnings:');
result.warnings.forEach(w => console.log(` - ${w.message}`));
}
console.log('\n');
});
console.log('✅ Expression vs Code Node validation tests completed!');
@@ -0,0 +1,230 @@
#!/usr/bin/env node
/**
* Test script for expression format validation
* Tests the validation of expression prefixes and resource locator formats
*/
const { WorkflowValidator } = require('../dist/services/workflow-validator.js');
const { NodeRepository } = require('../dist/database/node-repository.js');
const { EnhancedConfigValidator } = require('../dist/services/enhanced-config-validator.js');
const { createDatabaseAdapter } = require('../dist/database/database-adapter.js');
const path = require('path');
async function runTests() {
// Initialize database
const dbPath = path.join(__dirname, '..', 'data', 'nodes.db');
const adapter = await createDatabaseAdapter(dbPath);
const db = adapter;
const nodeRepository = new NodeRepository(db);
const validator = new WorkflowValidator(nodeRepository, EnhancedConfigValidator);
console.log('\n🧪 Testing Expression Format Validation\n');
console.log('=' .repeat(60));
// Test 1: Email node with missing = prefix
console.log('\n📝 Test 1: Email Send node - Missing = prefix');
console.log('-'.repeat(40));
const emailWorkflowIncorrect = {
nodes: [
{
id: 'b9dd1cfd-ee66-4049-97e7-1af6d976a4e0',
name: 'Error Handler',
type: 'n8n-nodes-base.emailSend',
typeVersion: 2.1,
position: [-128, 400],
parameters: {
fromEmail: '{{ $env.ADMIN_EMAIL }}', // INCORRECT - missing =
toEmail: 'admin@company.com',
subject: 'GitHub Issue Workflow Error - HIGH PRIORITY',
options: {}
},
credentials: {
smtp: {
id: '7AQ08VMFHubmfvzR',
name: 'romuald@aiadvisors.pl'
}
}
}
],
connections: {}
};
const result1 = await validator.validateWorkflow(emailWorkflowIncorrect);
if (result1.errors.some(e => e.message.includes('Expression format'))) {
console.log('✅ ERROR DETECTED (correct behavior):');
const formatError = result1.errors.find(e => e.message.includes('Expression format'));
console.log('\n' + formatError.message);
} else {
console.log('❌ No expression format error detected (should have detected missing prefix)');
}
// Test 2: Email node with correct = prefix
console.log('\n📝 Test 2: Email Send node - Correct = prefix');
console.log('-'.repeat(40));
const emailWorkflowCorrect = {
nodes: [
{
id: 'b9dd1cfd-ee66-4049-97e7-1af6d976a4e0',
name: 'Error Handler',
type: 'n8n-nodes-base.emailSend',
typeVersion: 2.1,
position: [-128, 400],
parameters: {
fromEmail: '={{ $env.ADMIN_EMAIL }}', // CORRECT - has =
toEmail: 'admin@company.com',
subject: 'GitHub Issue Workflow Error - HIGH PRIORITY',
options: {}
}
}
],
connections: {}
};
const result2 = await validator.validateWorkflow(emailWorkflowCorrect);
if (result2.errors.some(e => e.message.includes('Expression format'))) {
console.log('❌ Unexpected expression format error (should accept = prefix)');
} else {
console.log('✅ No expression format errors (correct!)');
}
// Test 3: GitHub node without resource locator format
console.log('\n📝 Test 3: GitHub node - Missing resource locator format');
console.log('-'.repeat(40));
const githubWorkflowIncorrect = {
nodes: [
{
id: '3c742ca1-af8f-4d80-a47e-e68fb1ced491',
name: 'Send Welcome Comment',
type: 'n8n-nodes-base.github',
typeVersion: 1.1,
position: [-240, 96],
parameters: {
operation: 'createComment',
owner: '{{ $vars.GITHUB_OWNER }}', // INCORRECT - needs RL format
repository: '{{ $vars.GITHUB_REPO }}', // INCORRECT - needs RL format
issueNumber: null,
body: '👋 Hi @{{ $(\'Extract Issue Data\').first().json.author }}!' // INCORRECT - missing =
},
credentials: {
githubApi: {
id: 'edgpwh6ldYN07MXx',
name: 'GitHub account'
}
}
}
],
connections: {}
};
const result3 = await validator.validateWorkflow(githubWorkflowIncorrect);
const formatErrors = result3.errors.filter(e => e.message.includes('Expression format'));
console.log(`\nFound ${formatErrors.length} expression format errors:`);
if (formatErrors.length >= 3) {
console.log('✅ All format issues detected:');
formatErrors.forEach((error, index) => {
const field = error.message.match(/Field '([^']+)'/)?.[1] || 'unknown';
console.log(` ${index + 1}. Field '${field}' - ${error.message.includes('resource locator') ? 'Needs RL format' : 'Missing = prefix'}`);
});
} else {
console.log('❌ Not all format issues detected');
}
// Test 4: GitHub node with correct resource locator format
console.log('\n📝 Test 4: GitHub node - Correct resource locator format');
console.log('-'.repeat(40));
const githubWorkflowCorrect = {
nodes: [
{
id: '3c742ca1-af8f-4d80-a47e-e68fb1ced491',
name: 'Send Welcome Comment',
type: 'n8n-nodes-base.github',
typeVersion: 1.1,
position: [-240, 96],
parameters: {
operation: 'createComment',
owner: {
__rl: true,
value: '={{ $vars.GITHUB_OWNER }}', // CORRECT - RL format with =
mode: 'expression'
},
repository: {
__rl: true,
value: '={{ $vars.GITHUB_REPO }}', // CORRECT - RL format with =
mode: 'expression'
},
issueNumber: 123,
body: '=👋 Hi @{{ $(\'Extract Issue Data\').first().json.author }}!' // CORRECT - has =
}
}
],
connections: {}
};
const result4 = await validator.validateWorkflow(githubWorkflowCorrect);
const formatErrors4 = result4.errors.filter(e => e.message.includes('Expression format'));
if (formatErrors4.length === 0) {
console.log('✅ No expression format errors (correct!)');
} else {
console.log(`❌ Unexpected expression format errors: ${formatErrors4.length}`);
formatErrors4.forEach(e => console.log(' - ' + e.message.split('\n')[0]));
}
// Test 5: Mixed content expressions
console.log('\n📝 Test 5: Mixed content with expressions');
console.log('-'.repeat(40));
const mixedContentWorkflow = {
nodes: [
{
id: '1',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4,
position: [0, 0],
parameters: {
url: 'https://api.example.com/users/{{ $json.userId }}', // INCORRECT
headers: {
'Authorization': '=Bearer {{ $env.API_TOKEN }}' // CORRECT
}
}
}
],
connections: {}
};
const result5 = await validator.validateWorkflow(mixedContentWorkflow);
const urlError = result5.errors.find(e => e.message.includes('url') && e.message.includes('Expression format'));
if (urlError) {
console.log('✅ Mixed content error detected for URL field');
console.log(' Should be: "=https://api.example.com/users/{{ $json.userId }}"');
} else {
console.log('❌ Mixed content error not detected');
}
console.log('\n' + '='.repeat(60));
console.log('\n✨ Expression Format Validation Summary:');
console.log(' - Detects missing = prefix in expressions');
console.log(' - Identifies fields needing resource locator format');
console.log(' - Provides clear correction examples');
console.log(' - Handles mixed literal and expression content');
// Close database
db.close();
}
runTests().catch(error => {
console.error('Test failed:', error);
process.exit(1);
});
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env node
import { N8NDocumentationMCPServer } from '../src/mcp/server';
interface SearchTest {
query: string;
mode?: 'OR' | 'AND' | 'FUZZY';
description: string;
expectedTop?: string[];
}
async function testFTS5Search() {
console.log('Testing FTS5 Search Implementation\n');
console.log('='.repeat(50));
const server = new N8NDocumentationMCPServer();
// Wait for initialization
await new Promise(resolve => setTimeout(resolve, 1000));
const tests: SearchTest[] = [
{
query: 'webhook',
description: 'Basic search - should return Webhook node first',
expectedTop: ['nodes-base.webhook']
},
{
query: 'http call',
description: 'Multi-word OR search - should return HTTP Request node first',
expectedTop: ['nodes-base.httpRequest']
},
{
query: 'send message',
mode: 'AND',
description: 'AND mode - only nodes with both "send" AND "message"',
},
{
query: 'slak',
mode: 'FUZZY',
description: 'FUZZY mode - should find Slack despite typo',
expectedTop: ['nodes-base.slack']
},
{
query: '"email trigger"',
description: 'Exact phrase search with quotes',
},
{
query: 'http',
mode: 'FUZZY',
description: 'FUZZY mode with common term',
expectedTop: ['nodes-base.httpRequest']
},
{
query: 'google sheets',
mode: 'AND',
description: 'AND mode - find Google Sheets node',
expectedTop: ['nodes-base.googleSheets']
},
{
query: 'webhook trigger',
mode: 'OR',
description: 'OR mode - should return nodes with either word',
}
];
let passedTests = 0;
let failedTests = 0;
for (const test of tests) {
console.log(`\n${test.description}`);
console.log(`Query: "${test.query}" (Mode: ${test.mode || 'OR'})`);
console.log('-'.repeat(40));
try {
const results = await server.executeTool('search_nodes', {
query: test.query,
mode: test.mode,
limit: 5
});
if (!results.results || results.results.length === 0) {
console.log('❌ No results found');
if (test.expectedTop) {
failedTests++;
}
continue;
}
console.log(`Found ${results.results.length} results:`);
results.results.forEach((node: any, index: number) => {
const marker = test.expectedTop && index === 0 && test.expectedTop.includes(node.nodeType) ? ' ✅' : '';
console.log(` ${index + 1}. ${node.nodeType} - ${node.displayName}${marker}`);
});
// Verify search mode is returned
if (results.mode) {
console.log(`\nSearch mode used: ${results.mode}`);
}
// Check expected results
if (test.expectedTop) {
const firstResult = results.results[0];
if (test.expectedTop.includes(firstResult.nodeType)) {
console.log('✅ Test passed: Expected node found at top');
passedTests++;
} else {
console.log('❌ Test failed: Expected node not at top');
console.log(` Expected: ${test.expectedTop.join(' or ')}`);
console.log(` Got: ${firstResult.nodeType}`);
failedTests++;
}
} else {
// Test without specific expectations
console.log('✅ Search completed successfully');
passedTests++;
}
} catch (error) {
console.log(`❌ Error: ${error}`);
failedTests++;
}
}
console.log('\n' + '='.repeat(50));
console.log('FTS5 Feature Tests');
console.log('='.repeat(50));
// Test FTS5-specific features
console.log('\n1. Testing relevance ranking...');
const webhookResult = await server.executeTool('search_nodes', {
query: 'webhook',
limit: 10
});
console.log(` Primary "Webhook" node position: #${webhookResult.results.findIndex((r: any) => r.nodeType === 'nodes-base.webhook') + 1}`);
console.log('\n2. Testing fuzzy matching with various typos...');
const typoTests = ['webook', 'htpp', 'slck', 'googl sheet'];
for (const typo of typoTests) {
const result = await server.executeTool('search_nodes', {
query: typo,
mode: 'FUZZY',
limit: 1
});
if (result.results.length > 0) {
console.log(` "${typo}" → ${result.results[0].displayName}`);
} else {
console.log(` "${typo}" → No results ❌`);
}
}
console.log('\n' + '='.repeat(50));
console.log(`Test Summary: ${passedTests} passed, ${failedTests} failed`);
console.log('='.repeat(50));
process.exit(failedTests > 0 ? 1 : 0);
}
// Run tests
testFTS5Search().catch(error => {
console.error('Test execution failed:', error);
process.exit(1);
});
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env node
import { N8NDocumentationMCPServer } from '../src/mcp/server';
async function testFuzzyFix() {
console.log('Testing FUZZY mode fix...\n');
const server = new N8NDocumentationMCPServer();
// Wait for initialization
await new Promise(resolve => setTimeout(resolve, 1000));
// Test 1: FUZZY mode with typo
console.log('Test 1: FUZZY mode with "slak" (typo for "slack")');
const fuzzyResult = await server.executeTool('search_nodes', {
query: 'slak',
mode: 'FUZZY',
limit: 5
});
console.log(`Results: ${fuzzyResult.results.length} found`);
if (fuzzyResult.results.length > 0) {
console.log('✅ FUZZY mode now finds results!');
fuzzyResult.results.forEach((node: any, i: number) => {
console.log(` ${i + 1}. ${node.nodeType} - ${node.displayName}`);
});
} else {
console.log('❌ FUZZY mode still not working');
}
// Test 2: AND mode with explanation
console.log('\n\nTest 2: AND mode with "send message"');
const andResult = await server.executeTool('search_nodes', {
query: 'send message',
mode: 'AND',
limit: 5
});
console.log(`Results: ${andResult.results.length} found`);
if (andResult.searchInfo) {
console.log('✅ AND mode now includes search info:');
console.log(` ${andResult.searchInfo.message}`);
console.log(` Tip: ${andResult.searchInfo.tip}`);
}
console.log('\nFirst 5 results:');
andResult.results.slice(0, 5).forEach((node: any, i: number) => {
console.log(` ${i + 1}. ${node.nodeType} - ${node.displayName}`);
});
// Test 3: More typos
console.log('\n\nTest 3: More FUZZY tests');
const typos = ['htpp', 'webook', 'slck', 'emial'];
for (const typo of typos) {
const result = await server.executeTool('search_nodes', {
query: typo,
mode: 'FUZZY',
limit: 1
});
if (result.results.length > 0) {
console.log(`✅ "${typo}" → ${result.results[0].displayName}`);
} else {
console.log(`❌ "${typo}" → No results`);
}
}
process.exit(0);
}
// Run tests
testFuzzyFix().catch(error => {
console.error('Test failed:', error);
process.exit(1);
});
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
import { N8NDocumentationMCPServer } from '../src/mcp/server';
async function testSimple() {
const server = new N8NDocumentationMCPServer();
await new Promise(resolve => setTimeout(resolve, 1000));
// Just test one query
const result = await server.executeTool('search_nodes', {
query: 'slak',
mode: 'FUZZY',
limit: 5
});
console.log('Query: "slak" (FUZZY mode)');
console.log(`Results: ${result.results.length}`);
if (result.results.length === 0) {
// Let's check with a lower threshold
const serverAny = server as any;
const slackNode = {
node_type: 'nodes-base.slack',
display_name: 'Slack',
description: 'Consume Slack API'
};
const score = serverAny.calculateFuzzyScore(slackNode, 'slak');
console.log(`\nSlack node score for "slak": ${score}`);
console.log('Current threshold: 400');
console.log('Should it match?', score >= 400 ? 'YES' : 'NO');
} else {
result.results.forEach((r: any, i: number) => {
console.log(`${i + 1}. ${r.displayName}`);
});
}
}
testSimple().catch(console.error);
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env npx tsx
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator.js';
console.log('🧪 Testing $helpers Validation\n');
const testCases = [
{
name: 'Incorrect $helpers.getWorkflowStaticData',
config: {
language: 'javaScript',
jsCode: `const data = $helpers.getWorkflowStaticData('global');
data.counter = 1;
return [{json: {counter: data.counter}}];`
}
},
{
name: 'Correct $getWorkflowStaticData',
config: {
language: 'javaScript',
jsCode: `const data = $getWorkflowStaticData('global');
data.counter = 1;
return [{json: {counter: data.counter}}];`
}
},
{
name: '$helpers without check',
config: {
language: 'javaScript',
jsCode: `const response = await $helpers.httpRequest({
method: 'GET',
url: 'https://api.example.com'
});
return [{json: response}];`
}
},
{
name: '$helpers with proper check',
config: {
language: 'javaScript',
jsCode: `if (typeof $helpers !== 'undefined' && $helpers.httpRequest) {
const response = await $helpers.httpRequest({
method: 'GET',
url: 'https://api.example.com'
});
return [{json: response}];
}
return [{json: {error: 'HTTP not available'}}];`
}
},
{
name: 'Crypto without require',
config: {
language: 'javaScript',
jsCode: `const token = crypto.randomBytes(32).toString('hex');
return [{json: {token}}];`
}
},
{
name: 'Crypto with require',
config: {
language: 'javaScript',
jsCode: `const crypto = require('crypto');
const token = crypto.randomBytes(32).toString('hex');
return [{json: {token}}];`
}
}
];
for (const test of testCases) {
console.log(`Test: ${test.name}`);
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.code',
test.config,
[
{ name: 'language', type: 'options', options: ['javaScript', 'python'] },
{ name: 'jsCode', type: 'string' }
],
'operation',
'ai-friendly'
);
console.log(` Valid: ${result.valid}`);
if (result.errors.length > 0) {
console.log(` Errors: ${result.errors.map(e => e.message).join(', ')}`);
}
if (result.warnings.length > 0) {
console.log(` Warnings: ${result.warnings.map(w => w.message).join(', ')}`);
}
console.log();
}
console.log('✅ $helpers validation tests completed!');
+46
View File
@@ -0,0 +1,46 @@
#\!/usr/bin/env node
import { N8NDocumentationMCPServer } from '../src/mcp/server';
async function testHttpSearch() {
const server = new N8NDocumentationMCPServer();
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Testing search for "http"...\n');
const result = await server.executeTool('search_nodes', {
query: 'http',
limit: 50 // Get more results to see where HTTP Request is
});
console.log(`Total results: ${result.results.length}\n`);
// Find HTTP Request node in results
const httpRequestIndex = result.results.findIndex((r: any) =>
r.nodeType === 'nodes-base.httpRequest'
);
if (httpRequestIndex === -1) {
console.log('❌ HTTP Request node NOT FOUND in results\!');
} else {
console.log(`✅ HTTP Request found at position ${httpRequestIndex + 1}`);
}
console.log('\nTop 10 results:');
result.results.slice(0, 10).forEach((r: any, i: number) => {
console.log(`${i + 1}. ${r.nodeType} - ${r.displayName}`);
});
// Also check LIKE search directly
console.log('\n\nTesting LIKE search fallback:');
const serverAny = server as any;
const likeResult = await serverAny.searchNodesLIKE('http', 20);
console.log(`LIKE search found ${likeResult.results.length} results`);
console.log('Top 5 LIKE results:');
likeResult.results.slice(0, 5).forEach((r: any, i: number) => {
console.log(`${i + 1}. ${r.nodeType} - ${r.displayName}`);
});
}
testHttpSearch().catch(console.error);
+128
View File
@@ -0,0 +1,128 @@
#!/bin/bash
# Test script for n8n-MCP HTTP Server
set -e
# Configuration
URL="${1:-http://localhost:3000}"
TOKEN="${AUTH_TOKEN:-test-token}"
VERBOSE="${VERBOSE:-0}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "🧪 Testing n8n-MCP HTTP Server"
echo "================================"
echo "Server URL: $URL"
echo ""
# Check if jq is installed
if ! command -v jq &> /dev/null; then
echo -e "${YELLOW}Warning: jq not installed. Output will not be formatted.${NC}"
echo "Install with: brew install jq (macOS) or apt-get install jq (Linux)"
echo ""
JQ="cat"
else
JQ="jq ."
fi
# Function to make requests
make_request() {
local method="$1"
local endpoint="$2"
local data="$3"
local headers="$4"
local expected_status="$5"
if [ "$VERBOSE" = "1" ]; then
echo -e "${YELLOW}Request:${NC} $method $URL$endpoint"
[ -n "$data" ] && echo -e "${YELLOW}Data:${NC} $data"
fi
# Build curl command
local cmd="curl -s -w '\n%{http_code}' -X $method '$URL$endpoint'"
[ -n "$headers" ] && cmd="$cmd $headers"
[ -n "$data" ] && cmd="$cmd -d '$data'"
# Execute and capture response
local response=$(eval "$cmd")
local body=$(echo "$response" | sed '$d')
local status=$(echo "$response" | tail -n 1)
# Check status
if [ "$status" = "$expected_status" ]; then
echo -e "${GREEN}${NC} $method $endpoint - Status: $status"
else
echo -e "${RED}${NC} $method $endpoint - Expected: $expected_status, Got: $status"
fi
# Show response body
if [ -n "$body" ]; then
echo "$body" | $JQ
fi
echo ""
}
# Test 1: Health check
echo "1. Testing health endpoint..."
make_request "GET" "/health" "" "" "200"
# Test 2: OPTIONS request (CORS preflight)
echo "2. Testing CORS preflight..."
make_request "OPTIONS" "/mcp" "" "-H 'Origin: http://localhost' -H 'Access-Control-Request-Method: POST'" "204"
# Test 3: Authentication failure
echo "3. Testing authentication (should fail)..."
make_request "POST" "/mcp" \
'{"jsonrpc":"2.0","method":"tools/list","id":1}' \
"-H 'Content-Type: application/json' -H 'Authorization: Bearer wrong-token'" \
"401"
# Test 4: Missing authentication
echo "4. Testing missing authentication..."
make_request "POST" "/mcp" \
'{"jsonrpc":"2.0","method":"tools/list","id":1}' \
"-H 'Content-Type: application/json'" \
"401"
# Test 5: Valid MCP request to list tools
echo "5. Testing valid MCP request (list tools)..."
make_request "POST" "/mcp" \
'{"jsonrpc":"2.0","method":"tools/list","id":1}' \
"-H 'Content-Type: application/json' -H 'Authorization: Bearer $TOKEN' -H 'Accept: application/json, text/event-stream'" \
"200"
# Test 6: 404 for unknown endpoint
echo "6. Testing 404 response..."
make_request "GET" "/unknown" "" "" "404"
# Test 7: Invalid JSON
echo "7. Testing invalid JSON..."
make_request "POST" "/mcp" \
'{invalid json}' \
"-H 'Content-Type: application/json' -H 'Authorization: Bearer $TOKEN'" \
"400"
# Test 8: Request size limit
echo "8. Testing request size limit..."
# Use a different approach for large data
echo "Skipping large payload test (would exceed bash limits)"
# Test 9: MCP initialization
if [ "$VERBOSE" = "1" ]; then
echo "9. Testing MCP initialization..."
make_request "POST" "/mcp" \
'{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{"roots":{}}},"id":1}' \
"-H 'Content-Type: application/json' -H 'Authorization: Bearer $TOKEN' -H 'Accept: text/event-stream'" \
"200"
fi
echo "================================"
echo "🎉 Tests completed!"
echo ""
echo "To run with verbose output: VERBOSE=1 $0"
echo "To test a different server: $0 https://your-server.com"
echo "To use a different token: AUTH_TOKEN=your-token $0"
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env npx tsx
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator.js';
console.log('🧪 Testing JMESPath Validation\n');
const testCases = [
{
name: 'JMESPath with unquoted numeric literal',
config: {
language: 'javaScript',
jsCode: `const data = { users: [{ name: 'John', age: 30 }, { name: 'Jane', age: 25 }] };
const adults = $jmespath(data, 'users[?age >= 18]');
return [{json: {adults}}];`
},
expectError: true
},
{
name: 'JMESPath with properly quoted numeric literal',
config: {
language: 'javaScript',
jsCode: `const data = { users: [{ name: 'John', age: 30 }, { name: 'Jane', age: 25 }] };
const adults = $jmespath(data, 'users[?age >= \`18\`]');
return [{json: {adults}}];`
},
expectError: false
},
{
name: 'Multiple JMESPath filters with unquoted numbers',
config: {
language: 'javaScript',
jsCode: `const products = items.map(item => item.json);
const expensive = $jmespath(products, '[?price > 100]');
const lowStock = $jmespath(products, '[?quantity < 10]');
const highPriority = $jmespath(products, '[?priority == 1]');
return [{json: {expensive, lowStock, highPriority}}];`
},
expectError: true
},
{
name: 'JMESPath with string comparison (no backticks needed)',
config: {
language: 'javaScript',
jsCode: `const data = { users: [{ name: 'John', status: 'active' }, { name: 'Jane', status: 'inactive' }] };
const activeUsers = $jmespath(data, 'users[?status == "active"]');
return [{json: {activeUsers}}];`
},
expectError: false
},
{
name: 'Python JMESPath with unquoted numeric literal',
config: {
language: 'python',
pythonCode: `data = { 'users': [{ 'name': 'John', 'age': 30 }, { 'name': 'Jane', 'age': 25 }] }
adults = _jmespath(data, 'users[?age >= 18]')
return [{'json': {'adults': adults}}]`
},
expectError: true
},
{
name: 'Complex filter with decimal numbers',
config: {
language: 'javaScript',
jsCode: `const items = [{ price: 99.99 }, { price: 150.50 }, { price: 200 }];
const expensive = $jmespath(items, '[?price >= 99.95]');
return [{json: {expensive}}];`
},
expectError: true
}
];
let passCount = 0;
let failCount = 0;
for (const test of testCases) {
console.log(`Test: ${test.name}`);
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.code',
test.config,
[
{ name: 'language', type: 'options', options: ['javaScript', 'python'] },
{ name: 'jsCode', type: 'string' },
{ name: 'pythonCode', type: 'string' }
],
'operation',
'strict'
);
const hasJMESPathError = result.errors.some(e =>
e.message.includes('JMESPath numeric literal') ||
e.message.includes('must be wrapped in backticks')
);
const passed = hasJMESPathError === test.expectError;
console.log(` Expected error: ${test.expectError}`);
console.log(` Has JMESPath error: ${hasJMESPathError}`);
console.log(` Result: ${passed ? '✅ PASS' : '❌ FAIL'}`);
if (result.errors.length > 0) {
console.log(` Errors: ${result.errors.map(e => e.message).join(', ')}`);
}
if (result.warnings.length > 0) {
console.log(` Warnings: ${result.warnings.slice(0, 2).map(w => w.message).join(', ')}`);
}
if (passed) passCount++;
else failCount++;
console.log();
}
console.log(`\n📊 Results: ${passCount} passed, ${failCount} failed`);
console.log(failCount === 0 ? '✅ All JMESPath validation tests passed!' : '❌ Some tests failed');
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env ts-node
/**
* Simple test for multi-tenant functionality
* Tests that tools are registered correctly based on configuration
*/
import { isN8nApiConfigured } from '../src/config/n8n-api';
import { InstanceContext } from '../src/types/instance-context';
import dotenv from 'dotenv';
dotenv.config();
async function testMultiTenant() {
console.log('🧪 Testing Multi-Tenant Tool Registration\n');
console.log('=' .repeat(60));
// Save original environment
const originalEnv = {
ENABLE_MULTI_TENANT: process.env.ENABLE_MULTI_TENANT,
N8N_API_URL: process.env.N8N_API_URL,
N8N_API_KEY: process.env.N8N_API_KEY
};
try {
// Test 1: Default - no API config
console.log('\n✅ Test 1: No API configuration');
delete process.env.N8N_API_URL;
delete process.env.N8N_API_KEY;
delete process.env.ENABLE_MULTI_TENANT;
const hasConfig1 = isN8nApiConfigured();
console.log(` Environment API configured: ${hasConfig1}`);
console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`);
console.log(` Should show tools: ${hasConfig1 || process.env.ENABLE_MULTI_TENANT === 'true'}`);
// Test 2: Multi-tenant enabled
console.log('\n✅ Test 2: Multi-tenant enabled (no env API)');
process.env.ENABLE_MULTI_TENANT = 'true';
const hasConfig2 = isN8nApiConfigured();
console.log(` Environment API configured: ${hasConfig2}`);
console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`);
console.log(` Should show tools: ${hasConfig2 || process.env.ENABLE_MULTI_TENANT === 'true'}`);
// Test 3: Environment variables set
console.log('\n✅ Test 3: Environment variables set');
process.env.ENABLE_MULTI_TENANT = 'false';
process.env.N8N_API_URL = 'https://test.n8n.cloud';
process.env.N8N_API_KEY = 'test-key';
const hasConfig3 = isN8nApiConfigured();
console.log(` Environment API configured: ${hasConfig3}`);
console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`);
console.log(` Should show tools: ${hasConfig3 || process.env.ENABLE_MULTI_TENANT === 'true'}`);
// Test 4: Instance context simulation
console.log('\n✅ Test 4: Instance context (simulated)');
const instanceContext: InstanceContext = {
n8nApiUrl: 'https://instance.n8n.cloud',
n8nApiKey: 'instance-key',
instanceId: 'test-instance'
};
const hasInstanceConfig = !!(instanceContext.n8nApiUrl && instanceContext.n8nApiKey);
console.log(` Instance has API config: ${hasInstanceConfig}`);
console.log(` Environment API configured: ${hasConfig3}`);
console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`);
console.log(` Should show tools: ${hasConfig3 || hasInstanceConfig || process.env.ENABLE_MULTI_TENANT === 'true'}`);
// Test 5: Multi-tenant with instance strategy
console.log('\n✅ Test 5: Multi-tenant with instance strategy');
process.env.ENABLE_MULTI_TENANT = 'true';
process.env.MULTI_TENANT_SESSION_STRATEGY = 'instance';
delete process.env.N8N_API_URL;
delete process.env.N8N_API_KEY;
const hasConfig5 = isN8nApiConfigured();
const sessionStrategy = process.env.MULTI_TENANT_SESSION_STRATEGY || 'instance';
console.log(` Environment API configured: ${hasConfig5}`);
console.log(` Multi-tenant enabled: ${process.env.ENABLE_MULTI_TENANT === 'true'}`);
console.log(` Session strategy: ${sessionStrategy}`);
console.log(` Should show tools: ${hasConfig5 || process.env.ENABLE_MULTI_TENANT === 'true'}`);
if (instanceContext.instanceId) {
const sessionId = `instance-${instanceContext.instanceId}-uuid`;
console.log(` Session ID format: ${sessionId}`);
}
console.log('\n' + '=' .repeat(60));
console.log('✅ All configuration tests passed!');
console.log('\n📝 Summary:');
console.log(' - Tools are shown when: env API configured OR multi-tenant enabled OR instance context provided');
console.log(' - Session isolation works with instance-based session IDs in multi-tenant mode');
console.log(' - Backward compatibility maintained for env-based configuration');
} catch (error) {
console.error('\n❌ Test failed:', error);
process.exit(1);
} finally {
// Restore original environment
if (originalEnv.ENABLE_MULTI_TENANT !== undefined) {
process.env.ENABLE_MULTI_TENANT = originalEnv.ENABLE_MULTI_TENANT;
} else {
delete process.env.ENABLE_MULTI_TENANT;
}
if (originalEnv.N8N_API_URL !== undefined) {
process.env.N8N_API_URL = originalEnv.N8N_API_URL;
} else {
delete process.env.N8N_API_URL;
}
if (originalEnv.N8N_API_KEY !== undefined) {
process.env.N8N_API_KEY = originalEnv.N8N_API_KEY;
} else {
delete process.env.N8N_API_KEY;
}
}
}
// Run tests
testMultiTenant().catch(error => {
console.error('Test execution failed:', error);
process.exit(1);
});
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env ts-node
/**
* Test script for multi-tenant functionality
* Verifies that instance context from headers enables n8n API tools
*/
import { N8NDocumentationMCPServer } from '../src/mcp/server';
import { InstanceContext } from '../src/types/instance-context';
import { logger } from '../src/utils/logger';
import dotenv from 'dotenv';
dotenv.config();
async function testMultiTenant() {
console.log('🧪 Testing Multi-Tenant Functionality\n');
console.log('=' .repeat(60));
// Save original environment
const originalEnv = {
ENABLE_MULTI_TENANT: process.env.ENABLE_MULTI_TENANT,
N8N_API_URL: process.env.N8N_API_URL,
N8N_API_KEY: process.env.N8N_API_KEY
};
// Wait a moment for database initialization
await new Promise(resolve => setTimeout(resolve, 100));
try {
// Test 1: Without multi-tenant mode (default)
console.log('\n📌 Test 1: Without multi-tenant mode (no env vars)');
delete process.env.N8N_API_URL;
delete process.env.N8N_API_KEY;
process.env.ENABLE_MULTI_TENANT = 'false';
const server1 = new N8NDocumentationMCPServer();
const tools1 = await getToolsFromServer(server1);
const hasManagementTools1 = tools1.some(t => t.name.startsWith('n8n_'));
console.log(` Tools available: ${tools1.length}`);
console.log(` Has management tools: ${hasManagementTools1}`);
console.log(` ✅ Expected: No management tools (correct: ${!hasManagementTools1})`);
// Test 2: With instance context but multi-tenant disabled
console.log('\n📌 Test 2: With instance context but multi-tenant disabled');
const instanceContext: InstanceContext = {
n8nApiUrl: 'https://instance1.n8n.cloud',
n8nApiKey: 'test-api-key',
instanceId: 'instance-1'
};
const server2 = new N8NDocumentationMCPServer(instanceContext);
const tools2 = await getToolsFromServer(server2);
const hasManagementTools2 = tools2.some(t => t.name.startsWith('n8n_'));
console.log(` Tools available: ${tools2.length}`);
console.log(` Has management tools: ${hasManagementTools2}`);
console.log(` ✅ Expected: Has management tools (correct: ${hasManagementTools2})`);
// Test 3: With multi-tenant mode enabled
console.log('\n📌 Test 3: With multi-tenant mode enabled');
process.env.ENABLE_MULTI_TENANT = 'true';
const server3 = new N8NDocumentationMCPServer();
const tools3 = await getToolsFromServer(server3);
const hasManagementTools3 = tools3.some(t => t.name.startsWith('n8n_'));
console.log(` Tools available: ${tools3.length}`);
console.log(` Has management tools: ${hasManagementTools3}`);
console.log(` ✅ Expected: Has management tools (correct: ${hasManagementTools3})`);
// Test 4: Multi-tenant with instance context
console.log('\n📌 Test 4: Multi-tenant with instance context');
const server4 = new N8NDocumentationMCPServer(instanceContext);
const tools4 = await getToolsFromServer(server4);
const hasManagementTools4 = tools4.some(t => t.name.startsWith('n8n_'));
console.log(` Tools available: ${tools4.length}`);
console.log(` Has management tools: ${hasManagementTools4}`);
console.log(` ✅ Expected: Has management tools (correct: ${hasManagementTools4})`);
// Test 5: Environment variables (backward compatibility)
console.log('\n📌 Test 5: Environment variables (backward compatibility)');
process.env.ENABLE_MULTI_TENANT = 'false';
process.env.N8N_API_URL = 'https://env.n8n.cloud';
process.env.N8N_API_KEY = 'env-api-key';
const server5 = new N8NDocumentationMCPServer();
const tools5 = await getToolsFromServer(server5);
const hasManagementTools5 = tools5.some(t => t.name.startsWith('n8n_'));
console.log(` Tools available: ${tools5.length}`);
console.log(` Has management tools: ${hasManagementTools5}`);
console.log(` ✅ Expected: Has management tools (correct: ${hasManagementTools5})`);
console.log('\n' + '=' .repeat(60));
console.log('✅ All multi-tenant tests passed!');
} catch (error) {
console.error('\n❌ Test failed:', error);
process.exit(1);
} finally {
// Restore original environment
Object.assign(process.env, originalEnv);
}
}
// Helper function to get tools from server
async function getToolsFromServer(server: N8NDocumentationMCPServer): Promise<any[]> {
// Access the private server instance to simulate tool listing
const serverInstance = (server as any).server;
const handlers = (serverInstance as any)._requestHandlers;
// Find and call the ListToolsRequestSchema handler
if (handlers && handlers.size > 0) {
for (const [schema, handler] of handlers) {
// Check for the tools/list schema
if (schema && schema.method === 'tools/list') {
const result = await handler({ params: {} });
return result.tools || [];
}
}
}
// Fallback: directly check the handlers map
const ListToolsRequestSchema = { method: 'tools/list' };
const handler = handlers?.get(ListToolsRequestSchema);
if (handler) {
const result = await handler({ params: {} });
return result.tools || [];
}
console.log(' ⚠️ Warning: Could not find tools/list handler');
return [];
}
// Run tests
testMultiTenant().catch(error => {
console.error('Test execution failed:', error);
process.exit(1);
});
+387
View File
@@ -0,0 +1,387 @@
#!/bin/bash
# Script to test n8n integration with n8n-mcp server
set -e
# Check for command line arguments
if [ "$1" == "--clear-api-key" ] || [ "$1" == "-c" ]; then
echo "🗑️ Clearing saved n8n API key..."
rm -f "$HOME/.n8n-mcp-test/.n8n-api-key"
echo "✅ API key cleared. You'll be prompted for a new key on next run."
exit 0
fi
if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo " -c, --clear-api-key Clear the saved n8n API key"
echo ""
echo "The script will save your n8n API key on first use and reuse it on"
echo "subsequent runs. You can override the saved key at runtime or clear"
echo "it with the --clear-api-key option."
exit 0
fi
echo "🚀 Starting n8n integration test environment..."
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
N8N_PORT=5678
MCP_PORT=3001
AUTH_TOKEN="test-token-for-n8n-testing-minimum-32-chars"
# n8n data directory for persistence
N8N_DATA_DIR="$HOME/.n8n-mcp-test"
# API key storage file
API_KEY_FILE="$N8N_DATA_DIR/.n8n-api-key"
# Function to detect OS
detect_os() {
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
if [ -f /etc/os-release ]; then
. /etc/os-release
echo "$ID"
else
echo "linux"
fi
elif [[ "$OSTYPE" == "darwin"* ]]; then
echo "macos"
elif [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]]; then
echo "windows"
else
echo "unknown"
fi
}
# Function to check if Docker is installed
check_docker() {
if command -v docker &> /dev/null; then
echo -e "${GREEN}✅ Docker is installed${NC}"
# Check if Docker daemon is running
if ! docker info &> /dev/null; then
echo -e "${YELLOW}⚠️ Docker is installed but not running${NC}"
echo -e "${YELLOW}Please start Docker and run this script again${NC}"
exit 1
fi
return 0
else
return 1
fi
}
# Function to install Docker based on OS
install_docker() {
local os=$(detect_os)
echo -e "${YELLOW}📦 Docker is not installed. Attempting to install...${NC}"
case $os in
"ubuntu"|"debian")
echo -e "${BLUE}Installing Docker on Ubuntu/Debian...${NC}"
echo "This requires sudo privileges."
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
echo -e "${GREEN}✅ Docker installed successfully${NC}"
echo -e "${YELLOW}⚠️ Please log out and back in for group changes to take effect${NC}"
;;
"fedora"|"rhel"|"centos")
echo -e "${BLUE}Installing Docker on Fedora/RHEL/CentOS...${NC}"
echo "This requires sudo privileges."
sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl start docker
sudo systemctl enable docker
sudo usermod -aG docker $USER
echo -e "${GREEN}✅ Docker installed successfully${NC}"
echo -e "${YELLOW}⚠️ Please log out and back in for group changes to take effect${NC}"
;;
"macos")
echo -e "${BLUE}Installing Docker on macOS...${NC}"
if command -v brew &> /dev/null; then
echo "Installing Docker Desktop via Homebrew..."
brew install --cask docker
echo -e "${GREEN}✅ Docker Desktop installed${NC}"
echo -e "${YELLOW}⚠️ Please start Docker Desktop from Applications${NC}"
else
echo -e "${RED}❌ Homebrew not found${NC}"
echo "Please install Docker Desktop manually from:"
echo "https://www.docker.com/products/docker-desktop/"
fi
;;
"windows")
echo -e "${RED}❌ Windows detected${NC}"
echo "Please install Docker Desktop manually from:"
echo "https://www.docker.com/products/docker-desktop/"
;;
*)
echo -e "${RED}❌ Unknown operating system: $os${NC}"
echo "Please install Docker manually from https://docs.docker.com/get-docker/"
;;
esac
# If we installed Docker on Linux, we need to restart for group changes
if [[ "$os" == "ubuntu" ]] || [[ "$os" == "debian" ]] || [[ "$os" == "fedora" ]] || [[ "$os" == "rhel" ]] || [[ "$os" == "centos" ]]; then
echo -e "${YELLOW}Please run 'newgrp docker' or log out and back in, then run this script again${NC}"
exit 0
fi
exit 1
}
# Check for Docker
if ! check_docker; then
install_docker
fi
# Check for jq (optional but recommended)
if ! command -v jq &> /dev/null; then
echo -e "${YELLOW}⚠️ jq is not installed (optional)${NC}"
echo -e "${YELLOW} Install it for pretty JSON output in tests${NC}"
fi
# Function to cleanup on exit
cleanup() {
echo -e "\n${YELLOW}🧹 Cleaning up...${NC}"
# Stop n8n container
if docker ps -q -f name=n8n-test > /dev/null 2>&1; then
echo "Stopping n8n container..."
docker stop n8n-test >/dev/null 2>&1 || true
docker rm n8n-test >/dev/null 2>&1 || true
fi
# Kill MCP server if running
if [ -n "$MCP_PID" ] && kill -0 $MCP_PID 2>/dev/null; then
echo "Stopping MCP server..."
kill $MCP_PID 2>/dev/null || true
fi
echo -e "${GREEN}✅ Cleanup complete${NC}"
}
# Set trap to cleanup on exit
trap cleanup EXIT INT TERM
# Check if we're in the right directory
if [ ! -f "package.json" ] || [ ! -d "dist" ]; then
echo -e "${RED}❌ Error: Must run from n8n-mcp directory${NC}"
echo "Please cd to /Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp"
exit 1
fi
# Always build the project to ensure latest changes
echo -e "${YELLOW}📦 Building project...${NC}"
npm run build
# Create n8n data directory if it doesn't exist
if [ ! -d "$N8N_DATA_DIR" ]; then
echo -e "${YELLOW}📁 Creating n8n data directory: $N8N_DATA_DIR${NC}"
mkdir -p "$N8N_DATA_DIR"
fi
# Start n8n in Docker with persistent volume
echo -e "\n${GREEN}🐳 Starting n8n container with persistent data...${NC}"
docker run -d \
--name n8n-test \
-p ${N8N_PORT}:5678 \
-v "${N8N_DATA_DIR}:/home/node/.n8n" \
-e N8N_BASIC_AUTH_ACTIVE=false \
-e N8N_HOST=localhost \
-e N8N_PORT=5678 \
-e N8N_PROTOCOL=http \
-e NODE_ENV=development \
-e N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true \
n8nio/n8n:latest
# Wait for n8n to be ready
echo -e "${YELLOW}⏳ Waiting for n8n to start...${NC}"
for i in {1..30}; do
if curl -s http://localhost:${N8N_PORT}/ >/dev/null 2>&1; then
echo -e "${GREEN}✅ n8n is ready!${NC}"
break
fi
if [ $i -eq 30 ]; then
echo -e "${RED}❌ n8n failed to start${NC}"
exit 1
fi
sleep 1
done
# Check for saved API key
if [ -f "$API_KEY_FILE" ]; then
# Read saved API key
N8N_API_KEY=$(cat "$API_KEY_FILE" 2>/dev/null || echo "")
if [ -n "$N8N_API_KEY" ]; then
echo -e "\n${GREEN}✅ Using saved n8n API key${NC}"
echo -e "${YELLOW} To use a different key, delete: ${API_KEY_FILE}${NC}"
# Give user a chance to override
echo -e "\n${YELLOW}Press Enter to continue with saved key, or paste a new API key:${NC}"
read -r NEW_API_KEY
if [ -n "$NEW_API_KEY" ]; then
N8N_API_KEY="$NEW_API_KEY"
# Save the new key
echo "$N8N_API_KEY" > "$API_KEY_FILE"
chmod 600 "$API_KEY_FILE"
echo -e "${GREEN}✅ New API key saved${NC}"
fi
else
# File exists but is empty, remove it
rm -f "$API_KEY_FILE"
fi
fi
# If no saved key, prompt for one
if [ -z "$N8N_API_KEY" ]; then
# Guide user to get API key
echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${YELLOW}🔑 n8n API Key Setup${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "\nTo enable n8n management tools, you need to create an API key:"
echo -e "\n${GREEN}Steps:${NC}"
echo -e " 1. Open n8n in your browser: ${BLUE}http://localhost:${N8N_PORT}${NC}"
echo -e " 2. Click on your user menu (top right)"
echo -e " 3. Go to 'Settings'"
echo -e " 4. Navigate to 'API'"
echo -e " 5. Click 'Create API Key'"
echo -e " 6. Give it a name (e.g., 'n8n-mcp')"
echo -e " 7. Copy the generated API key"
echo -e "\n${YELLOW}Note: If this is your first time, you'll need to create an account first.${NC}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
# Wait for API key input
echo -e "\n${YELLOW}Please paste your n8n API key here (or press Enter to skip):${NC}"
read -r N8N_API_KEY
# Save the API key if provided
if [ -n "$N8N_API_KEY" ]; then
echo "$N8N_API_KEY" > "$API_KEY_FILE"
chmod 600 "$API_KEY_FILE"
echo -e "${GREEN}✅ API key saved for future use${NC}"
fi
fi
# Check if API key was provided
if [ -z "$N8N_API_KEY" ]; then
echo -e "${YELLOW}⚠️ No API key provided. n8n management tools will not be available.${NC}"
echo -e "${YELLOW} You can still use documentation and search tools.${NC}"
N8N_API_KEY=""
N8N_API_URL=""
else
echo -e "${GREEN}✅ API key received${NC}"
# Set the API URL for localhost access (MCP server runs on host, not in Docker)
N8N_API_URL="http://localhost:${N8N_PORT}/api/v1"
fi
# Start MCP server
echo -e "\n${GREEN}🚀 Starting MCP server in n8n mode...${NC}"
if [ -n "$N8N_API_KEY" ]; then
echo -e "${YELLOW} With n8n management tools enabled${NC}"
fi
N8N_MODE=true \
MCP_MODE=http \
AUTH_TOKEN="${AUTH_TOKEN}" \
PORT=${MCP_PORT} \
N8N_API_KEY="${N8N_API_KEY}" \
N8N_API_URL="${N8N_API_URL}" \
node dist/mcp/index.js > /tmp/mcp-server.log 2>&1 &
MCP_PID=$!
# Show log file location
echo -e "${YELLOW}📄 MCP server logs: /tmp/mcp-server.log${NC}"
# Wait for MCP server to be ready
echo -e "${YELLOW}⏳ Waiting for MCP server to start...${NC}"
for i in {1..10}; do
if curl -s http://localhost:${MCP_PORT}/health >/dev/null 2>&1; then
echo -e "${GREEN}✅ MCP server is ready!${NC}"
break
fi
if [ $i -eq 10 ]; then
echo -e "${RED}❌ MCP server failed to start${NC}"
exit 1
fi
sleep 1
done
# Show status and test endpoints
echo -e "\n${GREEN}🎉 Both services are running!${NC}"
echo -e "\n📍 Service URLs:"
echo -e " • n8n: http://localhost:${N8N_PORT}"
echo -e " • MCP server: http://localhost:${MCP_PORT}"
echo -e "\n🔑 Auth token: ${AUTH_TOKEN}"
echo -e "\n💾 n8n data stored in: ${N8N_DATA_DIR}"
echo -e " (Your workflows, credentials, and settings are preserved between runs)"
# Test MCP protocol endpoint
echo -e "\n${YELLOW}🧪 Testing MCP protocol endpoint...${NC}"
echo "Response from GET /mcp:"
curl -s http://localhost:${MCP_PORT}/mcp | jq '.' || curl -s http://localhost:${MCP_PORT}/mcp
# Test MCP initialization
echo -e "\n${YELLOW}🧪 Testing MCP initialization...${NC}"
echo "Response from POST /mcp (initialize):"
curl -s -X POST http://localhost:${MCP_PORT}/mcp \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{}},"id":1}' \
| jq '.' || echo "(Install jq for pretty JSON output)"
# Test available tools
echo -e "\n${YELLOW}🧪 Checking available MCP tools...${NC}"
if [ -n "$N8N_API_KEY" ]; then
echo -e "${GREEN}✅ n8n Management Tools Available:${NC}"
echo " • n8n_list_workflows - List all workflows"
echo " • n8n_get_workflow - Get workflow details"
echo " • n8n_create_workflow - Create new workflows"
echo " • n8n_update_workflow - Update existing workflows"
echo " • n8n_delete_workflow - Delete workflows"
echo " • n8n_trigger_webhook_workflow - Trigger webhook workflows"
echo " • n8n_list_executions - List workflow executions"
echo " • And more..."
else
echo -e "${YELLOW}⚠️ n8n Management Tools NOT Available${NC}"
echo " To enable, restart with an n8n API key"
fi
echo -e "\n${GREEN}✅ Documentation Tools Always Available:${NC}"
echo " • list_nodes - List available n8n nodes"
echo " • search_nodes - Search for specific nodes"
echo " • get_node_info - Get detailed node information"
echo " • validate_node_operation - Validate node configurations"
echo " • And many more..."
echo -e "\n${GREEN}✅ Setup complete!${NC}"
echo -e "\n📝 Next steps:"
echo -e " 1. Open n8n at http://localhost:${N8N_PORT}"
echo -e " 2. Create a workflow with the AI Agent node"
echo -e " 3. Add MCP Client Tool node"
echo -e " 4. Configure it with:"
echo -e " • Transport: HTTP"
echo -e " • URL: http://host.docker.internal:${MCP_PORT}/mcp"
echo -e " • Auth Token: ${BLUE}${AUTH_TOKEN}${NC}"
echo -e "\n${YELLOW}Press Ctrl+C to stop both services${NC}"
echo -e "\n${YELLOW}📋 To monitor MCP logs: tail -f /tmp/mcp-server.log${NC}"
echo -e "${YELLOW}📋 To monitor n8n logs: docker logs -f n8n-test${NC}"
# Wait for interrupt
wait $MCP_PID
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env node
/**
* Test get_node_info to diagnose timeout issues
*/
const { N8NDocumentationMCPServer } = require('../dist/mcp/server');
async function testNodeInfo() {
console.log('🔍 Testing get_node_info...\n');
try {
const server = new N8NDocumentationMCPServer();
await new Promise(resolve => setTimeout(resolve, 500));
const nodes = [
'nodes-base.httpRequest',
'nodes-base.webhook',
'nodes-langchain.agent'
];
for (const nodeType of nodes) {
console.log(`Testing ${nodeType}...`);
const start = Date.now();
try {
const result = await server.executeTool('get_node_info', { nodeType });
const elapsed = Date.now() - start;
const size = JSON.stringify(result).length;
console.log(`✅ Success in ${elapsed}ms`);
console.log(` Size: ${(size / 1024).toFixed(1)}KB`);
console.log(` Properties: ${result.properties?.length || 0}`);
console.log(` Operations: ${result.operations?.length || 0}`);
// Check for issues
if (size > 50000) {
console.log(` ⚠️ WARNING: Response over 50KB!`);
}
// Check property quality
const propsWithoutDesc = result.properties?.filter(p => !p.description && !p.displayName).length || 0;
if (propsWithoutDesc > 0) {
console.log(` ⚠️ ${propsWithoutDesc} properties without descriptions`);
}
} catch (error) {
const elapsed = Date.now() - start;
console.log(`❌ Failed after ${elapsed}ms: ${error.message}`);
}
console.log('');
}
} catch (error) {
console.error('Fatal error:', error);
}
}
testNodeInfo().catch(console.error);
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env tsx
/**
* Test script for node type validation
* Tests the improvements to catch invalid node types like "nodes-base.webhook"
*/
import { WorkflowValidator } from '../src/services/workflow-validator';
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
import { NodeRepository } from '../src/database/node-repository';
import { createDatabaseAdapter } from '../src/database/database-adapter';
import { validateWorkflowStructure } from '../src/services/n8n-validation';
import { Logger } from '../src/utils/logger';
const logger = new Logger({ prefix: '[TestNodeTypeValidation]' });
async function testValidation() {
const adapter = await createDatabaseAdapter('./data/nodes.db');
const repository = new NodeRepository(adapter);
const validator = new WorkflowValidator(repository, EnhancedConfigValidator);
logger.info('Testing node type validation...\n');
// Test 1: The exact broken workflow from Claude Desktop
const brokenWorkflowFromLogs = {
"nodes": [
{
"parameters": {},
"id": "webhook_node",
"name": "Webhook",
"type": "nodes-base.webhook", // WRONG! Missing n8n- prefix
"typeVersion": 2,
"position": [260, 300] as [number, number]
}
],
"connections": {},
"pinData": {},
"meta": {
"instanceId": "74e11c77e266f2c77f6408eb6c88e3fec63c9a5d8c4a3a2ea4c135c542012d6b"
}
};
logger.info('Test 1: Invalid node type "nodes-base.webhook" (missing n8n- prefix)');
const result1 = await validator.validateWorkflow(brokenWorkflowFromLogs as any);
logger.info('Validation result:');
logger.info(`Valid: ${result1.valid}`);
logger.info(`Errors: ${result1.errors.length}`);
result1.errors.forEach(err => {
if (typeof err === 'string') {
logger.error(` - ${err}`);
} else if (err && typeof err === 'object' && 'message' in err) {
logger.error(` - ${err.message}`);
}
});
// Check if the specific error about nodes-base.webhook was caught
const hasNodeBaseError = result1.errors.some(err =>
err && typeof err === 'object' && 'message' in err &&
err.message.includes('nodes-base.webhook') &&
err.message.includes('n8n-nodes-base.webhook')
);
logger.info(`Caught nodes-base.webhook error: ${hasNodeBaseError ? 'YES ✅' : 'NO ❌'}`);
// Test 2: Node type without any prefix
const noPrefixWorkflow = {
"name": "Test Workflow",
"nodes": [
{
"id": "webhook-1",
"name": "My Webhook",
"type": "webhook", // WRONG! No package prefix
"typeVersion": 2,
"position": [250, 300] as [number, number],
"parameters": {}
},
{
"id": "set-1",
"name": "Set Data",
"type": "set", // WRONG! No package prefix
"typeVersion": 3.4,
"position": [450, 300] as [number, number],
"parameters": {}
}
],
"connections": {
"My Webhook": {
"main": [[{
"node": "Set Data",
"type": "main",
"index": 0
}]]
}
}
};
logger.info('\nTest 2: Node types without package prefix ("webhook", "set")');
const result2 = await validator.validateWorkflow(noPrefixWorkflow as any);
logger.info('Validation result:');
logger.info(`Valid: ${result2.valid}`);
logger.info(`Errors: ${result2.errors.length}`);
result2.errors.forEach(err => {
if (typeof err === 'string') {
logger.error(` - ${err}`);
} else if (err && typeof err === 'object' && 'message' in err) {
logger.error(` - ${err.message}`);
}
});
// Test 3: Completely invalid node type
const invalidNodeWorkflow = {
"name": "Test Workflow",
"nodes": [
{
"id": "fake-1",
"name": "Fake Node",
"type": "n8n-nodes-base.fakeNodeThatDoesNotExist",
"typeVersion": 1,
"position": [250, 300] as [number, number],
"parameters": {}
}
],
"connections": {}
};
logger.info('\nTest 3: Completely invalid node type');
const result3 = await validator.validateWorkflow(invalidNodeWorkflow as any);
logger.info('Validation result:');
logger.info(`Valid: ${result3.valid}`);
logger.info(`Errors: ${result3.errors.length}`);
result3.errors.forEach(err => {
if (typeof err === 'string') {
logger.error(` - ${err}`);
} else if (err && typeof err === 'object' && 'message' in err) {
logger.error(` - ${err.message}`);
}
});
// Test 4: Using n8n-validation.ts function
logger.info('\nTest 4: Testing n8n-validation.ts with invalid node types');
const errors = validateWorkflowStructure(brokenWorkflowFromLogs as any);
logger.info('Validation errors:');
errors.forEach(err => logger.error(` - ${err}`));
// Test 5: Valid workflow (should pass)
const validWorkflow = {
"name": "Valid Webhook Workflow",
"nodes": [
{
"id": "webhook-1",
"name": "Webhook",
"type": "n8n-nodes-base.webhook", // CORRECT!
"typeVersion": 2,
"position": [250, 300] as [number, number],
"parameters": {
"path": "my-webhook",
"responseMode": "onReceived",
"responseData": "allEntries"
}
}
],
"connections": {}
};
logger.info('\nTest 5: Valid workflow with correct node type');
const result5 = await validator.validateWorkflow(validWorkflow as any);
logger.info('Validation result:');
logger.info(`Valid: ${result5.valid}`);
logger.info(`Errors: ${result5.errors.length}`);
logger.info(`Warnings: ${result5.warnings.length}`);
result5.warnings.forEach(warn => {
if (warn && typeof warn === 'object' && 'message' in warn) {
logger.warn(` - ${warn.message}`);
}
});
adapter.close();
}
testValidation().catch(err => {
logger.error('Test failed:', err);
process.exit(1);
});
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env tsx
/**
* Specific test for nodes-base. prefix validation
*/
import { WorkflowValidator } from '../src/services/workflow-validator';
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
import { NodeRepository } from '../src/database/node-repository';
import { createDatabaseAdapter } from '../src/database/database-adapter';
import { Logger } from '../src/utils/logger';
const logger = new Logger({ prefix: '[TestNodesBasePrefix]' });
async function testValidation() {
const adapter = await createDatabaseAdapter('./data/nodes.db');
const repository = new NodeRepository(adapter);
const validator = new WorkflowValidator(repository, EnhancedConfigValidator);
logger.info('Testing nodes-base. prefix validation...\n');
// Test various nodes-base. prefixed types
const testCases = [
{ type: 'nodes-base.webhook', expected: 'n8n-nodes-base.webhook' },
{ type: 'nodes-base.httpRequest', expected: 'n8n-nodes-base.httpRequest' },
{ type: 'nodes-base.set', expected: 'n8n-nodes-base.set' },
{ type: 'nodes-base.code', expected: 'n8n-nodes-base.code' },
{ type: 'nodes-base.slack', expected: 'n8n-nodes-base.slack' },
];
for (const testCase of testCases) {
const workflow = {
name: `Test ${testCase.type}`,
nodes: [{
id: 'test-node',
name: 'Test Node',
type: testCase.type,
typeVersion: 1,
position: [100, 100] as [number, number],
parameters: {}
}],
connections: {}
};
logger.info(`Testing: "${testCase.type}"`);
const result = await validator.validateWorkflow(workflow as any);
const nodeTypeError = result.errors.find(err =>
err && typeof err === 'object' && 'message' in err &&
err.message.includes(testCase.type) &&
err.message.includes(testCase.expected)
);
if (nodeTypeError) {
logger.info(`✅ Caught and suggested: "${testCase.expected}"`);
} else {
logger.error(`❌ Failed to catch invalid type: "${testCase.type}"`);
result.errors.forEach(err => {
if (err && typeof err === 'object' && 'message' in err) {
logger.error(` Error: ${err.message}`);
}
});
}
}
// Test that n8n-nodes-base. prefix still works
const validWorkflow = {
name: 'Valid Workflow',
nodes: [{
id: 'webhook',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 2,
position: [100, 100] as [number, number],
parameters: {}
}],
connections: {}
};
logger.info('\nTesting valid n8n-nodes-base.webhook:');
const validResult = await validator.validateWorkflow(validWorkflow as any);
const hasNodeTypeError = validResult.errors.some(err =>
err && typeof err === 'object' && 'message' in err &&
err.message.includes('node type')
);
if (!hasNodeTypeError) {
logger.info('✅ Correctly accepted n8n-nodes-base.webhook');
} else {
logger.error('❌ Incorrectly rejected valid n8n-nodes-base.webhook');
}
adapter.close();
}
testValidation().catch(err => {
logger.error('Test failed:', err);
process.exit(1);
});
+178
View File
@@ -0,0 +1,178 @@
/**
* Test script for operation and resource validation with Google Drive example
*/
import { DatabaseAdapter } from '../src/database/database-adapter';
import { NodeRepository } from '../src/database/node-repository';
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
import { WorkflowValidator } from '../src/services/workflow-validator';
import { createDatabaseAdapter } from '../src/database/database-adapter';
import { logger } from '../src/utils/logger';
import chalk from 'chalk';
async function testOperationValidation() {
console.log(chalk.blue('Testing Operation and Resource Validation'));
console.log('='.repeat(60));
// Initialize database
const dbPath = process.env.NODE_DB_PATH || 'data/nodes.db';
const db = await createDatabaseAdapter(dbPath);
const repository = new NodeRepository(db);
// Initialize similarity services
EnhancedConfigValidator.initializeSimilarityServices(repository);
// Test 1: Invalid operation "listFiles"
console.log(chalk.yellow('\n📝 Test 1: Google Drive with invalid operation "listFiles"'));
const invalidConfig = {
resource: 'fileFolder',
operation: 'listFiles'
};
const node = repository.getNode('nodes-base.googleDrive');
if (!node) {
console.error(chalk.red('Google Drive node not found in database'));
process.exit(1);
}
const result1 = EnhancedConfigValidator.validateWithMode(
'nodes-base.googleDrive',
invalidConfig,
node.properties,
'operation',
'ai-friendly'
);
console.log(`Valid: ${result1.valid ? chalk.green('✓') : chalk.red('✗')}`);
if (result1.errors.length > 0) {
console.log(chalk.red('Errors:'));
result1.errors.forEach(error => {
console.log(` - ${error.property}: ${error.message}`);
if (error.fix) {
console.log(chalk.cyan(` Fix: ${error.fix}`));
}
});
}
// Test 2: Invalid resource "files" (should be singular)
console.log(chalk.yellow('\n📝 Test 2: Google Drive with invalid resource "files"'));
const pluralResourceConfig = {
resource: 'files',
operation: 'download'
};
const result2 = EnhancedConfigValidator.validateWithMode(
'nodes-base.googleDrive',
pluralResourceConfig,
node.properties,
'operation',
'ai-friendly'
);
console.log(`Valid: ${result2.valid ? chalk.green('✓') : chalk.red('✗')}`);
if (result2.errors.length > 0) {
console.log(chalk.red('Errors:'));
result2.errors.forEach(error => {
console.log(` - ${error.property}: ${error.message}`);
if (error.fix) {
console.log(chalk.cyan(` Fix: ${error.fix}`));
}
});
}
// Test 3: Valid configuration
console.log(chalk.yellow('\n📝 Test 3: Google Drive with valid configuration'));
const validConfig = {
resource: 'file',
operation: 'download'
};
const result3 = EnhancedConfigValidator.validateWithMode(
'nodes-base.googleDrive',
validConfig,
node.properties,
'operation',
'ai-friendly'
);
console.log(`Valid: ${result3.valid ? chalk.green('✓') : chalk.red('✗')}`);
if (result3.errors.length > 0) {
console.log(chalk.red('Errors:'));
result3.errors.forEach(error => {
console.log(` - ${error.property}: ${error.message}`);
});
} else {
console.log(chalk.green('No errors - configuration is valid!'));
}
// Test 4: Test in workflow context
console.log(chalk.yellow('\n📝 Test 4: Full workflow with invalid Google Drive node'));
const workflow = {
name: 'Test Workflow',
nodes: [
{
id: '1',
name: 'Google Drive',
type: 'n8n-nodes-base.googleDrive',
position: [100, 100] as [number, number],
parameters: {
resource: 'fileFolder',
operation: 'listFiles' // Invalid operation
}
}
],
connections: {}
};
const validator = new WorkflowValidator(repository, EnhancedConfigValidator);
const workflowResult = await validator.validateWorkflow(workflow, {
validateNodes: true,
profile: 'ai-friendly'
});
console.log(`Workflow Valid: ${workflowResult.valid ? chalk.green('✓') : chalk.red('✗')}`);
if (workflowResult.errors.length > 0) {
console.log(chalk.red('Errors:'));
workflowResult.errors.forEach(error => {
console.log(` - ${error.nodeName || 'Workflow'}: ${error.message}`);
if (error.details?.fix) {
console.log(chalk.cyan(` Fix: ${error.details.fix}`));
}
});
}
// Test 5: Typo in operation
console.log(chalk.yellow('\n📝 Test 5: Typo in operation "downlod"'));
const typoConfig = {
resource: 'file',
operation: 'downlod' // Typo
};
const result5 = EnhancedConfigValidator.validateWithMode(
'nodes-base.googleDrive',
typoConfig,
node.properties,
'operation',
'ai-friendly'
);
console.log(`Valid: ${result5.valid ? chalk.green('✓') : chalk.red('✗')}`);
if (result5.errors.length > 0) {
console.log(chalk.red('Errors:'));
result5.errors.forEach(error => {
console.log(` - ${error.property}: ${error.message}`);
if (error.fix) {
console.log(chalk.cyan(` Fix: ${error.fix}`));
}
});
}
console.log(chalk.green('\n✅ All tests completed!'));
db.close();
}
// Run tests
testOperationValidation().catch(error => {
console.error(chalk.red('Error running tests:'), error);
process.exit(1);
});
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
# Test script for optimized Docker build
set -e
echo "🧪 Testing Optimized Docker Build"
echo "================================="
# Colors for output
GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Build the optimized image
echo -e "\n📦 Building optimized Docker image..."
docker build -f Dockerfile.optimized -t n8n-mcp:optimized .
# Check image size
echo -e "\n📊 Checking image size..."
SIZE=$(docker images n8n-mcp:optimized --format "{{.Size}}")
echo "Image size: $SIZE"
# Extract size in MB for comparison
SIZE_MB=$(docker images n8n-mcp:optimized --format "{{.Size}}" | sed 's/MB//' | sed 's/GB/*1024/' | bc 2>/dev/null || echo "0")
if [ "$SIZE_MB" != "0" ] && [ "$SIZE_MB" -lt "300" ]; then
echo -e "${GREEN}✅ Image size is optimized (<300MB)${NC}"
else
echo -e "${RED}⚠️ Image might be larger than expected${NC}"
fi
# Test stdio mode
echo -e "\n🔍 Testing stdio mode..."
TEST_RESULT=$(echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | \
docker run --rm -i -e MCP_MODE=stdio n8n-mcp:optimized 2>/dev/null | \
grep -o '"name":"[^"]*"' | head -1)
if [ -n "$TEST_RESULT" ]; then
echo -e "${GREEN}✅ Stdio mode working${NC}"
else
echo -e "${RED}❌ Stdio mode failed${NC}"
fi
# Test HTTP mode
echo -e "\n🌐 Testing HTTP mode..."
docker run -d --name test-optimized \
-e MCP_MODE=http \
-e AUTH_TOKEN=test-token \
-p 3002:3000 \
n8n-mcp:optimized
# Wait for startup
sleep 5
# Test health endpoint
HEALTH=$(curl -s http://localhost:3002/health | grep -o '"status":"healthy"' || echo "")
if [ -n "$HEALTH" ]; then
echo -e "${GREEN}✅ Health endpoint working${NC}"
else
echo -e "${RED}❌ Health endpoint failed${NC}"
fi
# Test MCP endpoint
MCP_TEST=$(curl -s -H "Authorization: Bearer test-token" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
http://localhost:3002/mcp | grep -o '"tools":\[' || echo "")
if [ -n "$MCP_TEST" ]; then
echo -e "${GREEN}✅ MCP endpoint working${NC}"
else
echo -e "${RED}❌ MCP endpoint failed${NC}"
fi
# Test database statistics tool
STATS_TEST=$(curl -s -H "Authorization: Bearer test-token" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_database_statistics","arguments":{}},"id":2}' \
http://localhost:3002/mcp | grep -o '"totalNodes":[0-9]*' || echo "")
if [ -n "$STATS_TEST" ]; then
echo -e "${GREEN}✅ Database statistics tool working${NC}"
echo "Database stats: $STATS_TEST"
else
echo -e "${RED}❌ Database statistics tool failed${NC}"
fi
# Cleanup
docker stop test-optimized >/dev/null 2>&1
docker rm test-optimized >/dev/null 2>&1
# Compare with original image
echo -e "\n📊 Size Comparison:"
echo "Original image: $(docker images n8n-mcp:latest --format "{{.Size}}" 2>/dev/null || echo "Not built")"
echo "Optimized image: $SIZE"
echo -e "\n✨ Testing complete!"
+583
View File
@@ -0,0 +1,583 @@
#!/usr/bin/env node
/**
* Test script for release automation
* Validates the release workflow components locally
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Color codes for output
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m'
};
function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
function header(title) {
log(`\n${'='.repeat(60)}`, 'cyan');
log(`🧪 ${title}`, 'cyan');
log(`${'='.repeat(60)}`, 'cyan');
}
function section(title) {
log(`\n📋 ${title}`, 'blue');
log(`${'-'.repeat(40)}`, 'blue');
}
function success(message) {
log(`${message}`, 'green');
}
function warning(message) {
log(`⚠️ ${message}`, 'yellow');
}
function error(message) {
log(`${message}`, 'red');
}
function info(message) {
log(`${message}`, 'blue');
}
class ReleaseAutomationTester {
constructor() {
this.rootDir = path.resolve(__dirname, '..');
this.errors = [];
this.warnings = [];
}
/**
* Test if required files exist
*/
testFileExistence() {
section('Testing File Existence');
const requiredFiles = [
'package.json',
'package.runtime.json',
'docs/CHANGELOG.md',
'.github/workflows/release.yml',
'scripts/sync-runtime-version.js',
'scripts/publish-npm.sh'
];
for (const file of requiredFiles) {
const filePath = path.join(this.rootDir, file);
if (fs.existsSync(filePath)) {
success(`Found: ${file}`);
} else {
error(`Missing: ${file}`);
this.errors.push(`Missing required file: ${file}`);
}
}
}
/**
* Test version detection logic
*/
testVersionDetection() {
section('Testing Version Detection');
try {
const packageJson = require(path.join(this.rootDir, 'package.json'));
const runtimeJson = require(path.join(this.rootDir, 'package.runtime.json'));
success(`Package.json version: ${packageJson.version}`);
success(`Runtime package version: ${runtimeJson.version}`);
if (packageJson.version === runtimeJson.version) {
success('Version sync: Both versions match');
} else {
warning('Version sync: Versions do not match - run sync:runtime-version');
this.warnings.push('Package versions are not synchronized');
}
// Test semantic version format
const semverRegex = /^\d+\.\d+\.\d+(?:-[\w\.-]+)?(?:\+[\w\.-]+)?$/;
if (semverRegex.test(packageJson.version)) {
success(`Version format: Valid semantic version (${packageJson.version})`);
} else {
error(`Version format: Invalid semantic version (${packageJson.version})`);
this.errors.push('Invalid semantic version format');
}
} catch (err) {
error(`Version detection failed: ${err.message}`);
this.errors.push(`Version detection error: ${err.message}`);
}
}
/**
* Test changelog parsing
*/
testChangelogParsing() {
section('Testing Changelog Parsing');
try {
const changelogPath = path.join(this.rootDir, 'docs/CHANGELOG.md');
if (!fs.existsSync(changelogPath)) {
error('Changelog file not found');
this.errors.push('Missing changelog file');
return;
}
const changelogContent = fs.readFileSync(changelogPath, 'utf8');
const packageJson = require(path.join(this.rootDir, 'package.json'));
const currentVersion = packageJson.version;
// Check if current version exists in changelog
const versionRegex = new RegExp(`^## \\[${currentVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm');
if (versionRegex.test(changelogContent)) {
success(`Changelog entry found for version ${currentVersion}`);
// Test extraction logic (simplified version of the GitHub Actions script)
const lines = changelogContent.split('\n');
let startIndex = -1;
let endIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (versionRegex.test(lines[i])) {
startIndex = i;
break;
}
}
if (startIndex !== -1) {
// Find the end of this version's section
for (let i = startIndex + 1; i < lines.length; i++) {
if (lines[i].startsWith('## [') && !lines[i].includes('Unreleased')) {
endIndex = i;
break;
}
}
if (endIndex === -1) {
endIndex = lines.length;
}
const sectionLines = lines.slice(startIndex + 1, endIndex);
const contentLines = sectionLines.filter(line => line.trim() !== '');
if (contentLines.length > 0) {
success(`Changelog content extracted: ${contentLines.length} lines`);
info(`Preview: ${contentLines[0].substring(0, 100)}...`);
} else {
warning('Changelog section appears to be empty');
this.warnings.push(`Empty changelog section for version ${currentVersion}`);
}
}
} else {
warning(`No changelog entry found for current version ${currentVersion}`);
this.warnings.push(`Missing changelog entry for version ${currentVersion}`);
}
// Check changelog format
if (changelogContent.includes('## [Unreleased]')) {
success('Changelog format: Contains Unreleased section');
} else {
warning('Changelog format: Missing Unreleased section');
}
if (changelogContent.includes('Keep a Changelog')) {
success('Changelog format: Follows Keep a Changelog format');
} else {
warning('Changelog format: Does not reference Keep a Changelog');
}
} catch (err) {
error(`Changelog parsing failed: ${err.message}`);
this.errors.push(`Changelog parsing error: ${err.message}`);
}
}
/**
* Test build process
*/
testBuildProcess() {
section('Testing Build Process');
try {
// Check if dist directory exists
const distPath = path.join(this.rootDir, 'dist');
if (fs.existsSync(distPath)) {
success('Build output: dist directory exists');
// Check for key build files
const keyFiles = [
'dist/index.js',
'dist/mcp/index.js',
'dist/mcp/server.js'
];
for (const file of keyFiles) {
const filePath = path.join(this.rootDir, file);
if (fs.existsSync(filePath)) {
success(`Build file: ${file} exists`);
} else {
warning(`Build file: ${file} missing - run 'npm run build'`);
this.warnings.push(`Missing build file: ${file}`);
}
}
} else {
warning('Build output: dist directory missing - run "npm run build"');
this.warnings.push('Missing build output');
}
// Check database
const dbPath = path.join(this.rootDir, 'data/nodes.db');
if (fs.existsSync(dbPath)) {
const stats = fs.statSync(dbPath);
success(`Database: nodes.db exists (${Math.round(stats.size / 1024 / 1024)}MB)`);
} else {
warning('Database: nodes.db missing - run "npm run rebuild"');
this.warnings.push('Missing database file');
}
} catch (err) {
error(`Build process test failed: ${err.message}`);
this.errors.push(`Build process error: ${err.message}`);
}
}
/**
* Test npm publish preparation
*/
testNpmPublishPrep() {
section('Testing NPM Publish Preparation');
try {
const packageJson = require(path.join(this.rootDir, 'package.json'));
const runtimeJson = require(path.join(this.rootDir, 'package.runtime.json'));
// Check package.json fields
const requiredFields = ['name', 'version', 'description', 'main', 'bin'];
for (const field of requiredFields) {
if (packageJson[field]) {
success(`Package field: ${field} is present`);
} else {
error(`Package field: ${field} is missing`);
this.errors.push(`Missing package.json field: ${field}`);
}
}
// Check runtime dependencies
if (runtimeJson.dependencies) {
const depCount = Object.keys(runtimeJson.dependencies).length;
success(`Runtime dependencies: ${depCount} packages`);
// List key dependencies
const keyDeps = ['@modelcontextprotocol/sdk', 'express', 'sql.js'];
for (const dep of keyDeps) {
if (runtimeJson.dependencies[dep]) {
success(`Key dependency: ${dep} (${runtimeJson.dependencies[dep]})`);
} else {
warning(`Key dependency: ${dep} is missing`);
this.warnings.push(`Missing key dependency: ${dep}`);
}
}
} else {
error('Runtime package has no dependencies');
this.errors.push('Missing runtime dependencies');
}
// Check files array
if (packageJson.files && Array.isArray(packageJson.files)) {
success(`Package files: ${packageJson.files.length} patterns specified`);
info(`Files: ${packageJson.files.join(', ')}`);
} else {
warning('Package files: No files array specified');
this.warnings.push('No files array in package.json');
}
} catch (err) {
error(`NPM publish prep test failed: ${err.message}`);
this.errors.push(`NPM publish prep error: ${err.message}`);
}
}
/**
* Test Docker configuration
*/
testDockerConfig() {
section('Testing Docker Configuration');
try {
const dockerfiles = ['Dockerfile', 'Dockerfile.railway'];
for (const dockerfile of dockerfiles) {
const dockerfilePath = path.join(this.rootDir, dockerfile);
if (fs.existsSync(dockerfilePath)) {
success(`Dockerfile: ${dockerfile} exists`);
const content = fs.readFileSync(dockerfilePath, 'utf8');
// Check for key instructions
if (content.includes('FROM node:')) {
success(`${dockerfile}: Uses Node.js base image`);
} else {
warning(`${dockerfile}: Does not use standard Node.js base image`);
}
if (content.includes('COPY dist')) {
success(`${dockerfile}: Copies build output`);
} else {
warning(`${dockerfile}: May not copy build output correctly`);
}
} else {
warning(`Dockerfile: ${dockerfile} not found`);
this.warnings.push(`Missing Dockerfile: ${dockerfile}`);
}
}
// Check docker-compose files
const composeFiles = ['docker-compose.yml', 'docker-compose.n8n.yml'];
for (const composeFile of composeFiles) {
const composePath = path.join(this.rootDir, composeFile);
if (fs.existsSync(composePath)) {
success(`Docker Compose: ${composeFile} exists`);
} else {
info(`Docker Compose: ${composeFile} not found (optional)`);
}
}
} catch (err) {
error(`Docker config test failed: ${err.message}`);
this.errors.push(`Docker config error: ${err.message}`);
}
}
/**
* Test workflow file syntax
*/
testWorkflowSyntax() {
section('Testing Workflow Syntax');
try {
const workflowPath = path.join(this.rootDir, '.github/workflows/release.yml');
if (!fs.existsSync(workflowPath)) {
error('Release workflow file not found');
this.errors.push('Missing release workflow file');
return;
}
const workflowContent = fs.readFileSync(workflowPath, 'utf8');
// Basic YAML structure checks
if (workflowContent.includes('name: Automated Release')) {
success('Workflow: Has correct name');
} else {
warning('Workflow: Name may be incorrect');
}
if (workflowContent.includes('on:') && workflowContent.includes('push:')) {
success('Workflow: Has push trigger');
} else {
error('Workflow: Missing push trigger');
this.errors.push('Workflow missing push trigger');
}
if (workflowContent.includes('branches: [main]')) {
success('Workflow: Configured for main branch');
} else {
warning('Workflow: May not be configured for main branch');
}
// Check for required jobs
const requiredJobs = [
'detect-version-change',
'extract-changelog',
'create-release',
'publish-npm',
'build-docker'
];
for (const job of requiredJobs) {
if (workflowContent.includes(`${job}:`)) {
success(`Workflow job: ${job} defined`);
} else {
error(`Workflow job: ${job} missing`);
this.errors.push(`Missing workflow job: ${job}`);
}
}
// Check for npm Trusted Publisher (OIDC) configuration — scoped to the publish-npm job
// Slice from " publish-npm:" to the next job at the same indent (2 spaces, non-space char)
const publishNpmMatch = workflowContent.match(/^ {2}publish-npm:\n([\s\S]*?)(?=^ {2}\S|\Z)/m);
const publishNpmBlock = publishNpmMatch ? publishNpmMatch[1] : '';
if (!publishNpmBlock) {
error('Workflow: could not locate publish-npm job block for OIDC checks');
this.errors.push('publish-npm job not found in workflow');
} else {
if (/\bid-token:\s*write\b/.test(publishNpmBlock)) {
success('Workflow: publish-npm has id-token: write permission (OIDC)');
} else {
error('Workflow: publish-npm is missing id-token: write permission');
this.errors.push('Trusted Publishing requires id-token: write on publish-npm job');
}
if (/\benvironment:\s*npm-publish\b/.test(publishNpmBlock)) {
success('Workflow: publish-npm uses npm-publish environment');
} else {
error('Workflow: publish-npm is missing environment: npm-publish');
this.errors.push('Trusted Publishing requires environment: npm-publish on publish-npm job');
}
}
if (workflowContent.includes('${{ secrets.NPM_TOKEN }}')) {
warning('Workflow: stale NPM_TOKEN reference found — Trusted Publishing makes this unnecessary');
this.warnings.push('Remove ${{ secrets.NPM_TOKEN }} from workflow now that OIDC is used');
}
if (workflowContent.includes('${{ secrets.GITHUB_TOKEN }}')) {
success('Workflow: GITHUB_TOKEN secret configured');
} else {
warning('Workflow: GITHUB_TOKEN secret may be missing');
}
} catch (err) {
error(`Workflow syntax test failed: ${err.message}`);
this.errors.push(`Workflow syntax error: ${err.message}`);
}
}
/**
* Test environment and dependencies
*/
testEnvironment() {
section('Testing Environment');
try {
// Check Node.js version
const nodeVersion = process.version;
success(`Node.js version: ${nodeVersion}`);
// Check if npm is available
try {
const npmVersion = execSync('npm --version', { encoding: 'utf8', stdio: 'pipe' }).trim();
success(`NPM version: ${npmVersion}`);
} catch (err) {
error('NPM not available');
this.errors.push('NPM not available');
}
// Check if git is available
try {
const gitVersion = execSync('git --version', { encoding: 'utf8', stdio: 'pipe' }).trim();
success(`Git available: ${gitVersion}`);
} catch (err) {
error('Git not available');
this.errors.push('Git not available');
}
// Check if we're in a git repository
try {
execSync('git rev-parse --git-dir', { stdio: 'pipe' });
success('Git repository: Detected');
// Check current branch
try {
const branch = execSync('git branch --show-current', { encoding: 'utf8', stdio: 'pipe' }).trim();
info(`Current branch: ${branch}`);
} catch (err) {
info('Could not determine current branch');
}
} catch (err) {
warning('Not in a git repository');
this.warnings.push('Not in a git repository');
}
} catch (err) {
error(`Environment test failed: ${err.message}`);
this.errors.push(`Environment error: ${err.message}`);
}
}
/**
* Run all tests
*/
async runAllTests() {
header('Release Automation Test Suite');
info('Testing release automation components...');
this.testFileExistence();
this.testVersionDetection();
this.testChangelogParsing();
this.testBuildProcess();
this.testNpmPublishPrep();
this.testDockerConfig();
this.testWorkflowSyntax();
this.testEnvironment();
// Summary
header('Test Summary');
if (this.errors.length === 0 && this.warnings.length === 0) {
log('🎉 All tests passed! Release automation is ready.', 'green');
} else {
if (this.errors.length > 0) {
log(`\n${this.errors.length} Error(s):`, 'red');
this.errors.forEach(err => log(`${err}`, 'red'));
}
if (this.warnings.length > 0) {
log(`\n⚠️ ${this.warnings.length} Warning(s):`, 'yellow');
this.warnings.forEach(warn => log(`${warn}`, 'yellow'));
}
if (this.errors.length > 0) {
log('\n🔧 Please fix the errors before running the release workflow.', 'red');
process.exit(1);
} else {
log('\n✅ No critical errors found. Warnings should be reviewed but won\'t prevent releases.', 'yellow');
}
}
// Next steps
log('\n📋 Next Steps:', 'cyan');
log('1. Ensure GitHub repository settings are configured:', 'cyan');
log(' • Secrets: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN', 'cyan');
log(' • Environment "npm-publish" exists (Settings → Environments)', 'cyan');
log(' • npm Trusted Publisher configured on npmjs.com (no NPM_TOKEN secret needed)', 'cyan');
log(' • GITHUB_TOKEN is provided automatically', 'cyan');
log('\n2. To trigger a release:', 'cyan');
log(' • Update version in package.json', 'cyan');
log(' • Update changelog in docs/CHANGELOG.md', 'cyan');
log(' • Commit and push to main branch', 'cyan');
log('\n3. Monitor the release workflow in GitHub Actions', 'cyan');
return this.errors.length === 0;
}
}
// Run the tests
if (require.main === module) {
const tester = new ReleaseAutomationTester();
tester.runAllTests().catch(err => {
console.error('Test suite failed:', err);
process.exit(1);
});
}
module.exports = ReleaseAutomationTester;
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env node
import { N8NDocumentationMCPServer } from '../src/mcp/server';
interface SearchTestCase {
query: string;
expectedTop: string[];
description: string;
}
async function testSearchImprovements() {
console.log('Testing search improvements...\n');
const server = new N8NDocumentationMCPServer();
// Wait for initialization
await new Promise(resolve => setTimeout(resolve, 1000));
const testCases: SearchTestCase[] = [
{
query: 'webhook',
expectedTop: ['nodes-base.webhook'],
description: 'Primary webhook node should appear first'
},
{
query: 'http',
expectedTop: ['nodes-base.httpRequest'],
description: 'HTTP Request node should appear first'
},
{
query: 'http call',
expectedTop: ['nodes-base.httpRequest'],
description: 'HTTP Request node should appear first for "http call"'
},
{
query: 'slack',
expectedTop: ['nodes-base.slack'],
description: 'Slack node should appear first'
},
{
query: 'email',
expectedTop: ['nodes-base.emailSend', 'nodes-base.gmail', 'nodes-base.emailReadImap'],
description: 'Email-related nodes should appear first'
},
{
query: 'http request',
expectedTop: ['nodes-base.httpRequest'],
description: 'HTTP Request node should appear first for exact name'
}
];
let passedTests = 0;
let failedTests = 0;
for (const testCase of testCases) {
try {
console.log(`\nTest: ${testCase.description}`);
console.log(`Query: "${testCase.query}"`);
const results = await server.executeTool('search_nodes', {
query: testCase.query,
limit: 10
});
if (!results.results || results.results.length === 0) {
console.log('❌ No results found');
failedTests++;
continue;
}
console.log(`Found ${results.results.length} results`);
console.log('Top 5 results:');
const top5 = results.results.slice(0, 5);
top5.forEach((node: any, index: number) => {
const isExpected = testCase.expectedTop.includes(node.nodeType);
const marker = index === 0 && isExpected ? '✅' : index === 0 && !isExpected ? '❌' : '';
console.log(` ${index + 1}. ${node.nodeType} - ${node.displayName} ${marker}`);
});
// Check if any expected node appears in top position
const firstResult = results.results[0];
if (testCase.expectedTop.includes(firstResult.nodeType)) {
console.log('✅ Test passed: Expected node found at top position');
passedTests++;
} else {
console.log('❌ Test failed: Expected nodes not at top position');
console.log(` Expected one of: ${testCase.expectedTop.join(', ')}`);
console.log(` Got: ${firstResult.nodeType}`);
failedTests++;
}
} catch (error) {
console.log(`❌ Test failed with error: ${error}`);
failedTests++;
}
}
console.log('\n' + '='.repeat(50));
console.log(`Test Summary: ${passedTests} passed, ${failedTests} failed`);
console.log('='.repeat(50));
// Test the old problematic queries to ensure improvement
console.log('\n\nTesting Original Problem Scenarios:');
console.log('=====================================\n');
// Test webhook query that was problematic
console.log('1. Testing "webhook" query (was returning service-specific webhooks first):');
const webhookResult = await server.executeTool('search_nodes', { query: 'webhook', limit: 10 });
const webhookFirst = webhookResult.results[0];
if (webhookFirst.nodeType === 'nodes-base.webhook') {
console.log(' ✅ SUCCESS: Primary Webhook node now appears first!');
} else {
console.log(` ❌ FAILED: Got ${webhookFirst.nodeType} instead of nodes-base.webhook`);
console.log(` First 3 results: ${webhookResult.results.slice(0, 3).map((r: any) => r.nodeType).join(', ')}`);
}
// Test http call query
console.log('\n2. Testing "http call" query (was not finding HTTP Request easily):');
const httpCallResult = await server.executeTool('search_nodes', { query: 'http call', limit: 10 });
const httpCallFirst = httpCallResult.results[0];
if (httpCallFirst.nodeType === 'nodes-base.httpRequest') {
console.log(' ✅ SUCCESS: HTTP Request node now appears first!');
} else {
console.log(` ❌ FAILED: Got ${httpCallFirst.nodeType} instead of nodes-base.httpRequest`);
console.log(` First 3 results: ${httpCallResult.results.slice(0, 3).map((r: any) => r.nodeType).join(', ')}`);
}
process.exit(failedTests > 0 ? 1 : 0);
}
// Run tests
testSearchImprovements().catch(error => {
console.error('Test execution failed:', error);
process.exit(1);
});
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
import axios from 'axios';
import { spawn } from 'child_process';
async function testMaliciousHeaders() {
console.log('🔒 Testing Security Fixes...\n');
// Start server with TRUST_PROXY enabled
const serverProcess = spawn('node', ['dist/mcp/index.js'], {
env: {
...process.env,
MCP_MODE: 'http',
AUTH_TOKEN: 'test-security-token-32-characters-long',
PORT: '3999',
TRUST_PROXY: '1'
}
});
// Wait for server to start
await new Promise(resolve => {
serverProcess.stdout.on('data', (data) => {
if (data.toString().includes('Press Ctrl+C to stop')) {
resolve(undefined);
}
});
});
const testCases = [
{
name: 'Valid proxy headers',
headers: {
'X-Forwarded-Host': 'example.com',
'X-Forwarded-Proto': 'https'
}
},
{
name: 'Malicious host header (with path)',
headers: {
'X-Forwarded-Host': 'evil.com/path/to/evil',
'X-Forwarded-Proto': 'https'
}
},
{
name: 'Malicious host header (with @)',
headers: {
'X-Forwarded-Host': 'user@evil.com',
'X-Forwarded-Proto': 'https'
}
},
{
name: 'Invalid hostname (multiple dots)',
headers: {
'X-Forwarded-Host': '.....',
'X-Forwarded-Proto': 'https'
}
},
{
name: 'IPv6 address',
headers: {
'X-Forwarded-Host': '[::1]:3000',
'X-Forwarded-Proto': 'https'
}
}
];
for (const testCase of testCases) {
try {
const response = await axios.get('http://localhost:3999/', {
headers: testCase.headers,
timeout: 2000
});
const endpoints = response.data.endpoints;
const healthUrl = endpoints?.health?.url || 'N/A';
console.log(`${testCase.name}`);
console.log(` Response: ${healthUrl}`);
// Check if malicious headers were blocked.
//
// NOTE: this is a substring presence check, not URL sanitization.
// The goal is to detect whether ANY of the attacker-supplied markers
// leaked into the server's echoed health URL — a hostname-only check
// would miss path/userinfo injection, which is exactly what we're
// testing for. CodeQL js/incomplete-url-substring-sanitization
// flagged this as if it were an auth gate; it is not.
if (testCase.name.includes('Malicious') || testCase.name.includes('Invalid')) {
const maliciousMarkers = ['evil.com', '@', '.....'];
const leaked = maliciousMarkers.some(marker => healthUrl.indexOf(marker) !== -1);
if (leaked) {
console.log(' ❌ SECURITY ISSUE: Malicious header was not blocked!');
} else {
console.log(' ✅ Malicious header was blocked');
}
}
} catch (error) {
console.log(`${testCase.name} - Request failed`);
}
console.log('');
}
serverProcess.kill();
}
testMaliciousHeaders().catch(console.error);
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
# Test script for single-session HTTP server
set -e
echo "🧪 Testing Single-Session HTTP Server..."
echo
# Generate test auth token if not set
if [ -z "$AUTH_TOKEN" ]; then
export AUTH_TOKEN="test-token-$(date +%s)"
echo "Generated test AUTH_TOKEN: $AUTH_TOKEN"
fi
# Start server in background
echo "Starting server..."
MCP_MODE=http npm start > server.log 2>&1 &
SERVER_PID=$!
# Wait for server to start
echo "Waiting for server to start..."
sleep 3
# Check health endpoint
echo
echo "Testing health endpoint..."
curl -s http://localhost:3000/health | jq .
# Test authentication failure
echo
echo "Testing authentication failure..."
curl -s -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer wrong-token" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' | jq .
# Test successful request
echo
echo "Testing successful request..."
curl -s -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' | jq .
# Test session reuse
echo
echo "Testing session reuse (second request)..."
curl -s -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-d '{"jsonrpc":"2.0","method":"get_database_statistics","id":2}' | jq .
# Liveness check (health body is intentionally minimal per GHSA-75hx-xj24-mqrw)
echo
echo "Liveness check..."
curl -s http://localhost:3000/health | jq .
# Clean up
echo
echo "Stopping server..."
kill $SERVER_PID 2>/dev/null || true
wait $SERVER_PID 2>/dev/null || true
echo
echo "✅ Test complete! Check server.log for details."
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Test script to verify trigger detection works with sql.js adapter
*/
import { createDatabaseAdapter } from '../src/database/database-adapter';
import { NodeRepository } from '../src/database/node-repository';
import { logger } from '../src/utils/logger';
import path from 'path';
async function testSqlJsTriggers() {
logger.info('🧪 Testing trigger detection with sql.js adapter...\n');
try {
// Force sql.js by temporarily renaming better-sqlite3
const originalRequire = require.cache[require.resolve('better-sqlite3')];
if (originalRequire) {
delete require.cache[require.resolve('better-sqlite3')];
}
// Mock better-sqlite3 to force sql.js usage
const Module = require('module');
const originalResolveFilename = Module._resolveFilename;
Module._resolveFilename = function(request: string, parent: any, isMain: boolean) {
if (request === 'better-sqlite3') {
throw new Error('Forcing sql.js adapter for testing');
}
return originalResolveFilename.apply(this, arguments);
};
// Now create adapter - should use sql.js
const dbPath = path.join(process.cwd(), 'data', 'nodes.db');
logger.info(`📁 Database path: ${dbPath}`);
const adapter = await createDatabaseAdapter(dbPath);
logger.info('✅ Adapter created (should be sql.js)\n');
// Test direct query
logger.info('📊 Testing direct database query:');
const triggerNodes = ['nodes-base.webhook', 'nodes-base.cron', 'nodes-base.interval', 'nodes-base.emailReadImap'];
for (const nodeType of triggerNodes) {
const row = adapter.prepare('SELECT * FROM nodes WHERE node_type = ?').get(nodeType);
if (row) {
logger.info(`${nodeType}:`);
logger.info(` is_trigger raw value: ${row.is_trigger} (type: ${typeof row.is_trigger})`);
logger.info(` !!is_trigger: ${!!row.is_trigger}`);
logger.info(` Number(is_trigger) === 1: ${Number(row.is_trigger) === 1}`);
}
}
// Test through repository
logger.info('\n📦 Testing through NodeRepository:');
const repository = new NodeRepository(adapter);
for (const nodeType of triggerNodes) {
const node = repository.getNode(nodeType);
if (node) {
logger.info(`${nodeType}: isTrigger = ${node.isTrigger}`);
}
}
// Test list query
logger.info('\n📋 Testing list query:');
const allTriggers = adapter.prepare(
'SELECT node_type, is_trigger FROM nodes WHERE node_type IN (?, ?, ?, ?)'
).all(...triggerNodes);
for (const node of allTriggers) {
logger.info(`${node.node_type}: is_trigger = ${node.is_trigger} (type: ${typeof node.is_trigger})`);
}
adapter.close();
logger.info('\n✅ Test complete!');
// Restore original require
Module._resolveFilename = originalResolveFilename;
} catch (error) {
logger.error('Test failed:', error);
process.exit(1);
}
}
// Run test
testSqlJsTriggers().catch(console.error);
+470
View File
@@ -0,0 +1,470 @@
#!/usr/bin/env ts-node
/**
* Phase 3: Real-World Type Structure Validation
*
* Tests type structure validation against real workflow templates from n8n.io
* to ensure production readiness. Validates filter, resourceMapper,
* assignmentCollection, and resourceLocator types.
*
* Usage:
* npm run build && node dist/scripts/test-structure-validation.js
*
* or with ts-node:
* npx ts-node scripts/test-structure-validation.ts
*/
import { createDatabaseAdapter } from '../src/database/database-adapter';
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
import type { NodePropertyTypes } from 'n8n-workflow';
import { gunzipSync } from 'zlib';
interface ValidationResult {
templateId: number;
templateName: string;
templateViews: number;
nodeId: string;
nodeName: string;
nodeType: string;
propertyName: string;
propertyType: NodePropertyTypes;
valid: boolean;
errors: Array<{ type: string; property?: string; message: string }>;
warnings: Array<{ type: string; property?: string; message: string }>;
validationTimeMs: number;
}
interface ValidationStats {
totalTemplates: number;
totalNodes: number;
totalValidations: number;
passedValidations: number;
failedValidations: number;
byType: Record<string, { passed: number; failed: number }>;
byError: Record<string, number>;
avgValidationTimeMs: number;
maxValidationTimeMs: number;
}
// Special types we want to validate
const SPECIAL_TYPES: NodePropertyTypes[] = [
'filter',
'resourceMapper',
'assignmentCollection',
'resourceLocator',
];
function decompressWorkflow(compressed: string): any {
try {
const buffer = Buffer.from(compressed, 'base64');
const decompressed = gunzipSync(buffer);
return JSON.parse(decompressed.toString('utf-8'));
} catch (error: any) {
throw new Error(`Failed to decompress workflow: ${error.message}`);
}
}
async function loadTopTemplates(db: any, limit: number = 100) {
console.log(`📥 Loading top ${limit} templates by popularity...\n`);
const stmt = db.prepare(`
SELECT
id,
name,
workflow_json_compressed,
views
FROM templates
WHERE workflow_json_compressed IS NOT NULL
ORDER BY views DESC
LIMIT ?
`);
const templates = stmt.all(limit);
console.log(`✓ Loaded ${templates.length} templates\n`);
return templates;
}
function extractNodesWithSpecialTypes(workflowJson: any): Array<{
nodeId: string;
nodeName: string;
nodeType: string;
properties: Array<{ name: string; type: NodePropertyTypes; value: any }>;
}> {
const results: Array<any> = [];
if (!workflowJson || !workflowJson.nodes || !Array.isArray(workflowJson.nodes)) {
return results;
}
for (const node of workflowJson.nodes) {
// Check if node has parameters with special types
if (!node.parameters || typeof node.parameters !== 'object') {
continue;
}
const specialProperties: Array<{ name: string; type: NodePropertyTypes; value: any }> = [];
// Check each parameter against our special types
for (const [paramName, paramValue] of Object.entries(node.parameters)) {
// Try to infer type from structure
const inferredType = inferPropertyType(paramValue);
if (inferredType && SPECIAL_TYPES.includes(inferredType)) {
specialProperties.push({
name: paramName,
type: inferredType,
value: paramValue,
});
}
}
if (specialProperties.length > 0) {
results.push({
nodeId: node.id,
nodeName: node.name,
nodeType: node.type,
properties: specialProperties,
});
}
}
return results;
}
function inferPropertyType(value: any): NodePropertyTypes | null {
if (!value || typeof value !== 'object') {
return null;
}
// Filter type: has combinator and conditions
if (value.combinator && value.conditions) {
return 'filter';
}
// ResourceMapper type: has mappingMode
if (value.mappingMode) {
return 'resourceMapper';
}
// AssignmentCollection type: has assignments array
if (value.assignments && Array.isArray(value.assignments)) {
return 'assignmentCollection';
}
// ResourceLocator type: has mode and value
if (value.mode && value.hasOwnProperty('value')) {
return 'resourceLocator';
}
return null;
}
async function validateTemplate(
templateId: number,
templateName: string,
templateViews: number,
workflowJson: any
): Promise<ValidationResult[]> {
const results: ValidationResult[] = [];
// Extract nodes with special types
const nodesWithSpecialTypes = extractNodesWithSpecialTypes(workflowJson);
for (const node of nodesWithSpecialTypes) {
for (const prop of node.properties) {
const startTime = Date.now();
// Create property definition for validation
const properties = [
{
name: prop.name,
type: prop.type,
required: true,
displayName: prop.name,
default: {},
},
];
// Create config with just this property
const config = {
[prop.name]: prop.value,
};
try {
// Run validation
const validationResult = EnhancedConfigValidator.validateWithMode(
node.nodeType,
config,
properties,
'operation',
'ai-friendly'
);
const validationTimeMs = Date.now() - startTime;
results.push({
templateId,
templateName,
templateViews,
nodeId: node.nodeId,
nodeName: node.nodeName,
nodeType: node.nodeType,
propertyName: prop.name,
propertyType: prop.type,
valid: validationResult.valid,
errors: validationResult.errors || [],
warnings: validationResult.warnings || [],
validationTimeMs,
});
} catch (error: any) {
const validationTimeMs = Date.now() - startTime;
results.push({
templateId,
templateName,
templateViews,
nodeId: node.nodeId,
nodeName: node.nodeName,
nodeType: node.nodeType,
propertyName: prop.name,
propertyType: prop.type,
valid: false,
errors: [
{
type: 'exception',
property: prop.name,
message: `Validation threw exception: ${error.message}`,
},
],
warnings: [],
validationTimeMs,
});
}
}
}
return results;
}
function calculateStats(results: ValidationResult[]): ValidationStats {
const stats: ValidationStats = {
totalTemplates: new Set(results.map(r => r.templateId)).size,
totalNodes: new Set(results.map(r => `${r.templateId}-${r.nodeId}`)).size,
totalValidations: results.length,
passedValidations: results.filter(r => r.valid).length,
failedValidations: results.filter(r => !r.valid).length,
byType: {},
byError: {},
avgValidationTimeMs: 0,
maxValidationTimeMs: 0,
};
// Stats by type
for (const type of SPECIAL_TYPES) {
const typeResults = results.filter(r => r.propertyType === type);
stats.byType[type] = {
passed: typeResults.filter(r => r.valid).length,
failed: typeResults.filter(r => !r.valid).length,
};
}
// Error frequency
for (const result of results.filter(r => !r.valid)) {
for (const error of result.errors) {
const key = `${error.type}: ${error.message}`;
stats.byError[key] = (stats.byError[key] || 0) + 1;
}
}
// Performance stats
if (results.length > 0) {
stats.avgValidationTimeMs =
results.reduce((sum, r) => sum + r.validationTimeMs, 0) / results.length;
stats.maxValidationTimeMs = Math.max(...results.map(r => r.validationTimeMs));
}
return stats;
}
function printStats(stats: ValidationStats) {
console.log('\n' + '='.repeat(80));
console.log('VALIDATION STATISTICS');
console.log('='.repeat(80) + '\n');
console.log(`📊 Total Templates Tested: ${stats.totalTemplates}`);
console.log(`📊 Total Nodes with Special Types: ${stats.totalNodes}`);
console.log(`📊 Total Property Validations: ${stats.totalValidations}\n`);
const passRate = (stats.passedValidations / stats.totalValidations * 100).toFixed(2);
const failRate = (stats.failedValidations / stats.totalValidations * 100).toFixed(2);
console.log(`✅ Passed: ${stats.passedValidations} (${passRate}%)`);
console.log(`❌ Failed: ${stats.failedValidations} (${failRate}%)\n`);
console.log('By Property Type:');
console.log('-'.repeat(80));
for (const [type, counts] of Object.entries(stats.byType)) {
const total = counts.passed + counts.failed;
if (total === 0) {
console.log(` ${type}: No occurrences found`);
} else {
const typePassRate = (counts.passed / total * 100).toFixed(2);
console.log(` ${type}: ${counts.passed}/${total} passed (${typePassRate}%)`);
}
}
console.log('\n⚡ Performance:');
console.log('-'.repeat(80));
console.log(` Average validation time: ${stats.avgValidationTimeMs.toFixed(2)}ms`);
console.log(` Maximum validation time: ${stats.maxValidationTimeMs.toFixed(2)}ms`);
const meetsTarget = stats.avgValidationTimeMs < 50;
console.log(` Target (<50ms): ${meetsTarget ? '✅ MET' : '❌ NOT MET'}\n`);
if (Object.keys(stats.byError).length > 0) {
console.log('🔍 Most Common Errors:');
console.log('-'.repeat(80));
const sortedErrors = Object.entries(stats.byError)
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
for (const [error, count] of sortedErrors) {
console.log(` ${count}x: ${error}`);
}
}
}
function printFailures(results: ValidationResult[], maxFailures: number = 20) {
const failures = results.filter(r => !r.valid);
if (failures.length === 0) {
console.log('\n✨ No failures! All validations passed.\n');
return;
}
console.log('\n' + '='.repeat(80));
console.log(`VALIDATION FAILURES (showing first ${Math.min(maxFailures, failures.length)})` );
console.log('='.repeat(80) + '\n');
for (let i = 0; i < Math.min(maxFailures, failures.length); i++) {
const failure = failures[i];
console.log(`Failure ${i + 1}/${failures.length}:`);
console.log(` Template: ${failure.templateName} (ID: ${failure.templateId}, Views: ${failure.templateViews})`);
console.log(` Node: ${failure.nodeName} (${failure.nodeType})`);
console.log(` Property: ${failure.propertyName} (type: ${failure.propertyType})`);
console.log(` Errors:`);
for (const error of failure.errors) {
console.log(` - [${error.type}] ${error.property}: ${error.message}`);
}
if (failure.warnings.length > 0) {
console.log(` Warnings:`);
for (const warning of failure.warnings) {
console.log(` - [${warning.type}] ${warning.property}: ${warning.message}`);
}
}
console.log('');
}
if (failures.length > maxFailures) {
console.log(`... and ${failures.length - maxFailures} more failures\n`);
}
}
async function main() {
console.log('='.repeat(80));
console.log('PHASE 3: REAL-WORLD TYPE STRUCTURE VALIDATION');
console.log('='.repeat(80) + '\n');
// Initialize database
console.log('🔌 Connecting to database...');
const db = await createDatabaseAdapter('./data/nodes.db');
console.log('✓ Database connected\n');
// Load templates
const templates = await loadTopTemplates(db, 100);
// Validate each template
console.log('🔍 Validating templates...\n');
const allResults: ValidationResult[] = [];
let processedCount = 0;
let nodesFound = 0;
for (const template of templates) {
processedCount++;
let workflowJson;
try {
workflowJson = decompressWorkflow(template.workflow_json_compressed);
} catch (error) {
console.warn(`⚠️ Template ${template.id}: Decompression failed, skipping`);
continue;
}
const results = await validateTemplate(
template.id,
template.name,
template.views,
workflowJson
);
if (results.length > 0) {
nodesFound += new Set(results.map(r => r.nodeId)).size;
allResults.push(...results);
const passedCount = results.filter(r => r.valid).length;
const status = passedCount === results.length ? '✓' : '✗';
console.log(
`${status} Template ${processedCount}/${templates.length}: ` +
`"${template.name}" (${results.length} validations, ${passedCount} passed)`
);
}
}
console.log(`\n✓ Processed ${processedCount} templates`);
console.log(`✓ Found ${nodesFound} nodes with special types\n`);
// Calculate and print statistics
const stats = calculateStats(allResults);
printStats(stats);
// Print detailed failures
printFailures(allResults);
// Success criteria check
console.log('='.repeat(80));
console.log('SUCCESS CRITERIA CHECK');
console.log('='.repeat(80) + '\n');
const passRate = (stats.passedValidations / stats.totalValidations * 100);
const falsePositiveRate = (stats.failedValidations / stats.totalValidations * 100);
const avgTime = stats.avgValidationTimeMs;
console.log(`Pass Rate: ${passRate.toFixed(2)}% (target: >95%) ${passRate > 95 ? '✅' : '❌'}`);
console.log(`False Positive Rate: ${falsePositiveRate.toFixed(2)}% (target: <5%) ${falsePositiveRate < 5 ? '✅' : '❌'}`);
console.log(`Avg Validation Time: ${avgTime.toFixed(2)}ms (target: <50ms) ${avgTime < 50 ? '✅' : '❌'}\n`);
const allCriteriaMet = passRate > 95 && falsePositiveRate < 5 && avgTime < 50;
if (allCriteriaMet) {
console.log('🎉 ALL SUCCESS CRITERIA MET! Phase 3 validation complete.\n');
} else {
console.log('⚠️ Some success criteria not met. Iteration required.\n');
}
// Close database
db.close();
process.exit(allCriteriaMet ? 0 : 1);
}
// Run the script
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env npx tsx
/**
* Debug script for telemetry integration
* Tests direct Supabase connection
*/
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config();
async function debugTelemetry() {
console.log('🔍 Debugging Telemetry Integration\n');
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
console.error('❌ Missing SUPABASE_URL or SUPABASE_ANON_KEY');
process.exit(1);
}
console.log('Environment:');
console.log(' URL:', supabaseUrl);
console.log(' Key:', supabaseAnonKey.substring(0, 30) + '...');
// Create Supabase client
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
}
});
// Test 1: Direct insert to telemetry_events
console.log('\n📝 Test 1: Direct insert to telemetry_events...');
const testEvent = {
user_id: 'test-user-123',
event: 'test_event',
properties: {
test: true,
timestamp: new Date().toISOString()
}
};
const { data: eventData, error: eventError } = await supabase
.from('telemetry_events')
.insert([testEvent])
.select();
if (eventError) {
console.error('❌ Event insert failed:', eventError);
} else {
console.log('✅ Event inserted successfully:', eventData);
}
// Test 2: Direct insert to telemetry_workflows
console.log('\n📝 Test 2: Direct insert to telemetry_workflows...');
const testWorkflow = {
user_id: 'test-user-123',
workflow_hash: 'test-hash-' + Date.now(),
node_count: 3,
node_types: ['webhook', 'http', 'slack'],
has_trigger: true,
has_webhook: true,
complexity: 'simple',
sanitized_workflow: {
nodes: [],
connections: {}
}
};
const { data: workflowData, error: workflowError } = await supabase
.from('telemetry_workflows')
.insert([testWorkflow])
.select();
if (workflowError) {
console.error('❌ Workflow insert failed:', workflowError);
} else {
console.log('✅ Workflow inserted successfully:', workflowData);
}
// Test 3: Try to read data (should fail with anon key due to RLS)
console.log('\n📖 Test 3: Attempting to read data (should fail due to RLS)...');
const { data: readData, error: readError } = await supabase
.from('telemetry_events')
.select('*')
.limit(1);
if (readError) {
console.log('✅ Read correctly blocked by RLS:', readError.message);
} else {
console.log('⚠️ Unexpected: Read succeeded (RLS may not be working):', readData);
}
// Test 4: Check table existence
console.log('\n🔍 Test 4: Verifying tables exist...');
const { data: tables, error: tablesError } = await supabase
.rpc('get_tables', { schema_name: 'public' })
.select('*');
if (tablesError) {
// This is expected - the RPC function might not exist
console.log('️ Cannot list tables (RPC function not available)');
} else {
console.log('Tables found:', tables);
}
console.log('\n✨ Debug completed! Check your Supabase dashboard for the test data.');
console.log('Dashboard: https://supabase.com/dashboard/project/ydyufsohxdfpopqbubwk/editor');
}
debugTelemetry().catch(error => {
console.error('❌ Debug failed:', error);
process.exit(1);
});
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env npx tsx
/**
* Direct telemetry test with hardcoded credentials
*/
import { createClient } from '@supabase/supabase-js';
const TELEMETRY_BACKEND = {
URL: 'https://ydyufsohxdfpopqbubwk.supabase.co',
ANON_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkeXVmc29oeGRmcG9wcWJ1YndrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Mzc2MzAxMDgsImV4cCI6MjA1MzIwNjEwOH0.LsUTx9OsNtnqg-jxXaJPc84aBHVDehHiMaFoF2Ir8s0'
};
async function testDirect() {
console.log('🧪 Direct Telemetry Test\n');
const supabase = createClient(TELEMETRY_BACKEND.URL, TELEMETRY_BACKEND.ANON_KEY, {
auth: {
persistSession: false,
autoRefreshToken: false,
}
});
const testEvent = {
user_id: 'direct-test-' + Date.now(),
event: 'direct_test',
properties: {
source: 'test-telemetry-direct.ts',
timestamp: new Date().toISOString()
}
};
console.log('Sending event:', testEvent);
const { data, error } = await supabase
.from('telemetry_events')
.insert([testEvent]);
if (error) {
console.error('❌ Failed:', error);
} else {
console.log('✅ Success! Event sent directly to Supabase');
console.log('Response:', data);
}
}
testDirect().catch(console.error);
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env npx tsx
/**
* Test telemetry environment variable override
*/
import { TelemetryConfigManager } from '../src/telemetry/config-manager';
import { telemetry } from '../src/telemetry/telemetry-manager';
async function testEnvOverride() {
console.log('🧪 Testing Telemetry Environment Variable Override\n');
const configManager = TelemetryConfigManager.getInstance();
// Test 1: Check current status without env var
console.log('Test 1: Without environment variable');
console.log('Is Enabled:', configManager.isEnabled());
console.log('Status:', configManager.getStatus());
// Test 2: Set environment variable and check again
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log('Test 2: With N8N_MCP_TELEMETRY_DISABLED=true');
process.env.N8N_MCP_TELEMETRY_DISABLED = 'true';
// Force reload by creating new instance (for testing)
const newConfigManager = TelemetryConfigManager.getInstance();
console.log('Is Enabled:', newConfigManager.isEnabled());
console.log('Status:', newConfigManager.getStatus());
// Test 3: Try tracking with env disabled
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log('Test 3: Attempting to track with telemetry disabled');
telemetry.trackToolUsage('test_tool', true, 100);
console.log('Tool usage tracking attempted (should be ignored)');
// Test 4: Alternative env vars
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log('Test 4: Alternative environment variables');
delete process.env.N8N_MCP_TELEMETRY_DISABLED;
process.env.TELEMETRY_DISABLED = 'true';
console.log('With TELEMETRY_DISABLED=true:', newConfigManager.isEnabled());
delete process.env.TELEMETRY_DISABLED;
process.env.DISABLE_TELEMETRY = 'true';
console.log('With DISABLE_TELEMETRY=true:', newConfigManager.isEnabled());
// Test 5: Env var takes precedence over config
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log('Test 5: Environment variable precedence');
// Enable via config
newConfigManager.enable();
console.log('After enabling via config:', newConfigManager.isEnabled());
// But env var should still override
process.env.N8N_MCP_TELEMETRY_DISABLED = 'true';
console.log('With env var set (should override config):', newConfigManager.isEnabled());
console.log('\n✅ All tests completed!');
}
testEnvOverride().catch(console.error);
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env npx tsx
/**
* Integration test for the telemetry manager
*/
import { telemetry } from '../src/telemetry/telemetry-manager';
async function testIntegration() {
console.log('🧪 Testing Telemetry Manager Integration\n');
// Check status
console.log('Status:', telemetry.getStatus());
// Track session start
console.log('\nTracking session start...');
telemetry.trackSessionStart();
// Track tool usage
console.log('Tracking tool usage...');
telemetry.trackToolUsage('search_nodes', true, 150);
telemetry.trackToolUsage('get_node_info', true, 75);
telemetry.trackToolUsage('validate_workflow', false, 200);
// Track errors
console.log('Tracking errors...');
telemetry.trackError('ValidationError', 'workflow_validation', 'validate_workflow', 'Required field missing: nodes array is empty');
// Track a test workflow
console.log('Tracking workflow creation...');
const testWorkflow = {
nodes: [
{
id: '1',
type: 'n8n-nodes-base.webhook',
name: 'Webhook',
position: [0, 0],
parameters: {
path: '/test-webhook',
httpMethod: 'POST'
}
},
{
id: '2',
type: 'n8n-nodes-base.httpRequest',
name: 'HTTP Request',
position: [250, 0],
parameters: {
url: 'https://api.example.com/endpoint',
method: 'POST',
authentication: 'genericCredentialType',
genericAuthType: 'httpHeaderAuth',
sendHeaders: true,
headerParameters: {
parameters: [
{
name: 'Authorization',
value: 'Bearer sk-1234567890abcdef'
}
]
}
}
},
{
id: '3',
type: 'n8n-nodes-base.slack',
name: 'Slack',
position: [500, 0],
parameters: {
channel: '#notifications',
text: 'Workflow completed!'
}
}
],
connections: {
'1': {
main: [[{ node: '2', type: 'main', index: 0 }]]
},
'2': {
main: [[{ node: '3', type: 'main', index: 0 }]]
}
}
};
telemetry.trackWorkflowCreation(testWorkflow, true);
// Force flush
console.log('\nFlushing telemetry data...');
await telemetry.flush();
console.log('\n✅ Telemetry integration test completed!');
console.log('Check your Supabase dashboard for the telemetry data.');
}
testIntegration().catch(console.error);
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env npx tsx
/**
* Test telemetry without requesting data back
*/
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';
dotenv.config();
async function testNoSelect() {
const supabaseUrl = process.env.SUPABASE_URL!;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY!;
console.log('🧪 Telemetry Test (No Select)\n');
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
}
});
// Insert WITHOUT .select() - just fire and forget
const testData = {
user_id: 'test-' + Date.now(),
event: 'test_event',
properties: { test: true }
};
console.log('Inserting:', testData);
const { error } = await supabase
.from('telemetry_events')
.insert([testData]); // No .select() here!
if (error) {
console.error('❌ Failed:', error);
} else {
console.log('✅ Success! Data inserted (no response data)');
}
// Test workflow insert too
const testWorkflow = {
user_id: 'test-' + Date.now(),
workflow_hash: 'hash-' + Date.now(),
node_count: 3,
node_types: ['webhook', 'http', 'slack'],
has_trigger: true,
has_webhook: true,
complexity: 'simple',
sanitized_workflow: { nodes: [], connections: {} }
};
console.log('\nInserting workflow:', testWorkflow);
const { error: workflowError } = await supabase
.from('telemetry_workflows')
.insert([testWorkflow]); // No .select() here!
if (workflowError) {
console.error('❌ Workflow failed:', workflowError);
} else {
console.log('✅ Workflow inserted successfully!');
}
}
testNoSelect().catch(console.error);
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env npx tsx
/**
* Test that RLS properly protects data
*/
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';
dotenv.config();
async function testSecurity() {
const supabaseUrl = process.env.SUPABASE_URL!;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY!;
console.log('🔒 Testing Telemetry Security (RLS)\n');
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
}
});
// Test 1: Verify anon can INSERT
console.log('Test 1: Anonymous INSERT (should succeed)...');
const testData = {
user_id: 'security-test-' + Date.now(),
event: 'security_test',
properties: { test: true }
};
const { error: insertError } = await supabase
.from('telemetry_events')
.insert([testData]);
if (insertError) {
console.error('❌ Insert failed:', insertError.message);
} else {
console.log('✅ Insert succeeded (as expected)');
}
// Test 2: Verify anon CANNOT SELECT
console.log('\nTest 2: Anonymous SELECT (should fail)...');
const { data, error: selectError } = await supabase
.from('telemetry_events')
.select('*')
.limit(1);
if (selectError) {
console.log('✅ Select blocked by RLS (as expected):', selectError.message);
} else if (data && data.length > 0) {
console.error('❌ SECURITY ISSUE: Anon can read data!', data);
} else if (data && data.length === 0) {
console.log('⚠️ Select returned empty array (might be RLS working)');
}
// Test 3: Verify anon CANNOT UPDATE
console.log('\nTest 3: Anonymous UPDATE (should fail)...');
const { error: updateError } = await supabase
.from('telemetry_events')
.update({ event: 'hacked' })
.eq('user_id', 'test');
if (updateError) {
console.log('✅ Update blocked (as expected):', updateError.message);
} else {
console.error('❌ SECURITY ISSUE: Anon can update data!');
}
// Test 4: Verify anon CANNOT DELETE
console.log('\nTest 4: Anonymous DELETE (should fail)...');
const { error: deleteError } = await supabase
.from('telemetry_events')
.delete()
.eq('user_id', 'test');
if (deleteError) {
console.log('✅ Delete blocked (as expected):', deleteError.message);
} else {
console.error('❌ SECURITY ISSUE: Anon can delete data!');
}
console.log('\n✨ Security test completed!');
console.log('Summary: Anonymous users can INSERT (for telemetry) but cannot READ/UPDATE/DELETE');
}
testSecurity().catch(console.error);
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env npx tsx
/**
* Simple test to verify telemetry works
*/
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';
dotenv.config();
async function testSimple() {
const supabaseUrl = process.env.SUPABASE_URL!;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY!;
console.log('🧪 Simple Telemetry Test\n');
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
}
});
// Simple insert
const testData = {
user_id: 'simple-test-' + Date.now(),
event: 'test_event',
properties: { test: true }
};
console.log('Inserting:', testData);
const { data, error } = await supabase
.from('telemetry_events')
.insert([testData])
.select();
if (error) {
console.error('❌ Failed:', error);
} else {
console.log('✅ Success! Inserted:', data);
}
}
testSimple().catch(console.error);
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env ts-node
/**
* Test script for typeVersion validation in workflow validator
*/
import { NodeRepository } from '../src/database/node-repository';
import { createDatabaseAdapter } from '../src/database/database-adapter';
import { WorkflowValidator } from '../src/services/workflow-validator';
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
import { Logger } from '../src/utils/logger';
const logger = new Logger({ prefix: '[test-typeversion]' });
// Test workflows with various typeVersion scenarios
const testWorkflows = {
// Workflow with missing typeVersion on versioned nodes
missingTypeVersion: {
name: 'Missing typeVersion Test',
nodes: [
{
id: 'webhook_1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
position: [250, 300],
parameters: {
path: '/test',
httpMethod: 'POST'
}
// Missing typeVersion - should error
},
{
id: 'execute_1',
name: 'Execute Command',
type: 'n8n-nodes-base.executeCommand',
position: [450, 300],
parameters: {
command: 'echo "test"'
}
// Missing typeVersion - should error
}
],
connections: {
'Webhook': {
main: [[{ node: 'Execute Command', type: 'main', index: 0 }]]
}
}
},
// Workflow with outdated typeVersion
outdatedTypeVersion: {
name: 'Outdated typeVersion Test',
nodes: [
{
id: 'http_1',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1, // Outdated - latest is likely 4+
position: [250, 300],
parameters: {
url: 'https://example.com',
method: 'GET'
}
},
{
id: 'code_1',
name: 'Code',
type: 'n8n-nodes-base.code',
typeVersion: 1, // Outdated - latest is likely 2
position: [450, 300],
parameters: {
jsCode: 'return items;'
}
}
],
connections: {
'HTTP Request': {
main: [[{ node: 'Code', type: 'main', index: 0 }]]
}
}
},
// Workflow with correct typeVersion
correctTypeVersion: {
name: 'Correct typeVersion Test',
nodes: [
{
id: 'webhook_1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 2,
position: [250, 300],
parameters: {
path: '/test',
httpMethod: 'POST'
}
},
{
id: 'http_1',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4,
position: [450, 300],
parameters: {
url: 'https://example.com',
method: 'GET'
}
}
],
connections: {
'Webhook': {
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
}
}
},
// Workflow with invalid typeVersion
invalidTypeVersion: {
name: 'Invalid typeVersion Test',
nodes: [
{
id: 'webhook_1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 0, // Invalid - must be positive
position: [250, 300],
parameters: {
path: '/test'
}
},
{
id: 'http_1',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 999, // Too high - exceeds maximum
position: [450, 300],
parameters: {
url: 'https://example.com'
}
}
],
connections: {
'Webhook': {
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
}
}
}
};
async function testTypeVersionValidation() {
const dbAdapter = await createDatabaseAdapter('./data/nodes.db');
const repository = new NodeRepository(dbAdapter);
const validator = new WorkflowValidator(repository, EnhancedConfigValidator);
console.log('\n====================================');
console.log('Testing typeVersion Validation');
console.log('====================================\n');
// Check some versioned nodes to show their versions
console.log('📊 Checking versioned nodes in database:');
const versionedNodes = ['nodes-base.webhook', 'nodes-base.httpRequest', 'nodes-base.code', 'nodes-base.executeCommand'];
for (const nodeType of versionedNodes) {
const nodeInfo = repository.getNode(nodeType);
if (nodeInfo) {
console.log(`- ${nodeType}: isVersioned=${nodeInfo.isVersioned}, maxVersion=${nodeInfo.version || 'N/A'}`);
}
}
console.log('\n');
// Test each workflow
for (const [testName, workflow] of Object.entries(testWorkflows)) {
console.log(`\n🧪 Testing: ${testName}`);
console.log('─'.repeat(50));
const result = await validator.validateWorkflow(workflow as any);
console.log(`\n✅ Valid: ${result.valid}`);
if (result.errors.length > 0) {
console.log('\n❌ Errors:');
result.errors.forEach(error => {
console.log(` - [${error.nodeName || 'Workflow'}] ${error.message}`);
});
}
if (result.warnings.length > 0) {
console.log('\n⚠️ Warnings:');
result.warnings.forEach(warning => {
console.log(` - [${warning.nodeName || 'Workflow'}] ${warning.message}`);
});
}
if (result.suggestions.length > 0) {
console.log('\n💡 Suggestions:');
result.suggestions.forEach(suggestion => {
console.log(` - ${suggestion}`);
});
}
}
console.log('\n\n✅ typeVersion validation test completed!');
}
// Run the test
testTypeVersionValidation().catch(console.error);
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env node
/**
* Test script for URL configuration in n8n-MCP HTTP server
* Tests various BASE_URL, TRUST_PROXY, and proxy header scenarios
*/
import axios from 'axios';
import { spawn } from 'child_process';
import { logger } from '../src/utils/logger';
interface TestCase {
name: string;
env: Record<string, string>;
expectedUrls?: {
health: string;
mcp: string;
};
proxyHeaders?: Record<string, string>;
}
const testCases: TestCase[] = [
{
name: 'Default configuration (no BASE_URL)',
env: {
MCP_MODE: 'http',
AUTH_TOKEN: 'test-token-for-testing-only',
PORT: '3001'
},
expectedUrls: {
health: 'http://localhost:3001/health',
mcp: 'http://localhost:3001/mcp'
}
},
{
name: 'With BASE_URL configured',
env: {
MCP_MODE: 'http',
AUTH_TOKEN: 'test-token-for-testing-only',
PORT: '3002',
BASE_URL: 'https://n8n-mcp.example.com'
},
expectedUrls: {
health: 'https://n8n-mcp.example.com/health',
mcp: 'https://n8n-mcp.example.com/mcp'
}
},
{
name: 'With PUBLIC_URL configured',
env: {
MCP_MODE: 'http',
AUTH_TOKEN: 'test-token-for-testing-only',
PORT: '3003',
PUBLIC_URL: 'https://api.company.com/mcp'
},
expectedUrls: {
health: 'https://api.company.com/mcp/health',
mcp: 'https://api.company.com/mcp/mcp'
}
},
{
name: 'With TRUST_PROXY and proxy headers',
env: {
MCP_MODE: 'http',
AUTH_TOKEN: 'test-token-for-testing-only',
PORT: '3004',
TRUST_PROXY: '1'
},
proxyHeaders: {
'X-Forwarded-Proto': 'https',
'X-Forwarded-Host': 'proxy.example.com'
}
},
{
// DEPRECATED: This test case tests the deprecated fixed HTTP implementation
// See: https://github.com/czlonkowski/n8n-mcp/issues/524
name: 'Fixed HTTP implementation (DEPRECATED)',
env: {
MCP_MODE: 'http',
USE_FIXED_HTTP: 'true', // DEPRECATED: Will be removed in future version
AUTH_TOKEN: 'test-token-for-testing-only',
PORT: '3005',
BASE_URL: 'https://fixed.example.com'
},
expectedUrls: {
health: 'https://fixed.example.com/health',
mcp: 'https://fixed.example.com/mcp'
}
}
];
async function runTest(testCase: TestCase): Promise<void> {
console.log(`\n🧪 Testing: ${testCase.name}`);
console.log('Environment:', testCase.env);
const serverProcess = spawn('node', ['dist/mcp/index.js'], {
env: { ...process.env, ...testCase.env }
});
let serverOutput = '';
let serverStarted = false;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
serverProcess.kill();
reject(new Error('Server startup timeout'));
}, 10000);
serverProcess.stdout.on('data', (data) => {
const output = data.toString();
serverOutput += output;
if (output.includes('Press Ctrl+C to stop the server')) {
serverStarted = true;
clearTimeout(timeout);
// Give server a moment to fully initialize
setTimeout(async () => {
try {
// Test root endpoint
const rootUrl = `http://localhost:${testCase.env.PORT}/`;
const rootResponse = await axios.get(rootUrl, {
headers: testCase.proxyHeaders || {}
});
console.log('✅ Root endpoint response:');
console.log(` - Endpoints: ${JSON.stringify(rootResponse.data.endpoints, null, 2)}`);
// Test health endpoint
const healthUrl = `http://localhost:${testCase.env.PORT}/health`;
const healthResponse = await axios.get(healthUrl);
console.log(`✅ Health endpoint status: ${healthResponse.data.status}`);
// Test MCP info endpoint (GET /mcp now requires auth per GHSA-75hx-xj24-mqrw)
const mcpUrl = `http://localhost:${testCase.env.PORT}/mcp`;
const mcpResponse = await axios.get(mcpUrl, {
headers: { Authorization: `Bearer ${testCase.env.AUTH_TOKEN}` }
});
console.log(`✅ MCP info endpoint: ${mcpResponse.data.description}`);
// Check console output
if (testCase.expectedUrls) {
const outputContainsExpectedUrls =
serverOutput.includes(testCase.expectedUrls.health) &&
serverOutput.includes(testCase.expectedUrls.mcp);
if (outputContainsExpectedUrls) {
console.log('✅ Console output shows expected URLs');
} else {
console.log('❌ Console output does not show expected URLs');
console.log('Expected:', testCase.expectedUrls);
}
}
serverProcess.kill();
resolve();
} catch (error) {
console.error('❌ Test failed:', error instanceof Error ? error.message : String(error));
serverProcess.kill();
reject(error);
}
}, 500);
}
});
serverProcess.stderr.on('data', (data) => {
console.error('Server error:', data.toString());
});
serverProcess.on('close', (code) => {
if (!serverStarted) {
reject(new Error(`Server exited with code ${code} before starting`));
} else {
resolve();
}
});
});
}
async function main() {
console.log('🚀 n8n-MCP URL Configuration Test Suite');
console.log('======================================');
for (const testCase of testCases) {
try {
await runTest(testCase);
console.log('✅ Test passed\n');
} catch (error) {
console.error('❌ Test failed:', error instanceof Error ? error.message : String(error));
console.log('\n');
}
}
console.log('✨ All tests completed');
}
main().catch(console.error);
+119
View File
@@ -0,0 +1,119 @@
/**
* Test User ID Persistence
* Verifies that user IDs are consistent across sessions and modes
*/
import { TelemetryConfigManager } from '../src/telemetry/config-manager';
import { hostname, platform, arch, homedir } from 'os';
import { createHash } from 'crypto';
console.log('=== User ID Persistence Test ===\n');
// Test 1: Verify deterministic ID generation
console.log('Test 1: Deterministic ID Generation');
console.log('-----------------------------------');
const machineId = `${hostname()}-${platform()}-${arch()}-${homedir()}`;
const expectedUserId = createHash('sha256')
.update(machineId)
.digest('hex')
.substring(0, 16);
console.log('Machine characteristics:');
console.log(' hostname:', hostname());
console.log(' platform:', platform());
console.log(' arch:', arch());
console.log(' homedir:', homedir());
console.log('\nGenerated machine ID:', machineId);
console.log('Expected user ID:', expectedUserId);
// Test 2: Load actual config
console.log('\n\nTest 2: Actual Config Manager');
console.log('-----------------------------------');
const configManager = TelemetryConfigManager.getInstance();
const actualUserId = configManager.getUserId();
const config = configManager.loadConfig();
console.log('Actual user ID:', actualUserId);
console.log('Config first run:', config.firstRun || 'Unknown');
console.log('Config version:', config.version || 'Unknown');
console.log('Telemetry enabled:', config.enabled);
// Test 3: Verify consistency
console.log('\n\nTest 3: Consistency Check');
console.log('-----------------------------------');
const match = actualUserId === expectedUserId;
console.log('User IDs match:', match ? '✓ YES' : '✗ NO');
if (!match) {
console.log('WARNING: User ID mismatch detected!');
console.log('This could indicate an implementation issue.');
}
// Test 4: Multiple loads (simulate multiple sessions)
console.log('\n\nTest 4: Multiple Session Simulation');
console.log('-----------------------------------');
const userId1 = configManager.getUserId();
const userId2 = TelemetryConfigManager.getInstance().getUserId();
const userId3 = configManager.getUserId();
console.log('Session 1 user ID:', userId1);
console.log('Session 2 user ID:', userId2);
console.log('Session 3 user ID:', userId3);
const consistent = userId1 === userId2 && userId2 === userId3;
console.log('All sessions consistent:', consistent ? '✓ YES' : '✗ NO');
// Test 5: Docker environment simulation
console.log('\n\nTest 5: Docker Environment Check');
console.log('-----------------------------------');
const isDocker = process.env.IS_DOCKER === 'true';
console.log('Running in Docker:', isDocker);
if (isDocker) {
console.log('\n⚠️ DOCKER MODE DETECTED');
console.log('In Docker, user IDs may change across container recreations because:');
console.log(' 1. Container hostname changes each time');
console.log(' 2. Config file is not persisted (no volume mount)');
console.log(' 3. Each container gets a new ephemeral filesystem');
console.log('\nRecommendation: Mount ~/.n8n-mcp as a volume for persistent user IDs');
}
// Test 6: Environment variable override check
console.log('\n\nTest 6: Environment Variable Override');
console.log('-----------------------------------');
const telemetryDisabledVars = [
'N8N_MCP_TELEMETRY_DISABLED',
'TELEMETRY_DISABLED',
'DISABLE_TELEMETRY'
];
telemetryDisabledVars.forEach(varName => {
const value = process.env[varName];
if (value !== undefined) {
console.log(`${varName}:`, value);
}
});
console.log('\nTelemetry status:', configManager.isEnabled() ? 'ENABLED' : 'DISABLED');
// Summary
console.log('\n\n=== SUMMARY ===');
console.log('User ID:', actualUserId);
console.log('Deterministic:', match ? 'YES ✓' : 'NO ✗');
console.log('Persistent across sessions:', consistent ? 'YES ✓' : 'NO ✗');
console.log('Telemetry enabled:', config.enabled ? 'YES' : 'NO');
console.log('Docker mode:', isDocker ? 'YES' : 'NO');
if (isDocker && !process.env.N8N_MCP_CONFIG_VOLUME) {
console.log('\n⚠️ WARNING: Running in Docker without persistent volume!');
console.log('User IDs will change on container recreation.');
console.log('Mount /home/nodejs/.n8n-mcp to persist telemetry config.');
}
console.log('\n');
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env npx tsx
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator.js';
console.log('🧪 Testing Webhook Data Access Validation\n');
const testCases = [
{
name: 'Direct webhook data access (incorrect)',
config: {
language: 'javaScript',
jsCode: `// Processing data from Webhook node
const prevWebhook = $('Webhook').first();
const command = items[0].json.testCommand;
const data = items[0].json.payload;
return [{json: {command, data}}];`
},
expectWarning: true
},
{
name: 'Correct webhook data access through body',
config: {
language: 'javaScript',
jsCode: `// Processing data from Webhook node
const webhookData = items[0].json.body;
const command = webhookData.testCommand;
const data = webhookData.payload;
return [{json: {command, data}}];`
},
expectWarning: false
},
{
name: 'Common webhook field names without body',
config: {
language: 'javaScript',
jsCode: `// Processing webhook
const command = items[0].json.command;
const action = items[0].json.action;
const payload = items[0].json.payload;
return [{json: {command, action, payload}}];`
},
expectWarning: true
},
{
name: 'Non-webhook data access (should not warn)',
config: {
language: 'javaScript',
jsCode: `// Processing data from HTTP Request node
const data = items[0].json.results;
const status = items[0].json.status;
return [{json: {data, status}}];`
},
expectWarning: false
},
{
name: 'Mixed correct and incorrect access',
config: {
language: 'javaScript',
jsCode: `// Mixed access patterns
const webhookBody = items[0].json.body; // Correct
const directAccess = items[0].json.command; // Incorrect if webhook
return [{json: {webhookBody, directAccess}}];`
},
expectWarning: false // If user already uses .body, we assume they know the pattern
}
];
let passCount = 0;
let failCount = 0;
for (const test of testCases) {
console.log(`Test: ${test.name}`);
const result = EnhancedConfigValidator.validateWithMode(
'nodes-base.code',
test.config,
[
{ name: 'language', type: 'options', options: ['javaScript', 'python'] },
{ name: 'jsCode', type: 'string' }
],
'operation',
'ai-friendly'
);
const hasWebhookWarning = result.warnings.some(w =>
w.message.includes('Webhook data is nested under .body') ||
w.message.includes('webhook data, remember it\'s nested under .body')
);
const passed = hasWebhookWarning === test.expectWarning;
console.log(` Expected warning: ${test.expectWarning}`);
console.log(` Has webhook warning: ${hasWebhookWarning}`);
console.log(` Result: ${passed ? '✅ PASS' : '❌ FAIL'}`);
if (result.warnings.length > 0) {
const relevantWarnings = result.warnings
.filter(w => w.message.includes('webhook') || w.message.includes('Webhook'))
.map(w => w.message);
if (relevantWarnings.length > 0) {
console.log(` Webhook warnings: ${relevantWarnings.join(', ')}`);
}
}
if (passed) passCount++;
else failCount++;
console.log();
}
console.log(`\n📊 Results: ${passCount} passed, ${failCount} failed`);
console.log(failCount === 0 ? '✅ All webhook validation tests passed!' : '❌ Some tests failed');
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env npx tsx
/**
* Test direct workflow insert to Supabase
*/
import { createClient } from '@supabase/supabase-js';
const TELEMETRY_BACKEND = {
URL: 'https://ydyufsohxdfpopqbubwk.supabase.co',
ANON_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkeXVmc29oeGRmcG9wcWJ1YndrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTg3OTYyMDAsImV4cCI6MjA3NDM3MjIwMH0.xESphg6h5ozaDsm4Vla3QnDJGc6Nc_cpfoqTHRynkCk'
};
async function testWorkflowInsert() {
const supabase = createClient(TELEMETRY_BACKEND.URL, TELEMETRY_BACKEND.ANON_KEY, {
auth: {
persistSession: false,
autoRefreshToken: false,
}
});
const testWorkflow = {
user_id: 'direct-test-' + Date.now(),
workflow_hash: 'hash-direct-' + Date.now(),
node_count: 2,
node_types: ['webhook', 'http'],
has_trigger: true,
has_webhook: true,
complexity: 'simple' as const,
sanitized_workflow: {
nodes: [
{ id: '1', type: 'webhook', parameters: {} },
{ id: '2', type: 'http', parameters: {} }
],
connections: {}
}
};
console.log('Attempting direct insert to telemetry_workflows...');
console.log('Data:', JSON.stringify(testWorkflow, null, 2));
const { data, error } = await supabase
.from('telemetry_workflows')
.insert([testWorkflow]);
if (error) {
console.error('\n❌ Error:', error);
} else {
console.log('\n✅ Success! Workflow inserted');
if (data) {
console.log('Response:', data);
}
}
}
testWorkflowInsert().catch(console.error);
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env npx tsx
/**
* Test workflow sanitizer
*/
import { WorkflowSanitizer } from '../src/telemetry/workflow-sanitizer';
const testWorkflow = {
nodes: [
{
id: 'webhook1',
type: 'n8n-nodes-base.webhook',
name: 'Webhook',
position: [0, 0],
parameters: {
path: '/test-webhook',
httpMethod: 'POST'
}
},
{
id: 'http1',
type: 'n8n-nodes-base.httpRequest',
name: 'HTTP Request',
position: [250, 0],
parameters: {
url: 'https://api.example.com/endpoint',
method: 'GET',
authentication: 'genericCredentialType',
sendHeaders: true,
headerParameters: {
parameters: [
{
name: 'Authorization',
value: 'Bearer sk-1234567890abcdef'
}
]
}
}
}
],
connections: {
'webhook1': {
main: [[{ node: 'http1', type: 'main', index: 0 }]]
}
}
};
console.log('🧪 Testing Workflow Sanitizer\n');
console.log('Original workflow has', testWorkflow.nodes.length, 'nodes');
try {
const sanitized = WorkflowSanitizer.sanitizeWorkflow(testWorkflow);
console.log('\n✅ Sanitization successful!');
console.log('\nSanitized output:');
console.log(JSON.stringify(sanitized, null, 2));
console.log('\n📊 Metrics:');
console.log('- Workflow Hash:', sanitized.workflowHash);
console.log('- Node Count:', sanitized.nodeCount);
console.log('- Node Types:', sanitized.nodeTypes);
console.log('- Has Trigger:', sanitized.hasTrigger);
console.log('- Has Webhook:', sanitized.hasWebhook);
console.log('- Complexity:', sanitized.complexity);
} catch (error) {
console.error('❌ Sanitization failed:', error);
}
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env npx tsx
/**
* Debug workflow tracking in telemetry manager
*/
import { TelemetryManager } from '../src/telemetry/telemetry-manager';
// Get the singleton instance
const telemetry = TelemetryManager.getInstance();
const testWorkflow = {
nodes: [
{
id: 'webhook1',
type: 'n8n-nodes-base.webhook',
name: 'Webhook',
position: [0, 0],
parameters: {
path: '/test-' + Date.now(),
httpMethod: 'POST'
}
},
{
id: 'http1',
type: 'n8n-nodes-base.httpRequest',
name: 'HTTP Request',
position: [250, 0],
parameters: {
url: 'https://api.example.com/data',
method: 'GET'
}
},
{
id: 'slack1',
type: 'n8n-nodes-base.slack',
name: 'Slack',
position: [500, 0],
parameters: {
channel: '#general',
text: 'Workflow complete!'
}
}
],
connections: {
'webhook1': {
main: [[{ node: 'http1', type: 'main', index: 0 }]]
},
'http1': {
main: [[{ node: 'slack1', type: 'main', index: 0 }]]
}
}
};
console.log('🧪 Testing Workflow Tracking\n');
console.log('Workflow has', testWorkflow.nodes.length, 'nodes');
// Track the workflow
console.log('Calling trackWorkflowCreation...');
telemetry.trackWorkflowCreation(testWorkflow, true);
console.log('Waiting for async processing...');
// Wait for setImmediate to process
setTimeout(async () => {
console.log('\nForcing flush...');
await telemetry.flush();
console.log('✅ Flush complete!');
console.log('\nWorkflow should now be in the telemetry_workflows table.');
console.log('Check with: SELECT * FROM telemetry_workflows ORDER BY created_at DESC LIMIT 1;');
}, 2000);
+287
View File
@@ -0,0 +1,287 @@
#!/usr/bin/env node
/**
* Test Workflow Versioning System
*
* Tests the complete workflow rollback and versioning functionality:
* - Automatic backup creation
* - Auto-pruning to 10 versions
* - Version history retrieval
* - Rollback with validation
* - Manual pruning and cleanup
* - Storage statistics
*/
import { NodeRepository } from '../src/database/node-repository';
import { createDatabaseAdapter } from '../src/database/database-adapter';
import { WorkflowVersioningService } from '../src/services/workflow-versioning-service';
import { logger } from '../src/utils/logger';
import { existsSync } from 'fs';
import * as path from 'path';
// Mock workflow for testing
const createMockWorkflow = (id: string, name: string, nodeCount: number = 3) => ({
id,
name,
active: false,
nodes: Array.from({ length: nodeCount }, (_, i) => ({
id: `node-${i}`,
name: `Node ${i}`,
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [250 + i * 200, 300],
parameters: { values: { string: [{ name: `field${i}`, value: `value${i}` }] } }
})),
connections: nodeCount > 1 ? {
'node-0': { main: [[{ node: 'node-1', type: 'main', index: 0 }]] },
...(nodeCount > 2 && { 'node-1': { main: [[{ node: 'node-2', type: 'main', index: 0 }]] } })
} : {},
settings: {}
});
async function runTests() {
console.log('🧪 Testing Workflow Versioning System\n');
// Find database path
const possiblePaths = [
path.join(process.cwd(), 'data', 'nodes.db'),
path.join(__dirname, '../../data', 'nodes.db'),
'./data/nodes.db'
];
let dbPath: string | null = null;
for (const p of possiblePaths) {
if (existsSync(p)) {
dbPath = p;
break;
}
}
if (!dbPath) {
console.error('❌ Database not found. Please run npm run rebuild first.');
process.exit(1);
}
console.log(`📁 Using database: ${dbPath}\n`);
// Initialize repository
const db = await createDatabaseAdapter(dbPath);
const repository = new NodeRepository(db);
const service = new WorkflowVersioningService(repository);
const workflowId = 'test-workflow-001';
let testsPassed = 0;
let testsFailed = 0;
try {
// Test 1: Create initial backup
console.log('📝 Test 1: Create initial backup');
const workflow1 = createMockWorkflow(workflowId, 'Test Workflow v1', 3);
const backup1 = await service.createBackup(workflowId, workflow1, {
trigger: 'partial_update',
operations: [{ type: 'addNode', node: workflow1.nodes[0] }]
});
if (backup1.versionId && backup1.versionNumber === 1 && backup1.pruned === 0) {
console.log('✅ Initial backup created successfully');
console.log(` Version ID: ${backup1.versionId}, Version Number: ${backup1.versionNumber}`);
testsPassed++;
} else {
console.log('❌ Failed to create initial backup');
testsFailed++;
}
// Test 2: Create multiple backups to test auto-pruning
console.log('\n📝 Test 2: Create 12 backups to test auto-pruning (should keep only 10)');
for (let i = 2; i <= 12; i++) {
const workflow = createMockWorkflow(workflowId, `Test Workflow v${i}`, 3 + i);
await service.createBackup(workflowId, workflow, {
trigger: i % 3 === 0 ? 'full_update' : 'partial_update',
operations: [{ type: 'addNode', node: { id: `node-${i}` } }]
});
}
const versions = await service.getVersionHistory(workflowId, 100);
if (versions.length === 10) {
console.log(`✅ Auto-pruning works correctly (kept exactly 10 versions)`);
console.log(` Latest version: ${versions[0].versionNumber}, Oldest: ${versions[9].versionNumber}`);
testsPassed++;
} else {
console.log(`❌ Auto-pruning failed (expected 10 versions, got ${versions.length})`);
testsFailed++;
}
// Test 3: Get version history
console.log('\n📝 Test 3: Get version history');
const history = await service.getVersionHistory(workflowId, 5);
if (history.length === 5 && history[0].versionNumber > history[4].versionNumber) {
console.log(`✅ Version history retrieved successfully (${history.length} versions)`);
console.log(' Recent versions:');
history.forEach(v => {
console.log(` - v${v.versionNumber} (${v.trigger}) - ${v.workflowName} - ${(v.size / 1024).toFixed(2)} KB`);
});
testsPassed++;
} else {
console.log('❌ Failed to get version history');
testsFailed++;
}
// Test 4: Get specific version
console.log('\n📝 Test 4: Get specific version details');
const specificVersion = await service.getVersion(history[2].id);
if (specificVersion && specificVersion.workflowSnapshot) {
console.log(`✅ Retrieved version ${specificVersion.versionNumber} successfully`);
console.log(` Workflow name: ${specificVersion.workflowName}`);
console.log(` Node count: ${specificVersion.workflowSnapshot.nodes.length}`);
console.log(` Trigger: ${specificVersion.trigger}`);
testsPassed++;
} else {
console.log('❌ Failed to get specific version');
testsFailed++;
}
// Test 5: Compare two versions
console.log('\n📝 Test 5: Compare two versions');
if (history.length >= 2) {
const diff = await service.compareVersions(history[0].id, history[1].id);
console.log(`✅ Version comparison successful`);
console.log(` Comparing v${diff.version1Number} → v${diff.version2Number}`);
console.log(` Added nodes: ${diff.addedNodes.length}`);
console.log(` Removed nodes: ${diff.removedNodes.length}`);
console.log(` Modified nodes: ${diff.modifiedNodes.length}`);
console.log(` Connection changes: ${diff.connectionChanges}`);
testsPassed++;
} else {
console.log('❌ Not enough versions to compare');
testsFailed++;
}
// Test 6: Manual pruning
console.log('\n📝 Test 6: Manual pruning (keep only 5 versions)');
const pruneResult = await service.pruneVersions(workflowId, 5);
if (pruneResult.pruned === 5 && pruneResult.remaining === 5) {
console.log(`✅ Manual pruning successful`);
console.log(` Pruned: ${pruneResult.pruned} versions, Remaining: ${pruneResult.remaining}`);
testsPassed++;
} else {
console.log(`❌ Manual pruning failed (expected 5 pruned, 5 remaining, got ${pruneResult.pruned} pruned, ${pruneResult.remaining} remaining)`);
testsFailed++;
}
// Test 7: Storage statistics
console.log('\n📝 Test 7: Storage statistics');
const stats = await service.getStorageStats();
if (stats.totalVersions > 0 && stats.byWorkflow.length > 0) {
console.log(`✅ Storage stats retrieved successfully`);
console.log(` Total versions: ${stats.totalVersions}`);
console.log(` Total size: ${stats.totalSizeFormatted}`);
console.log(` Workflows with versions: ${stats.byWorkflow.length}`);
stats.byWorkflow.forEach(w => {
console.log(` - ${w.workflowName}: ${w.versionCount} versions, ${w.totalSizeFormatted}`);
});
testsPassed++;
} else {
console.log('❌ Failed to get storage stats');
testsFailed++;
}
// Test 8: Delete specific version
console.log('\n📝 Test 8: Delete specific version');
const versionsBeforeDelete = await service.getVersionHistory(workflowId, 100);
const versionToDelete = versionsBeforeDelete[versionsBeforeDelete.length - 1];
const deleteResult = await service.deleteVersion(versionToDelete.id);
const versionsAfterDelete = await service.getVersionHistory(workflowId, 100);
if (deleteResult.success && versionsAfterDelete.length === versionsBeforeDelete.length - 1) {
console.log(`✅ Version deletion successful`);
console.log(` Deleted version ${versionToDelete.versionNumber}`);
console.log(` Remaining versions: ${versionsAfterDelete.length}`);
testsPassed++;
} else {
console.log('❌ Failed to delete version');
testsFailed++;
}
// Test 9: Test different trigger types
console.log('\n📝 Test 9: Test different trigger types');
const workflow2 = createMockWorkflow(workflowId, 'Test Workflow Autofix', 2);
const backupAutofix = await service.createBackup(workflowId, workflow2, {
trigger: 'autofix',
fixTypes: ['expression-format', 'typeversion-correction']
});
const workflow3 = createMockWorkflow(workflowId, 'Test Workflow Full Update', 4);
const backupFull = await service.createBackup(workflowId, workflow3, {
trigger: 'full_update',
metadata: { reason: 'Major refactoring' }
});
const allVersions = await service.getVersionHistory(workflowId, 100);
const autofixVersions = allVersions.filter(v => v.trigger === 'autofix');
const fullUpdateVersions = allVersions.filter(v => v.trigger === 'full_update');
const partialUpdateVersions = allVersions.filter(v => v.trigger === 'partial_update');
if (autofixVersions.length > 0 && fullUpdateVersions.length > 0 && partialUpdateVersions.length > 0) {
console.log(`✅ All trigger types working correctly`);
console.log(` Partial updates: ${partialUpdateVersions.length}`);
console.log(` Full updates: ${fullUpdateVersions.length}`);
console.log(` Autofixes: ${autofixVersions.length}`);
testsPassed++;
} else {
console.log('❌ Failed to create versions with different trigger types');
testsFailed++;
}
// Test 10: Cleanup - Delete all versions for workflow
console.log('\n📝 Test 10: Delete all versions for workflow');
const deleteAllResult = await service.deleteAllVersions(workflowId);
const versionsAfterDeleteAll = await service.getVersionHistory(workflowId, 100);
if (deleteAllResult.deleted > 0 && versionsAfterDeleteAll.length === 0) {
console.log(`✅ Delete all versions successful`);
console.log(` Deleted ${deleteAllResult.deleted} versions`);
testsPassed++;
} else {
console.log('❌ Failed to delete all versions');
testsFailed++;
}
// Test 11: Truncate all versions (requires confirmation)
console.log('\n📝 Test 11: Test truncate without confirmation');
const truncateResult1 = await service.truncateAllVersions(false);
if (truncateResult1.deleted === 0 && truncateResult1.message.includes('not confirmed')) {
console.log(`✅ Truncate safety check works (requires confirmation)`);
testsPassed++;
} else {
console.log('❌ Truncate safety check failed');
testsFailed++;
}
// Summary
console.log('\n' + '='.repeat(60));
console.log('📊 Test Summary');
console.log('='.repeat(60));
console.log(`✅ Passed: ${testsPassed}`);
console.log(`❌ Failed: ${testsFailed}`);
console.log(`📈 Success Rate: ${((testsPassed / (testsPassed + testsFailed)) * 100).toFixed(1)}%`);
console.log('='.repeat(60));
if (testsFailed === 0) {
console.log('\n🎉 All tests passed! Workflow versioning system is working correctly.');
process.exit(0);
} else {
console.log('\n⚠️ Some tests failed. Please review the implementation.');
process.exit(1);
}
} catch (error: any) {
console.error('\n❌ Test suite failed with error:', error.message);
console.error(error.stack);
process.exit(1);
}
}
// Run tests
runTests().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
});
+192
View File
@@ -0,0 +1,192 @@
#!/bin/bash
# Comprehensive script to update n8n dependencies, run tests, and prepare for npm publish
# Based on MEMORY_N8N_UPDATE.md but enhanced with test suite and publish preparation
set -e
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}🚀 n8n Update and Publish Preparation Script${NC}"
echo "=============================================="
echo ""
# 1. Check current branch
CURRENT_BRANCH=$(git branch --show-current)
if [ "$CURRENT_BRANCH" != "main" ]; then
echo -e "${YELLOW}⚠️ Warning: Not on main branch (current: $CURRENT_BRANCH)${NC}"
echo "It's recommended to run this on the main branch."
read -p "Continue anyway? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
# 2. Check for uncommitted changes
if ! git diff-index --quiet HEAD --; then
echo -e "${RED}❌ Error: You have uncommitted changes${NC}"
echo "Please commit or stash your changes before updating."
exit 1
fi
# 3. Get current versions for comparison
echo -e "${BLUE}📊 Current versions:${NC}"
CURRENT_N8N=$(node -e "console.log(require('./package.json').dependencies['n8n-nodes-base'])" 2>/dev/null || echo "not installed")
CURRENT_PROJECT=$(node -e "console.log(require('./package.json').version)")
echo "- n8n-nodes-base: $CURRENT_N8N"
echo "- n8n-mcp: $CURRENT_PROJECT"
echo ""
# 4. Check for updates first
echo -e "${BLUE}🔍 Checking for n8n updates...${NC}"
npm run update:n8n:check
echo ""
read -p "Do you want to proceed with the update? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Update cancelled."
exit 0
fi
# 5. Update n8n dependencies
echo ""
echo -e "${BLUE}📦 Updating n8n dependencies...${NC}"
npm run update:n8n
# 6. Run the test suite
echo ""
echo -e "${BLUE}🧪 Running comprehensive test suite (1,182 tests)...${NC}"
npm test
if [ $? -ne 0 ]; then
echo -e "${RED}❌ Tests failed! Please fix failing tests before proceeding.${NC}"
exit 1
fi
echo -e "${GREEN}✅ All tests passed!${NC}"
# 7. Run validation
echo ""
echo -e "${BLUE}✔️ Validating critical nodes...${NC}"
npm run validate
# 8. Build the project
echo ""
echo -e "${BLUE}🔨 Building project...${NC}"
npm run build
# 9. Bump version
echo ""
echo -e "${BLUE}📌 Bumping version...${NC}"
# Get new n8n-nodes-base version
NEW_N8N=$(node -e "console.log(require('./package.json').dependencies['n8n-nodes-base'])")
# Bump patch version
npm version patch --no-git-tag-version
# Get new project version
NEW_PROJECT=$(node -e "console.log(require('./package.json').version)")
# 10. Update n8n version badge in README
echo ""
echo -e "${BLUE}📝 Updating n8n version badge...${NC}"
sed -i.bak "s/n8n-v[0-9.]*/n8n-$NEW_N8N/" README.md && rm README.md.bak
# 11. Sync runtime version (this also updates the version badge in README)
echo ""
echo -e "${BLUE}🔄 Syncing runtime version and updating version badge...${NC}"
npm run sync:runtime-version
# 12. Get update details for commit message
echo ""
echo -e "${BLUE}📊 Gathering update information...${NC}"
# Get all tracked n8n package versions
N8N_CORE=$(node -e "console.log(require('./package.json').dependencies['n8n-core'])")
N8N_WORKFLOW=$(node -e "console.log(require('./package.json').dependencies['n8n-workflow'])")
N8N_LANGCHAIN=$(node -e "console.log(require('./package.json').dependencies['@n8n/n8n-nodes-langchain'])")
# Get node count from database
NODE_COUNT=$(node -e "
const Database = require('better-sqlite3');
const db = new Database('./data/nodes.db', { readonly: true });
const count = db.prepare('SELECT COUNT(*) as count FROM nodes').get().count;
console.log(count);
db.close();
" 2>/dev/null || echo "unknown")
# Check if templates were sanitized
TEMPLATES_SANITIZED=false
if [ -f "./data/nodes.db" ]; then
TEMPLATE_COUNT=$(node -e "
const Database = require('better-sqlite3');
const db = new Database('./data/nodes.db', { readonly: true });
const count = db.prepare('SELECT COUNT(*) as count FROM templates').get().count;
console.log(count);
db.close();
" 2>/dev/null || echo "0")
if [ "$TEMPLATE_COUNT" != "0" ]; then
TEMPLATES_SANITIZED=true
fi
fi
# 13. Create commit message
echo ""
echo -e "${BLUE}📝 Creating commit...${NC}"
COMMIT_MSG="chore: update n8n to $NEW_N8N and bump version to $NEW_PROJECT
- Updated n8n-nodes-base to $NEW_N8N
- Updated n8n-core to $N8N_CORE
- Updated n8n-workflow to $N8N_WORKFLOW
- Updated @n8n/n8n-nodes-langchain to $N8N_LANGCHAIN
- Rebuilt node database with $NODE_COUNT nodes"
if [ "$TEMPLATES_SANITIZED" = true ]; then
COMMIT_MSG="$COMMIT_MSG
- Sanitized $TEMPLATE_COUNT workflow templates"
fi
COMMIT_MSG="$COMMIT_MSG
- All 1,182 tests passing (933 unit, 249 integration)
- All validation tests passing
- Built and prepared for npm publish
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>"
# 14. Stage all changes
git add -A
# 15. Show what will be committed
echo ""
echo -e "${BLUE}📋 Changes to be committed:${NC}"
git status --short
# 16. Commit changes
git commit -m "$COMMIT_MSG"
# 17. Summary
echo ""
echo -e "${GREEN}✅ Update completed successfully!${NC}"
echo ""
echo -e "${BLUE}Summary:${NC}"
echo "- Updated n8n-nodes-base from $CURRENT_N8N to $NEW_N8N"
echo "- Bumped version from $CURRENT_PROJECT to $NEW_PROJECT"
echo "- All 1,182 tests passed"
echo "- Project built and ready for npm publish"
echo ""
echo -e "${YELLOW}Next steps:${NC}"
echo "1. Push to GitHub:"
echo -e " ${GREEN}git push origin $CURRENT_BRANCH${NC}"
echo ""
echo "2. Create a GitHub release (after push):"
echo -e " ${GREEN}gh release create v$NEW_PROJECT --title \"v$NEW_PROJECT\" --notes \"Updated n8n to $NEW_N8N\"${NC}"
echo ""
echo "3. Publish to npm:"
echo -e " ${GREEN}npm run prepare:publish${NC}"
echo " Then follow the instructions to publish with OTP"
echo ""
echo -e "${BLUE}🎉 Done!${NC}"
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env node
/**
* Update n8n dependencies to latest versions
* Can be run manually or via GitHub Actions
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
class N8nDependencyUpdater {
constructor() {
this.packageJsonPath = path.join(__dirname, '..', 'package.json');
// Track n8n-nodes-base directly (the package our loader actually requires).
// The full `n8n` meta package was dropped in favor of this leaner dep tree.
this.mainPackage = 'n8n-nodes-base';
}
/**
* Compare two semver-ish versions. Returns -1 / 0 / 1 (a<b, a==b, a>b).
* Enough for the "don't downgrade" guard; not a full semver parser.
*/
compareVersions(a, b) {
const parse = (v) => v.split('.').map((p) => parseInt(p, 10) || 0);
const [a1, a2, a3] = parse(a);
const [b1, b2, b3] = parse(b);
if (a1 !== b1) return a1 < b1 ? -1 : 1;
if (a2 !== b2) return a2 < b2 ? -1 : 1;
if (a3 !== b3) return a3 < b3 ? -1 : 1;
return 0;
}
/**
* Resolve the set of n8n sub-package versions compatible with the current
* `n8n@latest` release. The `n8n` meta package is the source of truth for
* which sub-package versions constitute "n8n X.Y.Z" — individual
* sub-packages (notably n8n-nodes-base, n8n-workflow) don't keep their
* `latest` dist-tag in sync, so querying each one's tag can return
* versions older than what n8n itself depends on.
*/
getN8nDependencySet() {
try {
const output = execSync('npm view n8n@latest dependencies --json', { encoding: 'utf8' });
return JSON.parse(output);
} catch (error) {
console.error('Failed to resolve n8n@latest dependencies:', error.message);
return null;
}
}
/**
* Get current version from package.json
*/
getCurrentVersion(packageName) {
const packageJson = JSON.parse(fs.readFileSync(this.packageJsonPath, 'utf8'));
const version = packageJson.dependencies[packageName];
return version ? version.replace(/^[\^~]/, '') : null;
}
/**
* Check which packages need updates.
*
* Versions are resolved from `n8n@latest`'s dependency pins rather than
* each sub-package's own `latest` dist-tag — n8n does not keep the
* per-package tags in sync, which previously caused this script to
* propose downgrades.
*/
async checkForUpdates() {
console.log('🔍 Checking for n8n dependency updates...\n');
const trackedDeps = [
'n8n-nodes-base',
'n8n-core',
'n8n-workflow',
'@n8n/n8n-nodes-langchain',
];
const metaDeps = this.getN8nDependencySet();
if (!metaDeps) {
console.error('Aborting: could not resolve n8n@latest dependency set');
return [];
}
const updates = [];
for (const dep of trackedDeps) {
const currentVersion = this.getCurrentVersion(dep);
const latestVersion = metaDeps[dep];
if (!currentVersion) {
console.error(`Failed to read current version for ${dep}`);
continue;
}
if (!latestVersion) {
console.error(`${dep} is not listed in n8n@latest dependencies — skipping`);
continue;
}
const cmp = this.compareVersions(currentVersion, latestVersion);
if (cmp === 0) {
console.log(`${dep}: ${currentVersion} (up to date)`);
} else if (cmp < 0) {
console.log(`📦 ${dep}: ${currentVersion}${latestVersion} (update available)`);
updates.push({
package: dep,
current: currentVersion,
latest: latestVersion,
});
} else {
console.log(`⏭️ ${dep}: ${currentVersion} is ahead of n8n@latest pin ${latestVersion} — skipping (no downgrade)`);
}
}
return updates;
}
/**
* Update package.json with new versions
*/
updatePackageJson(updates) {
if (updates.length === 0) {
console.log('\n✨ All n8n dependencies are up to date and in sync!');
return false;
}
console.log(`\n📝 Updating ${updates.length} packages in package.json...`);
const packageJson = JSON.parse(fs.readFileSync(this.packageJsonPath, 'utf8'));
for (const update of updates) {
// Exact pin (no caret) so a fresh `npm install` after a future minor release
// can't slip in a different node set than the database was rebuilt against.
// The DB rebuild step assumes these versions are reproducible.
packageJson.dependencies[update.package] = update.latest;
console.log(` Updated ${update.package} to ${update.latest}`);
}
fs.writeFileSync(
this.packageJsonPath,
JSON.stringify(packageJson, null, 2) + '\n',
'utf8'
);
return true;
}
/**
* Run npm install to update lock file
*/
runNpmInstall() {
console.log('\n📥 Running npm install to update lock file...');
try {
execSync('npm install', {
cwd: path.join(__dirname, '..'),
stdio: 'inherit'
});
return true;
} catch (error) {
console.error('❌ npm install failed:', error.message);
return false;
}
}
/**
* Rebuild the node database
*/
rebuildDatabase() {
console.log('\n🔨 Rebuilding node database...');
try {
execSync('npm run build && npm run rebuild', {
cwd: path.join(__dirname, '..'),
stdio: 'inherit'
});
return true;
} catch (error) {
console.error('❌ Database rebuild failed:', error.message);
return false;
}
}
/**
* Run validation tests
*/
runValidation() {
console.log('\n🧪 Running validation tests...');
try {
execSync('npm run validate', {
cwd: path.join(__dirname, '..'),
stdio: 'inherit'
});
console.log('✅ All tests passed!');
return true;
} catch (error) {
console.error('❌ Validation failed:', error.message);
return false;
}
}
/**
* Generate update summary for PR/commit message
*/
generateUpdateSummary(updates) {
if (updates.length === 0) return '';
const summary = ['Updated n8n dependencies:\n'];
for (const update of updates) {
summary.push(`- ${update.package}: ${update.current}${update.latest}`);
}
return summary.join('\n');
}
/**
* Main update process
*/
async run(options = {}) {
const { dryRun = false, skipTests = false } = options;
console.log('🚀 n8n Dependency Updater\n');
console.log('Mode:', dryRun ? 'DRY RUN' : 'LIVE UPDATE');
console.log('Skip tests:', skipTests ? 'YES' : 'NO');
console.log('Strategy: Update n8n and sync its required dependencies');
console.log('');
// Check for updates
const updates = await this.checkForUpdates();
if (updates.length === 0) {
process.exit(0);
}
if (dryRun) {
console.log('\n🔍 DRY RUN: No changes made');
console.log('\nUpdate summary:');
console.log(this.generateUpdateSummary(updates));
process.exit(0);
}
// Apply updates
if (!this.updatePackageJson(updates)) {
process.exit(0);
}
// Install dependencies
if (!this.runNpmInstall()) {
console.error('\n❌ Update failed at npm install step');
process.exit(1);
}
// Rebuild database
if (!this.rebuildDatabase()) {
console.error('\n❌ Update failed at database rebuild step');
process.exit(1);
}
// Run tests
if (!skipTests && !this.runValidation()) {
console.error('\n❌ Update failed at validation step');
process.exit(1);
}
// Success!
console.log('\n✅ Update completed successfully!');
console.log('\nUpdate summary:');
console.log(this.generateUpdateSummary(updates));
// Write summary to file for GitHub Actions
if (process.env.GITHUB_ACTIONS) {
fs.writeFileSync(
path.join(__dirname, '..', 'update-summary.txt'),
this.generateUpdateSummary(updates),
'utf8'
);
}
}
}
// CLI handling
if (require.main === module) {
const args = process.argv.slice(2);
const options = {
dryRun: args.includes('--dry-run') || args.includes('-d'),
skipTests: args.includes('--skip-tests') || args.includes('-s')
};
const updater = new N8nDependencyUpdater();
updater.run(options).catch(error => {
console.error('Unexpected error:', error);
process.exit(1);
});
}
module.exports = N8nDependencyUpdater;
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
// Read package.json
const packageJsonPath = path.join(__dirname, '..', 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const version = packageJson.version;
// Read README.md
const readmePath = path.join(__dirname, '..', 'README.md');
let readmeContent = fs.readFileSync(readmePath, 'utf8');
// Update the version badge on line 5
// The pattern matches: [![Version](https://img.shields.io/badge/version-X.X.X-blue.svg)]
const versionBadgeRegex = /(\[!\[Version\]\(https:\/\/img\.shields\.io\/badge\/version-)[^-]+(-.+?\)\])/;
const newVersionBadge = `$1${version}$2`;
readmeContent = readmeContent.replace(versionBadgeRegex, newVersionBadge);
// Write back to README.md
fs.writeFileSync(readmePath, readmeContent);
console.log(`✅ Updated README.md version badge to v${version}`);