feat: implement n8n integration improvements and protocol version negotiation
- Add intelligent protocol version negotiation (2024-11-05 for n8n, 2025-03-26 for standard clients) - Fix memory leak potential with async cleanup and connection close handling - Enhance error sanitization for production environments - Add schema validation for n8n nested output workaround - Improve Docker security with unpredictable UIDs/GIDs - Create n8n-friendly tool descriptions to reduce schema validation errors - Add comprehensive protocol negotiation test suite Addresses code review feedback: - Protocol version inconsistency resolved - Memory management improved - Error information leakage fixed - Docker security enhanced 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
36
.env.backup
36
.env.backup
@@ -1,36 +0,0 @@
|
|||||||
# n8n-mcp Docker Environment Configuration
|
|
||||||
# Copy this file to .env and customize for your deployment
|
|
||||||
|
|
||||||
# === n8n Configuration ===
|
|
||||||
# n8n basic auth (change these in production!)
|
|
||||||
N8N_BASIC_AUTH_ACTIVE=true
|
|
||||||
N8N_BASIC_AUTH_USER=admin
|
|
||||||
N8N_BASIC_AUTH_PASSWORD=changeme
|
|
||||||
|
|
||||||
# n8n host configuration
|
|
||||||
N8N_HOST=localhost
|
|
||||||
N8N_PORT=5678
|
|
||||||
N8N_PROTOCOL=http
|
|
||||||
N8N_WEBHOOK_URL=http://localhost:5678/
|
|
||||||
|
|
||||||
# n8n encryption key (generate with: openssl rand -hex 32)
|
|
||||||
N8N_ENCRYPTION_KEY=03ce3b083dce12577b8ba7889c57844ca3fe6557c8394bb67183c05357b418f9
|
|
||||||
|
|
||||||
# === n8n-mcp Configuration ===
|
|
||||||
# MCP server port
|
|
||||||
MCP_PORT=3000
|
|
||||||
|
|
||||||
# MCP authentication token (generate with: openssl rand -hex 32)
|
|
||||||
MCP_AUTH_TOKEN=0dcead8b41afe4d26bbe93d6e78784c974427a8b8db572ee356d976ec82d13f7
|
|
||||||
|
|
||||||
# n8n API key for MCP to access n8n
|
|
||||||
# Get this from n8n UI: Settings > n8n API > Create API Key
|
|
||||||
N8N_API_KEY=
|
|
||||||
|
|
||||||
# Logging level (debug, info, warn, error)
|
|
||||||
LOG_LEVEL=info
|
|
||||||
|
|
||||||
# === GitHub Container Registry (for CI/CD) ===
|
|
||||||
# Only needed if building custom images
|
|
||||||
GITHUB_REPOSITORY=czlonkowski/n8n-mcp
|
|
||||||
VERSION=latest
|
|
||||||
10
Dockerfile
10
Dockerfile
@@ -57,9 +57,13 @@ LABEL org.opencontainers.image.description="n8n MCP Server - Runtime Only"
|
|||||||
LABEL org.opencontainers.image.licenses="MIT"
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
LABEL org.opencontainers.image.title="n8n-mcp"
|
LABEL org.opencontainers.image.title="n8n-mcp"
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user with unpredictable UID/GID
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
# Using a hash of the build time to generate unpredictable IDs
|
||||||
adduser -S nodejs -u 1001 && \
|
RUN BUILD_HASH=$(date +%s | sha256sum | head -c 8) && \
|
||||||
|
UID=$((10000 + 0x${BUILD_HASH} % 50000)) && \
|
||||||
|
GID=$((10000 + 0x${BUILD_HASH} % 50000)) && \
|
||||||
|
addgroup -g ${GID} -S nodejs && \
|
||||||
|
adduser -S nodejs -u ${UID} -G nodejs && \
|
||||||
chown -R nodejs:nodejs /app
|
chown -R nodejs:nodejs /app
|
||||||
|
|
||||||
# Switch to non-root user
|
# Switch to non-root user
|
||||||
|
|||||||
@@ -29,9 +29,13 @@ RUN apk add --no-cache \
|
|||||||
tini \
|
tini \
|
||||||
&& rm -rf /var/cache/apk/*
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
# Create non-root user with less common UID/GID
|
# Create non-root user with unpredictable UID/GID
|
||||||
RUN addgroup -g 1001 n8n-mcp && \
|
# Using a hash of the build time to generate unpredictable IDs
|
||||||
adduser -u 1001 -G n8n-mcp -s /bin/sh -D n8n-mcp
|
RUN BUILD_HASH=$(date +%s | sha256sum | head -c 8) && \
|
||||||
|
UID=$((10000 + 0x${BUILD_HASH} % 50000)) && \
|
||||||
|
GID=$((10000 + 0x${BUILD_HASH} % 50000)) && \
|
||||||
|
addgroup -g ${GID} n8n-mcp && \
|
||||||
|
adduser -u ${UID} -G n8n-mcp -s /bin/sh -D n8n-mcp
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
13
coverage.json
Normal file
13
coverage.json
Normal file
File diff suppressed because one or more lines are too long
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
162
docs/issue-90-findings.md
Normal file
162
docs/issue-90-findings.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Issue #90: "propertyValues[itemName] is not iterable" Error - Research Findings
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The error "propertyValues[itemName] is not iterable" occurs when AI agents create workflows with incorrect data structures for n8n nodes that use `fixedCollection` properties. This primarily affects Switch Node v2, If Node, and Filter Node. The error prevents workflows from loading in the n8n UI, resulting in empty canvases.
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
### 1. Data Structure Mismatch
|
||||||
|
|
||||||
|
The error occurs when n8n's validation engine expects an iterable array but encounters a non-iterable object. This happens with nodes using `fixedCollection` type properties.
|
||||||
|
|
||||||
|
**Incorrect Structure (causes error):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rules": {
|
||||||
|
"conditions": {
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"value1": "={{$json.status}}",
|
||||||
|
"operation": "equals",
|
||||||
|
"value2": "active"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct Structure:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rules": {
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"value1": "={{$json.status}}",
|
||||||
|
"operation": "equals",
|
||||||
|
"value2": "active"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Affected Nodes
|
||||||
|
|
||||||
|
Based on the research and issue comments, the following nodes are affected:
|
||||||
|
|
||||||
|
1. **Switch Node v2** (`n8n-nodes-base.switch` with typeVersion: 2)
|
||||||
|
- Uses `rules` parameter with `conditions` fixedCollection
|
||||||
|
- v3 doesn't have this issue due to restructured schema
|
||||||
|
|
||||||
|
2. **If Node** (`n8n-nodes-base.if` with typeVersion: 1)
|
||||||
|
- Uses `conditions` parameter with nested conditions array
|
||||||
|
- Similar structure to Switch v2
|
||||||
|
|
||||||
|
3. **Filter Node** (`n8n-nodes-base.filter`)
|
||||||
|
- Uses `conditions` parameter
|
||||||
|
- Same fixedCollection pattern
|
||||||
|
|
||||||
|
### 3. Why AI Agents Create Incorrect Structures
|
||||||
|
|
||||||
|
1. **Training Data Issues**: AI models may have been trained on outdated or incorrect n8n workflow examples
|
||||||
|
2. **Nested Object Inference**: AI tends to create unnecessarily nested structures when it sees collection-type parameters
|
||||||
|
3. **Legacy Format Confusion**: Mixing v2 and v3 Switch node formats
|
||||||
|
4. **Schema Misinterpretation**: The term "fixedCollection" may lead AI to create object wrappers
|
||||||
|
|
||||||
|
## Current Impact
|
||||||
|
|
||||||
|
From issue #90 comments:
|
||||||
|
- Multiple users experiencing the issue
|
||||||
|
- Workflows fail to load completely (empty canvas)
|
||||||
|
- Users resort to using Switch Node v3 or direct API calls
|
||||||
|
- The issue appears in "most MCPs" according to user feedback
|
||||||
|
|
||||||
|
## Recommended Actions
|
||||||
|
|
||||||
|
### 1. Immediate Validation Enhancement
|
||||||
|
|
||||||
|
Add specific validation for fixedCollection properties in the workflow validator:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In workflow-validator.ts or enhanced-config-validator.ts
|
||||||
|
function validateFixedCollectionParameters(node, result) {
|
||||||
|
const problematicNodes = {
|
||||||
|
'n8n-nodes-base.switch': { version: 2, fields: ['rules'] },
|
||||||
|
'n8n-nodes-base.if': { version: 1, fields: ['conditions'] },
|
||||||
|
'n8n-nodes-base.filter': { version: 1, fields: ['conditions'] }
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeConfig = problematicNodes[node.type];
|
||||||
|
if (nodeConfig && node.typeVersion === nodeConfig.version) {
|
||||||
|
// Validate structure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Enhanced MCP Tool Validation
|
||||||
|
|
||||||
|
Update the validation tools to detect and prevent this specific error pattern:
|
||||||
|
|
||||||
|
1. **In `validate_node_operation` tool**: Add checks for fixedCollection structures
|
||||||
|
2. **In `validate_workflow` tool**: Include specific validation for Switch/If nodes
|
||||||
|
3. **In `n8n_create_workflow` tool**: Pre-validate parameters before submission
|
||||||
|
|
||||||
|
### 3. AI-Friendly Examples
|
||||||
|
|
||||||
|
Update workflow examples to show correct structures:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In workflow-examples.ts
|
||||||
|
export const SWITCH_NODE_EXAMPLE = {
|
||||||
|
name: "Switch",
|
||||||
|
type: "n8n-nodes-base.switch",
|
||||||
|
typeVersion: 3, // Prefer v3 over v2
|
||||||
|
parameters: {
|
||||||
|
// Correct v3 structure
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Migration Strategy
|
||||||
|
|
||||||
|
For existing workflows with Switch v2:
|
||||||
|
1. Detect Switch v2 nodes in validation
|
||||||
|
2. Suggest migration to v3
|
||||||
|
3. Provide automatic conversion utility
|
||||||
|
|
||||||
|
### 5. Documentation Updates
|
||||||
|
|
||||||
|
1. Add warnings about fixedCollection structures in tool documentation
|
||||||
|
2. Include specific examples of correct vs incorrect structures
|
||||||
|
3. Document the Switch v2 to v3 migration path
|
||||||
|
|
||||||
|
## Proposed Implementation Priority
|
||||||
|
|
||||||
|
1. **High Priority**: Add validation to prevent creation of invalid structures
|
||||||
|
2. **High Priority**: Update existing validation tools to catch this error
|
||||||
|
3. **Medium Priority**: Add auto-fix capabilities to correct structures
|
||||||
|
4. **Medium Priority**: Update examples and documentation
|
||||||
|
5. **Low Priority**: Create migration utilities for v2 to v3
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
1. Create test cases for each affected node type
|
||||||
|
2. Test both correct and incorrect structures
|
||||||
|
3. Verify validation catches all variants of the error
|
||||||
|
4. Test auto-fix suggestions work correctly
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
- Zero instances of "propertyValues[itemName] is not iterable" in newly created workflows
|
||||||
|
- Clear error messages that guide users to correct structures
|
||||||
|
- Successful validation of all Switch/If node configurations before workflow creation
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Implement validation enhancements in the workflow validator
|
||||||
|
2. Update MCP tools to include these validations
|
||||||
|
3. Add comprehensive tests
|
||||||
|
4. Update documentation with clear examples
|
||||||
|
5. Consider adding a migration tool for existing workflows
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"fetch:templates:robust": "node dist/scripts/fetch-templates-robust.js",
|
"fetch:templates:robust": "node dist/scripts/fetch-templates-robust.js",
|
||||||
"prebuild:fts5": "npx tsx scripts/prebuild-fts5.ts",
|
"prebuild:fts5": "npx tsx scripts/prebuild-fts5.ts",
|
||||||
"test:templates": "node dist/scripts/test-templates.js",
|
"test:templates": "node dist/scripts/test-templates.js",
|
||||||
|
"test:protocol-negotiation": "npx tsx src/scripts/test-protocol-negotiation.ts",
|
||||||
"test:workflow-validation": "node dist/scripts/test-workflow-validation.js",
|
"test:workflow-validation": "node dist/scripts/test-workflow-validation.js",
|
||||||
"test:template-validation": "node dist/scripts/test-template-validation.js",
|
"test:template-validation": "node dist/scripts/test-template-validation.js",
|
||||||
"test:essentials": "node dist/scripts/test-essentials.js",
|
"test:essentials": "node dist/scripts/test-essentials.js",
|
||||||
|
|||||||
327
scripts/debug-n8n-mode.js
Normal file
327
scripts/debug-n8n-mode.js
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug script for n8n integration issues
|
||||||
|
* Tests MCP protocol compliance and identifies schema validation problems
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const MCP_PORT = process.env.MCP_PORT || 3001;
|
||||||
|
const AUTH_TOKEN = process.env.AUTH_TOKEN || 'test-token-for-n8n-testing-minimum-32-chars';
|
||||||
|
|
||||||
|
console.log('🔍 Debugging n8n MCP Integration Issues');
|
||||||
|
console.log('=====================================\n');
|
||||||
|
|
||||||
|
// Test data for different MCP protocol calls
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
name: 'MCP Initialize',
|
||||||
|
path: '/mcp',
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'initialize',
|
||||||
|
params: {
|
||||||
|
protocolVersion: '2025-03-26',
|
||||||
|
capabilities: {
|
||||||
|
tools: {}
|
||||||
|
},
|
||||||
|
clientInfo: {
|
||||||
|
name: 'n8n-debug-test',
|
||||||
|
version: '1.0.0'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
id: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Tools List',
|
||||||
|
path: '/mcp',
|
||||||
|
method: 'POST',
|
||||||
|
sessionId: null, // Will be set after initialize
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'tools/list',
|
||||||
|
params: {},
|
||||||
|
id: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Tools Call - tools_documentation',
|
||||||
|
path: '/mcp',
|
||||||
|
method: 'POST',
|
||||||
|
sessionId: null, // Will be set after initialize
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'tools_documentation',
|
||||||
|
arguments: {}
|
||||||
|
},
|
||||||
|
id: 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Tools Call - get_node_essentials',
|
||||||
|
path: '/mcp',
|
||||||
|
method: 'POST',
|
||||||
|
sessionId: null, // Will be set after initialize
|
||||||
|
data: {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'get_node_essentials',
|
||||||
|
arguments: {
|
||||||
|
nodeType: 'nodes-base.httpRequest'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
id: 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function makeRequest(testCase) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const data = JSON.stringify(testCase.data);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: MCP_PORT,
|
||||||
|
path: testCase.path,
|
||||||
|
method: testCase.method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(data),
|
||||||
|
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||||
|
'Accept': 'application/json, text/event-stream' // Fix for StreamableHTTPServerTransport
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add session ID header if available
|
||||||
|
if (testCase.sessionId) {
|
||||||
|
options.headers['Mcp-Session-Id'] = testCase.sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📤 Making request: ${testCase.name}`);
|
||||||
|
console.log(` Method: ${testCase.method} ${testCase.path}`);
|
||||||
|
if (testCase.sessionId) {
|
||||||
|
console.log(` Session-ID: ${testCase.sessionId}`);
|
||||||
|
}
|
||||||
|
console.log(` Data: ${data}`);
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
let responseData = '';
|
||||||
|
|
||||||
|
console.log(`📥 Response Status: ${res.statusCode}`);
|
||||||
|
console.log(` Headers:`, res.headers);
|
||||||
|
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
responseData += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
let parsed;
|
||||||
|
|
||||||
|
// Handle SSE format response
|
||||||
|
if (responseData.startsWith('event: message\ndata: ')) {
|
||||||
|
const dataLine = responseData.split('\n').find(line => line.startsWith('data: '));
|
||||||
|
if (dataLine) {
|
||||||
|
const jsonData = dataLine.substring(6); // Remove 'data: '
|
||||||
|
parsed = JSON.parse(jsonData);
|
||||||
|
} else {
|
||||||
|
throw new Error('Could not extract JSON from SSE response');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parsed = JSON.parse(responseData);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
headers: res.headers,
|
||||||
|
data: parsed,
|
||||||
|
raw: responseData
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
resolve({
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
headers: res.headers,
|
||||||
|
data: null,
|
||||||
|
raw: responseData,
|
||||||
|
parseError: e.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(data);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateMCPResponse(testCase, response) {
|
||||||
|
console.log(`✅ Validating response for: ${testCase.name}`);
|
||||||
|
|
||||||
|
const issues = [];
|
||||||
|
|
||||||
|
// Check HTTP status
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
issues.push(`❌ Expected HTTP 200, got ${response.statusCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check JSON-RPC structure
|
||||||
|
if (!response.data) {
|
||||||
|
issues.push(`❌ Response is not valid JSON: ${response.parseError}`);
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data.jsonrpc !== '2.0') {
|
||||||
|
issues.push(`❌ Missing or invalid jsonrpc field: ${response.data.jsonrpc}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data.id !== testCase.data.id) {
|
||||||
|
issues.push(`❌ ID mismatch: expected ${testCase.data.id}, got ${response.data.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method-specific validation
|
||||||
|
if (testCase.data.method === 'initialize') {
|
||||||
|
if (!response.data.result) {
|
||||||
|
issues.push(`❌ Initialize response missing result field`);
|
||||||
|
} else {
|
||||||
|
if (!response.data.result.protocolVersion) {
|
||||||
|
issues.push(`❌ Initialize response missing protocolVersion`);
|
||||||
|
} else if (response.data.result.protocolVersion !== '2025-03-26') {
|
||||||
|
issues.push(`❌ Protocol version mismatch: expected 2025-03-26, got ${response.data.result.protocolVersion}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.data.result.capabilities) {
|
||||||
|
issues.push(`❌ Initialize response missing capabilities`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.data.result.serverInfo) {
|
||||||
|
issues.push(`❌ Initialize response missing serverInfo`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract session ID for subsequent requests
|
||||||
|
if (response.headers['mcp-session-id']) {
|
||||||
|
console.log(`📋 Session ID: ${response.headers['mcp-session-id']}`);
|
||||||
|
return { issues, sessionId: response.headers['mcp-session-id'] };
|
||||||
|
} else {
|
||||||
|
issues.push(`❌ Initialize response missing Mcp-Session-Id header`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testCase.data.method === 'tools/list') {
|
||||||
|
if (!response.data.result || !response.data.result.tools) {
|
||||||
|
issues.push(`❌ Tools list response missing tools array`);
|
||||||
|
} else {
|
||||||
|
console.log(`📋 Found ${response.data.result.tools.length} tools`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testCase.data.method === 'tools/call') {
|
||||||
|
if (!response.data.result) {
|
||||||
|
issues.push(`❌ Tool call response missing result field`);
|
||||||
|
} else if (!response.data.result.content) {
|
||||||
|
issues.push(`❌ Tool call response missing content array`);
|
||||||
|
} else if (!Array.isArray(response.data.result.content)) {
|
||||||
|
issues.push(`❌ Tool call response content is not an array`);
|
||||||
|
} else {
|
||||||
|
// Validate content structure
|
||||||
|
for (let i = 0; i < response.data.result.content.length; i++) {
|
||||||
|
const content = response.data.result.content[i];
|
||||||
|
if (!content.type) {
|
||||||
|
issues.push(`❌ Content item ${i} missing type field`);
|
||||||
|
}
|
||||||
|
if (content.type === 'text' && !content.text) {
|
||||||
|
issues.push(`❌ Text content item ${i} missing text field`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issues.length === 0) {
|
||||||
|
console.log(`✅ ${testCase.name} validation passed`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ ${testCase.name} validation failed:`);
|
||||||
|
issues.forEach(issue => console.log(` ${issue}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTests() {
|
||||||
|
console.log('Starting MCP protocol compliance tests...\n');
|
||||||
|
|
||||||
|
let sessionId = null;
|
||||||
|
let allIssues = [];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
try {
|
||||||
|
// Set session ID from previous test
|
||||||
|
if (sessionId && testCase.name !== 'MCP Initialize') {
|
||||||
|
testCase.sessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await makeRequest(testCase);
|
||||||
|
console.log(`📄 Raw Response: ${response.raw}\n`);
|
||||||
|
|
||||||
|
const validation = await validateMCPResponse(testCase, response);
|
||||||
|
|
||||||
|
if (validation.sessionId) {
|
||||||
|
sessionId = validation.sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
allIssues.push(...validation.issues);
|
||||||
|
|
||||||
|
console.log('─'.repeat(50));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Request failed for ${testCase.name}:`, error.message);
|
||||||
|
allIssues.push(`Request failed for ${testCase.name}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log('\n📊 SUMMARY');
|
||||||
|
console.log('==========');
|
||||||
|
|
||||||
|
if (allIssues.length === 0) {
|
||||||
|
console.log('🎉 All tests passed! MCP protocol compliance looks good.');
|
||||||
|
} else {
|
||||||
|
console.log(`❌ Found ${allIssues.length} issues:`);
|
||||||
|
allIssues.forEach((issue, i) => {
|
||||||
|
console.log(` ${i + 1}. ${issue}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🔍 Recommendations:');
|
||||||
|
console.log('1. Check MCP server logs at /tmp/mcp-server.log');
|
||||||
|
console.log('2. Verify protocol version consistency (should be 2025-03-26)');
|
||||||
|
console.log('3. Ensure tool schemas match MCP specification exactly');
|
||||||
|
console.log('4. Test with actual n8n MCP Client Tool node');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if MCP server is running
|
||||||
|
console.log(`Checking if MCP server is running at localhost:${MCP_PORT}...`);
|
||||||
|
|
||||||
|
const healthCheck = http.get(`http://localhost:${MCP_PORT}/health`, (res) => {
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
console.log('✅ MCP server is running\n');
|
||||||
|
runTests().catch(console.error);
|
||||||
|
} else {
|
||||||
|
console.error('❌ MCP server health check failed:', res.statusCode);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}).on('error', (err) => {
|
||||||
|
console.error('❌ MCP server is not running. Please start it first:', err.message);
|
||||||
|
console.error('Use: npm run start:n8n');
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -3,6 +3,27 @@
|
|||||||
# Script to test n8n integration with n8n-mcp server
|
# Script to test n8n integration with n8n-mcp server
|
||||||
set -e
|
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..."
|
echo "🚀 Starting n8n integration test environment..."
|
||||||
|
|
||||||
# Colors for output
|
# Colors for output
|
||||||
@@ -19,6 +40,8 @@ AUTH_TOKEN="test-token-for-n8n-testing-minimum-32-chars"
|
|||||||
|
|
||||||
# n8n data directory for persistence
|
# n8n data directory for persistence
|
||||||
N8N_DATA_DIR="$HOME/.n8n-mcp-test"
|
N8N_DATA_DIR="$HOME/.n8n-mcp-test"
|
||||||
|
# API key storage file
|
||||||
|
API_KEY_FILE="$N8N_DATA_DIR/.n8n-api-key"
|
||||||
|
|
||||||
# Function to detect OS
|
# Function to detect OS
|
||||||
detect_os() {
|
detect_os() {
|
||||||
@@ -199,25 +222,61 @@ for i in {1..30}; do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
# Guide user to get API key
|
# Check for saved API key
|
||||||
echo -e "\n${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
if [ -f "$API_KEY_FILE" ]; then
|
||||||
echo -e "${YELLOW}🔑 n8n API Key Setup${NC}"
|
# Read saved API key
|
||||||
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
N8N_API_KEY=$(cat "$API_KEY_FILE" 2>/dev/null || echo "")
|
||||||
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
|
if [ -n "$N8N_API_KEY" ]; then
|
||||||
echo -e "\n${YELLOW}Please paste your n8n API key here (or press Enter to skip):${NC}"
|
echo -e "\n${GREEN}✅ Using saved n8n API key${NC}"
|
||||||
read -r N8N_API_KEY
|
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
|
# Check if API key was provided
|
||||||
if [ -z "$N8N_API_KEY" ]; then
|
if [ -z "$N8N_API_KEY" ]; then
|
||||||
|
|||||||
95
scripts/test-n8n-mode.sh
Executable file
95
scripts/test-n8n-mode.sh
Executable file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Test script for n8n MCP integration fixes
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🔧 Testing n8n MCP Integration Fixes"
|
||||||
|
echo "===================================="
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
MCP_PORT=${MCP_PORT:-3001}
|
||||||
|
AUTH_TOKEN=${AUTH_TOKEN:-"test-token-for-n8n-testing-minimum-32-chars"}
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Cleanup function
|
||||||
|
cleanup() {
|
||||||
|
echo -e "\n${YELLOW}🧹 Cleaning up...${NC}"
|
||||||
|
if [ -n "$MCP_PID" ] && kill -0 $MCP_PID 2>/dev/null; then
|
||||||
|
echo "Stopping MCP server..."
|
||||||
|
kill $MCP_PID 2>/dev/null || true
|
||||||
|
wait $MCP_PID 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo -e "${GREEN}✅ Cleanup complete${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
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}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build the project (our fixes)
|
||||||
|
echo -e "${YELLOW}📦 Building project with fixes...${NC}"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Start MCP server in n8n mode
|
||||||
|
echo -e "\n${GREEN}🚀 Starting MCP server in n8n mode...${NC}"
|
||||||
|
N8N_MODE=true \
|
||||||
|
MCP_MODE=http \
|
||||||
|
AUTH_TOKEN="${AUTH_TOKEN}" \
|
||||||
|
PORT=${MCP_PORT} \
|
||||||
|
DEBUG_MCP=true \
|
||||||
|
node dist/mcp/index.js > /tmp/mcp-n8n-test.log 2>&1 &
|
||||||
|
|
||||||
|
MCP_PID=$!
|
||||||
|
echo -e "${YELLOW}📄 MCP server logs: /tmp/mcp-n8n-test.log${NC}"
|
||||||
|
|
||||||
|
# Wait for server to start
|
||||||
|
echo -e "${YELLOW}⏳ Waiting for MCP server to start...${NC}"
|
||||||
|
for i in {1..15}; 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 15 ]; then
|
||||||
|
echo -e "${RED}❌ MCP server failed to start${NC}"
|
||||||
|
echo "Server logs:"
|
||||||
|
cat /tmp/mcp-n8n-test.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Test the protocol fixes
|
||||||
|
echo -e "\n${BLUE}🧪 Testing protocol fixes...${NC}"
|
||||||
|
|
||||||
|
# Run our debug script
|
||||||
|
echo -e "${YELLOW}Running comprehensive MCP protocol tests...${NC}"
|
||||||
|
node scripts/debug-n8n-mode.js
|
||||||
|
|
||||||
|
echo -e "\n${GREEN}🎉 Test complete!${NC}"
|
||||||
|
echo -e "\n📋 Summary of fixes applied:"
|
||||||
|
echo -e " ✅ Fixed protocol version mismatch (now using 2025-03-26)"
|
||||||
|
echo -e " ✅ Enhanced tool response formatting and size validation"
|
||||||
|
echo -e " ✅ Added comprehensive parameter validation"
|
||||||
|
echo -e " ✅ Improved error handling and logging"
|
||||||
|
echo -e " ✅ Added initialization request debugging"
|
||||||
|
|
||||||
|
echo -e "\n📝 Next steps:"
|
||||||
|
echo -e " 1. If tests pass, the n8n schema validation errors should be resolved"
|
||||||
|
echo -e " 2. Test with actual n8n MCP Client Tool node"
|
||||||
|
echo -e " 3. Monitor logs at /tmp/mcp-n8n-test.log for any remaining issues"
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}Press any key to view recent server logs, or Ctrl+C to exit...${NC}"
|
||||||
|
read -n 1
|
||||||
|
|
||||||
|
echo -e "\n${BLUE}📄 Recent server logs:${NC}"
|
||||||
|
tail -50 /tmp/mcp-n8n-test.log
|
||||||
428
scripts/test-n8n-mode.ts
Normal file
428
scripts/test-n8n-mode.ts
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
#!/usr/bin/env ts-node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TypeScript test script for n8n MCP integration fixes
|
||||||
|
* Tests the protocol changes and identifies any remaining issues
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'http';
|
||||||
|
import { spawn, ChildProcess } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
interface TestResult {
|
||||||
|
name: string;
|
||||||
|
passed: boolean;
|
||||||
|
error?: string;
|
||||||
|
response?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
class N8nMcpTester {
|
||||||
|
private mcpProcess: ChildProcess | null = null;
|
||||||
|
private readonly mcpPort = 3001;
|
||||||
|
private readonly authToken = 'test-token-for-n8n-testing-minimum-32-chars';
|
||||||
|
private sessionId: string | null = null;
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
console.log('🔧 Testing n8n MCP Integration Fixes');
|
||||||
|
console.log('====================================\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.startMcpServer();
|
||||||
|
await this.runTests();
|
||||||
|
} finally {
|
||||||
|
await this.cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startMcpServer(): Promise<void> {
|
||||||
|
console.log('📦 Starting MCP server in n8n mode...');
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
this.mcpProcess = spawn('node', ['dist/mcp/index.js'], {
|
||||||
|
cwd: projectRoot,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
N8N_MODE: 'true',
|
||||||
|
MCP_MODE: 'http',
|
||||||
|
AUTH_TOKEN: this.authToken,
|
||||||
|
PORT: this.mcpPort.toString(),
|
||||||
|
DEBUG_MCP: 'true'
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log server output
|
||||||
|
this.mcpProcess.stdout?.on('data', (data) => {
|
||||||
|
console.log(`[MCP] ${data.toString().trim()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mcpProcess.stderr?.on('data', (data) => {
|
||||||
|
console.error(`[MCP ERROR] ${data.toString().trim()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for server to be ready
|
||||||
|
await this.waitForServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForServer(): Promise<void> {
|
||||||
|
console.log('⏳ Waiting for MCP server to be ready...');
|
||||||
|
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
try {
|
||||||
|
await this.makeHealthCheck();
|
||||||
|
console.log('✅ MCP server is ready!\n');
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (i === 29) {
|
||||||
|
throw new Error('MCP server failed to start within 30 seconds');
|
||||||
|
}
|
||||||
|
await this.sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeHealthCheck(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.get(`http://localhost:${this.mcpPort}/health`, (res) => {
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Health check failed: ${res.statusCode}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(5000, () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Health check timeout'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runTests(): Promise<void> {
|
||||||
|
const tests: TestResult[] = [];
|
||||||
|
|
||||||
|
// Test 1: Initialize with correct protocol version
|
||||||
|
tests.push(await this.testInitialize());
|
||||||
|
|
||||||
|
// Test 2: List tools
|
||||||
|
tests.push(await this.testListTools());
|
||||||
|
|
||||||
|
// Test 3: Call tools_documentation
|
||||||
|
tests.push(await this.testToolCall('tools_documentation', {}));
|
||||||
|
|
||||||
|
// Test 4: Call get_node_essentials with parameters
|
||||||
|
tests.push(await this.testToolCall('get_node_essentials', {
|
||||||
|
nodeType: 'nodes-base.httpRequest'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Test 5: Call with invalid parameters (should handle gracefully)
|
||||||
|
tests.push(await this.testToolCallInvalid());
|
||||||
|
|
||||||
|
this.printResults(tests);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testInitialize(): Promise<TestResult> {
|
||||||
|
console.log('🧪 Testing MCP Initialize...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('POST', '/mcp', {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'initialize',
|
||||||
|
params: {
|
||||||
|
protocolVersion: '2025-03-26',
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
clientInfo: { name: 'n8n-test', version: '1.0.0' }
|
||||||
|
},
|
||||||
|
id: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
return {
|
||||||
|
name: 'Initialize',
|
||||||
|
passed: false,
|
||||||
|
error: `HTTP ${response.statusCode}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(response.body);
|
||||||
|
|
||||||
|
// Extract session ID
|
||||||
|
this.sessionId = response.headers['mcp-session-id'] as string;
|
||||||
|
|
||||||
|
if (data.result?.protocolVersion === '2025-03-26') {
|
||||||
|
return {
|
||||||
|
name: 'Initialize',
|
||||||
|
passed: true,
|
||||||
|
response: data
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: 'Initialize',
|
||||||
|
passed: false,
|
||||||
|
error: `Wrong protocol version: ${data.result?.protocolVersion}`,
|
||||||
|
response: data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
name: 'Initialize',
|
||||||
|
passed: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testListTools(): Promise<TestResult> {
|
||||||
|
console.log('🧪 Testing Tools List...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('POST', '/mcp', {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'tools/list',
|
||||||
|
params: {},
|
||||||
|
id: 2
|
||||||
|
}, this.sessionId);
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
return {
|
||||||
|
name: 'List Tools',
|
||||||
|
passed: false,
|
||||||
|
error: `HTTP ${response.statusCode}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(response.body);
|
||||||
|
|
||||||
|
if (data.result?.tools && Array.isArray(data.result.tools)) {
|
||||||
|
return {
|
||||||
|
name: 'List Tools',
|
||||||
|
passed: true,
|
||||||
|
response: { toolCount: data.result.tools.length }
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: 'List Tools',
|
||||||
|
passed: false,
|
||||||
|
error: 'Missing or invalid tools array',
|
||||||
|
response: data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
name: 'List Tools',
|
||||||
|
passed: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testToolCall(toolName: string, args: any): Promise<TestResult> {
|
||||||
|
console.log(`🧪 Testing Tool Call: ${toolName}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('POST', '/mcp', {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: toolName,
|
||||||
|
arguments: args
|
||||||
|
},
|
||||||
|
id: 3
|
||||||
|
}, this.sessionId);
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
return {
|
||||||
|
name: `Tool Call: ${toolName}`,
|
||||||
|
passed: false,
|
||||||
|
error: `HTTP ${response.statusCode}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(response.body);
|
||||||
|
|
||||||
|
if (data.result?.content && Array.isArray(data.result.content)) {
|
||||||
|
return {
|
||||||
|
name: `Tool Call: ${toolName}`,
|
||||||
|
passed: true,
|
||||||
|
response: { contentItems: data.result.content.length }
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: `Tool Call: ${toolName}`,
|
||||||
|
passed: false,
|
||||||
|
error: 'Missing or invalid content array',
|
||||||
|
response: data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
name: `Tool Call: ${toolName}`,
|
||||||
|
passed: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testToolCallInvalid(): Promise<TestResult> {
|
||||||
|
console.log('🧪 Testing Tool Call with invalid parameters...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.makeRequest('POST', '/mcp', {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'get_node_essentials',
|
||||||
|
arguments: {} // Missing required nodeType parameter
|
||||||
|
},
|
||||||
|
id: 4
|
||||||
|
}, this.sessionId);
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
return {
|
||||||
|
name: 'Tool Call: Invalid Params',
|
||||||
|
passed: false,
|
||||||
|
error: `HTTP ${response.statusCode}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(response.body);
|
||||||
|
|
||||||
|
// Should either return an error response or handle gracefully
|
||||||
|
if (data.error || (data.result?.isError && data.result?.content)) {
|
||||||
|
return {
|
||||||
|
name: 'Tool Call: Invalid Params',
|
||||||
|
passed: true,
|
||||||
|
response: { handledGracefully: true }
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: 'Tool Call: Invalid Params',
|
||||||
|
passed: false,
|
||||||
|
error: 'Did not handle invalid parameters properly',
|
||||||
|
response: data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
name: 'Tool Call: Invalid Params',
|
||||||
|
passed: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeRequest(method: string, path: string, data?: any, sessionId?: string | null): Promise<{
|
||||||
|
statusCode: number;
|
||||||
|
headers: http.IncomingHttpHeaders;
|
||||||
|
body: string;
|
||||||
|
}> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const postData = data ? JSON.stringify(data) : '';
|
||||||
|
|
||||||
|
const options: http.RequestOptions = {
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: this.mcpPort,
|
||||||
|
path,
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.authToken}`,
|
||||||
|
...(postData && { 'Content-Length': Buffer.byteLength(postData) }),
|
||||||
|
...(sessionId && { 'Mcp-Session-Id': sessionId })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (chunk) => body += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve({
|
||||||
|
statusCode: res.statusCode || 0,
|
||||||
|
headers: res.headers,
|
||||||
|
body
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
req.setTimeout(10000, () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Request timeout'));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (postData) {
|
||||||
|
req.write(postData);
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private printResults(tests: TestResult[]): void {
|
||||||
|
console.log('\n📊 TEST RESULTS');
|
||||||
|
console.log('================');
|
||||||
|
|
||||||
|
const passed = tests.filter(t => t.passed).length;
|
||||||
|
const total = tests.length;
|
||||||
|
|
||||||
|
tests.forEach(test => {
|
||||||
|
const status = test.passed ? '✅' : '❌';
|
||||||
|
console.log(`${status} ${test.name}`);
|
||||||
|
if (!test.passed && test.error) {
|
||||||
|
console.log(` Error: ${test.error}`);
|
||||||
|
}
|
||||||
|
if (test.response) {
|
||||||
|
console.log(` Response: ${JSON.stringify(test.response, null, 2)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n📈 Summary: ${passed}/${total} tests passed`);
|
||||||
|
|
||||||
|
if (passed === total) {
|
||||||
|
console.log('🎉 All tests passed! The n8n integration fixes should resolve the schema validation errors.');
|
||||||
|
} else {
|
||||||
|
console.log('❌ Some tests failed. Please review the errors above.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanup(): Promise<void> {
|
||||||
|
console.log('\n🧹 Cleaning up...');
|
||||||
|
|
||||||
|
if (this.mcpProcess) {
|
||||||
|
this.mcpProcess.kill('SIGTERM');
|
||||||
|
|
||||||
|
// Wait for graceful shutdown
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (!this.mcpProcess) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.mcpProcess?.kill('SIGKILL');
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
this.mcpProcess.on('exit', () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Cleanup complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the tests
|
||||||
|
if (require.main === module) {
|
||||||
|
const tester = new N8nMcpTester();
|
||||||
|
tester.start().catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { N8nMcpTester };
|
||||||
@@ -16,11 +16,16 @@ import { getStartupBaseUrl, formatEndpointUrls, detectBaseUrl } from './utils/ur
|
|||||||
import { PROJECT_VERSION } from './utils/version';
|
import { PROJECT_VERSION } from './utils/version';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import {
|
||||||
|
negotiateProtocolVersion,
|
||||||
|
logProtocolNegotiation,
|
||||||
|
STANDARD_PROTOCOL_VERSION
|
||||||
|
} from './utils/protocol-version';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
// Protocol version constant
|
// Protocol version constant - will be negotiated per client
|
||||||
const PROTOCOL_VERSION = '2024-11-05';
|
const DEFAULT_PROTOCOL_VERSION = STANDARD_PROTOCOL_VERSION;
|
||||||
|
|
||||||
// Session management constants
|
// Session management constants
|
||||||
const MAX_SESSIONS = 100;
|
const MAX_SESSIONS = 100;
|
||||||
@@ -67,8 +72,12 @@ export class SingleSessionHTTPServer {
|
|||||||
* Start periodic session cleanup
|
* Start periodic session cleanup
|
||||||
*/
|
*/
|
||||||
private startSessionCleanup(): void {
|
private startSessionCleanup(): void {
|
||||||
this.cleanupTimer = setInterval(() => {
|
this.cleanupTimer = setInterval(async () => {
|
||||||
this.cleanupExpiredSessions();
|
try {
|
||||||
|
await this.cleanupExpiredSessions();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during session cleanup', error);
|
||||||
|
}
|
||||||
}, SESSION_CLEANUP_INTERVAL);
|
}, SESSION_CLEANUP_INTERVAL);
|
||||||
|
|
||||||
logger.info('Session cleanup started', {
|
logger.info('Session cleanup started', {
|
||||||
@@ -150,6 +159,40 @@ export class SingleSessionHTTPServer {
|
|||||||
return uuidv4Regex.test(sessionId);
|
return uuidv4Regex.test(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize error information for client responses
|
||||||
|
*/
|
||||||
|
private sanitizeErrorForClient(error: unknown): { message: string; code: string } {
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
// In production, only return generic messages
|
||||||
|
if (isProduction) {
|
||||||
|
// Map known error types to safe messages
|
||||||
|
if (error.message.includes('Unauthorized') || error.message.includes('authentication')) {
|
||||||
|
return { message: 'Authentication failed', code: 'AUTH_ERROR' };
|
||||||
|
}
|
||||||
|
if (error.message.includes('Session') || error.message.includes('session')) {
|
||||||
|
return { message: 'Session error', code: 'SESSION_ERROR' };
|
||||||
|
}
|
||||||
|
if (error.message.includes('Invalid') || error.message.includes('validation')) {
|
||||||
|
return { message: 'Validation error', code: 'VALIDATION_ERROR' };
|
||||||
|
}
|
||||||
|
// Default generic error
|
||||||
|
return { message: 'Internal server error', code: 'INTERNAL_ERROR' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// In development, return more details but no stack traces
|
||||||
|
return {
|
||||||
|
message: error.message.substring(0, 200), // Limit message length
|
||||||
|
code: error.name || 'ERROR'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-Error objects
|
||||||
|
return { message: 'An error occurred', code: 'UNKNOWN_ERROR' };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update session last access time
|
* Update session last access time
|
||||||
*/
|
*/
|
||||||
@@ -304,11 +347,12 @@ export class SingleSessionHTTPServer {
|
|||||||
// For initialize requests: always create new transport and server
|
// For initialize requests: always create new transport and server
|
||||||
logger.info('handleRequest: Creating new transport for initialize request');
|
logger.info('handleRequest: Creating new transport for initialize request');
|
||||||
|
|
||||||
const newSessionId = uuidv4();
|
// Use client-provided session ID or generate one if not provided
|
||||||
|
const sessionIdToUse = sessionId || uuidv4();
|
||||||
const server = new N8NDocumentationMCPServer();
|
const server = new N8NDocumentationMCPServer();
|
||||||
|
|
||||||
transport = new StreamableHTTPServerTransport({
|
transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => newSessionId,
|
sessionIdGenerator: () => sessionIdToUse,
|
||||||
onsessioninitialized: (initializedSessionId: string) => {
|
onsessioninitialized: (initializedSessionId: string) => {
|
||||||
// Store both transport and server by session ID when session is initialized
|
// Store both transport and server by session ID when session is initialized
|
||||||
logger.info('handleRequest: Session initialized, storing transport and server', {
|
logger.info('handleRequest: Session initialized, storing transport and server', {
|
||||||
@@ -415,11 +459,16 @@ export class SingleSessionHTTPServer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
|
// Send sanitized error to client
|
||||||
|
const sanitizedError = this.sanitizeErrorForClient(error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
error: {
|
error: {
|
||||||
code: -32603,
|
code: -32603,
|
||||||
message: error instanceof Error ? error.message : 'Internal server error'
|
message: sanitizedError.message,
|
||||||
|
data: {
|
||||||
|
code: sanitizedError.code
|
||||||
|
}
|
||||||
},
|
},
|
||||||
id: req.body?.id || null
|
id: req.body?.id || null
|
||||||
});
|
});
|
||||||
@@ -618,12 +667,22 @@ export class SingleSessionHTTPServer {
|
|||||||
bodyContent: req.body ? JSON.stringify(req.body, null, 2) : 'undefined'
|
bodyContent: req.body ? JSON.stringify(req.body, null, 2) : 'undefined'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Negotiate protocol version for test endpoint
|
||||||
|
const negotiationResult = negotiateProtocolVersion(
|
||||||
|
undefined, // no client version in test
|
||||||
|
undefined, // no client info
|
||||||
|
req.get('user-agent'),
|
||||||
|
req.headers
|
||||||
|
);
|
||||||
|
|
||||||
|
logProtocolNegotiation(negotiationResult, logger, 'TEST_ENDPOINT');
|
||||||
|
|
||||||
// Test what a basic MCP initialize request should look like
|
// Test what a basic MCP initialize request should look like
|
||||||
const testResponse = {
|
const testResponse = {
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
id: req.body?.id || 1,
|
id: req.body?.id || 1,
|
||||||
result: {
|
result: {
|
||||||
protocolVersion: PROTOCOL_VERSION,
|
protocolVersion: negotiationResult.version,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {}
|
tools: {}
|
||||||
},
|
},
|
||||||
@@ -681,8 +740,18 @@ export class SingleSessionHTTPServer {
|
|||||||
|
|
||||||
// In n8n mode, return protocol version and server info
|
// In n8n mode, return protocol version and server info
|
||||||
if (process.env.N8N_MODE === 'true') {
|
if (process.env.N8N_MODE === 'true') {
|
||||||
|
// Negotiate protocol version for n8n mode
|
||||||
|
const negotiationResult = negotiateProtocolVersion(
|
||||||
|
undefined, // no client version in GET request
|
||||||
|
undefined, // no client info
|
||||||
|
req.get('user-agent'),
|
||||||
|
req.headers
|
||||||
|
);
|
||||||
|
|
||||||
|
logProtocolNegotiation(negotiationResult, logger, 'N8N_MODE_GET');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
protocolVersion: PROTOCOL_VERSION,
|
protocolVersion: negotiationResult.version,
|
||||||
serverInfo: {
|
serverInfo: {
|
||||||
name: 'n8n-mcp',
|
name: 'n8n-mcp',
|
||||||
version: PROJECT_VERSION,
|
version: PROJECT_VERSION,
|
||||||
@@ -800,6 +869,28 @@ export class SingleSessionHTTPServer {
|
|||||||
originalUrl: req.originalUrl
|
originalUrl: req.originalUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle connection close to immediately clean up sessions
|
||||||
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||||
|
// Only add event listener if the request object supports it (not in test mocks)
|
||||||
|
if (typeof req.on === 'function') {
|
||||||
|
req.on('close', () => {
|
||||||
|
if (!res.headersSent && sessionId) {
|
||||||
|
logger.info('Connection closed before response sent', { sessionId });
|
||||||
|
// Schedule immediate cleanup if connection closes unexpectedly
|
||||||
|
setImmediate(() => {
|
||||||
|
if (this.sessionMetadata[sessionId]) {
|
||||||
|
const metadata = this.sessionMetadata[sessionId];
|
||||||
|
const timeSinceAccess = Date.now() - metadata.lastAccess.getTime();
|
||||||
|
// Only remove if it's been inactive for a bit to avoid race conditions
|
||||||
|
if (timeSinceAccess > 60000) { // 1 minute
|
||||||
|
this.removeSession(sessionId, 'connection_closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Enhanced authentication check with specific logging
|
// Enhanced authentication check with specific logging
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import { isN8nApiConfigured } from './config/n8n-api';
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { getStartupBaseUrl, formatEndpointUrls, detectBaseUrl } from './utils/url-detector';
|
import { getStartupBaseUrl, formatEndpointUrls, detectBaseUrl } from './utils/url-detector';
|
||||||
|
import {
|
||||||
|
negotiateProtocolVersion,
|
||||||
|
logProtocolNegotiation,
|
||||||
|
N8N_PROTOCOL_VERSION
|
||||||
|
} from './utils/protocol-version';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -342,10 +347,20 @@ export async function startFixedHTTPServer() {
|
|||||||
|
|
||||||
switch (jsonRpcRequest.method) {
|
switch (jsonRpcRequest.method) {
|
||||||
case 'initialize':
|
case 'initialize':
|
||||||
|
// Negotiate protocol version for this client/request
|
||||||
|
const negotiationResult = negotiateProtocolVersion(
|
||||||
|
jsonRpcRequest.params?.protocolVersion,
|
||||||
|
jsonRpcRequest.params?.clientInfo,
|
||||||
|
req.get('user-agent'),
|
||||||
|
req.headers
|
||||||
|
);
|
||||||
|
|
||||||
|
logProtocolNegotiation(negotiationResult, logger, 'HTTP_SERVER_INITIALIZE');
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
result: {
|
result: {
|
||||||
protocolVersion: '2024-11-05',
|
protocolVersion: negotiationResult.version,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
resources: {}
|
resources: {}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { existsSync, promises as fs } from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { n8nDocumentationToolsFinal } from './tools';
|
import { n8nDocumentationToolsFinal } from './tools';
|
||||||
import { n8nManagementTools } from './tools-n8n-manager';
|
import { n8nManagementTools } from './tools-n8n-manager';
|
||||||
|
import { makeToolsN8nFriendly } from './tools-n8n-friendly';
|
||||||
|
import { getWorkflowExampleString } from './workflow-examples';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { NodeRepository } from '../database/node-repository';
|
import { NodeRepository } from '../database/node-repository';
|
||||||
import { DatabaseAdapter, createDatabaseAdapter } from '../database/database-adapter';
|
import { DatabaseAdapter, createDatabaseAdapter } from '../database/database-adapter';
|
||||||
@@ -26,6 +28,11 @@ import { handleUpdatePartialWorkflow } from './handlers-workflow-diff';
|
|||||||
import { getToolDocumentation, getToolsOverview } from './tools-documentation';
|
import { getToolDocumentation, getToolsOverview } from './tools-documentation';
|
||||||
import { PROJECT_VERSION } from '../utils/version';
|
import { PROJECT_VERSION } from '../utils/version';
|
||||||
import { normalizeNodeType, getNodeTypeAlternatives, getWorkflowNodeType } from '../utils/node-utils';
|
import { normalizeNodeType, getNodeTypeAlternatives, getWorkflowNodeType } from '../utils/node-utils';
|
||||||
|
import {
|
||||||
|
negotiateProtocolVersion,
|
||||||
|
logProtocolNegotiation,
|
||||||
|
STANDARD_PROTOCOL_VERSION
|
||||||
|
} from '../utils/protocol-version';
|
||||||
|
|
||||||
interface NodeRow {
|
interface NodeRow {
|
||||||
node_type: string;
|
node_type: string;
|
||||||
@@ -52,6 +59,7 @@ export class N8NDocumentationMCPServer {
|
|||||||
private templateService: TemplateService | null = null;
|
private templateService: TemplateService | null = null;
|
||||||
private initialized: Promise<void>;
|
private initialized: Promise<void>;
|
||||||
private cache = new SimpleCache();
|
private cache = new SimpleCache();
|
||||||
|
private clientInfo: any = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Check for test environment first
|
// Check for test environment first
|
||||||
@@ -154,9 +162,39 @@ export class N8NDocumentationMCPServer {
|
|||||||
|
|
||||||
private setupHandlers(): void {
|
private setupHandlers(): void {
|
||||||
// Handle initialization
|
// Handle initialization
|
||||||
this.server.setRequestHandler(InitializeRequestSchema, async () => {
|
this.server.setRequestHandler(InitializeRequestSchema, async (request) => {
|
||||||
|
const clientVersion = request.params.protocolVersion;
|
||||||
|
const clientCapabilities = request.params.capabilities;
|
||||||
|
const clientInfo = request.params.clientInfo;
|
||||||
|
|
||||||
|
logger.info('MCP Initialize request received', {
|
||||||
|
clientVersion,
|
||||||
|
clientCapabilities,
|
||||||
|
clientInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store client info for later use
|
||||||
|
this.clientInfo = clientInfo;
|
||||||
|
|
||||||
|
// Negotiate protocol version based on client information
|
||||||
|
const negotiationResult = negotiateProtocolVersion(
|
||||||
|
clientVersion,
|
||||||
|
clientInfo,
|
||||||
|
undefined, // no user agent in MCP protocol
|
||||||
|
undefined // no headers in MCP protocol
|
||||||
|
);
|
||||||
|
|
||||||
|
logProtocolNegotiation(negotiationResult, logger, 'MCP_INITIALIZE');
|
||||||
|
|
||||||
|
// Warn if there's a version mismatch (for debugging)
|
||||||
|
if (clientVersion && clientVersion !== negotiationResult.version) {
|
||||||
|
logger.warn(`Protocol version negotiated: client requested ${clientVersion}, server will use ${negotiationResult.version}`, {
|
||||||
|
reasoning: negotiationResult.reasoning
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
protocolVersion: '2024-11-05',
|
protocolVersion: negotiationResult.version,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
},
|
},
|
||||||
@@ -166,18 +204,14 @@ export class N8NDocumentationMCPServer {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debug logging
|
logger.info('MCP Initialize response', { response });
|
||||||
if (process.env.DEBUG_MCP === 'true') {
|
|
||||||
logger.debug('Initialize handler called', { response });
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle tool listing
|
// Handle tool listing
|
||||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
this.server.setRequestHandler(ListToolsRequestSchema, async (request) => {
|
||||||
// Combine documentation tools with management tools if API is configured
|
// Combine documentation tools with management tools if API is configured
|
||||||
const tools = [...n8nDocumentationToolsFinal];
|
let tools = [...n8nDocumentationToolsFinal];
|
||||||
const isConfigured = isN8nApiConfigured();
|
const isConfigured = isN8nApiConfigured();
|
||||||
|
|
||||||
if (isConfigured) {
|
if (isConfigured) {
|
||||||
@@ -187,6 +221,27 @@ export class N8NDocumentationMCPServer {
|
|||||||
logger.debug(`Tool listing: ${tools.length} tools available (documentation only)`);
|
logger.debug(`Tool listing: ${tools.length} tools available (documentation only)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if client is n8n (from initialization)
|
||||||
|
const clientInfo = this.clientInfo;
|
||||||
|
const isN8nClient = clientInfo?.name?.includes('n8n') ||
|
||||||
|
clientInfo?.name?.includes('langchain');
|
||||||
|
|
||||||
|
if (isN8nClient) {
|
||||||
|
logger.info('Detected n8n client, using n8n-friendly tool descriptions');
|
||||||
|
tools = makeToolsN8nFriendly(tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log validation tools' input schemas for debugging
|
||||||
|
const validationTools = tools.filter(t => t.name.startsWith('validate_'));
|
||||||
|
validationTools.forEach(tool => {
|
||||||
|
logger.info('Validation tool schema', {
|
||||||
|
toolName: tool.name,
|
||||||
|
inputSchema: JSON.stringify(tool.inputSchema, null, 2),
|
||||||
|
hasOutputSchema: !!tool.outputSchema,
|
||||||
|
description: tool.description
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return { tools };
|
return { tools };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,25 +249,124 @@ export class N8NDocumentationMCPServer {
|
|||||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
const { name, arguments: args } = request.params;
|
const { name, arguments: args } = request.params;
|
||||||
|
|
||||||
|
// Enhanced logging for debugging tool calls
|
||||||
|
logger.info('Tool call received - DETAILED DEBUG', {
|
||||||
|
toolName: name,
|
||||||
|
arguments: JSON.stringify(args, null, 2),
|
||||||
|
argumentsType: typeof args,
|
||||||
|
argumentsKeys: args ? Object.keys(args) : [],
|
||||||
|
hasNodeType: args && 'nodeType' in args,
|
||||||
|
hasConfig: args && 'config' in args,
|
||||||
|
configType: args && args.config ? typeof args.config : 'N/A',
|
||||||
|
rawRequest: JSON.stringify(request.params)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Workaround for n8n's nested output bug
|
||||||
|
// Check if args contains nested 'output' structure from n8n's memory corruption
|
||||||
|
let processedArgs = args;
|
||||||
|
if (args && typeof args === 'object' && 'output' in args) {
|
||||||
|
try {
|
||||||
|
const possibleNestedData = args.output;
|
||||||
|
// If output is a string that looks like JSON, try to parse it
|
||||||
|
if (typeof possibleNestedData === 'string' && possibleNestedData.trim().startsWith('{')) {
|
||||||
|
const parsed = JSON.parse(possibleNestedData);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
logger.warn('Detected n8n nested output bug, attempting to extract actual arguments', {
|
||||||
|
originalArgs: args,
|
||||||
|
extractedArgs: parsed
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate the extracted arguments match expected tool schema
|
||||||
|
if (this.validateExtractedArgs(name, parsed)) {
|
||||||
|
// Use the extracted data as args
|
||||||
|
processedArgs = parsed;
|
||||||
|
} else {
|
||||||
|
logger.warn('Extracted arguments failed validation, using original args', {
|
||||||
|
toolName: name,
|
||||||
|
extractedArgs: parsed
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
logger.debug('Failed to parse nested output, continuing with original args', {
|
||||||
|
error: parseError instanceof Error ? parseError.message : String(parseError)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.debug(`Executing tool: ${name}`, { args });
|
logger.debug(`Executing tool: ${name}`, { args: processedArgs });
|
||||||
const result = await this.executeTool(name, args);
|
const result = await this.executeTool(name, processedArgs);
|
||||||
logger.debug(`Tool ${name} executed successfully`);
|
logger.debug(`Tool ${name} executed successfully`);
|
||||||
return {
|
|
||||||
|
// Ensure the result is properly formatted for MCP
|
||||||
|
let responseText: string;
|
||||||
|
let structuredContent: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For validation tools, check if we should use structured content
|
||||||
|
if (name.startsWith('validate_') && typeof result === 'object' && result !== null) {
|
||||||
|
// Clean up the result to ensure it matches the outputSchema
|
||||||
|
const cleanResult = this.sanitizeValidationResult(result, name);
|
||||||
|
structuredContent = cleanResult;
|
||||||
|
responseText = JSON.stringify(cleanResult, null, 2);
|
||||||
|
} else {
|
||||||
|
responseText = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
||||||
|
}
|
||||||
|
} catch (jsonError) {
|
||||||
|
logger.warn(`Failed to stringify tool result for ${name}:`, jsonError);
|
||||||
|
responseText = String(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate response size (n8n might have limits)
|
||||||
|
if (responseText.length > 1000000) { // 1MB limit
|
||||||
|
logger.warn(`Tool ${name} response is very large (${responseText.length} chars), truncating`);
|
||||||
|
responseText = responseText.substring(0, 999000) + '\n\n[Response truncated due to size limits]';
|
||||||
|
structuredContent = null; // Don't use structured content for truncated responses
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build MCP response with strict schema compliance
|
||||||
|
const mcpResponse: any = {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text' as const,
|
||||||
text: JSON.stringify(result, null, 2),
|
text: responseText,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// For tools with outputSchema, structuredContent is REQUIRED by MCP spec
|
||||||
|
if (name.startsWith('validate_') && structuredContent !== null) {
|
||||||
|
mcpResponse.structuredContent = structuredContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcpResponse;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error executing tool ${name}`, error);
|
logger.error(`Error executing tool ${name}`, error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
|
// Provide more helpful error messages for common n8n issues
|
||||||
|
let helpfulMessage = `Error executing tool ${name}: ${errorMessage}`;
|
||||||
|
|
||||||
|
if (errorMessage.includes('required') || errorMessage.includes('missing')) {
|
||||||
|
helpfulMessage += '\n\nNote: This error often occurs when the AI agent sends incomplete or incorrectly formatted parameters. Please ensure all required fields are provided with the correct types.';
|
||||||
|
} else if (errorMessage.includes('type') || errorMessage.includes('expected')) {
|
||||||
|
helpfulMessage += '\n\nNote: This error indicates a type mismatch. The AI agent may be sending data in the wrong format (e.g., string instead of object).';
|
||||||
|
} else if (errorMessage.includes('Unknown category') || errorMessage.includes('not found')) {
|
||||||
|
helpfulMessage += '\n\nNote: The requested resource or category was not found. Please check the available options.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// For n8n schema errors, add specific guidance
|
||||||
|
if (name.startsWith('validate_') && (errorMessage.includes('config') || errorMessage.includes('nodeType'))) {
|
||||||
|
helpfulMessage += '\n\nFor validation tools:\n- nodeType should be a string (e.g., "nodes-base.webhook")\n- config should be an object (e.g., {})';
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: `Error executing tool ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
text: helpfulMessage,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isError: true,
|
isError: true,
|
||||||
@@ -221,6 +375,90 @@ export class N8NDocumentationMCPServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize validation result to match outputSchema
|
||||||
|
*/
|
||||||
|
private sanitizeValidationResult(result: any, toolName: string): any {
|
||||||
|
if (!result || typeof result !== 'object') {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = { ...result };
|
||||||
|
|
||||||
|
// Ensure required fields exist with proper types and filter to schema-defined fields only
|
||||||
|
if (toolName === 'validate_node_minimal') {
|
||||||
|
// Filter to only schema-defined fields
|
||||||
|
const filtered = {
|
||||||
|
nodeType: String(sanitized.nodeType || ''),
|
||||||
|
displayName: String(sanitized.displayName || ''),
|
||||||
|
valid: Boolean(sanitized.valid),
|
||||||
|
missingRequiredFields: Array.isArray(sanitized.missingRequiredFields)
|
||||||
|
? sanitized.missingRequiredFields.map(String)
|
||||||
|
: []
|
||||||
|
};
|
||||||
|
return filtered;
|
||||||
|
} else if (toolName === 'validate_node_operation') {
|
||||||
|
// Ensure summary exists
|
||||||
|
let summary = sanitized.summary;
|
||||||
|
if (!summary || typeof summary !== 'object') {
|
||||||
|
summary = {
|
||||||
|
hasErrors: Array.isArray(sanitized.errors) ? sanitized.errors.length > 0 : false,
|
||||||
|
errorCount: Array.isArray(sanitized.errors) ? sanitized.errors.length : 0,
|
||||||
|
warningCount: Array.isArray(sanitized.warnings) ? sanitized.warnings.length : 0,
|
||||||
|
suggestionCount: Array.isArray(sanitized.suggestions) ? sanitized.suggestions.length : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only schema-defined fields
|
||||||
|
const filtered = {
|
||||||
|
nodeType: String(sanitized.nodeType || ''),
|
||||||
|
workflowNodeType: String(sanitized.workflowNodeType || sanitized.nodeType || ''),
|
||||||
|
displayName: String(sanitized.displayName || ''),
|
||||||
|
valid: Boolean(sanitized.valid),
|
||||||
|
errors: Array.isArray(sanitized.errors) ? sanitized.errors : [],
|
||||||
|
warnings: Array.isArray(sanitized.warnings) ? sanitized.warnings : [],
|
||||||
|
suggestions: Array.isArray(sanitized.suggestions) ? sanitized.suggestions : [],
|
||||||
|
summary: summary
|
||||||
|
};
|
||||||
|
return filtered;
|
||||||
|
} else if (toolName.startsWith('validate_workflow')) {
|
||||||
|
sanitized.valid = Boolean(sanitized.valid);
|
||||||
|
|
||||||
|
// Ensure arrays exist
|
||||||
|
sanitized.errors = Array.isArray(sanitized.errors) ? sanitized.errors : [];
|
||||||
|
sanitized.warnings = Array.isArray(sanitized.warnings) ? sanitized.warnings : [];
|
||||||
|
|
||||||
|
// Ensure statistics/summary exists
|
||||||
|
if (toolName === 'validate_workflow') {
|
||||||
|
if (!sanitized.summary || typeof sanitized.summary !== 'object') {
|
||||||
|
sanitized.summary = {
|
||||||
|
totalNodes: 0,
|
||||||
|
enabledNodes: 0,
|
||||||
|
triggerNodes: 0,
|
||||||
|
validConnections: 0,
|
||||||
|
invalidConnections: 0,
|
||||||
|
expressionsValidated: 0,
|
||||||
|
errorCount: sanitized.errors.length,
|
||||||
|
warningCount: sanitized.warnings.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!sanitized.statistics || typeof sanitized.statistics !== 'object') {
|
||||||
|
sanitized.statistics = {
|
||||||
|
totalNodes: 0,
|
||||||
|
triggerNodes: 0,
|
||||||
|
validConnections: 0,
|
||||||
|
invalidConnections: 0,
|
||||||
|
expressionsValidated: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove undefined values to ensure clean JSON
|
||||||
|
return JSON.parse(JSON.stringify(sanitized));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate required parameters for tool execution
|
* Validate required parameters for tool execution
|
||||||
*/
|
*/
|
||||||
@@ -238,10 +476,95 @@ export class N8NDocumentationMCPServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate extracted arguments match expected tool schema
|
||||||
|
*/
|
||||||
|
private validateExtractedArgs(toolName: string, args: any): boolean {
|
||||||
|
if (!args || typeof args !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all available tools
|
||||||
|
const allTools = [...n8nDocumentationToolsFinal, ...n8nManagementTools];
|
||||||
|
const tool = allTools.find(t => t.name === toolName);
|
||||||
|
if (!tool || !tool.inputSchema) {
|
||||||
|
return true; // If no schema, assume valid
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = tool.inputSchema;
|
||||||
|
const required = schema.required || [];
|
||||||
|
const properties = schema.properties || {};
|
||||||
|
|
||||||
|
// Check all required fields are present
|
||||||
|
for (const requiredField of required) {
|
||||||
|
if (!(requiredField in args)) {
|
||||||
|
logger.debug(`Extracted args missing required field: ${requiredField}`, {
|
||||||
|
toolName,
|
||||||
|
extractedArgs: args,
|
||||||
|
required
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check field types match schema
|
||||||
|
for (const [fieldName, fieldValue] of Object.entries(args)) {
|
||||||
|
if (properties[fieldName]) {
|
||||||
|
const expectedType = properties[fieldName].type;
|
||||||
|
const actualType = Array.isArray(fieldValue) ? 'array' : typeof fieldValue;
|
||||||
|
|
||||||
|
// Basic type validation
|
||||||
|
if (expectedType && expectedType !== actualType) {
|
||||||
|
// Special case: number can be coerced from string
|
||||||
|
if (expectedType === 'number' && actualType === 'string' && !isNaN(Number(fieldValue))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Extracted args field type mismatch: ${fieldName}`, {
|
||||||
|
toolName,
|
||||||
|
expectedType,
|
||||||
|
actualType,
|
||||||
|
fieldValue
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for extraneous fields if additionalProperties is false
|
||||||
|
if (schema.additionalProperties === false) {
|
||||||
|
const allowedFields = Object.keys(properties);
|
||||||
|
const extraFields = Object.keys(args).filter(field => !allowedFields.includes(field));
|
||||||
|
|
||||||
|
if (extraFields.length > 0) {
|
||||||
|
logger.debug(`Extracted args have extra fields`, {
|
||||||
|
toolName,
|
||||||
|
extraFields,
|
||||||
|
allowedFields
|
||||||
|
});
|
||||||
|
// For n8n compatibility, we'll still consider this valid but log it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async executeTool(name: string, args: any): Promise<any> {
|
async executeTool(name: string, args: any): Promise<any> {
|
||||||
// Ensure args is an object
|
// Ensure args is an object and validate it
|
||||||
args = args || {};
|
args = args || {};
|
||||||
|
|
||||||
|
// Log the tool call for debugging n8n issues
|
||||||
|
logger.info(`Tool execution: ${name}`, {
|
||||||
|
args: typeof args === 'object' ? JSON.stringify(args) : args,
|
||||||
|
argsType: typeof args,
|
||||||
|
argsKeys: typeof args === 'object' ? Object.keys(args) : 'not-object'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate that args is actually an object
|
||||||
|
if (typeof args !== 'object' || args === null) {
|
||||||
|
throw new Error(`Invalid arguments for tool ${name}: expected object, got ${typeof args}`);
|
||||||
|
}
|
||||||
|
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case 'tools_documentation':
|
case 'tools_documentation':
|
||||||
// No required parameters
|
// No required parameters
|
||||||
@@ -281,9 +604,43 @@ export class N8NDocumentationMCPServer {
|
|||||||
return this.listTasks(args.category);
|
return this.listTasks(args.category);
|
||||||
case 'validate_node_operation':
|
case 'validate_node_operation':
|
||||||
this.validateToolParams(name, args, ['nodeType', 'config']);
|
this.validateToolParams(name, args, ['nodeType', 'config']);
|
||||||
|
// Ensure config is an object
|
||||||
|
if (typeof args.config !== 'object' || args.config === null) {
|
||||||
|
logger.warn(`validate_node_operation called with invalid config type: ${typeof args.config}`);
|
||||||
|
return {
|
||||||
|
nodeType: args.nodeType || 'unknown',
|
||||||
|
workflowNodeType: args.nodeType || 'unknown',
|
||||||
|
displayName: 'Unknown Node',
|
||||||
|
valid: false,
|
||||||
|
errors: [{
|
||||||
|
type: 'config',
|
||||||
|
property: 'config',
|
||||||
|
message: 'Invalid config format - expected object',
|
||||||
|
fix: 'Provide config as an object with node properties'
|
||||||
|
}],
|
||||||
|
warnings: [],
|
||||||
|
suggestions: [],
|
||||||
|
summary: {
|
||||||
|
hasErrors: true,
|
||||||
|
errorCount: 1,
|
||||||
|
warningCount: 0,
|
||||||
|
suggestionCount: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
return this.validateNodeConfig(args.nodeType, args.config, 'operation', args.profile);
|
return this.validateNodeConfig(args.nodeType, args.config, 'operation', args.profile);
|
||||||
case 'validate_node_minimal':
|
case 'validate_node_minimal':
|
||||||
this.validateToolParams(name, args, ['nodeType', 'config']);
|
this.validateToolParams(name, args, ['nodeType', 'config']);
|
||||||
|
// Ensure config is an object
|
||||||
|
if (typeof args.config !== 'object' || args.config === null) {
|
||||||
|
logger.warn(`validate_node_minimal called with invalid config type: ${typeof args.config}`);
|
||||||
|
return {
|
||||||
|
nodeType: args.nodeType || 'unknown',
|
||||||
|
displayName: 'Unknown Node',
|
||||||
|
valid: false,
|
||||||
|
missingRequiredFields: ['Invalid config format - expected object']
|
||||||
|
};
|
||||||
|
}
|
||||||
return this.validateNodeMinimal(args.nodeType, args.config);
|
return this.validateNodeMinimal(args.nodeType, args.config);
|
||||||
case 'get_property_dependencies':
|
case 'get_property_dependencies':
|
||||||
this.validateToolParams(name, args, ['nodeType']);
|
this.validateToolParams(name, args, ['nodeType']);
|
||||||
@@ -1909,6 +2266,56 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
|||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
if (!this.repository) throw new Error('Repository not initialized');
|
if (!this.repository) throw new Error('Repository not initialized');
|
||||||
|
|
||||||
|
// Enhanced logging for workflow validation
|
||||||
|
logger.info('Workflow validation requested', {
|
||||||
|
hasWorkflow: !!workflow,
|
||||||
|
workflowType: typeof workflow,
|
||||||
|
hasNodes: workflow?.nodes !== undefined,
|
||||||
|
nodesType: workflow?.nodes ? typeof workflow.nodes : 'undefined',
|
||||||
|
nodesIsArray: Array.isArray(workflow?.nodes),
|
||||||
|
nodesCount: Array.isArray(workflow?.nodes) ? workflow.nodes.length : 0,
|
||||||
|
hasConnections: workflow?.connections !== undefined,
|
||||||
|
connectionsType: workflow?.connections ? typeof workflow.connections : 'undefined',
|
||||||
|
options: options
|
||||||
|
});
|
||||||
|
|
||||||
|
// Help n8n AI agents with common mistakes
|
||||||
|
if (!workflow || typeof workflow !== 'object') {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors: [{
|
||||||
|
node: 'workflow',
|
||||||
|
message: 'Workflow must be an object with nodes and connections',
|
||||||
|
details: 'Expected format: ' + getWorkflowExampleString()
|
||||||
|
}],
|
||||||
|
summary: { errorCount: 1 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workflow.nodes || !Array.isArray(workflow.nodes)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors: [{
|
||||||
|
node: 'workflow',
|
||||||
|
message: 'Workflow must have a nodes array',
|
||||||
|
details: 'Expected: workflow.nodes = [array of node objects]. ' + getWorkflowExampleString()
|
||||||
|
}],
|
||||||
|
summary: { errorCount: 1 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workflow.connections || typeof workflow.connections !== 'object') {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors: [{
|
||||||
|
node: 'workflow',
|
||||||
|
message: 'Workflow must have a connections object',
|
||||||
|
details: 'Expected: workflow.connections = {} (can be empty object). ' + getWorkflowExampleString()
|
||||||
|
}],
|
||||||
|
summary: { errorCount: 1 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Create workflow validator instance
|
// Create workflow validator instance
|
||||||
const validator = new WorkflowValidator(
|
const validator = new WorkflowValidator(
|
||||||
this.repository,
|
this.repository,
|
||||||
|
|||||||
175
src/mcp/tools-n8n-friendly.ts
Normal file
175
src/mcp/tools-n8n-friendly.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* n8n-friendly tool descriptions
|
||||||
|
* These descriptions are optimized to reduce schema validation errors in n8n's AI Agent
|
||||||
|
*
|
||||||
|
* Key principles:
|
||||||
|
* 1. Use exact JSON examples in descriptions
|
||||||
|
* 2. Be explicit about data types
|
||||||
|
* 3. Keep descriptions short and directive
|
||||||
|
* 4. Avoid ambiguity
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const n8nFriendlyDescriptions: Record<string, {
|
||||||
|
description: string;
|
||||||
|
params: Record<string, string>;
|
||||||
|
}> = {
|
||||||
|
// Validation tools - most prone to errors
|
||||||
|
validate_node_operation: {
|
||||||
|
description: 'Validate n8n node. ALWAYS pass two parameters: nodeType (string) and config (object). Example call: {"nodeType": "nodes-base.slack", "config": {"resource": "channel", "operation": "create"}}',
|
||||||
|
params: {
|
||||||
|
nodeType: 'String value like "nodes-base.slack"',
|
||||||
|
config: 'Object value like {"resource": "channel", "operation": "create"} or empty object {}',
|
||||||
|
profile: 'Optional string: "minimal" or "runtime" or "ai-friendly" or "strict"'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
validate_node_minimal: {
|
||||||
|
description: 'Check required fields. MUST pass: nodeType (string) and config (object). Example: {"nodeType": "nodes-base.webhook", "config": {}}',
|
||||||
|
params: {
|
||||||
|
nodeType: 'String like "nodes-base.webhook"',
|
||||||
|
config: 'Object, use {} for empty'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Search and info tools
|
||||||
|
search_nodes: {
|
||||||
|
description: 'Search nodes. Pass query (string). Example: {"query": "webhook"}',
|
||||||
|
params: {
|
||||||
|
query: 'String keyword like "webhook" or "database"',
|
||||||
|
limit: 'Optional number, default 20'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get_node_info: {
|
||||||
|
description: 'Get node details. Pass nodeType (string). Example: {"nodeType": "nodes-base.httpRequest"}',
|
||||||
|
params: {
|
||||||
|
nodeType: 'String with prefix like "nodes-base.httpRequest"'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get_node_essentials: {
|
||||||
|
description: 'Get node basics. Pass nodeType (string). Example: {"nodeType": "nodes-base.slack"}',
|
||||||
|
params: {
|
||||||
|
nodeType: 'String with prefix like "nodes-base.slack"'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task tools
|
||||||
|
get_node_for_task: {
|
||||||
|
description: 'Find node for task. Pass task (string). Example: {"task": "send_http_request"}',
|
||||||
|
params: {
|
||||||
|
task: 'String task name like "send_http_request"'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
list_tasks: {
|
||||||
|
description: 'List tasks by category. Pass category (string). Example: {"category": "HTTP/API"}',
|
||||||
|
params: {
|
||||||
|
category: 'String: "HTTP/API" or "Webhooks" or "Database" or "AI/LangChain" or "Data Processing" or "Communication"'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Workflow validation
|
||||||
|
validate_workflow: {
|
||||||
|
description: 'Validate workflow. Pass workflow object. MUST have: {"workflow": {"nodes": [array of node objects], "connections": {object with node connections}}}. Each node needs: name, type, typeVersion, position.',
|
||||||
|
params: {
|
||||||
|
workflow: 'Object with two required fields: nodes (array) and connections (object). Example: {"nodes": [{"name": "Webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [250, 300], "parameters": {}}], "connections": {}}',
|
||||||
|
options: 'Optional object. Example: {"validateNodes": true, "profile": "runtime"}'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
validate_workflow_connections: {
|
||||||
|
description: 'Validate workflow connections only. Pass workflow object. Example: {"workflow": {"nodes": [...], "connections": {}}}',
|
||||||
|
params: {
|
||||||
|
workflow: 'Object with nodes array and connections object. Minimal example: {"nodes": [{"name": "Webhook"}], "connections": {}}'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
validate_workflow_expressions: {
|
||||||
|
description: 'Validate n8n expressions in workflow. Pass workflow object. Example: {"workflow": {"nodes": [...], "connections": {}}}',
|
||||||
|
params: {
|
||||||
|
workflow: 'Object with nodes array and connections object containing n8n expressions like {{ $json.data }}'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Property tools
|
||||||
|
get_property_dependencies: {
|
||||||
|
description: 'Get field dependencies. Pass nodeType (string) and optional config (object). Example: {"nodeType": "nodes-base.httpRequest", "config": {}}',
|
||||||
|
params: {
|
||||||
|
nodeType: 'String like "nodes-base.httpRequest"',
|
||||||
|
config: 'Optional object, use {} for empty'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// AI tool info
|
||||||
|
get_node_as_tool_info: {
|
||||||
|
description: 'Get AI tool usage. Pass nodeType (string). Example: {"nodeType": "nodes-base.slack"}',
|
||||||
|
params: {
|
||||||
|
nodeType: 'String with prefix like "nodes-base.slack"'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Template tools
|
||||||
|
search_templates: {
|
||||||
|
description: 'Search workflow templates. Pass query (string). Example: {"query": "chatbot"}',
|
||||||
|
params: {
|
||||||
|
query: 'String keyword like "chatbot" or "webhook"',
|
||||||
|
limit: 'Optional number, default 20'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get_template: {
|
||||||
|
description: 'Get template by ID. Pass templateId (number). Example: {"templateId": 1234}',
|
||||||
|
params: {
|
||||||
|
templateId: 'Number ID like 1234'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Documentation tool
|
||||||
|
tools_documentation: {
|
||||||
|
description: 'Get tool docs. Pass optional depth (string). Example: {"depth": "essentials"} or {}',
|
||||||
|
params: {
|
||||||
|
depth: 'Optional string: "essentials" or "overview" or "detailed"',
|
||||||
|
topic: 'Optional string topic name'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply n8n-friendly descriptions to tools
|
||||||
|
* This function modifies tool descriptions to be more explicit for n8n's AI agent
|
||||||
|
*/
|
||||||
|
export function makeToolsN8nFriendly(tools: any[]): any[] {
|
||||||
|
return tools.map(tool => {
|
||||||
|
const toolName = tool.name as string;
|
||||||
|
const friendlyDesc = n8nFriendlyDescriptions[toolName];
|
||||||
|
if (friendlyDesc) {
|
||||||
|
// Clone the tool to avoid mutating the original
|
||||||
|
const updatedTool = { ...tool };
|
||||||
|
|
||||||
|
// Update the main description
|
||||||
|
updatedTool.description = friendlyDesc.description;
|
||||||
|
|
||||||
|
// Clone inputSchema if it exists
|
||||||
|
if (tool.inputSchema?.properties) {
|
||||||
|
updatedTool.inputSchema = {
|
||||||
|
...tool.inputSchema,
|
||||||
|
properties: { ...tool.inputSchema.properties }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update parameter descriptions
|
||||||
|
Object.keys(updatedTool.inputSchema.properties).forEach(param => {
|
||||||
|
if (friendlyDesc.params[param]) {
|
||||||
|
updatedTool.inputSchema.properties[param] = {
|
||||||
|
...updatedTool.inputSchema.properties[param],
|
||||||
|
description: friendlyDesc.params[param]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedTool;
|
||||||
|
}
|
||||||
|
return tool;
|
||||||
|
});
|
||||||
|
}
|
||||||
198
src/mcp/tools.ts
198
src/mcp/tools.ts
@@ -59,7 +59,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_node_info',
|
name: 'get_node_info',
|
||||||
description: `Get FULL node schema (100KB+). TIP: Use get_node_essentials first! Returns all properties/operations/credentials. Prefix required: "nodes-base.httpRequest" not "httpRequest".`,
|
description: `Get full node documentation. Pass nodeType as string with prefix. Example: nodeType="nodes-base.webhook"`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -73,7 +73,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'search_nodes',
|
name: 'search_nodes',
|
||||||
description: `Search nodes by keywords. Modes: OR (any word), AND (all words), FUZZY (typos OK). Primary nodes ranked first. Examples: "webhook"→Webhook, "http call"→HTTP Request.`,
|
description: `Search n8n nodes by keyword. Pass query as string. Example: query="webhook" or query="database". Returns max 20 results.`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -128,7 +128,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'get_node_essentials',
|
name: 'get_node_essentials',
|
||||||
description: `Get 10-20 key properties only (<5KB vs 100KB+). USE THIS FIRST! Includes examples. Format: "nodes-base.httpRequest"`,
|
description: `Get node essential info. Pass nodeType as string with prefix. Example: nodeType="nodes-base.slack"`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -192,44 +192,103 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'validate_node_operation',
|
name: 'validate_node_operation',
|
||||||
description: `Validate node config. Checks required fields, types, operation rules. Returns errors with fixes. Essential for Slack/Sheets/DB nodes.`,
|
description: `Validate n8n node configuration. Pass nodeType as string and config as object. Example: nodeType="nodes-base.slack", config={resource:"channel",operation:"create"}`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
nodeType: {
|
nodeType: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'The node type to validate (e.g., "nodes-base.slack")',
|
description: 'Node type as string. Example: "nodes-base.slack"',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description: 'Your node configuration. Must include operation fields (resource/operation/action) if the node has multiple operations.',
|
description: 'Configuration as object. For simple nodes use {}. For complex nodes include fields like {resource:"channel",operation:"create"}',
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
enum: ['strict', 'runtime', 'ai-friendly', 'minimal'],
|
enum: ['strict', 'runtime', 'ai-friendly', 'minimal'],
|
||||||
description: 'Validation profile: minimal (only required fields), runtime (critical errors only), ai-friendly (balanced - default), strict (all checks including best practices)',
|
description: 'Profile string: "minimal", "runtime", "ai-friendly", or "strict". Default is "ai-friendly"',
|
||||||
default: 'ai-friendly',
|
default: 'ai-friendly',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['nodeType', 'config'],
|
required: ['nodeType', 'config'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
nodeType: { type: 'string' },
|
||||||
|
workflowNodeType: { type: 'string' },
|
||||||
|
displayName: { type: 'string' },
|
||||||
|
valid: { type: 'boolean' },
|
||||||
|
errors: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
type: { type: 'string' },
|
||||||
|
property: { type: 'string' },
|
||||||
|
message: { type: 'string' },
|
||||||
|
fix: { type: 'string' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
warnings: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
type: { type: 'string' },
|
||||||
|
property: { type: 'string' },
|
||||||
|
message: { type: 'string' },
|
||||||
|
suggestion: { type: 'string' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
suggestions: { type: 'array', items: { type: 'string' } },
|
||||||
|
summary: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
hasErrors: { type: 'boolean' },
|
||||||
|
errorCount: { type: 'number' },
|
||||||
|
warningCount: { type: 'number' },
|
||||||
|
suggestionCount: { type: 'number' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['nodeType', 'displayName', 'valid', 'errors', 'warnings', 'suggestions', 'summary']
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'validate_node_minimal',
|
name: 'validate_node_minimal',
|
||||||
description: `Fast check for missing required fields only. No warnings/suggestions. Returns: list of missing fields.`,
|
description: `Check n8n node required fields. Pass nodeType as string and config as empty object {}. Example: nodeType="nodes-base.webhook", config={}`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
nodeType: {
|
nodeType: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'The node type to validate (e.g., "nodes-base.slack")',
|
description: 'Node type as string. Example: "nodes-base.slack"',
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description: 'The node configuration to check',
|
description: 'Configuration object. Always pass {} for empty config',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['nodeType', 'config'],
|
required: ['nodeType', 'config'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
nodeType: { type: 'string' },
|
||||||
|
displayName: { type: 'string' },
|
||||||
|
valid: { type: 'boolean' },
|
||||||
|
missingRequiredFields: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['nodeType', 'displayName', 'valid', 'missingRequiredFields']
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -306,7 +365,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
|||||||
properties: {
|
properties: {
|
||||||
query: {
|
query: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Search query for template names/descriptions. NOT for node types! Examples: "chatbot", "automation", "social media", "webhook". For node-based search use list_node_templates instead.',
|
description: 'Search keyword as string. Example: "chatbot"',
|
||||||
},
|
},
|
||||||
limit: {
|
limit: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -382,6 +441,50 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['workflow'],
|
required: ['workflow'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
valid: { type: 'boolean' },
|
||||||
|
summary: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
totalNodes: { type: 'number' },
|
||||||
|
enabledNodes: { type: 'number' },
|
||||||
|
triggerNodes: { type: 'number' },
|
||||||
|
validConnections: { type: 'number' },
|
||||||
|
invalidConnections: { type: 'number' },
|
||||||
|
expressionsValidated: { type: 'number' },
|
||||||
|
errorCount: { type: 'number' },
|
||||||
|
warningCount: { type: 'number' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
node: { type: 'string' },
|
||||||
|
message: { type: 'string' },
|
||||||
|
details: { type: 'string' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
warnings: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
node: { type: 'string' },
|
||||||
|
message: { type: 'string' },
|
||||||
|
details: { type: 'string' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
suggestions: { type: 'array', items: { type: 'string' } }
|
||||||
|
},
|
||||||
|
required: ['valid', 'summary']
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -396,6 +499,43 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['workflow'],
|
required: ['workflow'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
valid: { type: 'boolean' },
|
||||||
|
statistics: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
totalNodes: { type: 'number' },
|
||||||
|
triggerNodes: { type: 'number' },
|
||||||
|
validConnections: { type: 'number' },
|
||||||
|
invalidConnections: { type: 'number' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
node: { type: 'string' },
|
||||||
|
message: { type: 'string' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
warnings: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
node: { type: 'string' },
|
||||||
|
message: { type: 'string' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['valid', 'statistics']
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -410,6 +550,42 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['workflow'],
|
required: ['workflow'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
valid: { type: 'boolean' },
|
||||||
|
statistics: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
totalNodes: { type: 'number' },
|
||||||
|
expressionsValidated: { type: 'number' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
node: { type: 'string' },
|
||||||
|
message: { type: 'string' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
warnings: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
node: { type: 'string' },
|
||||||
|
message: { type: 'string' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tips: { type: 'array', items: { type: 'string' } }
|
||||||
|
},
|
||||||
|
required: ['valid', 'statistics']
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
112
src/mcp/workflow-examples.ts
Normal file
112
src/mcp/workflow-examples.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Example workflows for n8n AI agents to understand the structure
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MINIMAL_WORKFLOW_EXAMPLE = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
name: "Webhook",
|
||||||
|
type: "n8n-nodes-base.webhook",
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [250, 300],
|
||||||
|
parameters: {
|
||||||
|
httpMethod: "POST",
|
||||||
|
path: "webhook"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SIMPLE_WORKFLOW_EXAMPLE = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
name: "Webhook",
|
||||||
|
type: "n8n-nodes-base.webhook",
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [250, 300],
|
||||||
|
parameters: {
|
||||||
|
httpMethod: "POST",
|
||||||
|
path: "webhook"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Set",
|
||||||
|
type: "n8n-nodes-base.set",
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [450, 300],
|
||||||
|
parameters: {
|
||||||
|
mode: "manual",
|
||||||
|
assignments: {
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
name: "message",
|
||||||
|
type: "string",
|
||||||
|
value: "Hello"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Respond to Webhook",
|
||||||
|
type: "n8n-nodes-base.respondToWebhook",
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [650, 300],
|
||||||
|
parameters: {
|
||||||
|
respondWith: "firstIncomingItem"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
"Webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Set",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Set": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Respond to Webhook",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getWorkflowExampleString(): string {
|
||||||
|
return `Example workflow structure:
|
||||||
|
${JSON.stringify(MINIMAL_WORKFLOW_EXAMPLE, null, 2)}
|
||||||
|
|
||||||
|
Each node MUST have:
|
||||||
|
- name: unique string identifier
|
||||||
|
- type: full node type with prefix (e.g., "n8n-nodes-base.webhook")
|
||||||
|
- typeVersion: number (usually 1 or 2)
|
||||||
|
- position: [x, y] coordinates array
|
||||||
|
- parameters: object with node-specific settings
|
||||||
|
|
||||||
|
Connections format:
|
||||||
|
{
|
||||||
|
"SourceNodeName": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "TargetNodeName",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
}
|
||||||
206
src/scripts/test-protocol-negotiation.ts
Normal file
206
src/scripts/test-protocol-negotiation.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Test Protocol Version Negotiation
|
||||||
|
*
|
||||||
|
* This script tests the protocol version negotiation logic with different client scenarios.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
negotiateProtocolVersion,
|
||||||
|
isN8nClient,
|
||||||
|
STANDARD_PROTOCOL_VERSION,
|
||||||
|
N8N_PROTOCOL_VERSION
|
||||||
|
} from '../utils/protocol-version';
|
||||||
|
|
||||||
|
interface TestCase {
|
||||||
|
name: string;
|
||||||
|
clientVersion?: string;
|
||||||
|
clientInfo?: any;
|
||||||
|
userAgent?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
expectedVersion: string;
|
||||||
|
expectedIsN8nClient: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCases: TestCase[] = [
|
||||||
|
{
|
||||||
|
name: 'Standard MCP client (Claude Desktop)',
|
||||||
|
clientVersion: '2025-03-26',
|
||||||
|
clientInfo: { name: 'Claude Desktop', version: '1.0.0' },
|
||||||
|
expectedVersion: '2025-03-26',
|
||||||
|
expectedIsN8nClient: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'n8n client with specific client info',
|
||||||
|
clientVersion: '2025-03-26',
|
||||||
|
clientInfo: { name: 'n8n', version: '1.0.0' },
|
||||||
|
expectedVersion: N8N_PROTOCOL_VERSION,
|
||||||
|
expectedIsN8nClient: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'LangChain client',
|
||||||
|
clientVersion: '2025-03-26',
|
||||||
|
clientInfo: { name: 'langchain-js', version: '0.1.0' },
|
||||||
|
expectedVersion: N8N_PROTOCOL_VERSION,
|
||||||
|
expectedIsN8nClient: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'n8n client via user agent',
|
||||||
|
clientVersion: '2025-03-26',
|
||||||
|
userAgent: 'n8n/1.0.0',
|
||||||
|
expectedVersion: N8N_PROTOCOL_VERSION,
|
||||||
|
expectedIsN8nClient: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'n8n mode environment variable',
|
||||||
|
clientVersion: '2025-03-26',
|
||||||
|
expectedVersion: N8N_PROTOCOL_VERSION,
|
||||||
|
expectedIsN8nClient: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Client requesting older version',
|
||||||
|
clientVersion: '2024-06-25',
|
||||||
|
clientInfo: { name: 'Some Client', version: '1.0.0' },
|
||||||
|
expectedVersion: '2024-06-25',
|
||||||
|
expectedIsN8nClient: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Client requesting unsupported version',
|
||||||
|
clientVersion: '2020-01-01',
|
||||||
|
clientInfo: { name: 'Old Client', version: '1.0.0' },
|
||||||
|
expectedVersion: STANDARD_PROTOCOL_VERSION,
|
||||||
|
expectedIsN8nClient: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'No client info provided',
|
||||||
|
expectedVersion: STANDARD_PROTOCOL_VERSION,
|
||||||
|
expectedIsN8nClient: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'n8n headers detection',
|
||||||
|
clientVersion: '2025-03-26',
|
||||||
|
headers: { 'x-n8n-version': '1.0.0' },
|
||||||
|
expectedVersion: N8N_PROTOCOL_VERSION,
|
||||||
|
expectedIsN8nClient: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function runTests(): Promise<void> {
|
||||||
|
console.log('🧪 Testing Protocol Version Negotiation\n');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
// Set N8N_MODE for the environment variable test
|
||||||
|
const originalN8nMode = process.env.N8N_MODE;
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
try {
|
||||||
|
// Set N8N_MODE for specific test
|
||||||
|
if (testCase.name.includes('environment variable')) {
|
||||||
|
process.env.N8N_MODE = 'true';
|
||||||
|
} else {
|
||||||
|
delete process.env.N8N_MODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test isN8nClient function
|
||||||
|
const detectedAsN8n = isN8nClient(testCase.clientInfo, testCase.userAgent, testCase.headers);
|
||||||
|
|
||||||
|
// Test negotiateProtocolVersion function
|
||||||
|
const result = negotiateProtocolVersion(
|
||||||
|
testCase.clientVersion,
|
||||||
|
testCase.clientInfo,
|
||||||
|
testCase.userAgent,
|
||||||
|
testCase.headers
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check results
|
||||||
|
const versionCorrect = result.version === testCase.expectedVersion;
|
||||||
|
const n8nDetectionCorrect = result.isN8nClient === testCase.expectedIsN8nClient;
|
||||||
|
const isN8nFunctionCorrect = detectedAsN8n === testCase.expectedIsN8nClient;
|
||||||
|
|
||||||
|
if (versionCorrect && n8nDetectionCorrect && isN8nFunctionCorrect) {
|
||||||
|
console.log(`✅ ${testCase.name}`);
|
||||||
|
console.log(` Version: ${result.version}, n8n client: ${result.isN8nClient}`);
|
||||||
|
console.log(` Reasoning: ${result.reasoning}\n`);
|
||||||
|
passed++;
|
||||||
|
} else {
|
||||||
|
console.log(`❌ ${testCase.name}`);
|
||||||
|
console.log(` Expected: version=${testCase.expectedVersion}, isN8n=${testCase.expectedIsN8nClient}`);
|
||||||
|
console.log(` Got: version=${result.version}, isN8n=${result.isN8nClient}`);
|
||||||
|
console.log(` isN8nClient function: ${detectedAsN8n} (expected: ${testCase.expectedIsN8nClient})`);
|
||||||
|
console.log(` Reasoning: ${result.reasoning}\n`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`💥 ${testCase.name} - ERROR`);
|
||||||
|
console.log(` ${error instanceof Error ? error.message : String(error)}\n`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original N8N_MODE
|
||||||
|
if (originalN8nMode) {
|
||||||
|
process.env.N8N_MODE = originalN8nMode;
|
||||||
|
} else {
|
||||||
|
delete process.env.N8N_MODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log(`\n📊 Test Results:`);
|
||||||
|
console.log(` ✅ Passed: ${passed}`);
|
||||||
|
console.log(` ❌ Failed: ${failed}`);
|
||||||
|
console.log(` Total: ${passed + failed}`);
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
console.log(`\n❌ Some tests failed!`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log(`\n🎉 All tests passed!`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional integration test
|
||||||
|
async function testIntegration(): Promise<void> {
|
||||||
|
console.log('\n🔧 Integration Test - MCP Server Protocol Negotiation\n');
|
||||||
|
|
||||||
|
// This would normally test the actual MCP server, but we'll just verify
|
||||||
|
// the negotiation logic works in typical scenarios
|
||||||
|
|
||||||
|
const scenarios = [
|
||||||
|
{
|
||||||
|
name: 'Claude Desktop connecting',
|
||||||
|
clientInfo: { name: 'Claude Desktop', version: '1.0.0' },
|
||||||
|
clientVersion: '2025-03-26'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'n8n connecting via HTTP',
|
||||||
|
headers: { 'user-agent': 'n8n/1.52.0' },
|
||||||
|
clientVersion: '2025-03-26'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
const result = negotiateProtocolVersion(
|
||||||
|
scenario.clientVersion,
|
||||||
|
scenario.clientInfo,
|
||||||
|
scenario.headers?.['user-agent'],
|
||||||
|
scenario.headers
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`🔍 ${scenario.name}:`);
|
||||||
|
console.log(` Negotiated version: ${result.version}`);
|
||||||
|
console.log(` Is n8n client: ${result.isN8nClient}`);
|
||||||
|
console.log(` Reasoning: ${result.reasoning}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
runTests()
|
||||||
|
.then(() => testIntegration())
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Test execution failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,12 @@ export interface ToolDefinition {
|
|||||||
required?: string[];
|
required?: string[];
|
||||||
additionalProperties?: boolean | Record<string, any>;
|
additionalProperties?: boolean | Record<string, any>;
|
||||||
};
|
};
|
||||||
|
outputSchema?: {
|
||||||
|
type: string;
|
||||||
|
properties: Record<string, any>;
|
||||||
|
required?: string[];
|
||||||
|
additionalProperties?: boolean | Record<string, any>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResourceDefinition {
|
export interface ResourceDefinition {
|
||||||
|
|||||||
175
src/utils/protocol-version.ts
Normal file
175
src/utils/protocol-version.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Protocol Version Negotiation Utility
|
||||||
|
*
|
||||||
|
* Handles MCP protocol version negotiation between server and clients,
|
||||||
|
* with special handling for n8n clients that require specific versions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ClientInfo {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtocolNegotiationResult {
|
||||||
|
version: string;
|
||||||
|
isN8nClient: boolean;
|
||||||
|
reasoning: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard MCP protocol version (latest)
|
||||||
|
*/
|
||||||
|
export const STANDARD_PROTOCOL_VERSION = '2025-03-26';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* n8n specific protocol version (what n8n expects)
|
||||||
|
*/
|
||||||
|
export const N8N_PROTOCOL_VERSION = '2024-11-05';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported protocol versions in order of preference
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_VERSIONS = [
|
||||||
|
STANDARD_PROTOCOL_VERSION,
|
||||||
|
N8N_PROTOCOL_VERSION,
|
||||||
|
'2024-06-25', // Older fallback
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if the client is n8n based on various indicators
|
||||||
|
*/
|
||||||
|
export function isN8nClient(
|
||||||
|
clientInfo?: ClientInfo,
|
||||||
|
userAgent?: string,
|
||||||
|
headers?: Record<string, string | string[] | undefined>
|
||||||
|
): boolean {
|
||||||
|
// Check client info
|
||||||
|
if (clientInfo?.name) {
|
||||||
|
const clientName = clientInfo.name.toLowerCase();
|
||||||
|
if (clientName.includes('n8n') || clientName.includes('langchain')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user agent
|
||||||
|
if (userAgent) {
|
||||||
|
const ua = userAgent.toLowerCase();
|
||||||
|
if (ua.includes('n8n') || ua.includes('langchain')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check headers for n8n-specific indicators
|
||||||
|
if (headers) {
|
||||||
|
// Check for n8n-specific headers or values
|
||||||
|
const headerValues = Object.values(headers).join(' ').toLowerCase();
|
||||||
|
if (headerValues.includes('n8n') || headerValues.includes('langchain')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check specific header patterns that n8n might use
|
||||||
|
if (headers['x-n8n-version'] || headers['x-langchain-version']) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check environment variable that might indicate n8n mode
|
||||||
|
if (process.env.N8N_MODE === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Negotiate protocol version based on client information
|
||||||
|
*/
|
||||||
|
export function negotiateProtocolVersion(
|
||||||
|
clientRequestedVersion?: string,
|
||||||
|
clientInfo?: ClientInfo,
|
||||||
|
userAgent?: string,
|
||||||
|
headers?: Record<string, string | string[] | undefined>
|
||||||
|
): ProtocolNegotiationResult {
|
||||||
|
const isN8n = isN8nClient(clientInfo, userAgent, headers);
|
||||||
|
|
||||||
|
// For n8n clients, always use the n8n-specific version
|
||||||
|
if (isN8n) {
|
||||||
|
return {
|
||||||
|
version: N8N_PROTOCOL_VERSION,
|
||||||
|
isN8nClient: true,
|
||||||
|
reasoning: 'n8n client detected, using n8n-compatible protocol version'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If client requested a specific version, try to honor it if supported
|
||||||
|
if (clientRequestedVersion && SUPPORTED_VERSIONS.includes(clientRequestedVersion)) {
|
||||||
|
return {
|
||||||
|
version: clientRequestedVersion,
|
||||||
|
isN8nClient: false,
|
||||||
|
reasoning: `Using client-requested version: ${clientRequestedVersion}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If client requested an unsupported version, use the closest supported one
|
||||||
|
if (clientRequestedVersion) {
|
||||||
|
// For now, default to standard version for unknown requests
|
||||||
|
return {
|
||||||
|
version: STANDARD_PROTOCOL_VERSION,
|
||||||
|
isN8nClient: false,
|
||||||
|
reasoning: `Client requested unsupported version ${clientRequestedVersion}, using standard version`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to standard protocol version for unknown clients
|
||||||
|
return {
|
||||||
|
version: STANDARD_PROTOCOL_VERSION,
|
||||||
|
isN8nClient: false,
|
||||||
|
reasoning: 'No specific client detected, using standard protocol version'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a protocol version is supported
|
||||||
|
*/
|
||||||
|
export function isVersionSupported(version: string): boolean {
|
||||||
|
return SUPPORTED_VERSIONS.includes(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the most appropriate protocol version for backwards compatibility
|
||||||
|
* This is used when we need to maintain compatibility with older clients
|
||||||
|
*/
|
||||||
|
export function getCompatibleVersion(targetVersion?: string): string {
|
||||||
|
if (!targetVersion) {
|
||||||
|
return STANDARD_PROTOCOL_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SUPPORTED_VERSIONS.includes(targetVersion)) {
|
||||||
|
return targetVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not supported, return the most recent supported version
|
||||||
|
return STANDARD_PROTOCOL_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log protocol version negotiation for debugging
|
||||||
|
*/
|
||||||
|
export function logProtocolNegotiation(
|
||||||
|
result: ProtocolNegotiationResult,
|
||||||
|
logger: any,
|
||||||
|
context?: string
|
||||||
|
): void {
|
||||||
|
const logContext = context ? `[${context}] ` : '';
|
||||||
|
|
||||||
|
logger.info(`${logContext}Protocol version negotiated`, {
|
||||||
|
version: result.version,
|
||||||
|
isN8nClient: result.isN8nClient,
|
||||||
|
reasoning: result.reasoning
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.isN8nClient) {
|
||||||
|
logger.info(`${logContext}Using n8n-compatible protocol version for better integration`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user