mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-21 01:43:08 +00:00
* fix: use correct MCP SDK API for server capabilities in test getServerVersion() returns Implementation (name/version only), not the full init result. Use client.getServerCapabilities() instead to access server capabilities, fixing the CI typecheck failure. Concieved by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve multiple n8n_update_partial_workflow bugs (#592, #599, #610, #623, #624, #625, #629, #630, #633) Phase 1 - Data loss prevention: - Add missing unary operators (empty, notEmpty, exists, notExists) to sanitizer (#592) - Preserve positional empty arrays in connections during removeNode/cleanStale (#610) - Scope sanitization to modified nodes only, preventing unrelated node corruption - Add empty body {} to activate/deactivate POST calls to fix 415 errors (#633) Phase 2 - Error handling & response clarity: - Serialize Zod errors to readable "path: message" strings (#630) - Add saved:true/false field to all response paths (#625) - Improve updateNode error hint with correct structure example (#623) - Track removed node names for better removeConnection errors (#624) Phase 3 - Connection & type fixes: - Coerce sourceOutput/targetInput to String() consistently (#629) - Accept numeric sourceOutput/targetInput at Zod schema level via transform Phase 4 - Tag operations via dedicated API (#599): - Track tags as tagsToAdd/tagsToRemove instead of mutating workflow.tags - Orchestrate tag creation and association via listTags/createTag/updateWorkflowTags - Reconcile conflicting add/remove for same tag (last operation wins) - Tag failures produce warnings, not hard errors Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add v2.37.0 changelog entry Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: resolve pre-existing integration test failures in CI - Create new MCP Server instance per connection in test helpers (SDK 1.27+ requires separate Protocol instance per connection) - Normalize database paths with path.resolve() in shared-database singleton to prevent path mismatch errors across test files - Add no-op catch handler to deferred initialization promise in server.ts to prevent unhandled rejection warnings - Properly call mcpServer.shutdown() in test helper close() to release shared database references Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
204 lines
6.3 KiB
TypeScript
204 lines
6.3 KiB
TypeScript
/**
|
|
* Shared Database Manager - Singleton for cross-session database connection
|
|
*
|
|
* This module implements a singleton pattern to share a single database connection
|
|
* across all MCP server sessions. This prevents memory leaks caused by each session
|
|
* creating its own database connection (~900MB per session).
|
|
*
|
|
* Memory impact: Reduces per-session memory from ~900MB to near-zero by sharing
|
|
* a single ~68MB database connection across all sessions.
|
|
*
|
|
* Issue: https://github.com/czlonkowski/n8n-mcp/issues/XXX
|
|
*/
|
|
|
|
import path from 'path';
|
|
import { DatabaseAdapter, createDatabaseAdapter } from './database-adapter';
|
|
import { NodeRepository } from './node-repository';
|
|
import { TemplateService } from '../templates/template-service';
|
|
import { EnhancedConfigValidator } from '../services/enhanced-config-validator';
|
|
import { logger } from '../utils/logger';
|
|
|
|
/**
|
|
* Shared database state - holds the singleton connection and services
|
|
*/
|
|
export interface SharedDatabaseState {
|
|
db: DatabaseAdapter;
|
|
repository: NodeRepository;
|
|
templateService: TemplateService;
|
|
dbPath: string;
|
|
refCount: number;
|
|
initialized: boolean;
|
|
}
|
|
|
|
// Module-level singleton state
|
|
let sharedState: SharedDatabaseState | null = null;
|
|
let initializationPromise: Promise<SharedDatabaseState> | null = null;
|
|
|
|
/**
|
|
* Get or create the shared database connection
|
|
*
|
|
* Thread-safe initialization using a promise lock pattern.
|
|
* Multiple concurrent calls will wait for the same initialization.
|
|
*
|
|
* @param dbPath - Path to the SQLite database file
|
|
* @returns Shared database state with connection and services
|
|
*/
|
|
export async function getSharedDatabase(dbPath: string): Promise<SharedDatabaseState> {
|
|
// Normalize to a canonical absolute path so that callers using different
|
|
// relative or join-based paths (e.g. "./data/nodes.db" vs an absolute path)
|
|
// resolve to the same string and do not trigger a false "different path" error.
|
|
const normalizedPath = dbPath === ':memory:' ? dbPath : path.resolve(dbPath);
|
|
|
|
// If already initialized with the same path, increment ref count and return
|
|
if (sharedState && sharedState.initialized && sharedState.dbPath === normalizedPath) {
|
|
sharedState.refCount++;
|
|
logger.debug('Reusing shared database connection', {
|
|
refCount: sharedState.refCount,
|
|
dbPath: normalizedPath
|
|
});
|
|
return sharedState;
|
|
}
|
|
|
|
// If already initialized with a DIFFERENT path, this is a configuration error
|
|
if (sharedState && sharedState.initialized && sharedState.dbPath !== normalizedPath) {
|
|
logger.error('Attempted to initialize shared database with different path', {
|
|
existingPath: sharedState.dbPath,
|
|
requestedPath: normalizedPath
|
|
});
|
|
throw new Error(`Shared database already initialized with different path: ${sharedState.dbPath}`);
|
|
}
|
|
|
|
// If initialization is in progress, wait for it
|
|
if (initializationPromise) {
|
|
try {
|
|
const state = await initializationPromise;
|
|
state.refCount++;
|
|
logger.debug('Reusing shared database (waited for init)', {
|
|
refCount: state.refCount,
|
|
dbPath: normalizedPath
|
|
});
|
|
return state;
|
|
} catch (error) {
|
|
// Initialization failed while we were waiting, clear promise and rethrow
|
|
initializationPromise = null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Start new initialization
|
|
initializationPromise = initializeSharedDatabase(normalizedPath);
|
|
|
|
try {
|
|
const state = await initializationPromise;
|
|
// Clear the promise on success to allow future re-initialization after close
|
|
initializationPromise = null;
|
|
return state;
|
|
} catch (error) {
|
|
// Clear promise on failure to allow retry
|
|
initializationPromise = null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize the shared database connection and services
|
|
*/
|
|
async function initializeSharedDatabase(dbPath: string): Promise<SharedDatabaseState> {
|
|
logger.info('Initializing shared database connection', { dbPath });
|
|
|
|
const db = await createDatabaseAdapter(dbPath);
|
|
const repository = new NodeRepository(db);
|
|
const templateService = new TemplateService(db);
|
|
|
|
// Initialize similarity services for enhanced validation
|
|
EnhancedConfigValidator.initializeSimilarityServices(repository);
|
|
|
|
sharedState = {
|
|
db,
|
|
repository,
|
|
templateService,
|
|
dbPath,
|
|
refCount: 1,
|
|
initialized: true
|
|
};
|
|
|
|
logger.info('Shared database initialized successfully', {
|
|
dbPath,
|
|
refCount: sharedState.refCount
|
|
});
|
|
|
|
return sharedState;
|
|
}
|
|
|
|
/**
|
|
* Release a reference to the shared database
|
|
*
|
|
* Decrements the reference count. Does NOT close the database
|
|
* as it's shared across all sessions for the lifetime of the process.
|
|
*
|
|
* @param state - The shared database state to release
|
|
*/
|
|
export function releaseSharedDatabase(state: SharedDatabaseState): void {
|
|
if (!state || !sharedState) {
|
|
return;
|
|
}
|
|
|
|
// Guard against double-release (refCount going negative)
|
|
if (sharedState.refCount <= 0) {
|
|
logger.warn('Attempted to release shared database with refCount already at or below 0', {
|
|
refCount: sharedState.refCount
|
|
});
|
|
return;
|
|
}
|
|
|
|
sharedState.refCount--;
|
|
logger.debug('Released shared database reference', {
|
|
refCount: sharedState.refCount
|
|
});
|
|
|
|
// Note: We intentionally do NOT close the database even when refCount hits 0
|
|
// The database should remain open for the lifetime of the process to handle
|
|
// new sessions. Only process shutdown should close it.
|
|
}
|
|
|
|
/**
|
|
* Force close the shared database (for graceful shutdown only)
|
|
*
|
|
* This should only be called during process shutdown, not during normal
|
|
* session cleanup. Closing the database would break other active sessions.
|
|
*/
|
|
export async function closeSharedDatabase(): Promise<void> {
|
|
if (!sharedState) {
|
|
return;
|
|
}
|
|
|
|
logger.info('Closing shared database connection', {
|
|
refCount: sharedState.refCount
|
|
});
|
|
|
|
try {
|
|
sharedState.db.close();
|
|
} catch (error) {
|
|
logger.warn('Error closing shared database', {
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
|
|
sharedState = null;
|
|
initializationPromise = null;
|
|
}
|
|
|
|
/**
|
|
* Check if shared database is initialized
|
|
*/
|
|
export function isSharedDatabaseInitialized(): boolean {
|
|
return sharedState !== null && sharedState.initialized;
|
|
}
|
|
|
|
/**
|
|
* Get current reference count (for debugging/monitoring)
|
|
*/
|
|
export function getSharedDatabaseRefCount(): number {
|
|
return sharedState?.refCount ?? 0;
|
|
}
|