knowledgegraph-mcp
Version:
MCP server for enabling persistent knowledge storage for Claude through a knowledge graph with multiple storage backends
343 lines • 12.6 kB
JavaScript
// Import database drivers directly - PostgreSQL only
import { Pool as PgPool } from 'pg';
import { StorageType } from '../types.js';
/**
* SQL database storage provider for PostgreSQL only
*/
export class SQLStorageProvider {
config;
pgPool = null;
constructor(config) {
this.config = config;
if (!config.connectionString) {
throw new Error(`Connection string is required for ${config.type} storage`);
}
if (config.type !== StorageType.POSTGRESQL) {
throw new Error(`Only PostgreSQL is supported, got: ${config.type}`);
}
// Validate PostgreSQL connection string format
this.validateConnectionString(config.connectionString);
}
/**
* Validate PostgreSQL connection string format
*/
validateConnectionString(connectionString) {
try {
// Basic format validation for PostgreSQL connection strings
if (!connectionString.startsWith('postgresql://') && !connectionString.startsWith('postgres://')) {
throw new Error(`PostgreSQL connection string must start with 'postgresql://' or 'postgres://'`);
}
// Try to parse as URL to validate format
const url = new URL(connectionString);
if (url.protocol !== 'postgresql:' && url.protocol !== 'postgres:') {
throw new Error(`Invalid PostgreSQL protocol: ${url.protocol}`);
}
// Validate required components
if (!url.hostname) {
throw new Error('PostgreSQL connection string must include hostname');
}
}
catch (error) {
if (error instanceof TypeError && error.message.includes('Invalid URL')) {
throw new Error(`Invalid PostgreSQL connection string format. Expected format: postgresql://username:password@host:port/database`);
}
throw error;
}
}
/**
* Initialize database connection and create tables
*/
async initialize() {
try {
console.log(`Initializing PostgreSQL with connection string: ${this.config.connectionString.replace(/:[^:@]*@/, ':***@')}`);
// Initialize PostgreSQL connection
this.pgPool = new PgPool({
connectionString: this.config.connectionString,
max: this.config.options?.maxConnections || 10,
idleTimeoutMillis: this.config.options?.idleTimeout || 30000,
connectionTimeoutMillis: this.config.options?.connectionTimeout || 2000,
});
// Test connection
const client = await this.pgPool.connect();
client.release();
await this.createPostgreSQLTables();
await this.initializeFuzzySearch();
console.log(`PostgreSQL database initialized successfully`);
}
catch (error) {
// Clean up pool if initialization failed
if (this.pgPool) {
try {
await this.pgPool.end();
}
catch (cleanupError) {
console.warn('Error cleaning up failed PostgreSQL pool:', cleanupError);
}
this.pgPool = null;
}
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`PostgreSQL initialization failed: ${errorMessage}`);
throw new Error(`Failed to initialize PostgreSQL database: ${errorMessage}`);
}
}
/**
* Close database connections
*/
async close() {
try {
if (this.pgPool) {
await this.pgPool.end();
this.pgPool = null;
}
}
catch (error) {
console.warn(`Error closing database connections: ${error}`);
}
}
/**
* Health check for SQL storage
*/
async healthCheck() {
try {
if (this.pgPool) {
const client = await this.pgPool.connect();
await client.query('SELECT 1');
client.release();
return true;
}
return false;
}
catch {
return false;
}
}
/**
* Load knowledge graph from PostgreSQL database
*/
async loadGraph(project) {
try {
return await this.loadGraphPostgreSQL(project);
}
catch (error) {
throw new Error(`Failed to load graph for project ${project}: ${error}`);
}
}
/**
* Save knowledge graph to PostgreSQL database
*/
async saveGraph(graph, project) {
try {
await this.saveGraphPostgreSQL(graph, project);
}
catch (error) {
throw new Error(`Failed to save graph for project ${project}: ${error}`);
}
}
/**
* Load graph from PostgreSQL
*/
async loadGraphPostgreSQL(project) {
if (!this.pgPool)
throw new Error('PostgreSQL pool not initialized');
const client = await this.pgPool.connect();
try {
// Load entities
const entitiesResult = await client.query('SELECT name, entity_type, observations, tags FROM entities WHERE project = $1 ORDER BY updated_at DESC, name', [project]);
const entities = entitiesResult.rows.map((row) => ({
name: row.name,
entityType: row.entity_type,
observations: Array.isArray(row.observations) ? row.observations : [], // JSONB is already parsed by PostgreSQL
tags: Array.isArray(row.tags) ? row.tags : [] // JSONB is already parsed by PostgreSQL
}));
// Load relations
const relationsResult = await client.query('SELECT from_entity, to_entity, relation_type FROM relations WHERE project = $1 ORDER BY from_entity, to_entity, created_at DESC', [project]);
const relations = relationsResult.rows.map((row) => ({
from: row.from_entity,
to: row.to_entity,
relationType: row.relation_type
}));
return { entities, relations };
}
finally {
client.release();
}
}
/**
* Save graph to PostgreSQL
*/
async saveGraphPostgreSQL(graph, project) {
if (!this.pgPool)
throw new Error('PostgreSQL pool not initialized');
const client = await this.pgPool.connect();
try {
await client.query('BEGIN');
// Clear existing data
await client.query('DELETE FROM entities WHERE project = $1', [project]);
await client.query('DELETE FROM relations WHERE project = $1', [project]);
// Insert entities
for (const entity of graph.entities) {
await client.query('INSERT INTO entities (project, name, entity_type, observations, tags, updated_at) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)', [project, entity.name, entity.entityType, JSON.stringify(entity.observations), JSON.stringify(entity.tags || [])]);
}
// Insert relations
for (const relation of graph.relations) {
await client.query('INSERT INTO relations (project, from_entity, to_entity, relation_type) VALUES ($1, $2, $3, $4)', [project, relation.from, relation.to, relation.relationType]);
}
await client.query('COMMIT');
}
catch (error) {
await client.query('ROLLBACK');
throw error;
}
finally {
client.release();
}
}
/**
* Create PostgreSQL tables
*/
async createPostgreSQLTables() {
if (!this.pgPool)
throw new Error('PostgreSQL pool not initialized');
const client = await this.pgPool.connect();
try {
// Create entities table
await client.query(`
CREATE TABLE IF NOT EXISTS entities (
id SERIAL PRIMARY KEY,
project TEXT NOT NULL,
name TEXT NOT NULL,
entity_type TEXT NOT NULL,
observations JSONB NOT NULL,
tags JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project, name)
)
`);
// Create relations table
await client.query(`
CREATE TABLE IF NOT EXISTS relations (
id SERIAL PRIMARY KEY,
project TEXT NOT NULL,
from_entity TEXT NOT NULL,
to_entity TEXT NOT NULL,
relation_type TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project, from_entity, to_entity, relation_type)
)
`);
// Create indexes
await client.query(`
CREATE INDEX IF NOT EXISTS idx_entities_project ON entities(project);
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(project, name);
CREATE INDEX IF NOT EXISTS idx_relations_project ON relations(project);
CREATE INDEX IF NOT EXISTS idx_relations_from ON relations(project, from_entity);
CREATE INDEX IF NOT EXISTS idx_relations_to ON relations(project, to_entity);
`);
}
finally {
client.release();
}
}
/**
* Optional migration method for data migration
*/
async migrate(_project) {
// Migration logic can be implemented here if needed
// For now, this is a no-op
}
/**
* Check if PostgreSQL trigram extension is available
*/
async hasTrigramExtension() {
if (!this.pgPool)
return false;
const client = await this.pgPool.connect();
try {
const result = await client.query(`
SELECT EXISTS(
SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm'
) as has_extension
`);
return result.rows[0].has_extension;
}
catch (error) {
return false;
}
finally {
client.release();
}
}
/**
* Enable PostgreSQL trigram extension
*/
async enableTrigramExtension() {
if (!this.pgPool)
return;
const client = await this.pgPool.connect();
try {
await client.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
await client.query('CREATE EXTENSION IF NOT EXISTS fuzzystrmatch');
}
finally {
client.release();
}
}
/**
* Create fuzzy search indexes for PostgreSQL
*/
async createFuzzySearchIndexes() {
if (!this.pgPool)
return;
const client = await this.pgPool.connect();
try {
// Create trigram indexes for text fields only
await client.query(`
CREATE INDEX IF NOT EXISTS entities_name_trgm_idx
ON entities USING GIN (name gin_trgm_ops)
`);
await client.query(`
CREATE INDEX IF NOT EXISTS entities_type_trgm_idx
ON entities USING GIN (entity_type gin_trgm_ops)
`);
// For JSONB arrays, we need to convert to text first
// This creates an index on the text representation of the observations array
await client.query(`
CREATE INDEX IF NOT EXISTS entities_obs_trgm_idx
ON entities USING GIN ((observations::text) gin_trgm_ops)
`);
// Create an index for tags array as well
await client.query(`
CREATE INDEX IF NOT EXISTS entities_tags_trgm_idx
ON entities USING GIN ((tags::text) gin_trgm_ops)
`);
}
finally {
client.release();
}
}
/**
* Initialize fuzzy search capabilities
*/
async initializeFuzzySearch() {
try {
// Check if trigram extension is available before enabling
const hasExtension = await this.hasTrigramExtension();
if (!hasExtension) {
await this.enableTrigramExtension();
}
await this.createFuzzySearchIndexes();
console.log('PostgreSQL fuzzy search initialized');
}
catch (error) {
console.warn('Failed to initialize PostgreSQL fuzzy search:', error);
}
}
/**
* Get the PostgreSQL pool for search strategies
*/
getPostgreSQLPool() {
return this.pgPool;
}
}
//# sourceMappingURL=sql-storage.js.map