UNPKG

knowledgegraph-mcp

Version:

MCP server for enabling persistent knowledge storage for Claude through a knowledge graph with multiple storage backends

542 lines 27 kB
import { StorageType } from './storage/types.js'; import { StorageFactory } from './storage/factory.js'; import { resolveProject } from './utils.js'; import { SearchManager } from './search/search-manager.js'; import { PostgreSQLFuzzyStrategy } from './search/strategies/postgresql-strategy.js'; import { SQLiteFuzzyStrategy } from './search/strategies/sqlite-strategy.js'; // The KnowledgeGraphManager class contains all operations to interact with the knowledge graph export class KnowledgeGraphManager { storage; searchManager = null; config; constructor(storageConfig) { this.config = storageConfig || this.getStorageConfigFromEnv(); this.storage = StorageFactory.create(this.config); // Initialize storage asynchronously this.initializeStorage(); } async initializeStorage() { try { await this.storage.initialize(); this.initializeSearchManager(); } catch (error) { console.error('Failed to initialize storage:', error); throw error; } } initializeSearchManager() { const searchConfig = { useDatabaseSearch: this.config.fuzzySearch?.useDatabaseSearch ?? false, threshold: this.config.fuzzySearch?.threshold ?? 0.3, clientSideFallback: this.config.fuzzySearch?.clientSideFallback ?? true }; // Initialize search strategy based on storage type if (this.config.type === StorageType.POSTGRESQL) { const sqlStorage = this.storage; const pgPool = sqlStorage.getPostgreSQLPool(); if (pgPool) { const strategy = new PostgreSQLFuzzyStrategy(searchConfig, pgPool, 'default'); this.searchManager = new SearchManager(searchConfig, strategy); } } else if (this.config.type === StorageType.SQLITE) { const sqliteStorage = this.storage; const db = sqliteStorage.getSQLiteDatabase(); if (db) { const strategy = new SQLiteFuzzyStrategy(searchConfig, db, 'default'); this.searchManager = new SearchManager(searchConfig, strategy); } } } getStorageConfigFromEnv() { return StorageFactory.getDefaultConfig(); } async loadGraph(project) { const graph = await this.storage.loadGraph(project); // Ensure backward compatibility: add empty tags array to entities that don't have it graph.entities = graph.entities.map(entity => ({ ...entity, tags: entity.tags || [] })); return graph; } async saveGraph(graph, project) { return this.storage.saveGraph(graph, project); } /** * Close storage connections and cleanup resources */ async close() { await this.storage.close(); } /** * Health check for the storage provider */ async healthCheck() { return this.storage.healthCheck?.() ?? true; } async createEntities(entities, project) { const resolvedProject = resolveProject(project); // Validate input entities this.validateEntities(entities); const graph = await this.loadGraph(resolvedProject); // Ensure backward compatibility and proper defaults const entitiesWithDefaults = entities.map(entity => ({ ...entity, observations: entity.observations || [], tags: entity.tags || [] })); const newEntities = entitiesWithDefaults.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name)); graph.entities.push(...newEntities); await this.saveGraph(graph, resolvedProject); return newEntities; } async createRelations(relations, project) { const resolvedProject = resolveProject(project); // Validate input relations this.validateRelations(relations); const graph = await this.loadGraph(resolvedProject); // Check that all referenced entities exist relations.forEach(relation => { const fromEntity = graph.entities.find(e => e.name === relation.from); const toEntity = graph.entities.find(e => e.name === relation.to); if (!fromEntity) { throw new Error(`RELATION ERROR: Entity '${relation.from}' not found. ACTION: Create entity first with create_entities`); } if (!toEntity) { throw new Error(`RELATION ERROR: Entity '${relation.to}' not found. ACTION: Create entity first with create_entities`); } }); // Separate new relations from duplicates with detailed tracking const newRelations = []; const skippedRelations = []; relations.forEach(relation => { const existingRelation = graph.relations.find(existingRelation => existingRelation.from === relation.from && existingRelation.to === relation.to && existingRelation.relationType === relation.relationType); if (existingRelation) { skippedRelations.push({ relation, reason: `Relation already exists: ${relation.from}${relation.relationType}${relation.to}` }); } else { newRelations.push(relation); } }); graph.relations.push(...newRelations); await this.saveGraph(graph, resolvedProject); return { newRelations, skippedRelations, totalRequested: relations.length }; } async addObservations(observations, project) { const resolvedProject = resolveProject(project); // Validate input observations this.validateObservationUpdates(observations); const graph = await this.loadGraph(resolvedProject); const results = observations.map(o => { const entity = graph.entities.find(e => e.name === o.entityName); if (!entity) { throw new Error(`ENTITY ERROR: '${o.entityName}' not found. ACTION: Create entity first with create_entities`); } // Ensure entity has observations array (backward compatibility) if (!entity.observations) { entity.observations = []; } const newObservations = o.observations.filter(content => !entity.observations.includes(content)); entity.observations.push(...newObservations); return { entityName: o.entityName, addedObservations: newObservations }; }); await this.saveGraph(graph, resolvedProject); return results; } async deleteEntities(entityNames, project) { const resolvedProject = resolveProject(project); const graph = await this.loadGraph(resolvedProject); graph.entities = graph.entities.filter(e => !entityNames.includes(e.name)); graph.relations = graph.relations.filter(r => !entityNames.includes(r.from) && !entityNames.includes(r.to)); await this.saveGraph(graph, resolvedProject); } async deleteObservations(deletions, project) { const resolvedProject = resolveProject(project); const graph = await this.loadGraph(resolvedProject); deletions.forEach(d => { const entity = graph.entities.find(e => e.name === d.entityName); if (entity) { entity.observations = entity.observations.filter(o => !d.observations.includes(o)); } }); await this.saveGraph(graph, resolvedProject); } async deleteRelations(relations, project) { const resolvedProject = resolveProject(project); const graph = await this.loadGraph(resolvedProject); graph.relations = graph.relations.filter(r => !relations.some(delRelation => r.from === delRelation.from && r.to === delRelation.to && r.relationType === delRelation.relationType)); await this.saveGraph(graph, resolvedProject); } async readGraph(project) { const resolvedProject = resolveProject(project); return this.loadGraph(resolvedProject); } // Enhanced search function that includes tags and supports exact tag matching and fuzzy search async searchNodes(query, optionsOrProject, project) { // Handle backward compatibility: if second param is string, it's the project let resolvedOptions; let resolvedProject; if (typeof optionsOrProject === 'string') { resolvedOptions = undefined; resolvedProject = resolveProject(optionsOrProject); } else { resolvedOptions = optionsOrProject; resolvedProject = resolveProject(project); } // Handle multiple queries const queries = Array.isArray(query) ? query : [query]; // Handle null/undefined queries - convert to empty string if exactTags is provided const hasExactTags = resolvedOptions && 'exactTags' in resolvedOptions && resolvedOptions.exactTags && Array.isArray(resolvedOptions.exactTags) && resolvedOptions.exactTags.length > 0; const processedQueries = []; for (const q of queries) { if (q === null || q === undefined) { if (hasExactTags) { processedQueries.push(''); // Convert to empty string for tag-only search } else { return { entities: [], relations: [] }; // Return empty result for null/undefined without exactTags } } else if (typeof q === 'string') { processedQueries.push(q); } else { return { entities: [], relations: [] }; // Return empty result for non-string queries } } // Handle empty queries array if (processedQueries.length === 0) { return { entities: [], relations: [] }; } const graph = await this.loadGraph(resolvedProject); // If multiple queries, process each and merge results if (processedQueries.length > 1) { let allResults = { entities: [], relations: [] }; for (const singleQuery of processedQueries) { const result = await this.searchSingleQuery(singleQuery, resolvedOptions, resolvedProject, graph); // Merge results with deduplication const existingEntityNames = new Set(allResults.entities.map(e => e.name)); const newEntities = result.entities.filter(e => !existingEntityNames.has(e.name)); allResults.entities.push(...newEntities); // Merge relations with deduplication const existingRelations = new Set(allResults.relations.map(r => `${r.from}-${r.relationType}-${r.to}`)); const newRelations = result.relations.filter(r => !existingRelations.has(`${r.from}-${r.relationType}-${r.to}`)); allResults.relations.push(...newRelations); } return allResults; } // Single query - use existing logic return this.searchSingleQuery(processedQueries[0], resolvedOptions, resolvedProject, graph); } // Helper method for single query search async searchSingleQuery(query, options, resolvedProject, graph) { // If exact tags are specified, use exact tag matching if (options?.exactTags && options.exactTags.length > 0) { const filteredEntities = graph.entities.filter(entity => { // Ensure entity has tags array (backward compatibility) if (!entity.tags || entity.tags.length === 0) return false; if (options.tagMatchMode === 'all') { return options.exactTags.every(tag => entity.tags.includes(tag)); } else { return options.exactTags.some(tag => entity.tags.includes(tag)); } }); return this.buildFilteredGraph(filteredEntities, graph); } // Use search manager if available and fuzzy search is requested if (this.searchManager && options && 'searchMode' in options && options.searchMode === 'fuzzy') { try { const filteredEntities = await this.searchManager.search(query, graph.entities, options, resolvedProject); return this.buildFilteredGraph(filteredEntities, graph); } catch (error) { console.warn('Fuzzy search failed, falling back to exact search:', error); // Fall through to exact search } } // Handle empty query string - return all entities if no specific search criteria if (!query || query.trim() === '') { return this.buildFilteredGraph(graph.entities, graph); } // Default behavior: general text search across all fields including tags const filteredEntities = graph.entities.filter(e => { // Ensure entity has tags array (backward compatibility) const entityTags = e.tags || []; return (e.name.toLowerCase().includes(query.toLowerCase()) || e.entityType.toLowerCase().includes(query.toLowerCase()) || e.observations.some(o => o.toLowerCase().includes(query.toLowerCase())) || entityTags.some(tag => tag.toLowerCase().includes(query.toLowerCase()))); }); return this.buildFilteredGraph(filteredEntities, graph); } /** * Paginated search function with database-level pagination when possible */ async searchNodesPaginated(query, pagination, optionsOrProject, project) { // Handle backward compatibility: if third param is string, it's the project let resolvedOptions; let resolvedProject; if (typeof optionsOrProject === 'string') { resolvedOptions = undefined; resolvedProject = resolveProject(optionsOrProject); } else { resolvedOptions = optionsOrProject; resolvedProject = resolveProject(project); } // Use SearchManager for pagination if available if (this.searchManager) { try { const paginatedResult = await this.searchManager.searchPaginated(query, pagination, resolvedOptions, resolvedProject); // Build relations for the paginated entities const graph = await this.loadGraph(resolvedProject); const entityNames = new Set(paginatedResult.data.map(e => e.name)); const filteredRelations = graph.relations.filter(r => entityNames.has(r.from) && entityNames.has(r.to)); return { entities: paginatedResult.data, relations: filteredRelations, pagination: paginatedResult.pagination }; } catch (error) { console.warn('Paginated search failed, falling back to post-search pagination:', error); } } // Fallback: Use regular search and apply post-search pagination const fullResult = await this.searchNodes(query, resolvedOptions, resolvedProject); return this.applyPostSearchPagination(fullResult, pagination); } /** * Apply pagination to a full KnowledgeGraph result (post-search pagination) */ applyPostSearchPagination(graph, pagination) { const page = pagination.page || 0; const pageSize = pagination.pageSize || 100; const offset = page * pageSize; const totalCount = graph.entities.length; const totalPages = Math.ceil(totalCount / pageSize); const paginatedEntities = graph.entities.slice(offset, offset + pageSize); // Filter relations to only include those between paginated entities const entityNames = new Set(paginatedEntities.map(e => e.name)); const paginatedRelations = graph.relations.filter(r => entityNames.has(r.from) && entityNames.has(r.to)); return { entities: paginatedEntities, relations: paginatedRelations, pagination: { currentPage: page, pageSize: pageSize, totalCount: totalCount, totalPages: totalPages, hasNextPage: page < totalPages - 1, hasPreviousPage: page > 0 } }; } async openNodes(names, project) { const resolvedProject = resolveProject(project); const graph = await this.loadGraph(resolvedProject); // Filter entities const filteredEntities = graph.entities.filter(e => names.includes(e.name)); // Create a Set of filtered entity names for quick lookup const filteredEntityNames = new Set(filteredEntities.map(e => e.name)); // Filter relations to only include those between filtered entities const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)); const filteredGraph = { entities: filteredEntities, relations: filteredRelations, }; return filteredGraph; } // Tag-specific methods /** * Add tags to existing entities */ async addTags(updates, project) { const resolvedProject = resolveProject(project); const graph = await this.loadGraph(resolvedProject); const results = updates.map(update => { const entity = graph.entities.find(e => e.name === update.entityName); if (!entity) { throw new Error(`ENTITY ERROR: '${update.entityName}' not found. ACTION: Create entity first with create_entities`); } // Ensure entity has tags array (backward compatibility) if (!entity.tags) { entity.tags = []; } // Add only new tags (avoid duplicates) const newTags = update.tags.filter(tag => !entity.tags.includes(tag)); entity.tags.push(...newTags); return { entityName: update.entityName, addedTags: newTags }; }); await this.saveGraph(graph, resolvedProject); return results; } /** * Remove specific tags from entities */ async removeTags(updates, project) { const resolvedProject = resolveProject(project); const graph = await this.loadGraph(resolvedProject); const results = updates.map(update => { const entity = graph.entities.find(e => e.name === update.entityName); if (!entity) { throw new Error(`ENTITY ERROR: '${update.entityName}' not found. ACTION: Create entity first with create_entities`); } // Ensure entity has tags array (backward compatibility) if (!entity.tags) { entity.tags = []; return { entityName: update.entityName, removedTags: [] }; } // Remove specified tags const removedTags = update.tags.filter(tag => entity.tags.includes(tag)); entity.tags = entity.tags.filter(tag => !update.tags.includes(tag)); return { entityName: update.entityName, removedTags }; }); await this.saveGraph(graph, resolvedProject); return results; } /** * Validate entities before creation to prevent database constraint violations */ validateEntities(entities) { if (!entities || !Array.isArray(entities)) { throw new Error('ENTITIES ERROR: Must be array of entity objects. REQUIRED: [{name, entityType, observations}]'); } if (entities.length === 0) { throw new Error('ENTITIES ERROR: Empty array provided. REQUIRED: At least 1 entity object'); } entities.forEach((entity, index) => { // Validate entity structure if (!entity || typeof entity !== 'object') { throw new Error(`ENTITY ERROR: Entity #${index} must be object. REQUIRED: {name, entityType, observations}`); } // Validate required fields if (!entity.name || typeof entity.name !== 'string' || entity.name.trim() === '') { throw new Error(`ENTITY ERROR: Entity #${index} missing name. REQUIRED: Non-empty string (e.g., 'John_Smith', 'Project_Alpha')`); } if (!entity.entityType || typeof entity.entityType !== 'string' || entity.entityType.trim() === '') { throw new Error(`ENTITY ERROR: Entity #${index} missing entityType. REQUIRED: Non-empty string (e.g., 'person', 'project', 'company')`); } // Validate observations - must be array and not null/undefined if (entity.observations !== undefined && entity.observations !== null) { if (!Array.isArray(entity.observations)) { throw new Error(`ENTITY ERROR: "${entity.name}" observations must be array. REQUIRED: ['fact1', 'fact2']`); } // Check each observation is a string entity.observations.forEach((obs, obsIndex) => { if (typeof obs !== 'string') { throw new Error(`ENTITY ERROR: "${entity.name}" observation #${obsIndex} must be string. REQUIRED: Non-empty text fact`); } }); } // Validate tags if provided if (entity.tags !== undefined && entity.tags !== null) { if (!Array.isArray(entity.tags)) { throw new Error(`ENTITY ERROR: "${entity.name}" tags must be array. REQUIRED: ['urgent', 'completed'] or []`); } // Check each tag is a string entity.tags.forEach((tag, tagIndex) => { if (typeof tag !== 'string') { throw new Error(`ENTITY ERROR: "${entity.name}" tag #${tagIndex} must be string. EXAMPLE: 'urgent', 'completed', 'technical'`); } }); } }); } /** * Validate relations before creation to prevent database constraint violations */ validateRelations(relations) { if (!relations || !Array.isArray(relations)) { throw new Error('RELATIONS ERROR: Must be array of relation objects. REQUIRED: [{from, to, relationType}]'); } if (relations.length === 0) { throw new Error('RELATIONS ERROR: Empty array provided. REQUIRED: At least 1 relation object'); } relations.forEach((relation, index) => { // Validate relation structure if (!relation || typeof relation !== 'object') { throw new Error(`RELATION ERROR: Relation #${index} must be object. REQUIRED: {from, to, relationType}`); } // Validate required fields if (!relation.from || typeof relation.from !== 'string' || relation.from.trim() === '') { throw new Error(`RELATION ERROR: Relation #${index} missing 'from' entity. REQUIRED: Existing entity name (e.g., 'John_Smith')`); } if (!relation.to || typeof relation.to !== 'string' || relation.to.trim() === '') { throw new Error(`RELATION ERROR: Relation #${index} missing 'to' entity. REQUIRED: Existing entity name (e.g., 'Google')`); } if (!relation.relationType || typeof relation.relationType !== 'string' || relation.relationType.trim() === '') { throw new Error(`RELATION ERROR: Relation #${index} missing relationType. REQUIRED: Active voice (e.g., 'works_at', 'manages', 'uses')`); } // Check for passive voice patterns const passivePatterns = ['_by', 'is_', 'was_', 'been_']; if (passivePatterns.some(pattern => relation.relationType.includes(pattern))) { throw new Error(`RELATION ERROR: Relation #${index} uses passive voice '${relation.relationType}'. REQUIRED: Active voice (e.g., 'works_at' not 'is_worked_at')`); } }); } /** * Validate observation updates to prevent database constraint violations */ validateObservationUpdates(observations) { if (!observations || !Array.isArray(observations)) { throw new Error('OBSERVATIONS ERROR: Must be array of update objects. REQUIRED: [{entityName, observations}]'); } if (observations.length === 0) { throw new Error('OBSERVATIONS ERROR: Empty array provided. REQUIRED: At least 1 observation update'); } observations.forEach((update, index) => { // Validate update structure if (!update || typeof update !== 'object') { throw new Error(`OBSERVATION ERROR: Update #${index} must be object. REQUIRED: {entityName, observations}`); } // Validate entityName if (!update.entityName || typeof update.entityName !== 'string' || update.entityName.trim() === '') { throw new Error(`OBSERVATION ERROR: Update #${index} missing entityName. REQUIRED: Existing entity name (e.g., 'John_Smith')`); } // Validate observations array if (!update.observations || !Array.isArray(update.observations)) { throw new Error(`OBSERVATION ERROR: "${update.entityName}" observations must be array. REQUIRED: ['fact1', 'fact2']`); } if (update.observations.length === 0) { throw new Error(`OBSERVATION ERROR: "${update.entityName}" needs observations. REQUIRED: At least 1 fact (e.g., ['Promoted to senior', 'Moved to NYC'])`); } // Check each observation is a string update.observations.forEach((obs, obsIndex) => { if (typeof obs !== 'string' || obs.trim() === '') { throw new Error(`OBSERVATION ERROR: "${update.entityName}" observation #${obsIndex} must be non-empty string. EXAMPLE: 'Works at Google'`); } }); }); } /** * Helper method to build a filtered graph with relations */ buildFilteredGraph(filteredEntities, originalGraph) { // Create a Set of filtered entity names for quick lookup const filteredEntityNames = new Set(filteredEntities.map(e => e.name)); // Filter relations to only include those between filtered entities const filteredRelations = originalGraph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)); return { entities: filteredEntities, relations: filteredRelations, }; } } //# sourceMappingURL=core.js.map