knowledgegraph-mcp
Version:
MCP server for enabling persistent knowledge storage for Claude through a knowledge graph with multiple storage backends
542 lines • 27 kB
JavaScript
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