feat: SSE (Server-Sent Events) support for n8n integration
- Added SSE server implementation for real-time event streaming - Created n8n compatibility mode with strict schema validation - Implemented session management for concurrent connections - Added comprehensive SSE documentation and examples - Enhanced MCP tools with async execution support - Added Docker Compose configuration for SSE deployment - Included test scripts and integration tests 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,15 @@ AUTH_TOKEN=your-secure-token-here
|
||||
# Default: 0 (disabled)
|
||||
# TRUST_PROXY=0
|
||||
|
||||
# =========================
|
||||
# N8N COMPATIBILITY MODE
|
||||
# =========================
|
||||
# Enable strict schema compatibility for n8n's MCP Client Tool
|
||||
# This mode adds additionalProperties: false to all tool schemas
|
||||
# to work around n8n's LangChain schema validation
|
||||
# Default: false (standard mode)
|
||||
# N8N_COMPATIBILITY_MODE=false
|
||||
|
||||
# =========================
|
||||
# N8N API CONFIGURATION
|
||||
# =========================
|
||||
|
||||
25
CLAUDE.md
25
CLAUDE.md
@@ -6,7 +6,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
n8n-mcp is a comprehensive documentation and knowledge server that provides AI assistants with complete access to n8n node information through the Model Context Protocol (MCP). It serves as a bridge between n8n's workflow automation platform and AI models, enabling them to understand and work with n8n nodes effectively.
|
||||
|
||||
## ✅ Latest Updates (v2.7.6)
|
||||
## ✅ Latest Updates (v2.8.1)
|
||||
|
||||
### Update (v2.8.1) - n8n Compatibility Mode for Strict Schema Validation:
|
||||
- ✅ **NEW: N8N_COMPATIBILITY_MODE** - Enable strict schema validation for n8n's MCP Client Tool
|
||||
- ✅ **FIXED: Schema validation errors** - "Received tool input did not match expected schema" error in n8n
|
||||
- ✅ **ENHANCED: Schema strictness** - All tools have `additionalProperties: false` in compatibility mode
|
||||
- ✅ **SEPARATE TOOL FILES** - Clean architecture with separate n8n-compatible tool definitions
|
||||
- ✅ **ENVIRONMENT TOGGLE** - Set `N8N_COMPATIBILITY_MODE=true` to enable strict schemas
|
||||
- ✅ **BACKWARD COMPATIBLE**: Defaults to standard mode when not configured
|
||||
|
||||
### Update (v2.8.0) - SSE (Server-Sent Events) Support for n8n Integration:
|
||||
- ✅ **NEW: SSE mode** - Full Server-Sent Events implementation for n8n MCP Server Trigger
|
||||
- ✅ **NEW: Real-time streaming** - Push-based event streaming from server to n8n workflows
|
||||
- ✅ **NEW: Async tool execution** - Better support for long-running operations
|
||||
- ✅ **NEW: Session management** - Handle multiple concurrent n8n connections
|
||||
- ✅ **NEW: Keep-alive mechanism** - Automatic connection maintenance with 30s pings
|
||||
- ✅ **ADDED: SSE endpoints** - `/sse` for event stream, `/mcp/message` for requests
|
||||
- ✅ **BACKWARD COMPATIBLE** - Legacy `/mcp` endpoint continues to work
|
||||
- ✅ **Docker support** - New `docker-compose.sse.yml` for easy deployment
|
||||
- ✅ **Complete documentation** - See [SSE_IMPLEMENTATION.md](./docs/SSE_IMPLEMENTATION.md)
|
||||
|
||||
### Update (v2.7.6) - Trust Proxy Support for Correct IP Logging:
|
||||
- ✅ **NEW: TRUST_PROXY support** - Log real client IPs when behind reverse proxy
|
||||
@@ -491,6 +510,10 @@ AUTH_TOKEN=your-secure-token
|
||||
# Set to 1 when behind a reverse proxy (Nginx, etc.)
|
||||
TRUST_PROXY=0
|
||||
|
||||
# n8n Compatibility Mode (optional)
|
||||
# Enable strict schema validation for n8n's MCP Client Tool
|
||||
N8N_COMPATIBILITY_MODE=false
|
||||
|
||||
# MCP Configuration
|
||||
MCP_SERVER_NAME=n8n-documentation-mcp
|
||||
MCP_SERVER_VERSION=1.0.0
|
||||
|
||||
@@ -506,6 +506,7 @@ npm run rebuild
|
||||
# 5. Start the server
|
||||
npm start # stdio mode for Claude Desktop
|
||||
npm run start:http # HTTP mode for remote access
|
||||
npm run start:sse # SSE mode for n8n MCP Server Trigger
|
||||
```
|
||||
|
||||
### Development Commands
|
||||
@@ -525,6 +526,7 @@ npm run update:n8n # Update n8n packages
|
||||
# Run Server
|
||||
npm run dev # Development with auto-reload
|
||||
npm run dev:http # HTTP dev mode
|
||||
npm run dev:sse # SSE dev mode
|
||||
```
|
||||
|
||||
## 📚 Documentation
|
||||
@@ -543,6 +545,7 @@ npm run dev:http # HTTP dev mode
|
||||
|
||||
### Development & Deployment
|
||||
- [HTTP Deployment](./docs/HTTP_DEPLOYMENT.md) - Remote server setup guide
|
||||
- [SSE Implementation](./docs/SSE_IMPLEMENTATION.md) - Server-Sent Events for n8n triggers
|
||||
- [Dependency Management](./docs/DEPENDENCY_UPDATES.md) - Keeping n8n packages in sync
|
||||
- [Claude's Interview](./docs/CLAUDE_INTERVIEW.md) - Real-world impact of n8n-MCP
|
||||
|
||||
|
||||
56
docker-compose.sse.yml
Normal file
56
docker-compose.sse.yml
Normal file
@@ -0,0 +1,56 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
n8n-mcp-sse:
|
||||
image: ghcr.io/czlonkowski/n8n-mcp:latest
|
||||
container_name: n8n-mcp-sse
|
||||
command: npm run start:sse
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- AUTH_TOKEN=${AUTH_TOKEN:-test-secure-token-123456789}
|
||||
- PORT=3000
|
||||
- HOST=0.0.0.0
|
||||
- NODE_ENV=production
|
||||
- LOG_LEVEL=info
|
||||
- CORS_ORIGIN=*
|
||||
- TRUST_PROXY=0
|
||||
volumes:
|
||||
- ./data:/app/data:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- n8n-network
|
||||
|
||||
# Optional: n8n instance for testing
|
||||
n8n:
|
||||
image: n8nio/n8n:latest
|
||||
container_name: n8n
|
||||
ports:
|
||||
- "5678:5678"
|
||||
environment:
|
||||
- N8N_BASIC_AUTH_ACTIVE=true
|
||||
- N8N_BASIC_AUTH_USER=admin
|
||||
- N8N_BASIC_AUTH_PASSWORD=password
|
||||
- N8N_HOST=0.0.0.0
|
||||
- N8N_PORT=5678
|
||||
- N8N_PROTOCOL=http
|
||||
- N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true
|
||||
- WEBHOOK_URL=http://n8n:5678/
|
||||
volumes:
|
||||
- n8n_data:/home/node/.n8n
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- n8n-network
|
||||
|
||||
networks:
|
||||
n8n-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
n8n_data:
|
||||
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.8.0] - 2025-07-08
|
||||
|
||||
### Added
|
||||
- **NEW: SSE (Server-Sent Events) mode** - Full implementation for n8n MCP Server Trigger integration
|
||||
- **NEW: SSE endpoints** - `/sse` for event streams, `/mcp/message` for async requests
|
||||
- **NEW: SSE Session Manager** - Manages multiple concurrent SSE connections with lifecycle handling
|
||||
- **NEW: MCP protocol over SSE** - Enables real-time event streaming and async tool execution
|
||||
- **NEW: Docker Compose SSE configuration** - `docker-compose.sse.yml` for easy deployment
|
||||
- **NEW: SSE test scripts** - `npm run test:sse` for verification and debugging
|
||||
- **NEW: n8n workflow example** - Example workflow for MCP Server Trigger with SSE
|
||||
|
||||
### Features
|
||||
- **Real-time communication** - SSE enables push-based updates from server to n8n
|
||||
- **Long-running operations** - Better support for async and long-running tool executions
|
||||
- **Multiple connections** - Support for multiple concurrent n8n workflows
|
||||
- **Keep-alive pings** - Automatic connection maintenance every 30 seconds
|
||||
- **Session management** - Automatic cleanup of inactive sessions (5-minute timeout)
|
||||
- **Backward compatibility** - Legacy `/mcp` endpoint still available
|
||||
|
||||
### Documentation
|
||||
- Complete SSE implementation guide at `docs/SSE_IMPLEMENTATION.md`
|
||||
- Updated README with SSE mode instructions
|
||||
- Added SSE testing and deployment documentation
|
||||
- n8n configuration examples for MCP Server Trigger
|
||||
|
||||
## [2.7.10] - 2025-07-07
|
||||
|
||||
### Added
|
||||
|
||||
221
docs/SSE_IMPLEMENTATION.md
Normal file
221
docs/SSE_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# SSE (Server-Sent Events) Implementation for n8n MCP
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the SSE implementation that enables n8n's MCP Server Trigger to connect to n8n-mcp server using Server-Sent Events protocol.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **SSE Server** (`src/sse-server.ts`)
|
||||
- Main Express server with SSE endpoints
|
||||
- Handles authentication and CORS
|
||||
- Manages both SSE connections and message processing
|
||||
|
||||
2. **SSE Session Manager** (`src/utils/sse-session-manager.ts`)
|
||||
- Manages active SSE client connections
|
||||
- Handles session lifecycle and cleanup
|
||||
- Sends events to connected clients
|
||||
|
||||
3. **Type Definitions** (`src/types/sse.ts`)
|
||||
- TypeScript interfaces for SSE messages
|
||||
- MCP protocol message types
|
||||
|
||||
## Endpoints
|
||||
|
||||
### GET /sse, GET /mcp, and GET /mcp/:path/sse
|
||||
- **Purpose**: SSE connection endpoint for n8n MCP Server Trigger
|
||||
- **Authentication**: Multiple methods supported (see Authentication section)
|
||||
- **Query Parameters** (optional):
|
||||
- `workflowId`: n8n workflow ID
|
||||
- `executionId`: n8n execution ID
|
||||
- `nodeId`: n8n node ID
|
||||
- `nodeName`: n8n node name
|
||||
- `runId`: n8n run ID
|
||||
- `token`: Authentication token (for SSE connections)
|
||||
- **Headers** (optional):
|
||||
- `X-Workflow-ID`: n8n workflow ID
|
||||
- `X-Execution-ID`: n8n execution ID
|
||||
- `X-Node-ID`: n8n node ID
|
||||
- `X-Node-Name`: n8n node name
|
||||
- `X-Run-ID`: n8n run ID
|
||||
- **Response**: Event stream with MCP protocol messages
|
||||
- **Events**:
|
||||
- `connected`: Initial connection confirmation with client ID
|
||||
- `mcp-response`: MCP protocol responses
|
||||
- `mcp-error`: Error messages
|
||||
- `ping`: Keep-alive messages (every 30 seconds)
|
||||
|
||||
### POST /mcp/message and POST /mcp/:path/message
|
||||
- **Purpose**: Receive MCP requests from n8n
|
||||
- **Authentication**: Multiple methods supported (see Authentication section)
|
||||
- **Headers**:
|
||||
- `X-Client-ID`: SSE session client ID (required)
|
||||
- **Request Body**: JSON-RPC 2.0 format
|
||||
- **Response**: Acknowledgment with message ID
|
||||
|
||||
### POST /mcp and POST /mcp/:path (Legacy)
|
||||
- **Purpose**: Backward compatibility with HTTP POST mode
|
||||
- **Authentication**: Multiple methods supported (see Authentication section)
|
||||
- **Request/Response**: Standard JSON-RPC 2.0
|
||||
|
||||
### GET /health
|
||||
- **Purpose**: Health check endpoint
|
||||
- **Response**: Server status including active SSE sessions
|
||||
|
||||
## Protocol Flow
|
||||
|
||||
1. **Connection**:
|
||||
```
|
||||
n8n → GET /mcp/workflow-123/sse?workflowId=123&nodeId=456 (with auth)
|
||||
← SSE connection established
|
||||
← Event: connected {clientId: "uuid"}
|
||||
← Event: mcp-response {method: "mcp/ready"}
|
||||
```
|
||||
|
||||
2. **Tool Discovery**:
|
||||
```
|
||||
n8n → POST /mcp/workflow-123/message {method: "tools/list"}
|
||||
← Response: {status: "ok"}
|
||||
← Event: mcp-response {result: {tools: [...]}}
|
||||
```
|
||||
|
||||
3. **Tool Execution**:
|
||||
```
|
||||
n8n → POST /mcp/workflow-123/message {method: "tools/call", params: {name, arguments}}
|
||||
← Response: {status: "ok"}
|
||||
← Event: mcp-response {result: {content: [...]}}
|
||||
```
|
||||
|
||||
4. **Resources and Prompts** (empty implementations):
|
||||
```
|
||||
n8n → POST /mcp/message {method: "resources/list"}
|
||||
← Event: mcp-response {result: {resources: []}}
|
||||
|
||||
n8n → POST /mcp/message {method: "prompts/list"}
|
||||
← Event: mcp-response {result: {prompts: []}}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
- `AUTH_TOKEN` or `AUTH_TOKEN_FILE`: Authentication token (required)
|
||||
- `AUTH_HEADER_NAME`: Custom authentication header name (default: x-auth-token)
|
||||
- `PORT`: Server port (default: 3000)
|
||||
- `HOST`: Server host (default: 0.0.0.0)
|
||||
- `CORS_ORIGIN`: Allowed CORS origin (default: *)
|
||||
- `TRUST_PROXY`: Number of proxy hops for correct IP logging
|
||||
|
||||
## Usage
|
||||
|
||||
### Starting the SSE Server
|
||||
|
||||
```bash
|
||||
# Build and start
|
||||
npm run sse
|
||||
|
||||
# Development mode with auto-reload
|
||||
npm run dev:sse
|
||||
|
||||
# With environment variables
|
||||
AUTH_TOKEN=your-secure-token npm run sse
|
||||
```
|
||||
|
||||
### Testing the Implementation
|
||||
|
||||
```bash
|
||||
# Run SSE tests
|
||||
npm run test:sse
|
||||
|
||||
# Manual test with curl
|
||||
# 1. Connect to SSE endpoint
|
||||
curl -N -H "Authorization: Bearer your-token" http://localhost:3000/sse
|
||||
|
||||
# 2. Send a message (in another terminal)
|
||||
curl -X POST http://localhost:3000/mcp/message \
|
||||
-H "Authorization: Bearer your-token" \
|
||||
-H "X-Client-ID: <client-id-from-sse>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
|
||||
```
|
||||
|
||||
## n8n Configuration
|
||||
|
||||
### MCP Client Tool Node
|
||||
|
||||
1. **SSE Endpoint**: `http://your-server:3000/mcp/your-path/sse`
|
||||
2. **Authentication**: Choose from supported methods
|
||||
3. **Token**: Your AUTH_TOKEN value
|
||||
4. **Optional Headers**: Add workflow context headers for better tracking
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication Methods
|
||||
The SSE server supports multiple authentication methods:
|
||||
|
||||
1. **Bearer Token** (recommended):
|
||||
- Header: `Authorization: Bearer <token>`
|
||||
|
||||
2. **Custom Header**:
|
||||
- Header: `X-Auth-Token: <token>` (or custom via AUTH_HEADER_NAME env var)
|
||||
|
||||
3. **Query Parameter** (for SSE connections):
|
||||
- URL: `/sse?token=<token>`
|
||||
|
||||
4. **API Key Header**:
|
||||
- Header: `X-API-Key: <token>`
|
||||
|
||||
### Additional Security Features
|
||||
- **CORS**: Configure CORS_ORIGIN for production deployments
|
||||
- **HTTPS**: Use reverse proxy with SSL in production
|
||||
- **Session Timeout**: Sessions expire after 5 minutes of inactivity
|
||||
- **Workflow Context**: Track requests by workflow/node for auditing
|
||||
|
||||
## Performance
|
||||
|
||||
- Keep-alive pings every 30 seconds prevent connection timeouts
|
||||
- Session cleanup runs every 30 seconds
|
||||
- Supports up to 1000 concurrent SSE connections (configurable)
|
||||
- Minimal memory footprint per connection
|
||||
- Enhanced debug logging available with LOG_LEVEL=debug
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Issues
|
||||
- Check AUTH_TOKEN is set correctly
|
||||
- Verify firewall allows SSE connections
|
||||
- Check proxy configuration if behind reverse proxy
|
||||
- **n8n Connection Failed**: If you see "Could not connect to your MCP server" in n8n logs, this is likely due to gzip compression breaking SSE. The server now explicitly disables compression with `Content-Encoding: identity` header
|
||||
|
||||
### Message Delivery
|
||||
- Ensure X-Client-ID header matches active session
|
||||
- Check server logs for session expiration
|
||||
- Verify JSON-RPC format is correct
|
||||
|
||||
### Nginx Configuration
|
||||
If behind Nginx, add these directives:
|
||||
```nginx
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 86400s;
|
||||
gzip off; # Important: Disable gzip for SSE endpoints
|
||||
```
|
||||
|
||||
**Note**: n8n has known issues with gzip compression on SSE connections. Always disable compression for SSE endpoints.
|
||||
|
||||
## Integration with n8n
|
||||
|
||||
The SSE implementation enables n8n workflows to:
|
||||
1. Receive real-time MCP events
|
||||
2. Execute long-running tool operations
|
||||
3. Handle asynchronous responses
|
||||
4. Support multiple concurrent workflows
|
||||
|
||||
This provides a more robust integration compared to simple HTTP polling, especially for:
|
||||
- Long-running operations
|
||||
- Real-time notifications
|
||||
- Event-driven workflows
|
||||
- Scalable deployments
|
||||
95
examples/n8n-mcp-sse-workflow.json
Normal file
95
examples/n8n-mcp-sse-workflow.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"name": "MCP Server Trigger with SSE Example",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"eventSourceUrl": "http://localhost:3000/sse",
|
||||
"messageEndpoint": "http://localhost:3000/mcp/message",
|
||||
"authentication": "bearerToken",
|
||||
"options": {
|
||||
"reconnect": true,
|
||||
"reconnectInterval": 5000
|
||||
}
|
||||
},
|
||||
"id": "mcp-server-trigger",
|
||||
"name": "MCP Server Trigger",
|
||||
"type": "n8n-nodes-base.mcpServerTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [250, 300],
|
||||
"credentials": {
|
||||
"mcpApi": {
|
||||
"id": "1",
|
||||
"name": "MCP API"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "callTool",
|
||||
"toolName": "={{ $json.toolName }}",
|
||||
"toolArguments": "={{ JSON.stringify($json.arguments) }}"
|
||||
},
|
||||
"id": "mcp-client",
|
||||
"name": "MCP Client",
|
||||
"type": "n8n-nodes-base.mcp",
|
||||
"typeVersion": 1,
|
||||
"position": [450, 300],
|
||||
"credentials": {
|
||||
"mcpApi": {
|
||||
"id": "1",
|
||||
"name": "MCP API"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"values": {
|
||||
"string": [
|
||||
{
|
||||
"name": "response",
|
||||
"value": "={{ JSON.stringify($json) }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "set-response",
|
||||
"name": "Format Response",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 1,
|
||||
"position": [650, 300]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"MCP Server Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "MCP Client",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"MCP Client": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Format Response",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"staticData": null,
|
||||
"pinData": {},
|
||||
"versionId": "sse-example-v1",
|
||||
"triggerCount": 0,
|
||||
"tags": []
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.7.10",
|
||||
"version": "2.8.0",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
@@ -15,9 +15,12 @@
|
||||
"start": "node dist/mcp/index.js",
|
||||
"start:http": "MCP_MODE=http node dist/mcp/index.js",
|
||||
"start:http:fixed": "MCP_MODE=http USE_FIXED_HTTP=true node dist/mcp/index.js",
|
||||
"start:sse": "MCP_MODE=sse node dist/mcp/index.js",
|
||||
"http": "npm run build && npm run start:http:fixed",
|
||||
"sse": "npm run build && npm run start:sse",
|
||||
"dev": "npm run build && npm run rebuild && npm run validate",
|
||||
"dev:http": "MCP_MODE=http nodemon --watch src --ext ts --exec 'npm run build && npm run start:http'",
|
||||
"dev:sse": "MCP_MODE=sse nodemon --watch src --ext ts --exec 'npm run build && node dist/mcp/index.js'",
|
||||
"test:single-session": "./scripts/test-single-session.sh",
|
||||
"test": "jest",
|
||||
"lint": "tsc --noEmit",
|
||||
@@ -42,6 +45,10 @@
|
||||
"test:mcp:update-partial": "node dist/scripts/test-mcp-n8n-update-partial.js",
|
||||
"test:update-partial:debug": "node dist/scripts/test-update-partial-debug.js",
|
||||
"test:auth-logging": "tsx scripts/test-auth-logging.ts",
|
||||
"test:sse": "npm run build && jest tests/sse-*.test.ts --passWithNoTests",
|
||||
"test:sse:manual": "npm run build && npx ts-node tests/test-sse-endpoints.ts",
|
||||
"test:sse:integration": "npm run build && jest tests/sse-integration.test.ts --passWithNoTests",
|
||||
"test:sse:unit": "npm run build && jest tests/sse-session-manager.test.ts --passWithNoTests",
|
||||
"sanitize:templates": "node dist/scripts/sanitize-templates.js",
|
||||
"db:rebuild": "node dist/scripts/rebuild-database.js",
|
||||
"db:init": "node -e \"new (require('./dist/services/sqlite-storage-service').SQLiteStorageService)(); console.log('Database initialized')\"",
|
||||
|
||||
104
scripts/test-sse.sh
Executable file
104
scripts/test-sse.sh
Executable file
@@ -0,0 +1,104 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test script for SSE server
|
||||
# Usage: ./scripts/test-sse.sh
|
||||
|
||||
SERVER_URL="${SERVER_URL:-http://localhost:3000}"
|
||||
AUTH_TOKEN="${AUTH_TOKEN:-test-secure-token-123456789}"
|
||||
|
||||
echo "🧪 Testing SSE Server Implementation"
|
||||
echo "Server URL: $SERVER_URL"
|
||||
echo "Auth Token: ${AUTH_TOKEN:0:8}..."
|
||||
echo ""
|
||||
|
||||
# Function to test endpoint
|
||||
test_endpoint() {
|
||||
local method=$1
|
||||
local endpoint=$2
|
||||
local data=$3
|
||||
local headers=$4
|
||||
|
||||
echo -n "Testing $method $endpoint... "
|
||||
|
||||
if [ "$method" = "GET" ]; then
|
||||
response=$(curl -s -w "\n%{http_code}" -X GET "$SERVER_URL$endpoint" $headers)
|
||||
else
|
||||
response=$(curl -s -w "\n%{http_code}" -X POST "$SERVER_URL$endpoint" \
|
||||
-H "Content-Type: application/json" \
|
||||
$headers \
|
||||
-d "$data")
|
||||
fi
|
||||
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | head -n-1)
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
echo "✅ OK ($http_code)"
|
||||
if [ -n "$body" ]; then
|
||||
echo " Response: $(echo "$body" | jq -c . 2>/dev/null || echo "$body")"
|
||||
fi
|
||||
else
|
||||
echo "❌ FAILED ($http_code)"
|
||||
if [ -n "$body" ]; then
|
||||
echo " Error: $body"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Test health check
|
||||
test_endpoint "GET" "/health" "" ""
|
||||
|
||||
# Test SSE connection (limited test with curl)
|
||||
echo -n "Testing SSE connection... "
|
||||
timeout 2 curl -s -N -H "Authorization: Bearer $AUTH_TOKEN" "$SERVER_URL/sse" > /tmp/sse-test.log 2>&1 &
|
||||
SSE_PID=$!
|
||||
sleep 1
|
||||
|
||||
if kill -0 $SSE_PID 2>/dev/null; then
|
||||
echo "✅ Connection established"
|
||||
kill $SSE_PID 2>/dev/null
|
||||
if [ -s /tmp/sse-test.log ]; then
|
||||
echo " Initial events:"
|
||||
cat /tmp/sse-test.log | head -5
|
||||
fi
|
||||
else
|
||||
echo "❌ Connection failed"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test legacy MCP endpoint
|
||||
test_endpoint "POST" "/mcp" '{"jsonrpc":"2.0","method":"tools/list","id":1}' "-H 'Authorization: Bearer $AUTH_TOKEN'"
|
||||
|
||||
# Test invalid auth
|
||||
echo -n "Testing authentication rejection... "
|
||||
response=$(curl -s -w "\n%{http_code}" -X GET "$SERVER_URL/sse" -H "Authorization: Bearer invalid-token")
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
if [ "$http_code" = "401" ]; then
|
||||
echo "✅ Correctly rejected ($http_code)"
|
||||
else
|
||||
echo "❌ Expected 401, got $http_code"
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "📊 Test Summary:"
|
||||
echo "SSE server endpoint: $SERVER_URL/sse"
|
||||
echo "Message endpoint: $SERVER_URL/mcp/message"
|
||||
echo "Legacy endpoint: $SERVER_URL/mcp"
|
||||
|
||||
# Instructions for manual testing
|
||||
echo ""
|
||||
echo "📝 Manual Testing Instructions:"
|
||||
echo ""
|
||||
echo "1. Connect to SSE stream:"
|
||||
echo " curl -N -H \"Authorization: Bearer $AUTH_TOKEN\" $SERVER_URL/sse"
|
||||
echo ""
|
||||
echo "2. In another terminal, get the client ID from the connected event and send a message:"
|
||||
echo " curl -X POST $SERVER_URL/mcp/message \\"
|
||||
echo " -H \"Authorization: Bearer $AUTH_TOKEN\" \\"
|
||||
echo " -H \"X-Client-ID: <client-id-from-sse>\" \\"
|
||||
echo " -H \"Content-Type: application/json\" \\"
|
||||
echo " -d '{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"id\":1}'"
|
||||
echo ""
|
||||
echo "3. You should see the response in the SSE stream"
|
||||
242
scripts/test-sse.ts
Executable file
242
scripts/test-sse.ts
Executable file
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env ts-node
|
||||
/**
|
||||
* Test script for SSE server implementation
|
||||
* Tests the SSE connection and MCP protocol communication
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
const SERVER_URL = process.env.SERVER_URL || 'http://localhost:3000';
|
||||
const AUTH_TOKEN = process.env.AUTH_TOKEN || 'test-token';
|
||||
|
||||
interface TestResult {
|
||||
test: string;
|
||||
status: 'passed' | 'failed';
|
||||
message?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
function logTest(test: string, status: 'passed' | 'failed', message?: string, duration?: number) {
|
||||
results.push({ test, status, message, duration });
|
||||
console.log(`${status === 'passed' ? '✅' : '❌'} ${test}${message ? `: ${message}` : ''}${duration ? ` (${duration}ms)` : ''}`);
|
||||
}
|
||||
|
||||
async function testHealthCheck() {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const response = await axios.get(`${SERVER_URL}/health`);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
if (response.data.status === 'ok' && response.data.mode === 'sse') {
|
||||
logTest('Health check', 'passed', `Server is running in SSE mode`, duration);
|
||||
} else {
|
||||
logTest('Health check', 'failed', `Unexpected response: ${JSON.stringify(response.data)}`, duration);
|
||||
}
|
||||
} catch (error) {
|
||||
logTest('Health check', 'failed', error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
async function testSSEConnection(): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now();
|
||||
let clientId: string | null = null;
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
try {
|
||||
// Note: eventsource package doesn't support headers in constructor
|
||||
// We'll need to use a different approach or library for authenticated SSE
|
||||
const EventSourcePolyfill = require('eventsource');
|
||||
eventSource = new EventSourcePolyfill(`${SERVER_URL}/sse`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`
|
||||
}
|
||||
}) as EventSource;
|
||||
|
||||
eventSource.addEventListener('connected', (event: any) => {
|
||||
const duration = Date.now() - start;
|
||||
const data = JSON.parse(event.data);
|
||||
clientId = data.clientId;
|
||||
logTest('SSE connection', 'passed', `Connected with client ID: ${clientId}`, duration);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('mcp-response', (event: any) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log(' Received MCP response:', data);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('ping', (event: any) => {
|
||||
console.log(' Received ping:', event.data);
|
||||
});
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
logTest('SSE connection', 'failed', `Connection error: ${error}`);
|
||||
eventSource?.close();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
// Wait for connection and initial message
|
||||
setTimeout(() => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
resolve(clientId);
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
logTest('SSE connection', 'failed', error instanceof Error ? error.message : 'Unknown error');
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function testMCPMessage(clientId: string) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
// Test initialize
|
||||
const initResponse = await axios.post(
|
||||
`${SERVER_URL}/mcp/message`,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 'test-init-1',
|
||||
params: {}
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
'X-Client-ID': clientId,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const duration = Date.now() - start;
|
||||
|
||||
if (initResponse.data.status === 'ok') {
|
||||
logTest('MCP initialize message', 'passed', `Message acknowledged`, duration);
|
||||
} else {
|
||||
logTest('MCP initialize message', 'failed', `Unexpected response: ${JSON.stringify(initResponse.data)}`, duration);
|
||||
}
|
||||
} catch (error) {
|
||||
logTest('MCP initialize message', 'failed', error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
async function testToolsList(clientId: string) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${SERVER_URL}/mcp/message`,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: 'test-tools-1',
|
||||
params: {}
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
'X-Client-ID': clientId,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const duration = Date.now() - start;
|
||||
|
||||
if (response.data.status === 'ok') {
|
||||
logTest('MCP tools/list message', 'passed', `Message acknowledged`, duration);
|
||||
} else {
|
||||
logTest('MCP tools/list message', 'failed', `Unexpected response: ${JSON.stringify(response.data)}`, duration);
|
||||
}
|
||||
} catch (error) {
|
||||
logTest('MCP tools/list message', 'failed', error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
async function testLegacyEndpoint() {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${SERVER_URL}/mcp`,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: 'test-legacy-1',
|
||||
params: {}
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const duration = Date.now() - start;
|
||||
|
||||
if (response.data.result && response.data.result.tools) {
|
||||
logTest('Legacy /mcp endpoint', 'passed', `Found ${response.data.result.tools.length} tools`, duration);
|
||||
} else {
|
||||
logTest('Legacy /mcp endpoint', 'failed', `Unexpected response: ${JSON.stringify(response.data)}`, duration);
|
||||
}
|
||||
} catch (error) {
|
||||
logTest('Legacy /mcp endpoint', 'failed', error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('🧪 Testing SSE Server Implementation');
|
||||
console.log(`Server URL: ${SERVER_URL}`);
|
||||
console.log(`Auth Token: ${AUTH_TOKEN.substring(0, 8)}...`);
|
||||
console.log('');
|
||||
|
||||
// Health check
|
||||
await testHealthCheck();
|
||||
|
||||
// SSE connection
|
||||
const clientId = await testSSEConnection();
|
||||
|
||||
if (clientId) {
|
||||
// Test MCP messages
|
||||
await testMCPMessage(clientId);
|
||||
await testToolsList(clientId);
|
||||
}
|
||||
|
||||
// Test legacy endpoint
|
||||
await testLegacyEndpoint();
|
||||
|
||||
// Summary
|
||||
console.log('\n📊 Test Summary:');
|
||||
const passed = results.filter(r => r.status === 'passed').length;
|
||||
const failed = results.filter(r => r.status === 'failed').length;
|
||||
console.log(`Total: ${results.length}, Passed: ${passed}, Failed: ${failed}`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\n❌ Failed tests:');
|
||||
results.filter(r => r.status === 'failed').forEach(r => {
|
||||
console.log(` - ${r.test}: ${r.message}`);
|
||||
});
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\n✅ All tests passed!');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Install eventsource if not available
|
||||
try {
|
||||
require('eventsource');
|
||||
} catch {
|
||||
console.log('Installing eventsource package...');
|
||||
require('child_process').execSync('npm install --no-save eventsource', { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
// Run tests
|
||||
runTests().catch(error => {
|
||||
console.error('Test runner error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import express from 'express';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { n8nDocumentationToolsFinal } from './mcp/tools';
|
||||
import { n8nManagementTools } from './mcp/tools-n8n-manager';
|
||||
import { N8NDocumentationMCPServer } from './mcp/server';
|
||||
@@ -269,7 +270,7 @@ export async function startFixedHTTPServer() {
|
||||
response = {
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
protocolVersion: '2024-11-05',
|
||||
protocolVersion: DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {}
|
||||
|
||||
@@ -53,6 +53,11 @@ async function main() {
|
||||
|
||||
await server.start();
|
||||
}
|
||||
} else if (mode === 'sse') {
|
||||
// SSE mode - for n8n MCP Server Trigger integration
|
||||
console.error('Starting n8n Documentation MCP Server in SSE mode...');
|
||||
const { startSSEServer } = await import('../sse-server');
|
||||
await startSSEServer();
|
||||
} else {
|
||||
// Stdio mode - for local Claude Desktop
|
||||
const server = new N8NDocumentationMCPServer();
|
||||
|
||||
@@ -4,11 +4,14 @@ import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
InitializeRequestSchema,
|
||||
DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { n8nDocumentationToolsFinal } from './tools';
|
||||
import { n8nManagementTools } from './tools-n8n-manager';
|
||||
import { n8nDocumentationToolsFinal as n8nCompatTools } from './tools-n8n-compat';
|
||||
import { n8nManagementTools as n8nCompatManagementTools } from './tools-n8n-manager-compat';
|
||||
import { logger } from '../utils/logger';
|
||||
import { NodeRepository } from '../database/node-repository';
|
||||
import { DatabaseAdapter, createDatabaseAdapter } from '../database/database-adapter';
|
||||
@@ -52,8 +55,12 @@ export class N8NDocumentationMCPServer {
|
||||
private templateService: TemplateService | null = null;
|
||||
private initialized: Promise<void>;
|
||||
private cache = new SimpleCache();
|
||||
private isN8nCompatMode: boolean;
|
||||
|
||||
constructor() {
|
||||
// Check for n8n compatibility mode
|
||||
this.isN8nCompatMode = process.env.N8N_COMPATIBILITY_MODE === 'true';
|
||||
|
||||
// Try multiple database paths
|
||||
const possiblePaths = [
|
||||
path.join(process.cwd(), 'data', 'nodes.db'),
|
||||
@@ -77,15 +84,19 @@ export class N8NDocumentationMCPServer {
|
||||
// Initialize database asynchronously
|
||||
this.initialized = this.initializeDatabase(dbPath);
|
||||
|
||||
logger.info('Initializing n8n Documentation MCP server');
|
||||
logger.info('Initializing n8n Documentation MCP server', {
|
||||
n8nCompatMode: this.isN8nCompatMode
|
||||
});
|
||||
|
||||
// Log n8n API configuration status at startup
|
||||
const apiConfigured = isN8nApiConfigured();
|
||||
const docTools = this.isN8nCompatMode ? n8nCompatTools : n8nDocumentationToolsFinal;
|
||||
const mgmtTools = this.isN8nCompatMode ? n8nCompatManagementTools : n8nManagementTools;
|
||||
const totalTools = apiConfigured ?
|
||||
n8nDocumentationToolsFinal.length + n8nManagementTools.length :
|
||||
n8nDocumentationToolsFinal.length;
|
||||
docTools.length + mgmtTools.length :
|
||||
docTools.length;
|
||||
|
||||
logger.info(`MCP server initialized with ${totalTools} tools (n8n API: ${apiConfigured ? 'configured' : 'not configured'})`);
|
||||
logger.info(`MCP server initialized with ${totalTools} tools (n8n API: ${apiConfigured ? 'configured' : 'not configured'}, compatibility mode: ${this.isN8nCompatMode})`);
|
||||
|
||||
this.server = new Server(
|
||||
{
|
||||
@@ -125,7 +136,7 @@ export class N8NDocumentationMCPServer {
|
||||
// Handle initialization
|
||||
this.server.setRequestHandler(InitializeRequestSchema, async () => {
|
||||
const response = {
|
||||
protocolVersion: '2024-11-05',
|
||||
protocolVersion: DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
@@ -145,13 +156,17 @@ export class N8NDocumentationMCPServer {
|
||||
|
||||
// Handle tool listing
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
// Use compatibility mode tools if enabled
|
||||
const docTools = this.isN8nCompatMode ? n8nCompatTools : n8nDocumentationToolsFinal;
|
||||
const mgmtTools = this.isN8nCompatMode ? n8nCompatManagementTools : n8nManagementTools;
|
||||
|
||||
// Combine documentation tools with management tools if API is configured
|
||||
const tools = [...n8nDocumentationToolsFinal];
|
||||
const tools = [...docTools];
|
||||
const isConfigured = isN8nApiConfigured();
|
||||
|
||||
if (isConfigured) {
|
||||
tools.push(...n8nManagementTools);
|
||||
logger.debug(`Tool listing: ${tools.length} tools available (${n8nDocumentationToolsFinal.length} documentation + ${n8nManagementTools.length} management)`);
|
||||
tools.push(...mgmtTools);
|
||||
logger.debug(`Tool listing: ${tools.length} tools available (${docTools.length} documentation + ${mgmtTools.length} management)`);
|
||||
} else {
|
||||
logger.debug(`Tool listing: ${tools.length} tools available (documentation only)`);
|
||||
}
|
||||
@@ -193,13 +208,16 @@ export class N8NDocumentationMCPServer {
|
||||
async executeTool(name: string, args: any): Promise<any> {
|
||||
switch (name) {
|
||||
case 'tools_documentation':
|
||||
return this.getToolsDocumentation(args.topic, args.depth);
|
||||
return this.getToolsDocumentation(args.topic || undefined, args.depth || 'essentials');
|
||||
case 'list_nodes':
|
||||
return this.listNodes(args);
|
||||
return this.listNodes({
|
||||
...args,
|
||||
limit: args.limit || 50
|
||||
});
|
||||
case 'get_node_info':
|
||||
return this.getNodeInfo(args.nodeType);
|
||||
case 'search_nodes':
|
||||
return this.searchNodes(args.query, args.limit);
|
||||
return this.searchNodes(args.query, args.limit || 20);
|
||||
case 'list_ai_tools':
|
||||
return this.listAITools();
|
||||
case 'get_node_documentation':
|
||||
|
||||
486
src/mcp/tools-n8n-compat.ts
Normal file
486
src/mcp/tools-n8n-compat.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import { ToolDefinition } from '../types';
|
||||
|
||||
/**
|
||||
* n8n-Compatible Documentation MCP Tools
|
||||
*
|
||||
* This is a strict schema version of tools.ts designed specifically for n8n compatibility.
|
||||
* Changes from original:
|
||||
* - All schemas have additionalProperties: false for strict validation
|
||||
* - All schemas have explicit required arrays (even if empty)
|
||||
* - Simplified descriptions without special characters
|
||||
* - Consistent schema structure for LangChain compatibility
|
||||
*/
|
||||
export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
{
|
||||
name: 'tools_documentation',
|
||||
description: 'Get documentation for n8n MCP tools. Call without parameters for quick start guide. Use topic parameter to get documentation for specific tools. Use depth parameter for detailed documentation.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
topic: {
|
||||
type: 'string',
|
||||
description: 'Tool name like search_nodes or overview for general guide. Leave empty for quick reference.',
|
||||
},
|
||||
depth: {
|
||||
type: 'string',
|
||||
enum: ['essentials', 'full'],
|
||||
description: 'Level of detail. essentials is default for quick reference, full for comprehensive docs.',
|
||||
default: 'essentials',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_nodes',
|
||||
description: 'List n8n nodes with optional filters. Common usage: list_nodes with limit 200 for all nodes, or with category trigger for triggers. Use exact package names like n8n-nodes-base. Categories include trigger, transform, output, input. Returns node names and descriptions.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
package: {
|
||||
type: 'string',
|
||||
description: 'EXACT package name: n8n-nodes-base for 435 core integrations or @n8n/n8n-nodes-langchain for 90 AI nodes.',
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
description: 'Single category: trigger, transform, output, input, or AI. Returns all nodes in that category.',
|
||||
},
|
||||
developmentStyle: {
|
||||
type: 'string',
|
||||
enum: ['declarative', 'programmatic'],
|
||||
description: 'Implementation type. Most nodes are programmatic. Rarely needed.',
|
||||
},
|
||||
isAITool: {
|
||||
type: 'boolean',
|
||||
description: 'true returns only nodes with usableAsTool for AI agents. 263 nodes available. Use list_ai_tools instead for better results.',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Results limit. Default 50 may miss nodes. Use 200 or more for complete results. Max 500.',
|
||||
default: 50,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_info',
|
||||
description: 'Get COMPLETE technical schema for a node. WARNING: Returns massive JSON often over 100KB with all properties, operations, credentials. Contains duplicates and complex conditional logic. TIPS: Use get_node_essentials first for common use cases. Try get_node_documentation for human-readable info. Look for required true properties. Find properties without displayOptions for simpler versions. Node type MUST include prefix like nodes-base.httpRequest NOT just httpRequest. NOW INCLUDES aiToolCapabilities section showing how to use any node as an AI tool.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'FULL node type with prefix. Format: nodes-base.name or nodes-langchain.name. Common examples: nodes-base.httpRequest, nodes-base.webhook, nodes-base.code, nodes-base.slack, nodes-base.gmail, nodes-base.googleSheets, nodes-base.postgres, nodes-langchain.openAi, nodes-langchain.agent. CASE SENSITIVE.',
|
||||
},
|
||||
},
|
||||
required: ['nodeType'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_nodes',
|
||||
description: 'Search nodes by keywords. Returns nodes containing ANY of the search words using OR logic. Examples: slack finds Slack node, send message finds any node with send OR message. Best practice: Use single words for precise results, multiple words for broader search. Searches in node names and descriptions. If no results, try shorter words or use list_nodes by category.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search term - MUST BE SINGLE WORD for best results. Good: slack, email, http, sheet, database, webhook. Bad: send slack message, read spreadsheet. Case-insensitive.',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Max results. Default 20 is usually enough. Increase if needed.',
|
||||
default: 20,
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_ai_tools',
|
||||
description: 'List all 263 nodes marked with usableAsTool true property. IMPORTANT: ANY node in n8n can be used as an AI tool - not just these. These nodes are optimized for AI usage but you can connect any node like Slack, Google Sheets, HTTP Request to an AI Agent tool port. Returns names and descriptions. For community nodes as tools, set N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE true. Use get_node_as_tool_info for guidance on using any node as a tool.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_documentation',
|
||||
description: 'Get human-readable documentation for a node. USE THIS BEFORE get_node_info. Returns markdown with explanations, examples, auth setup, common patterns. Much easier to understand than raw schema. 87 percent of nodes have docs. Returns No documentation available otherwise. Same nodeType format as get_node_info. Best for understanding what a node does and how to use it.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'Full node type WITH prefix same as get_node_info: nodes-base.slack, nodes-base.httpRequest, etc. CASE SENSITIVE.',
|
||||
},
|
||||
},
|
||||
required: ['nodeType'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_database_statistics',
|
||||
description: 'Quick summary of the n8n node ecosystem. Shows: total nodes 525, AI tools 263, triggers 104, versioned nodes, documentation coverage 87 percent, package breakdown. No parameters needed. Useful for verifying MCP is working and understanding available scope.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_essentials',
|
||||
description: 'Get only the 10-20 most important properties for a node with 95 percent size reduction. USE THIS INSTEAD OF get_node_info for basic configuration. Returns: required properties, common properties, working examples. Perfect for quick workflow building. Same nodeType format as get_node_info like nodes-base.httpRequest. Reduces 100KB responses to under 5KB focused data.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'Full node type WITH prefix: nodes-base.httpRequest, nodes-base.webhook, etc. Same format as get_node_info.',
|
||||
},
|
||||
},
|
||||
required: ['nodeType'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_node_properties',
|
||||
description: 'Search for specific properties within a node. Find authentication options, body parameters, headers, etc. without parsing the entire schema. Returns matching properties with their paths and descriptions. Use this when you need to find specific configuration options like auth, header, body, etc.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'Full node type WITH prefix same as get_node_info.',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Property name or keyword to search for. Examples: auth, header, body, json, timeout.',
|
||||
},
|
||||
maxResults: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results to return. Default 20.',
|
||||
default: 20,
|
||||
},
|
||||
},
|
||||
required: ['nodeType', 'query'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_for_task',
|
||||
description: 'Get pre-configured node settings for common tasks. USE THIS to quickly configure nodes for specific use cases like post_json_request, receive_webhook, query_database, etc. Returns ready-to-use configuration with clear indication of what user must provide. Much faster than figuring out configuration from scratch.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
task: {
|
||||
type: 'string',
|
||||
description: 'The task to accomplish. Available tasks: get_api_data, post_json_request, call_api_with_auth, receive_webhook, webhook_with_response, query_postgres, insert_postgres_data, chat_with_ai, ai_agent_workflow, transform_data, filter_data, send_slack_message, send_email. Use list_tasks to see all available tasks.',
|
||||
},
|
||||
},
|
||||
required: ['task'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_tasks',
|
||||
description: 'List all available task templates. Use this to discover what pre-configured tasks are available before using get_node_for_task. Tasks are organized by category: HTTP/API, Webhooks, Database, AI/LangChain, Data Processing, Communication.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
category: {
|
||||
type: 'string',
|
||||
description: 'Optional category filter: HTTP/API, Webhooks, Database, AI/LangChain, Data Processing, Communication',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'validate_node_operation',
|
||||
description: 'Verify your node configuration is correct before using it. Checks: required fields are present, values are valid types and formats, operation-specific rules are met. Returns specific errors with fixes like Channel required to send Slack message - add channel with value general, warnings about common issues, working examples when errors found, and suggested next steps. Smart validation that only checks properties relevant to your selected operation or action. Essential for Slack, Google Sheets, MongoDB, OpenAI nodes. Supports validation profiles for different use cases.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'The node type to validate like nodes-base.slack',
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
description: 'Your node configuration. Must include operation fields like resource, operation, action if the node has multiple operations.',
|
||||
additionalProperties: true,
|
||||
},
|
||||
profile: {
|
||||
type: 'string',
|
||||
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',
|
||||
default: 'ai-friendly',
|
||||
},
|
||||
},
|
||||
required: ['nodeType', 'config'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'validate_node_minimal',
|
||||
description: 'Quick validation that ONLY checks for missing required fields. Returns just the list of required fields that are missing. Fastest validation option - use when you only need to know if required fields are present. No warnings, no suggestions, no examples - just missing required fields.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'The node type to validate like nodes-base.slack',
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
description: 'The node configuration to check',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
required: ['nodeType', 'config'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_property_dependencies',
|
||||
description: 'Shows which properties control the visibility of other properties. Helps understand why certain fields appear or disappear based on configuration. Example: In HTTP Request, sendBody true reveals body-related properties. Optionally provide a config to see what would be visible or hidden with those settings.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'The node type to analyze like nodes-base.httpRequest',
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
description: 'Optional partial configuration to check visibility impact',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
required: ['nodeType'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_as_tool_info',
|
||||
description: 'Get specific information about using a node as an AI tool. Returns whether the node can be used as a tool, common use cases, requirements, and examples. Essential for understanding how to connect regular nodes to AI Agents. Works for ANY node - not just those marked as AI tools.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeType: {
|
||||
type: 'string',
|
||||
description: 'Full node type WITH prefix: nodes-base.slack, nodes-base.googleSheets, etc.',
|
||||
},
|
||||
},
|
||||
required: ['nodeType'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_node_templates',
|
||||
description: 'List workflow templates that use specific node types. Returns ready-to-use workflows from n8n.io community. Templates are from the last year with 399 total. Use FULL node types like n8n-nodes-base.httpRequest or @n8n/n8n-nodes-langchain.openAi. Great for finding proven workflow patterns.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
nodeTypes: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Array of node types to search for like n8n-nodes-base.httpRequest, n8n-nodes-base.openAi',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of templates to return. Default 10.',
|
||||
default: 10,
|
||||
},
|
||||
},
|
||||
required: ['nodeTypes'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_template',
|
||||
description: 'Get a specific workflow template with complete JSON. Returns the full workflow definition ready to import into n8n. Use template IDs from list_node_templates or search_templates results.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
templateId: {
|
||||
type: 'number',
|
||||
description: 'The template ID to retrieve',
|
||||
},
|
||||
},
|
||||
required: ['templateId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_templates',
|
||||
description: 'Search workflow templates by keywords in template NAMES and DESCRIPTIONS only. NOTE: This does NOT search by node types. To find templates using specific nodes, use list_node_templates with node types array instead. Examples: search_templates with chatbot finds templates with chatbot in the name or description. All templates are from the last year and include view counts to gauge popularity.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query for template names and descriptions. NOT for node types. Examples: chatbot, automation, social media, webhook. For node-based search use list_node_templates instead.',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results. Default 20.',
|
||||
default: 20,
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_templates_for_task',
|
||||
description: 'Get recommended templates for common automation tasks. Returns curated templates that solve specific use cases. Available tasks: ai_automation, data_sync, webhook_processing, email_automation, slack_integration, data_transformation, file_processing, scheduling, api_integration, database_operations.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
task: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'ai_automation',
|
||||
'data_sync',
|
||||
'webhook_processing',
|
||||
'email_automation',
|
||||
'slack_integration',
|
||||
'data_transformation',
|
||||
'file_processing',
|
||||
'scheduling',
|
||||
'api_integration',
|
||||
'database_operations'
|
||||
],
|
||||
description: 'The type of task to get templates for',
|
||||
},
|
||||
},
|
||||
required: ['task'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'validate_workflow',
|
||||
description: 'Validate an entire n8n workflow before deployment. Checks: workflow structure, node connections including ai_tool connections, expressions, best practices, AI Agent configurations, and more. Returns comprehensive validation report with errors, warnings, and suggestions. Essential for AI agents building complete workflows. Validates AI tool connections and fromAI expressions. Prevents common workflow errors before they happen.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
workflow: {
|
||||
type: 'object',
|
||||
description: 'The complete workflow JSON to validate. Must include nodes array and connections object.',
|
||||
additionalProperties: true,
|
||||
},
|
||||
options: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
validateNodes: {
|
||||
type: 'boolean',
|
||||
description: 'Validate individual node configurations. Default true.',
|
||||
default: true,
|
||||
},
|
||||
validateConnections: {
|
||||
type: 'boolean',
|
||||
description: 'Validate node connections and flow. Default true.',
|
||||
default: true,
|
||||
},
|
||||
validateExpressions: {
|
||||
type: 'boolean',
|
||||
description: 'Validate n8n expressions syntax and references. Default true.',
|
||||
default: true,
|
||||
},
|
||||
profile: {
|
||||
type: 'string',
|
||||
enum: ['minimal', 'runtime', 'ai-friendly', 'strict'],
|
||||
description: 'Validation profile for node validation. Default runtime.',
|
||||
default: 'runtime',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
description: 'Optional validation settings',
|
||||
},
|
||||
},
|
||||
required: ['workflow'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'validate_workflow_connections',
|
||||
description: 'Validate only the connections in a workflow. Checks: all connections point to existing nodes, no cycles or infinite loops, no orphaned nodes, proper trigger node setup, AI tool connections are valid. Validates ai_tool connection types between AI Agents and tool nodes. Faster than full validation when you only need to check workflow structure.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
workflow: {
|
||||
type: 'object',
|
||||
description: 'The workflow JSON with nodes array and connections object.',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
required: ['workflow'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'validate_workflow_expressions',
|
||||
description: 'Validate all n8n expressions in a workflow. Checks: expression syntax with double curly braces, variable references like json, node, input, node references exist, context availability. Returns specific errors with locations. Use this to catch expression errors before runtime.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
workflow: {
|
||||
type: 'object',
|
||||
description: 'The workflow JSON to check for expression errors.',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
required: ['workflow'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* QUICK REFERENCE for AI Agents:
|
||||
*
|
||||
* 1. RECOMMENDED WORKFLOW:
|
||||
* - Start: search_nodes → get_node_essentials → get_node_for_task → validate_node_operation
|
||||
* - Discovery: list_nodes with category trigger for browsing categories
|
||||
* - Quick Config: get_node_essentials for nodes-base.httpRequest - only essential properties
|
||||
* - Full Details: get_node_info only when essentials aren't enough
|
||||
* - Validation: Use validate_node_operation for complex nodes like Slack, Google Sheets, etc.
|
||||
*
|
||||
* 2. COMMON NODE TYPES:
|
||||
* Triggers: webhook, schedule, emailReadImap, slackTrigger
|
||||
* Core: httpRequest, code, set, if, merge, splitInBatches
|
||||
* Integrations: slack, gmail, googleSheets, postgres, mongodb
|
||||
* AI: agent, openAi, chainLlm, documentLoader
|
||||
*
|
||||
* 3. SEARCH TIPS:
|
||||
* - search_nodes returns ANY word match using OR logic
|
||||
* - Single words more precise, multiple words broader
|
||||
* - If no results: use list_nodes with category filter
|
||||
*
|
||||
* 4. TEMPLATE SEARCHING:
|
||||
* - search_templates with slack searches template names and descriptions, NOT node types
|
||||
* - To find templates using Slack node: list_node_templates with n8n-nodes-base.slack
|
||||
* - For task-based templates: get_templates_for_task with slack_integration
|
||||
* - 399 templates available from the last year
|
||||
*
|
||||
* 5. KNOWN ISSUES:
|
||||
* - Some nodes have duplicate properties with different conditions
|
||||
* - Package names: use n8n-nodes-base not @n8n/n8n-nodes-base
|
||||
* - Check showWhen and hideWhen to identify the right property variant
|
||||
*
|
||||
* 6. PERFORMANCE:
|
||||
* - get_node_essentials: Fast under 5KB
|
||||
* - get_node_info: Slow over 100KB - use sparingly
|
||||
* - search_nodes and list_nodes: Fast, cached
|
||||
*/
|
||||
442
src/mcp/tools-n8n-manager-compat.ts
Normal file
442
src/mcp/tools-n8n-manager-compat.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import { ToolDefinition } from '../types';
|
||||
|
||||
/**
|
||||
* n8n-Compatible Management Tools
|
||||
*
|
||||
* This is a strict schema version of tools-n8n-manager.ts designed specifically for n8n compatibility.
|
||||
* Changes from original:
|
||||
* - All schemas have additionalProperties: false for strict validation
|
||||
* - All schemas have explicit required arrays (even if empty)
|
||||
* - All nested objects have required arrays
|
||||
* - Simplified descriptions without special characters
|
||||
* - Consistent schema structure for LangChain compatibility
|
||||
*/
|
||||
export const n8nManagementTools: ToolDefinition[] = [
|
||||
// Workflow Management Tools
|
||||
{
|
||||
name: 'n8n_create_workflow',
|
||||
description: 'Create a new workflow in n8n. Requires workflow name, nodes array, and connections object. The workflow will be created in inactive state and must be manually activated in the UI. Returns the created workflow with its ID.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Workflow name - required'
|
||||
},
|
||||
nodes: {
|
||||
type: 'array',
|
||||
description: 'Array of workflow nodes. Each node must have: id, name, type, typeVersion, position, and parameters',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['id', 'name', 'type', 'typeVersion', 'position', 'parameters'],
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
typeVersion: { type: 'number' },
|
||||
position: {
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
minItems: 2,
|
||||
maxItems: 2
|
||||
},
|
||||
parameters: { type: 'object', additionalProperties: true },
|
||||
credentials: { type: 'object', additionalProperties: true },
|
||||
disabled: { type: 'boolean' },
|
||||
notes: { type: 'string' },
|
||||
continueOnFail: { type: 'boolean' },
|
||||
retryOnFail: { type: 'boolean' },
|
||||
maxTries: { type: 'number' },
|
||||
waitBetweenTries: { type: 'number' }
|
||||
},
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
connections: {
|
||||
type: 'object',
|
||||
description: 'Workflow connections object. Keys are source node IDs, values define output connections',
|
||||
additionalProperties: true
|
||||
},
|
||||
settings: {
|
||||
type: 'object',
|
||||
description: 'Optional workflow settings for execution order, timezone, error handling',
|
||||
properties: {
|
||||
executionOrder: { type: 'string', enum: ['v0', 'v1'] },
|
||||
timezone: { type: 'string' },
|
||||
saveDataErrorExecution: { type: 'string', enum: ['all', 'none'] },
|
||||
saveDataSuccessExecution: { type: 'string', enum: ['all', 'none'] },
|
||||
saveManualExecutions: { type: 'boolean' },
|
||||
saveExecutionProgress: { type: 'boolean' },
|
||||
executionTimeout: { type: 'number' },
|
||||
errorWorkflow: { type: 'string' }
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
required: ['name', 'nodes', 'connections'],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_get_workflow',
|
||||
description: 'Get a workflow by ID. Returns the complete workflow including nodes, connections, and settings.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Workflow ID'
|
||||
}
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_get_workflow_details',
|
||||
description: 'Get detailed workflow information including metadata, version, and execution statistics. More comprehensive than get_workflow.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Workflow ID'
|
||||
}
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_get_workflow_structure',
|
||||
description: 'Get a simplified view of workflow structure. Returns only node types and connections without full configurations. Useful for understanding workflow flow.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Workflow ID'
|
||||
}
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_get_workflow_minimal',
|
||||
description: 'Get minimal workflow information. Returns only ID, name, active status, and node count. Fastest workflow info retrieval.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Workflow ID'
|
||||
}
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_update_full_workflow',
|
||||
description: 'Update an existing workflow. Replaces the entire workflow definition. Use update_partial_workflow for incremental changes. The workflow must be deactivated before updating.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Workflow ID to update'
|
||||
},
|
||||
workflow: {
|
||||
type: 'object',
|
||||
description: 'Complete workflow definition including name, nodes, connections, and settings',
|
||||
additionalProperties: true
|
||||
}
|
||||
},
|
||||
required: ['id', 'workflow'],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_update_partial_workflow',
|
||||
description: 'Update a workflow using diff operations. More efficient than full updates. Supports operations: addNode, removeNode, updateNode, moveNode, enableNode, disableNode, addConnection, removeConnection, updateConnection, updateSettings, updateName, addTag, removeTag. Sends only changes, not the entire workflow.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Workflow ID to update'
|
||||
},
|
||||
operations: {
|
||||
type: 'array',
|
||||
description: 'Array of diff operations to apply. Maximum 5 operations per request.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
operation: {
|
||||
type: 'string',
|
||||
enum: ['addNode', 'removeNode', 'updateNode', 'moveNode', 'enableNode', 'disableNode',
|
||||
'addConnection', 'removeConnection', 'updateConnection', 'updateSettings',
|
||||
'updateName', 'addTag', 'removeTag'],
|
||||
description: 'The type of operation to perform'
|
||||
},
|
||||
nodeId: { type: 'string', description: 'Node ID or name for node operations' },
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Operation-specific data',
|
||||
additionalProperties: true
|
||||
},
|
||||
sourceNodeId: { type: 'string', description: 'Source node for connection operations' },
|
||||
targetNodeId: { type: 'string', description: 'Target node for connection operations' },
|
||||
outputIndex: { type: 'number', description: 'Output index for connections' },
|
||||
inputIndex: { type: 'number', description: 'Input index for connections' }
|
||||
},
|
||||
required: ['operation'],
|
||||
additionalProperties: false
|
||||
},
|
||||
maxItems: 5
|
||||
},
|
||||
validateOnly: {
|
||||
type: 'boolean',
|
||||
description: 'If true, validates operations without applying them. Default false',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
required: ['id', 'operations'],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_delete_workflow',
|
||||
description: 'Delete a workflow permanently. This action cannot be undone. The workflow must be deactivated before deletion.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Workflow ID to delete'
|
||||
}
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_list_workflows',
|
||||
description: 'List workflows with optional filters. Returns basic workflow information. Supports pagination and filtering by active status.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Number of workflows to return. 1-100, default 100',
|
||||
default: 100
|
||||
},
|
||||
active: {
|
||||
type: 'boolean',
|
||||
description: 'Filter by active status. Omit to get all workflows'
|
||||
},
|
||||
search: {
|
||||
type: 'string',
|
||||
description: 'Search workflows by name'
|
||||
}
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_validate_workflow',
|
||||
description: 'Validate a workflow from n8n instance by ID. Fetches the workflow and runs comprehensive validation including node configurations, connections, and expressions. Returns detailed validation report with errors, warnings, and suggestions.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Workflow ID to validate'
|
||||
},
|
||||
options: {
|
||||
type: 'object',
|
||||
description: 'Validation options',
|
||||
properties: {
|
||||
validateNodes: {
|
||||
type: 'boolean',
|
||||
description: 'Validate node configurations. Default true',
|
||||
default: true
|
||||
},
|
||||
validateConnections: {
|
||||
type: 'boolean',
|
||||
description: 'Validate workflow connections. Default true',
|
||||
default: true
|
||||
},
|
||||
validateExpressions: {
|
||||
type: 'boolean',
|
||||
description: 'Validate n8n expressions. Default true',
|
||||
default: true
|
||||
},
|
||||
profile: {
|
||||
type: 'string',
|
||||
enum: ['minimal', 'runtime', 'ai-friendly', 'strict'],
|
||||
description: 'Validation profile to use. Default runtime',
|
||||
default: 'runtime'
|
||||
}
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
|
||||
// Workflow Execution Tools
|
||||
{
|
||||
name: 'n8n_trigger_webhook_workflow',
|
||||
description: 'Trigger a workflow via webhook URL. The workflow must have a webhook trigger node configured. Can send GET or POST requests with optional data payload.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
webhookUrl: {
|
||||
type: 'string',
|
||||
description: 'Full webhook URL from n8n workflow like https://n8n.example.com/webhook/abc-def-ghi'
|
||||
},
|
||||
httpMethod: {
|
||||
type: 'string',
|
||||
enum: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
description: 'HTTP method to use. Default POST',
|
||||
default: 'POST'
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Data payload to send. For GET requests, converts to query parameters',
|
||||
additionalProperties: true
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Optional HTTP headers to include',
|
||||
additionalProperties: true
|
||||
},
|
||||
waitForResponse: {
|
||||
type: 'boolean',
|
||||
description: 'Wait for workflow completion. Default true',
|
||||
default: true
|
||||
}
|
||||
},
|
||||
required: ['webhookUrl'],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_get_execution',
|
||||
description: 'Get details of a specific execution by ID.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Execution ID'
|
||||
},
|
||||
includeData: {
|
||||
type: 'boolean',
|
||||
description: 'Include full execution data. Default false',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_list_executions',
|
||||
description: 'List workflow executions with optional filters. Supports pagination.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Number of executions to return. 1-100, default 100',
|
||||
default: 100
|
||||
},
|
||||
cursor: {
|
||||
type: 'string',
|
||||
description: 'Pagination cursor from previous response'
|
||||
},
|
||||
workflowId: {
|
||||
type: 'string',
|
||||
description: 'Filter by workflow ID'
|
||||
},
|
||||
projectId: {
|
||||
type: 'string',
|
||||
description: 'Filter by project ID - enterprise feature'
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['success', 'error', 'waiting'],
|
||||
description: 'Filter by execution status'
|
||||
},
|
||||
includeData: {
|
||||
type: 'boolean',
|
||||
description: 'Include execution data. Default false',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_delete_execution',
|
||||
description: 'Delete an execution record. This only removes the execution history, not any data processed.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Execution ID to delete'
|
||||
}
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
|
||||
// System Tools
|
||||
{
|
||||
name: 'n8n_health_check',
|
||||
description: 'Check n8n instance health and API connectivity. Returns status and available features.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_list_available_tools',
|
||||
description: 'List all available n8n management tools and their capabilities. Useful for understanding what operations are possible.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_diagnostic',
|
||||
description: 'Diagnose n8n API configuration and management tools availability. Shows current configuration status, which tools are enabled or disabled, and helps troubleshoot why management tools might not be appearing. Returns detailed diagnostic information including environment variables, API connectivity, and tool registration status.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
verbose: {
|
||||
type: 'boolean',
|
||||
description: 'Include detailed debug information. Default false',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: false
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -62,7 +62,8 @@ export const n8nManagementTools: ToolDefinition[] = [
|
||||
saveExecutionProgress: { type: 'boolean' },
|
||||
executionTimeout: { type: 'number' },
|
||||
errorWorkflow: { type: 'string' }
|
||||
}
|
||||
},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
required: ['name', 'nodes', 'connections']
|
||||
@@ -366,7 +367,8 @@ Validation example:
|
||||
enum: ['minimal', 'runtime', 'ai-friendly', 'strict'],
|
||||
description: 'Validation profile to use (default: runtime)'
|
||||
}
|
||||
}
|
||||
},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
required: ['id']
|
||||
@@ -454,7 +456,8 @@ Validation example:
|
||||
type: 'boolean',
|
||||
description: 'Include execution data (default: false)'
|
||||
}
|
||||
}
|
||||
},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -478,7 +481,8 @@ Validation example:
|
||||
description: `Check n8n instance health and API connectivity. Returns status and available features.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -486,7 +490,8 @@ Validation example:
|
||||
description: `List all available n8n management tools and their capabilities. Useful for understanding what operations are possible.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -499,7 +504,8 @@ Validation example:
|
||||
type: 'boolean',
|
||||
description: 'Include detailed debug information (default: false)'
|
||||
}
|
||||
}
|
||||
},
|
||||
required: []
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -24,6 +24,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
default: 'essentials',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -55,6 +56,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
default: 50,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -96,6 +98,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -118,6 +121,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -182,6 +186,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
description: 'Optional category filter: HTTP/API, Webhooks, Database, AI/LangChain, Data Processing, Communication',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -372,6 +377,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
default: 'runtime',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
description: 'Optional validation settings',
|
||||
},
|
||||
},
|
||||
|
||||
806
src/sse-server.ts
Normal file
806
src/sse-server.ts
Normal file
@@ -0,0 +1,806 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* SSE (Server-Sent Events) server for n8n MCP integration
|
||||
* Implements the SSE protocol expected by n8n's MCP Server Trigger
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { N8NDocumentationMCPServer } from './mcp/server';
|
||||
import { SSESessionManager } from './utils/sse-session-manager';
|
||||
import { logger } from './utils/logger';
|
||||
import { PROJECT_VERSION } from './utils/version';
|
||||
import { n8nDocumentationToolsFinal } from './mcp/tools';
|
||||
import { n8nManagementTools } from './mcp/tools-n8n-manager';
|
||||
import { n8nDocumentationToolsFinal as n8nCompatTools } from './mcp/tools-n8n-compat';
|
||||
import { n8nManagementTools as n8nCompatManagementTools } from './mcp/tools-n8n-manager-compat';
|
||||
import { isN8nApiConfigured } from './config/n8n-api';
|
||||
import { loadAuthToken } from './http-server';
|
||||
import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
let expressServer: any;
|
||||
let authToken: string | null = null;
|
||||
let sessionManager: SSESessionManager;
|
||||
let mcpServer: N8NDocumentationMCPServer;
|
||||
|
||||
/**
|
||||
* Validate required environment variables
|
||||
*/
|
||||
function validateEnvironment() {
|
||||
authToken = loadAuthToken();
|
||||
|
||||
if (!authToken || authToken.trim() === '') {
|
||||
logger.error('No authentication token found or token is empty');
|
||||
console.error('ERROR: AUTH_TOKEN is required for SSE mode and cannot be empty');
|
||||
console.error('Set AUTH_TOKEN environment variable or AUTH_TOKEN_FILE pointing to a file containing the token');
|
||||
console.error('Generate AUTH_TOKEN with: openssl rand -base64 32');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
authToken = authToken.trim();
|
||||
|
||||
if (authToken.length < 32) {
|
||||
logger.warn('AUTH_TOKEN should be at least 32 characters for security');
|
||||
console.warn('WARNING: AUTH_TOKEN should be at least 32 characters for security');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown handler
|
||||
*/
|
||||
async function shutdown() {
|
||||
logger.info('Shutting down SSE server...');
|
||||
console.log('Shutting down SSE server...');
|
||||
|
||||
if (sessionManager) {
|
||||
sessionManager.shutdown();
|
||||
}
|
||||
|
||||
if (expressServer) {
|
||||
expressServer.close(() => {
|
||||
logger.info('SSE server closed');
|
||||
console.log('SSE server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
logger.error('Forced shutdown after timeout');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startSSEServer() {
|
||||
validateEnvironment();
|
||||
|
||||
const app = express();
|
||||
sessionManager = new SSESessionManager();
|
||||
mcpServer = new N8NDocumentationMCPServer();
|
||||
|
||||
// Configure trust proxy
|
||||
const trustProxy = process.env.TRUST_PROXY ? Number(process.env.TRUST_PROXY) : 0;
|
||||
if (trustProxy > 0) {
|
||||
app.set('trust proxy', trustProxy);
|
||||
logger.info(`Trust proxy enabled with ${trustProxy} hop(s)`);
|
||||
}
|
||||
|
||||
// Parse JSON for message endpoint
|
||||
app.use(express.json());
|
||||
|
||||
// Security headers
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
next();
|
||||
});
|
||||
|
||||
// CORS configuration
|
||||
app.use((req, res, next) => {
|
||||
const allowedOrigin = process.env.CORS_ORIGIN || '*';
|
||||
res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, X-Client-ID, X-Auth-Token, X-API-Key');
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
logger.info('OPTIONS preflight request', {
|
||||
path: req.path,
|
||||
origin: req.headers.origin,
|
||||
headers: req.headers
|
||||
});
|
||||
res.sendStatus(204);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Request logging with enhanced debug info
|
||||
app.use((req, res, next) => {
|
||||
const logData = {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
url: req.url,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
contentLength: req.get('content-length'),
|
||||
headers: process.env.LOG_LEVEL === 'debug' ? req.headers : undefined,
|
||||
query: req.query,
|
||||
isSSERequest: req.headers.accept?.includes('text/event-stream') || false
|
||||
};
|
||||
|
||||
logger.info(`${req.method} ${req.path}`, logData);
|
||||
|
||||
// Special logging for SSE attempts
|
||||
if (req.path.includes('/sse') || req.path === '/mcp' || req.headers.accept?.includes('text/event-stream')) {
|
||||
logger.info('SSE connection attempt detected', {
|
||||
path: req.path,
|
||||
acceptHeader: req.headers.accept,
|
||||
authHeader: req.headers.authorization ? 'present' : 'missing'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Authentication middleware - supports multiple methods
|
||||
const authenticateRequest = (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
let token: string | null = null;
|
||||
let authMethod: string | null = null;
|
||||
|
||||
// Log authentication attempt
|
||||
logger.debug('Authentication attempt', {
|
||||
path: req.path,
|
||||
headers: Object.keys(req.headers),
|
||||
hasAuthHeader: !!req.headers.authorization,
|
||||
hasQuery: !!req.query.token
|
||||
});
|
||||
|
||||
// Method 1: Bearer token in Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.slice(7).trim();
|
||||
authMethod = 'Bearer';
|
||||
logger.debug('Using Bearer authentication');
|
||||
}
|
||||
|
||||
// Method 2: Custom header authentication
|
||||
if (!token) {
|
||||
const customHeaderName = process.env.AUTH_HEADER_NAME || 'x-auth-token';
|
||||
const customHeaderValue = req.headers[customHeaderName.toLowerCase()];
|
||||
if (customHeaderValue && typeof customHeaderValue === 'string') {
|
||||
token = customHeaderValue.trim();
|
||||
authMethod = `Custom header (${customHeaderName})`;
|
||||
logger.debug(`Using custom header authentication: ${customHeaderName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Query parameter authentication (for SSE connections)
|
||||
if (!token && req.query.token) {
|
||||
token = req.query.token as string;
|
||||
authMethod = 'Query parameter';
|
||||
logger.debug('Using query parameter authentication');
|
||||
}
|
||||
|
||||
// Method 4: API key in header
|
||||
if (!token && req.headers['x-api-key']) {
|
||||
token = req.headers['x-api-key'] as string;
|
||||
authMethod = 'API key header';
|
||||
logger.debug('Using API key header authentication');
|
||||
}
|
||||
|
||||
// Validate token
|
||||
if (!token) {
|
||||
logger.warn('Authentication failed: No token provided', {
|
||||
ip: req.ip,
|
||||
path: req.path,
|
||||
headers: Object.keys(req.headers),
|
||||
availableAuthMethods: ['Bearer', 'x-auth-token', 'query.token', 'x-api-key']
|
||||
});
|
||||
res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'No authentication token provided',
|
||||
hint: 'Use Bearer token, x-auth-token header, query parameter ?token=, or x-api-key header'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (token !== authToken) {
|
||||
logger.warn('Authentication failed: Invalid token', {
|
||||
ip: req.ip,
|
||||
path: req.path,
|
||||
authMethod: authMethod,
|
||||
tokenReceived: true
|
||||
});
|
||||
res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid authentication token'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('Authentication successful', {
|
||||
path: req.path,
|
||||
authMethod: authMethod
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
mode: 'sse',
|
||||
version: PROJECT_VERSION,
|
||||
uptime: Math.floor(process.uptime()),
|
||||
activeSessions: sessionManager.getActiveClientCount(),
|
||||
memory: {
|
||||
used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
||||
total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
|
||||
unit: 'MB'
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// SSE endpoint handler - shared logic for all SSE endpoints
|
||||
const handleSSE = (req: express.Request, res: express.Response) => {
|
||||
const path = req.params.path || 'default';
|
||||
|
||||
logger.info('SSE endpoint handler invoked', {
|
||||
endpoint: req.path,
|
||||
method: req.method,
|
||||
acceptHeader: req.headers.accept,
|
||||
userAgent: req.headers['user-agent'],
|
||||
path: path
|
||||
});
|
||||
|
||||
let clientId: string;
|
||||
try {
|
||||
clientId = sessionManager.registerClient(res);
|
||||
} catch (error) {
|
||||
logger.error('Failed to register SSE client:', error);
|
||||
res.status(503).json({
|
||||
error: 'Service Unavailable',
|
||||
message: error instanceof Error ? error.message : 'Failed to establish SSE connection'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`New SSE connection established: ${clientId} (path: ${path})`, {
|
||||
totalClients: sessionManager.getActiveClientCount(),
|
||||
headers: {
|
||||
accept: req.headers.accept,
|
||||
'content-type': req.headers['content-type'],
|
||||
'user-agent': req.headers['user-agent']
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to safely extract string parameters
|
||||
const getStringParam = (value: any): string | undefined => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value) && value.length > 0) return String(value[0]);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Extract workflow context from headers or query params
|
||||
const workflowContext = {
|
||||
workflowId: req.headers['x-workflow-id'] as string || getStringParam(req.query.workflowId),
|
||||
executionId: req.headers['x-execution-id'] as string || getStringParam(req.query.executionId),
|
||||
nodeId: req.headers['x-node-id'] as string || getStringParam(req.query.nodeId),
|
||||
nodeName: req.headers['x-node-name'] as string || getStringParam(req.query.nodeName),
|
||||
runId: req.headers['x-run-id'] as string || getStringParam(req.query.runId),
|
||||
};
|
||||
|
||||
// Filter out undefined values (optimized)
|
||||
const cleanContext: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(workflowContext)) {
|
||||
if (value !== undefined) {
|
||||
cleanContext[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(cleanContext).length > 0) {
|
||||
sessionManager.updateWorkflowContext(clientId, cleanContext);
|
||||
logger.info(`Workflow context for client ${clientId}:`, cleanContext);
|
||||
}
|
||||
|
||||
// Send endpoint event with session-specific message URL
|
||||
logger.debug('Sending endpoint event', { clientId });
|
||||
sessionManager.sendToClient(clientId, {
|
||||
event: 'endpoint',
|
||||
data: `/messages?session_id=${clientId}`
|
||||
});
|
||||
|
||||
// Keep connection alive with periodic pings
|
||||
const pingInterval = setInterval(() => {
|
||||
if (!sessionManager.hasClient(clientId)) {
|
||||
clearInterval(pingInterval);
|
||||
return;
|
||||
}
|
||||
sessionManager.sendPing(clientId);
|
||||
}, 30000); // Ping every 30 seconds
|
||||
|
||||
// Handle client disconnect
|
||||
req.on('close', () => {
|
||||
clearInterval(pingInterval);
|
||||
logger.info(`SSE connection closed: ${clientId}`, {
|
||||
path: path,
|
||||
remainingClients: sessionManager.getActiveClientCount() - 1
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// SSE endpoints - Support both legacy and n8n-expected patterns
|
||||
app.get('/sse', authenticateRequest, handleSSE);
|
||||
app.get('/mcp', authenticateRequest, handleSSE); // Direct /mcp endpoint for SSE
|
||||
app.get('/mcp/:path/sse', authenticateRequest, handleSSE);
|
||||
|
||||
// Message endpoint handler - shared logic for all message endpoints
|
||||
const handleMessage = async (req: express.Request, res: express.Response) => {
|
||||
const sessionId = req.query.session_id as string;
|
||||
|
||||
logger.info('Message endpoint called', {
|
||||
path: req.path,
|
||||
sessionId,
|
||||
headers: Object.keys(req.headers),
|
||||
body: req.body
|
||||
});
|
||||
|
||||
const clientId = sessionId;
|
||||
|
||||
if (!clientId || !sessionManager.hasClient(clientId)) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid session',
|
||||
message: 'Client ID not found or session expired'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonRpcRequest = req.body;
|
||||
const workflowContext = sessionManager.getWorkflowContext(clientId);
|
||||
logger.debug('Received MCP message:', {
|
||||
clientId,
|
||||
method: jsonRpcRequest.method,
|
||||
id: jsonRpcRequest.id,
|
||||
workflowContext
|
||||
});
|
||||
|
||||
// Handle the request
|
||||
let response;
|
||||
|
||||
switch (jsonRpcRequest.method) {
|
||||
case 'initialize':
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
result: {
|
||||
protocolVersion: DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'n8n-documentation-mcp',
|
||||
version: PROJECT_VERSION
|
||||
}
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
break;
|
||||
|
||||
case 'tools/list':
|
||||
const isN8nCompatMode = process.env.N8N_COMPATIBILITY_MODE === 'true';
|
||||
const docTools = isN8nCompatMode ? n8nCompatTools : n8nDocumentationToolsFinal;
|
||||
const mgmtTools = isN8nCompatMode ? n8nCompatManagementTools : n8nManagementTools;
|
||||
|
||||
const tools = [...docTools];
|
||||
if (isN8nApiConfigured()) {
|
||||
tools.push(...mgmtTools);
|
||||
}
|
||||
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
result: {
|
||||
tools
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
break;
|
||||
|
||||
case 'tools/call':
|
||||
const toolName = jsonRpcRequest.params?.name;
|
||||
const toolArgs = jsonRpcRequest.params?.arguments || {};
|
||||
|
||||
logger.debug('Tool call details:', {
|
||||
toolName,
|
||||
toolArgs,
|
||||
toolArgsType: typeof toolArgs,
|
||||
toolArgsKeys: Object.keys(toolArgs),
|
||||
rawParams: jsonRpcRequest.params
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await mcpServer.executeTool(toolName, toolArgs);
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result, null, 2)
|
||||
}
|
||||
]
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error executing tool ${toolName}:`, error);
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Error executing tool ${toolName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'resources/list':
|
||||
// MCP resources are not currently implemented
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
result: {
|
||||
resources: []
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
break;
|
||||
|
||||
case 'resources/read':
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Resource reading not implemented'
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
break;
|
||||
|
||||
case 'prompts/list':
|
||||
// MCP prompts are not currently implemented
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
result: {
|
||||
prompts: []
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
break;
|
||||
|
||||
case 'prompts/get':
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Prompt retrieval not implemented'
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `Method not found: ${jsonRpcRequest.method}`
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
}
|
||||
|
||||
// Send response via SSE
|
||||
sessionManager.sendMCPMessage(clientId, response);
|
||||
|
||||
// Acknowledge receipt
|
||||
res.json({
|
||||
status: 'ok',
|
||||
messageId: jsonRpcRequest.id
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error processing MCP message:', error);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Legacy MCP handler - for direct POST without SSE
|
||||
const handleLegacyMCP = async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const jsonRpcRequest = req.body;
|
||||
logger.debug('Received legacy MCP request:', { method: jsonRpcRequest.method });
|
||||
|
||||
// Process request synchronously for backward compatibility
|
||||
let response;
|
||||
|
||||
switch (jsonRpcRequest.method) {
|
||||
case 'initialize':
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
result: {
|
||||
protocolVersion: DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'n8n-documentation-mcp',
|
||||
version: PROJECT_VERSION
|
||||
}
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
break;
|
||||
|
||||
case 'tools/list':
|
||||
const isN8nCompatMode = process.env.N8N_COMPATIBILITY_MODE === 'true';
|
||||
const docTools = isN8nCompatMode ? n8nCompatTools : n8nDocumentationToolsFinal;
|
||||
const mgmtTools = isN8nCompatMode ? n8nCompatManagementTools : n8nManagementTools;
|
||||
|
||||
const tools = [...docTools];
|
||||
if (isN8nApiConfigured()) {
|
||||
tools.push(...mgmtTools);
|
||||
}
|
||||
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
result: {
|
||||
tools
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
break;
|
||||
|
||||
case 'tools/call':
|
||||
const toolName = jsonRpcRequest.params?.name;
|
||||
const toolArgs = jsonRpcRequest.params?.arguments || {};
|
||||
|
||||
logger.debug('Tool call details:', {
|
||||
toolName,
|
||||
toolArgs,
|
||||
toolArgsType: typeof toolArgs,
|
||||
toolArgsKeys: Object.keys(toolArgs),
|
||||
rawParams: jsonRpcRequest.params
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await mcpServer.executeTool(toolName, toolArgs);
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(result, null, 2)
|
||||
}
|
||||
]
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error executing tool ${toolName}:`, error);
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Error executing tool ${toolName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'resources/list':
|
||||
// MCP resources are not currently implemented
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
result: {
|
||||
resources: []
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
break;
|
||||
|
||||
case 'resources/read':
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Resource reading not implemented'
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
break;
|
||||
|
||||
case 'prompts/list':
|
||||
// MCP prompts are not currently implemented
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
result: {
|
||||
prompts: []
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
break;
|
||||
|
||||
case 'prompts/get':
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Prompt retrieval not implemented'
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
response = {
|
||||
jsonrpc: '2.0' as const,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: `Method not found: ${jsonRpcRequest.method}`
|
||||
},
|
||||
id: jsonRpcRequest.id
|
||||
};
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Legacy MCP request error:', error);
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0' as const,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal server error',
|
||||
data: error instanceof Error ? error.message : undefined
|
||||
},
|
||||
id: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Message endpoints - Support both legacy and n8n-expected patterns
|
||||
app.post('/messages', authenticateRequest, handleMessage); // MCP standard endpoint
|
||||
app.post('/mcp/message', authenticateRequest, handleMessage);
|
||||
app.post('/mcp/:path/message', authenticateRequest, handleMessage);
|
||||
|
||||
// Legacy MCP endpoints for backward compatibility
|
||||
app.post('/mcp', authenticateRequest, handleLegacyMCP);
|
||||
app.post('/mcp/:path', authenticateRequest, handleLegacyMCP);
|
||||
|
||||
// Also handle POST to SSE endpoints for n8n compatibility
|
||||
app.post('/sse', authenticateRequest, handleLegacyMCP);
|
||||
|
||||
// Catch-all route to log any unmatched requests
|
||||
app.use((req, res, next) => {
|
||||
// Only log if this is truly unmatched (will reach 404)
|
||||
const isKnownRoute = ['/health', '/sse', '/mcp', '/mcp/message'].some(route =>
|
||||
req.path === route || req.path.startsWith(route + '/')
|
||||
);
|
||||
|
||||
if (!isKnownRoute) {
|
||||
logger.warn('Unmatched request', {
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
url: req.url,
|
||||
headers: Object.keys(req.headers),
|
||||
hasAuth: !!req.headers.authorization,
|
||||
ip: req.ip
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
error: 'Not found',
|
||||
message: `Cannot ${req.method} ${req.path}`
|
||||
});
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
logger.error('Express error handler:', err);
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : 'An error occurred'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const port = parseInt(process.env.PORT || '3000');
|
||||
const host = process.env.HOST || '0.0.0.0';
|
||||
|
||||
expressServer = app.listen(port, host, () => {
|
||||
logger.info(`n8n MCP SSE Server started`, { port, host });
|
||||
console.log(`n8n MCP SSE Server running on ${host}:${port}`);
|
||||
console.log(`Health check: http://localhost:${port}/health`);
|
||||
console.log(`SSE endpoints:`);
|
||||
console.log(` - http://localhost:${port}/sse`);
|
||||
console.log(` - http://localhost:${port}/mcp`);
|
||||
console.log(` - http://localhost:${port}/mcp/{path}/sse`);
|
||||
console.log(`Message endpoints:`);
|
||||
console.log(` - http://localhost:${port}/messages?session_id={session_id}`);
|
||||
console.log(` - http://localhost:${port}/mcp/message (legacy)`);
|
||||
console.log('\nPress Ctrl+C to stop the server');
|
||||
});
|
||||
|
||||
expressServer.on('error', (error: any) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
logger.error(`Port ${port} is already in use`);
|
||||
console.error(`ERROR: Port ${port} is already in use`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
logger.error('Server error:', error);
|
||||
console.error('Server error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Graceful shutdown handlers
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
// Handle uncaught errors
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('Uncaught exception:', error);
|
||||
console.error('Uncaught exception:', error);
|
||||
shutdown();
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error('Unhandled rejection:', reason);
|
||||
console.error('Unhandled rejection at:', promise, 'reason:', reason);
|
||||
shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
// Make executeTool public on the server
|
||||
declare module './mcp/server' {
|
||||
interface N8NDocumentationMCPServer {
|
||||
executeTool(name: string, args: any): Promise<any>;
|
||||
}
|
||||
}
|
||||
|
||||
// Start if called directly
|
||||
if (require.main === module) {
|
||||
startSSEServer().catch(error => {
|
||||
logger.error('Failed to start SSE server:', error);
|
||||
console.error('Failed to start SSE server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
49
src/types/sse.ts
Normal file
49
src/types/sse.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* TypeScript types for SSE (Server-Sent Events) implementation
|
||||
*/
|
||||
|
||||
export interface SSEClient {
|
||||
id: string;
|
||||
response: any; // Express Response object
|
||||
lastActivity: number;
|
||||
isActive: boolean;
|
||||
workflowContext?: WorkflowContext;
|
||||
}
|
||||
|
||||
export interface WorkflowContext {
|
||||
workflowId?: string;
|
||||
executionId?: string;
|
||||
nodeId?: string;
|
||||
nodeName?: string;
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
export interface SSEMessage {
|
||||
id?: string;
|
||||
event?: string;
|
||||
data: any;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
export interface MCPSSEMessage extends SSEMessage {
|
||||
event: 'mcp-response' | 'mcp-notification' | 'mcp-error' | 'ping';
|
||||
data: {
|
||||
jsonrpc: '2.0';
|
||||
id?: string | number | null;
|
||||
method?: string;
|
||||
params?: any;
|
||||
result?: any;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: any;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface SSESessionData {
|
||||
clientId: string;
|
||||
connectedAt: number;
|
||||
lastRequestId?: string | number;
|
||||
authToken?: string;
|
||||
}
|
||||
268
src/utils/sse-session-manager.ts
Normal file
268
src/utils/sse-session-manager.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* SSE Session Manager
|
||||
* Manages Server-Sent Events connections and sessions for MCP protocol
|
||||
*/
|
||||
|
||||
import { SSEClient, SSEMessage, MCPSSEMessage, WorkflowContext } from '../types/sse';
|
||||
import { logger } from './logger';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export class SSESessionManager {
|
||||
private clients: Map<string, SSEClient> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout;
|
||||
private readonly CLEANUP_INTERVAL = 30000; // 30 seconds
|
||||
private readonly SESSION_TIMEOUT = 300000; // 5 minutes
|
||||
private readonly MAX_CLIENTS = 1000; // Maximum concurrent connections
|
||||
|
||||
constructor() {
|
||||
// Start cleanup interval
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanupInactiveSessions();
|
||||
}, this.CLEANUP_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new SSE client
|
||||
*/
|
||||
registerClient(response: any): string {
|
||||
// Check client limit
|
||||
if (this.clients.size >= this.MAX_CLIENTS) {
|
||||
logger.error(`Maximum client limit reached: ${this.MAX_CLIENTS}`);
|
||||
throw new Error('Maximum concurrent connections exceeded');
|
||||
}
|
||||
|
||||
const clientId = uuidv4();
|
||||
const client: SSEClient = {
|
||||
id: clientId,
|
||||
response,
|
||||
lastActivity: Date.now(),
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
this.clients.set(clientId, client);
|
||||
logger.info(`SSE client registered: ${clientId} (total: ${this.clients.size})`);
|
||||
|
||||
// Set up SSE headers (with compression disabled for n8n compatibility)
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'Connection': 'keep-alive',
|
||||
'Content-Encoding': 'identity', // Explicitly disable compression
|
||||
'X-Accel-Buffering': 'no', // Disable Nginx buffering
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
|
||||
// Don't send initial connection event - n8n expects endpoint event instead
|
||||
|
||||
// Set up disconnect handler
|
||||
response.on('close', () => {
|
||||
this.removeClient(clientId);
|
||||
});
|
||||
|
||||
return clientId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a client
|
||||
*/
|
||||
removeClient(clientId: string): void {
|
||||
const client = this.clients.get(clientId);
|
||||
if (client) {
|
||||
client.isActive = false;
|
||||
this.clients.delete(clientId);
|
||||
logger.info(`SSE client removed: ${clientId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a specific client
|
||||
*/
|
||||
sendToClient(clientId: string, message: SSEMessage): boolean {
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client || !client.isActive) {
|
||||
logger.warn(`Attempted to send to inactive client: ${clientId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = typeof message.data === 'string'
|
||||
? message.data
|
||||
: JSON.stringify(message.data);
|
||||
|
||||
let sseMessage = '';
|
||||
|
||||
if (message.id) {
|
||||
sseMessage += `id: ${message.id}\n`;
|
||||
}
|
||||
|
||||
if (message.event) {
|
||||
sseMessage += `event: ${message.event}\n`;
|
||||
}
|
||||
|
||||
// Split data by newlines to ensure proper SSE format
|
||||
const lines = data.split('\n');
|
||||
for (const line of lines) {
|
||||
sseMessage += `data: ${line}\n`;
|
||||
}
|
||||
|
||||
if (message.retry) {
|
||||
sseMessage += `retry: ${message.retry}\n`;
|
||||
}
|
||||
|
||||
sseMessage += '\n';
|
||||
|
||||
client.response.write(sseMessage);
|
||||
client.lastActivity = Date.now();
|
||||
|
||||
logger.debug(`SSE message sent to client ${clientId}`, {
|
||||
event: message.event,
|
||||
dataLength: sseMessage.length,
|
||||
preview: sseMessage.substring(0, 100)
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to send SSE message to client ${clientId}:`, error);
|
||||
this.removeClient(clientId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send MCP protocol message to client
|
||||
*/
|
||||
sendMCPMessage(clientId: string, mcpMessage: MCPSSEMessage['data']): boolean {
|
||||
// For MCP protocol, send JSON-RPC messages without custom event names
|
||||
const message: SSEMessage = {
|
||||
data: mcpMessage,
|
||||
};
|
||||
|
||||
if (mcpMessage.id) {
|
||||
message.id = String(mcpMessage.id);
|
||||
}
|
||||
|
||||
return this.sendToClient(clientId, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a message to all active clients
|
||||
*/
|
||||
broadcast(message: SSEMessage): void {
|
||||
const inactiveClients: string[] = [];
|
||||
|
||||
for (const [clientId, client] of this.clients) {
|
||||
if (client.isActive) {
|
||||
const success = this.sendToClient(clientId, message);
|
||||
if (!success) {
|
||||
inactiveClients.push(clientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up inactive clients
|
||||
inactiveClients.forEach(clientId => this.removeClient(clientId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send keepalive ping to a client
|
||||
*/
|
||||
sendPing(clientId: string): boolean {
|
||||
return this.sendToClient(clientId, {
|
||||
event: 'ping',
|
||||
data: { timestamp: Date.now() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send keepalive pings to all clients
|
||||
*/
|
||||
pingAllClients(): void {
|
||||
for (const [clientId, client] of this.clients) {
|
||||
if (client.isActive) {
|
||||
this.sendPing(clientId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up inactive sessions
|
||||
*/
|
||||
private cleanupInactiveSessions(): void {
|
||||
const now = Date.now();
|
||||
const inactiveClients: string[] = [];
|
||||
|
||||
for (const [clientId, client] of this.clients) {
|
||||
if (now - client.lastActivity > this.SESSION_TIMEOUT) {
|
||||
inactiveClients.push(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
if (inactiveClients.length > 0) {
|
||||
logger.info(`Cleaning up ${inactiveClients.length} inactive SSE sessions`);
|
||||
inactiveClients.forEach(clientId => this.removeClient(clientId));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a client exists and is active
|
||||
*/
|
||||
hasClient(clientId: string): boolean {
|
||||
const client = this.clients.get(clientId);
|
||||
return client ? client.isActive : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active client count
|
||||
*/
|
||||
getActiveClientCount(): number {
|
||||
return this.clients.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update workflow context for a client
|
||||
*/
|
||||
updateWorkflowContext(clientId: string, context: WorkflowContext): boolean {
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client || !client.isActive) {
|
||||
logger.warn(`Attempted to update context for inactive client: ${clientId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
client.workflowContext = {
|
||||
...client.workflowContext,
|
||||
...context
|
||||
};
|
||||
|
||||
logger.info(`Updated workflow context for client ${clientId}:`, client.workflowContext);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workflow context for a client
|
||||
*/
|
||||
getWorkflowContext(clientId: string): WorkflowContext | undefined {
|
||||
const client = this.clients.get(clientId);
|
||||
return client?.workflowContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the session manager
|
||||
*/
|
||||
shutdown(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
|
||||
// Close all client connections
|
||||
for (const [clientId, client] of this.clients) {
|
||||
try {
|
||||
client.response.end();
|
||||
} catch (error) {
|
||||
// Ignore errors during shutdown
|
||||
}
|
||||
}
|
||||
|
||||
this.clients.clear();
|
||||
logger.info('SSE session manager shut down');
|
||||
}
|
||||
}
|
||||
487
tests/sse-integration.test.ts
Normal file
487
tests/sse-integration.test.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* SSE Integration Tests for n8n MCP
|
||||
* Tests the enhanced SSE server functionality
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
|
||||
import request from 'supertest';
|
||||
import { EventSource } from 'eventsource';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// Test configuration
|
||||
const TEST_PORT = 3001;
|
||||
const TEST_AUTH_TOKEN = 'test-token-' + uuidv4();
|
||||
const TEST_URL = `http://localhost:${TEST_PORT}`;
|
||||
|
||||
// SSE server instance
|
||||
let server: any;
|
||||
let app: any;
|
||||
|
||||
describe('SSE Integration Tests', () => {
|
||||
beforeAll(async () => {
|
||||
// Set test environment
|
||||
process.env.AUTH_TOKEN = TEST_AUTH_TOKEN;
|
||||
process.env.PORT = String(TEST_PORT);
|
||||
process.env.MCP_MODE = 'sse';
|
||||
|
||||
// Import and start SSE server
|
||||
const { startSSEServer } = await import('../src/sse-server');
|
||||
// Note: We'd need to modify startSSEServer to return the express app for testing
|
||||
// For now, we'll test against the running server
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up
|
||||
if (server) {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
describe('Health Check', () => {
|
||||
test('should return server status', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.get('/health')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
status: 'ok',
|
||||
mode: 'sse',
|
||||
activeSessions: expect.any(Number),
|
||||
memory: expect.any(Object),
|
||||
timestamp: expect.any(String)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication', () => {
|
||||
test('should authenticate with Bearer token', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 1
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('jsonrpc', '2.0');
|
||||
expect(response.body).toHaveProperty('result');
|
||||
});
|
||||
|
||||
test('should authenticate with custom header', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp')
|
||||
.set('x-auth-token', TEST_AUTH_TOKEN)
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 1
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('jsonrpc', '2.0');
|
||||
});
|
||||
|
||||
test('should authenticate with API key header', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp')
|
||||
.set('x-api-key', TEST_AUTH_TOKEN)
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 1
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('jsonrpc', '2.0');
|
||||
});
|
||||
|
||||
test('should reject invalid token', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp')
|
||||
.set('Authorization', 'Bearer invalid-token')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 1
|
||||
})
|
||||
.expect(401);
|
||||
|
||||
expect(response.body).toHaveProperty('error', 'Unauthorized');
|
||||
});
|
||||
|
||||
test('should reject missing token', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 1
|
||||
})
|
||||
.expect(401);
|
||||
|
||||
expect(response.body).toHaveProperty('error', 'Unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Endpoint Patterns', () => {
|
||||
test('should support legacy /sse endpoint', async () => {
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const es = new EventSource(`${TEST_URL}/sse?token=${TEST_AUTH_TOKEN}`);
|
||||
|
||||
es.onopen = () => {
|
||||
es.close();
|
||||
resolve({ connected: true });
|
||||
};
|
||||
|
||||
es.onerror = (error) => {
|
||||
es.close();
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
expect(response).toEqual({ connected: true });
|
||||
});
|
||||
|
||||
test('should support n8n pattern /mcp/:path/sse', async () => {
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const es = new EventSource(`${TEST_URL}/mcp/workflow-123/sse?token=${TEST_AUTH_TOKEN}`);
|
||||
|
||||
es.onopen = () => {
|
||||
es.close();
|
||||
resolve({ connected: true });
|
||||
};
|
||||
|
||||
es.onerror = (error) => {
|
||||
es.close();
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
expect(response).toEqual({ connected: true });
|
||||
});
|
||||
|
||||
test('should support legacy /mcp/message endpoint', async () => {
|
||||
// First establish SSE connection to get client ID
|
||||
// This is simplified - in real test we'd extract the client ID from SSE messages
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp/message')
|
||||
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
|
||||
.set('X-Client-ID', 'test-client-id')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: 1
|
||||
});
|
||||
|
||||
// The real response would come via SSE, here we just check acknowledgment
|
||||
expect(response.status).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('should support n8n pattern /mcp/:path/message', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp/workflow-123/message')
|
||||
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
|
||||
.set('X-Client-ID', 'test-client-id')
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: 1
|
||||
});
|
||||
|
||||
expect(response.status).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Protocol Methods', () => {
|
||||
test('should handle initialize method', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 1
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
result: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {}
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'n8n-documentation-mcp'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle tools/list method', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: 2
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
result: {
|
||||
tools: expect.any(Array)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle resources/list method', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'resources/list',
|
||||
id: 3
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
result: {
|
||||
resources: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle prompts/list method', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'prompts/list',
|
||||
id: 4
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 4,
|
||||
result: {
|
||||
prompts: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should return error for resources/read', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'resources/read',
|
||||
params: { uri: 'test://resource' },
|
||||
id: 5
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 5,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Resource reading not implemented'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should return error for prompts/get', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'prompts/get',
|
||||
params: { name: 'test-prompt' },
|
||||
id: 6
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 6,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: 'Prompt retrieval not implemented'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should return error for unknown method', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
|
||||
.send({
|
||||
jsonrpc: '2.0',
|
||||
method: 'unknown/method',
|
||||
id: 7
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 7,
|
||||
error: {
|
||||
code: -32601,
|
||||
message: expect.stringContaining('Method not found')
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workflow Context', () => {
|
||||
test('should accept workflow context headers', async () => {
|
||||
const workflowId = 'workflow-' + uuidv4();
|
||||
const executionId = 'execution-' + uuidv4();
|
||||
const nodeId = 'node-' + uuidv4();
|
||||
|
||||
// Test SSE connection with workflow context
|
||||
const url = `${TEST_URL}/sse?token=${TEST_AUTH_TOKEN}&workflowId=${workflowId}&executionId=${executionId}&nodeId=${nodeId}`;
|
||||
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const es = new EventSource(url);
|
||||
|
||||
es.onopen = () => {
|
||||
es.close();
|
||||
resolve({ connected: true });
|
||||
};
|
||||
|
||||
es.onerror = (error) => {
|
||||
es.close();
|
||||
reject(error);
|
||||
};
|
||||
});
|
||||
|
||||
expect(response).toEqual({ connected: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSE Message Flow', () => {
|
||||
test('should receive connected event on SSE connection', async () => {
|
||||
const messages: any[] = [];
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const es = new EventSource(`${TEST_URL}/sse?token=${TEST_AUTH_TOKEN}`);
|
||||
|
||||
es.addEventListener('connected', (event: any) => {
|
||||
messages.push({
|
||||
type: event.type,
|
||||
data: JSON.parse(event.data)
|
||||
});
|
||||
es.close();
|
||||
resolve();
|
||||
});
|
||||
|
||||
es.onerror = (error) => {
|
||||
es.close();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
es.close();
|
||||
reject(new Error('Timeout waiting for connected event'));
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
type: 'connected',
|
||||
data: {
|
||||
clientId: expect.any(String),
|
||||
timestamp: expect.any(String)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('should receive mcp-response for initialization', async () => {
|
||||
const messages: any[] = [];
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const es = new EventSource(`${TEST_URL}/sse?token=${TEST_AUTH_TOKEN}`);
|
||||
|
||||
es.addEventListener('mcp-response', (event: any) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.method === 'mcp/ready') {
|
||||
messages.push({
|
||||
type: event.type,
|
||||
data
|
||||
});
|
||||
es.close();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = (error) => {
|
||||
es.close();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
// Timeout after 5 seconds
|
||||
setTimeout(() => {
|
||||
es.close();
|
||||
reject(new Error('Timeout waiting for mcp/ready'));
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
type: 'mcp-response',
|
||||
data: {
|
||||
jsonrpc: '2.0',
|
||||
method: 'mcp/ready',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
serverInfo: {
|
||||
name: 'n8n-documentation-mcp'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
test('should handle 404 for unknown endpoints', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.get('/unknown-endpoint')
|
||||
.expect(404);
|
||||
|
||||
expect(response.body).toMatchObject({
|
||||
error: 'Not found',
|
||||
message: expect.stringContaining('Cannot GET /unknown-endpoint')
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle invalid JSON in request body', async () => {
|
||||
const response = await request(TEST_URL)
|
||||
.post('/mcp')
|
||||
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.send('invalid-json')
|
||||
.expect(400);
|
||||
|
||||
expect(response.status).toBeGreaterThanOrEqual(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
313
tests/sse-session-manager.test.ts
Normal file
313
tests/sse-session-manager.test.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Unit tests for SSE Session Manager
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
import { SSESessionManager } from '../src/utils/sse-session-manager';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// Mock Express response
|
||||
class MockResponse extends EventEmitter {
|
||||
public headers: any = {};
|
||||
public statusCode?: number;
|
||||
public writtenData: string[] = [];
|
||||
|
||||
writeHead(status: number, headers: any) {
|
||||
this.statusCode = status;
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
write(data: string) {
|
||||
this.writtenData.push(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
end() {
|
||||
this.emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
describe('SSE Session Manager', () => {
|
||||
let sessionManager: SSESessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionManager = new SSESessionManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionManager.shutdown();
|
||||
});
|
||||
|
||||
describe('Client Registration', () => {
|
||||
test('should register a new client', () => {
|
||||
const mockResponse = new MockResponse();
|
||||
const clientId = sessionManager.registerClient(mockResponse);
|
||||
|
||||
expect(clientId).toBeTruthy();
|
||||
expect(sessionManager.hasClient(clientId)).toBe(true);
|
||||
expect(sessionManager.getActiveClientCount()).toBe(1);
|
||||
});
|
||||
|
||||
test('should set SSE headers correctly', () => {
|
||||
const mockResponse = new MockResponse();
|
||||
sessionManager.registerClient(mockResponse);
|
||||
|
||||
expect(mockResponse.statusCode).toBe(200);
|
||||
expect(mockResponse.headers['Content-Type']).toBe('text/event-stream');
|
||||
expect(mockResponse.headers['Cache-Control']).toBe('no-cache');
|
||||
expect(mockResponse.headers['Connection']).toBe('keep-alive');
|
||||
});
|
||||
|
||||
test('should send connected event on registration', () => {
|
||||
const mockResponse = new MockResponse();
|
||||
const clientId = sessionManager.registerClient(mockResponse);
|
||||
|
||||
// Check that connected event was sent
|
||||
expect(mockResponse.writtenData.length).toBeGreaterThan(0);
|
||||
const sentData = mockResponse.writtenData[0];
|
||||
expect(sentData).toContain('event: connected');
|
||||
expect(sentData).toContain(`"clientId":"${clientId}"`);
|
||||
});
|
||||
|
||||
test('should handle client disconnect', () => {
|
||||
const mockResponse = new MockResponse();
|
||||
const clientId = sessionManager.registerClient(mockResponse);
|
||||
|
||||
expect(sessionManager.hasClient(clientId)).toBe(true);
|
||||
|
||||
// Simulate disconnect
|
||||
mockResponse.emit('close');
|
||||
|
||||
expect(sessionManager.hasClient(clientId)).toBe(false);
|
||||
expect(sessionManager.getActiveClientCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Message Sending', () => {
|
||||
test('should send message to client', () => {
|
||||
const mockResponse = new MockResponse();
|
||||
const clientId = sessionManager.registerClient(mockResponse);
|
||||
|
||||
const result = sessionManager.sendToClient(clientId, {
|
||||
event: 'test-event',
|
||||
data: { message: 'Hello' }
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockResponse.writtenData.length).toBe(2); // connected + test message
|
||||
|
||||
const lastMessage = mockResponse.writtenData[1];
|
||||
expect(lastMessage).toContain('event: test-event');
|
||||
expect(lastMessage).toContain('data: {"message":"Hello"}');
|
||||
});
|
||||
|
||||
test('should handle message with ID and retry', () => {
|
||||
const mockResponse = new MockResponse();
|
||||
const clientId = sessionManager.registerClient(mockResponse);
|
||||
|
||||
sessionManager.sendToClient(clientId, {
|
||||
id: '123',
|
||||
event: 'test',
|
||||
data: 'test data',
|
||||
retry: 5000
|
||||
});
|
||||
|
||||
const lastMessage = mockResponse.writtenData[1];
|
||||
expect(lastMessage).toContain('id: 123');
|
||||
expect(lastMessage).toContain('retry: 5000');
|
||||
});
|
||||
|
||||
test('should send MCP message correctly', () => {
|
||||
const mockResponse = new MockResponse();
|
||||
const clientId = sessionManager.registerClient(mockResponse);
|
||||
|
||||
const mcpMessage = {
|
||||
jsonrpc: '2.0' as const,
|
||||
id: 1,
|
||||
result: { test: 'data' }
|
||||
};
|
||||
|
||||
const result = sessionManager.sendMCPMessage(clientId, mcpMessage);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const lastMessage = mockResponse.writtenData[1];
|
||||
expect(lastMessage).toContain('event: mcp-response');
|
||||
expect(lastMessage).toContain('"jsonrpc":"2.0"');
|
||||
});
|
||||
|
||||
test('should return false for invalid client', () => {
|
||||
const result = sessionManager.sendToClient('invalid-id', {
|
||||
data: 'test'
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workflow Context', () => {
|
||||
test('should update workflow context', () => {
|
||||
const mockResponse = new MockResponse();
|
||||
const clientId = sessionManager.registerClient(mockResponse);
|
||||
|
||||
const context = {
|
||||
workflowId: 'workflow-123',
|
||||
executionId: 'execution-456',
|
||||
nodeId: 'node-789'
|
||||
};
|
||||
|
||||
const result = sessionManager.updateWorkflowContext(clientId, context);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(sessionManager.getWorkflowContext(clientId)).toEqual(context);
|
||||
});
|
||||
|
||||
test('should merge workflow context updates', () => {
|
||||
const mockResponse = new MockResponse();
|
||||
const clientId = sessionManager.registerClient(mockResponse);
|
||||
|
||||
sessionManager.updateWorkflowContext(clientId, {
|
||||
workflowId: 'workflow-123'
|
||||
});
|
||||
|
||||
sessionManager.updateWorkflowContext(clientId, {
|
||||
executionId: 'execution-456'
|
||||
});
|
||||
|
||||
const context = sessionManager.getWorkflowContext(clientId);
|
||||
expect(context).toEqual({
|
||||
workflowId: 'workflow-123',
|
||||
executionId: 'execution-456'
|
||||
});
|
||||
});
|
||||
|
||||
test('should return false for invalid client context update', () => {
|
||||
const result = sessionManager.updateWorkflowContext('invalid-id', {
|
||||
workflowId: 'test'
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Broadcast', () => {
|
||||
test('should broadcast to all active clients', () => {
|
||||
const mockResponse1 = new MockResponse();
|
||||
const mockResponse2 = new MockResponse();
|
||||
|
||||
sessionManager.registerClient(mockResponse1);
|
||||
sessionManager.registerClient(mockResponse2);
|
||||
|
||||
sessionManager.broadcast({
|
||||
event: 'broadcast-test',
|
||||
data: { message: 'Hello all' }
|
||||
});
|
||||
|
||||
// Both clients should receive the message
|
||||
expect(mockResponse1.writtenData.length).toBe(2);
|
||||
expect(mockResponse2.writtenData.length).toBe(2);
|
||||
|
||||
const message1 = mockResponse1.writtenData[1];
|
||||
const message2 = mockResponse2.writtenData[1];
|
||||
|
||||
expect(message1).toContain('event: broadcast-test');
|
||||
expect(message2).toContain('event: broadcast-test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ping', () => {
|
||||
test('should send ping to client', () => {
|
||||
const mockResponse = new MockResponse();
|
||||
const clientId = sessionManager.registerClient(mockResponse);
|
||||
|
||||
const result = sessionManager.sendPing(clientId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const lastMessage = mockResponse.writtenData[1];
|
||||
expect(lastMessage).toContain('event: ping');
|
||||
expect(lastMessage).toContain('"timestamp"');
|
||||
});
|
||||
|
||||
test('should ping all clients', () => {
|
||||
const mockResponse1 = new MockResponse();
|
||||
const mockResponse2 = new MockResponse();
|
||||
|
||||
sessionManager.registerClient(mockResponse1);
|
||||
sessionManager.registerClient(mockResponse2);
|
||||
|
||||
sessionManager.pingAllClients();
|
||||
|
||||
// Both clients should receive ping
|
||||
expect(mockResponse1.writtenData.length).toBe(2);
|
||||
expect(mockResponse2.writtenData.length).toBe(2);
|
||||
|
||||
expect(mockResponse1.writtenData[1]).toContain('event: ping');
|
||||
expect(mockResponse2.writtenData[1]).toContain('event: ping');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Cleanup', () => {
|
||||
test('should clean up inactive sessions', async () => {
|
||||
// Mock the session timeout to a short value for testing
|
||||
jest.useFakeTimers();
|
||||
|
||||
const mockResponse = new MockResponse();
|
||||
const clientId = sessionManager.registerClient(mockResponse);
|
||||
|
||||
expect(sessionManager.hasClient(clientId)).toBe(true);
|
||||
|
||||
// Fast forward past session timeout
|
||||
jest.advanceTimersByTime(6 * 60 * 1000); // 6 minutes
|
||||
|
||||
// The cleanup interval should have run and removed the inactive session
|
||||
// Note: This test might need adjustment based on actual implementation
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client Limits', () => {
|
||||
test('should enforce maximum client limit', () => {
|
||||
// Temporarily set a lower limit for testing
|
||||
const maxClients = 5;
|
||||
(sessionManager as any).MAX_CLIENTS = maxClients;
|
||||
|
||||
// Register clients up to the limit
|
||||
const responses: MockResponse[] = [];
|
||||
for (let i = 0; i < maxClients; i++) {
|
||||
const mockResponse = new MockResponse();
|
||||
responses.push(mockResponse);
|
||||
sessionManager.registerClient(mockResponse);
|
||||
}
|
||||
|
||||
expect(sessionManager.getActiveClientCount()).toBe(maxClients);
|
||||
|
||||
// Try to register one more client
|
||||
const extraResponse = new MockResponse();
|
||||
expect(() => {
|
||||
sessionManager.registerClient(extraResponse);
|
||||
}).toThrow('Maximum concurrent connections exceeded');
|
||||
|
||||
// Clean up
|
||||
responses.forEach(r => r.emit('close'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shutdown', () => {
|
||||
test('should close all connections on shutdown', () => {
|
||||
const mockResponse1 = new MockResponse();
|
||||
const mockResponse2 = new MockResponse();
|
||||
|
||||
const endSpy1 = jest.spyOn(mockResponse1, 'end');
|
||||
const endSpy2 = jest.spyOn(mockResponse2, 'end');
|
||||
|
||||
sessionManager.registerClient(mockResponse1);
|
||||
sessionManager.registerClient(mockResponse2);
|
||||
|
||||
sessionManager.shutdown();
|
||||
|
||||
expect(endSpy1).toHaveBeenCalled();
|
||||
expect(endSpy2).toHaveBeenCalled();
|
||||
expect(sessionManager.getActiveClientCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
289
tests/test-sse-endpoints.ts
Normal file
289
tests/test-sse-endpoints.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env ts-node
|
||||
|
||||
/**
|
||||
* Manual test script for SSE endpoints
|
||||
* Usage: npm run build && npx ts-node tests/test-sse-endpoints.ts
|
||||
*/
|
||||
|
||||
import { EventSource } from 'eventsource';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const BASE_URL = process.env.SSE_TEST_URL || 'http://localhost:3000';
|
||||
const AUTH_TOKEN = process.env.AUTH_TOKEN || 'test-token';
|
||||
|
||||
interface TestResult {
|
||||
test: string;
|
||||
status: 'PASS' | 'FAIL';
|
||||
message?: string;
|
||||
error?: any;
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
function logResult(test: string, status: 'PASS' | 'FAIL', message?: string, error?: any) {
|
||||
results.push({ test, status, message, error });
|
||||
console.log(`[${status}] ${test}${message ? ': ' + message : ''}`);
|
||||
if (error) {
|
||||
console.error(' Error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function testHealthEndpoint() {
|
||||
console.log('\n=== Testing Health Endpoint ===');
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/health`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.status === 'ok' && data.mode === 'sse') {
|
||||
logResult('Health Check', 'PASS', `Server is healthy (mode: ${data.mode})`);
|
||||
} else {
|
||||
logResult('Health Check', 'FAIL', 'Unexpected response', data);
|
||||
}
|
||||
} catch (error) {
|
||||
logResult('Health Check', 'FAIL', 'Failed to connect', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function testAuthentication() {
|
||||
console.log('\n=== Testing Authentication Methods ===');
|
||||
|
||||
// Test Bearer token
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 1
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logResult('Bearer Token Auth', 'PASS');
|
||||
} else {
|
||||
logResult('Bearer Token Auth', 'FAIL', `Status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logResult('Bearer Token Auth', 'FAIL', 'Request failed', error);
|
||||
}
|
||||
|
||||
// Test custom header
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-auth-token': AUTH_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 2
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logResult('Custom Header Auth', 'PASS');
|
||||
} else {
|
||||
logResult('Custom Header Auth', 'FAIL', `Status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logResult('Custom Header Auth', 'FAIL', 'Request failed', error);
|
||||
}
|
||||
|
||||
// Test API key header
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': AUTH_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: 3
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logResult('API Key Auth', 'PASS');
|
||||
} else {
|
||||
logResult('API Key Auth', 'FAIL', `Status: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logResult('API Key Auth', 'FAIL', 'Request failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function testSSEEndpoints() {
|
||||
console.log('\n=== Testing SSE Endpoints ===');
|
||||
|
||||
// Test legacy /sse endpoint
|
||||
await testSSEConnection('/sse', 'Legacy SSE endpoint');
|
||||
|
||||
// Test n8n pattern endpoint
|
||||
await testSSEConnection('/mcp/test-workflow/sse', 'n8n pattern SSE endpoint');
|
||||
}
|
||||
|
||||
async function testSSEConnection(endpoint: string, testName: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const url = `${BASE_URL}${endpoint}?token=${AUTH_TOKEN}`;
|
||||
console.log(`Testing ${testName}: ${url}`);
|
||||
|
||||
const es = new EventSource(url);
|
||||
let receivedConnected = false;
|
||||
let receivedReady = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
es.close();
|
||||
if (!receivedConnected) {
|
||||
logResult(testName, 'FAIL', 'Timeout - no connected event');
|
||||
} else if (!receivedReady) {
|
||||
logResult(testName, 'FAIL', 'Timeout - no mcp/ready event');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
es.addEventListener('connected', (event: any) => {
|
||||
receivedConnected = true;
|
||||
const data = JSON.parse(event.data);
|
||||
console.log(` Received connected event: clientId=${data.clientId}`);
|
||||
});
|
||||
|
||||
es.addEventListener('mcp-response', (event: any) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.method === 'mcp/ready') {
|
||||
receivedReady = true;
|
||||
console.log(` Received mcp/ready event`);
|
||||
clearTimeout(timeout);
|
||||
es.close();
|
||||
logResult(testName, 'PASS', 'Connected and ready');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
es.onerror = (error: any) => {
|
||||
clearTimeout(timeout);
|
||||
es.close();
|
||||
logResult(testName, 'FAIL', 'Connection error', error);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function testMCPMethods() {
|
||||
console.log('\n=== Testing MCP Protocol Methods ===');
|
||||
|
||||
const methods = [
|
||||
{ method: 'initialize', expectedResult: true },
|
||||
{ method: 'tools/list', expectedResult: true },
|
||||
{ method: 'resources/list', expectedResult: true },
|
||||
{ method: 'prompts/list', expectedResult: true },
|
||||
{ method: 'resources/read', expectedResult: false },
|
||||
{ method: 'prompts/get', expectedResult: false },
|
||||
];
|
||||
|
||||
for (const { method, expectedResult } of methods) {
|
||||
try {
|
||||
const response = await fetch(`${BASE_URL}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method,
|
||||
params: method.includes('read') ? { uri: 'test://resource' } :
|
||||
method.includes('get') ? { name: 'test-prompt' } : undefined,
|
||||
id: Math.random()
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (expectedResult && data.result !== undefined) {
|
||||
logResult(`MCP ${method}`, 'PASS', 'Returned result');
|
||||
} else if (!expectedResult && data.error !== undefined) {
|
||||
logResult(`MCP ${method}`, 'PASS', 'Returned expected error');
|
||||
} else {
|
||||
logResult(`MCP ${method}`, 'FAIL', 'Unexpected response', data);
|
||||
}
|
||||
} catch (error) {
|
||||
logResult(`MCP ${method}`, 'FAIL', 'Request failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function testWorkflowContext() {
|
||||
console.log('\n=== Testing Workflow Context ===');
|
||||
|
||||
const workflowId = 'test-workflow-123';
|
||||
const executionId = 'test-execution-456';
|
||||
const nodeId = 'test-node-789';
|
||||
|
||||
const url = `${BASE_URL}/mcp/${workflowId}/sse?token=${AUTH_TOKEN}&workflowId=${workflowId}&executionId=${executionId}&nodeId=${nodeId}`;
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const es = new EventSource(url);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
es.close();
|
||||
logResult('Workflow Context', 'FAIL', 'Timeout');
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
es.addEventListener('connected', (event: any) => {
|
||||
clearTimeout(timeout);
|
||||
es.close();
|
||||
logResult('Workflow Context', 'PASS', 'Connected with context parameters');
|
||||
resolve();
|
||||
});
|
||||
|
||||
es.onerror = (error: any) => {
|
||||
clearTimeout(timeout);
|
||||
es.close();
|
||||
logResult('Workflow Context', 'FAIL', 'Connection error', error);
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function runAllTests() {
|
||||
console.log(`Testing SSE Server at ${BASE_URL}`);
|
||||
console.log(`Using auth token: ${AUTH_TOKEN.substring(0, 8)}...`);
|
||||
|
||||
await testHealthEndpoint();
|
||||
await testAuthentication();
|
||||
await testSSEEndpoints();
|
||||
await testMCPMethods();
|
||||
await testWorkflowContext();
|
||||
|
||||
// Summary
|
||||
console.log('\n=== Test Summary ===');
|
||||
const passed = results.filter(r => r.status === 'PASS').length;
|
||||
const failed = results.filter(r => r.status === 'FAIL').length;
|
||||
console.log(`Total: ${results.length} | Passed: ${passed} | Failed: ${failed}`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\nFailed tests:');
|
||||
results.filter(r => r.status === 'FAIL').forEach(r => {
|
||||
console.log(`- ${r.test}: ${r.message || 'No message'}`);
|
||||
});
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\nAll tests passed!');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests
|
||||
runAllTests().catch(error => {
|
||||
console.error('Test runner error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user