UNPKG

@mseep/atlas-mcp-server

Version:

A Model Context Protocol (MCP) server for ATLAS, a Neo4j-powered task management system for LLM Agents - implementing a three-tier architecture (Projects, Tasks, Knowledge) to manage complex workflows.

574 lines (571 loc) 25.8 kB
import { logger } from '../../utils/logger.js'; import { neo4jDriver } from './driver.js'; import { generateId } from './helpers.js'; import { // This type no longer has domain/citations NodeLabels, RelationshipTypes } from './types.js'; import { Neo4jUtils } from './utils.js'; import { int } from 'neo4j-driver'; // Import 'int' for pagination /** * Service for managing Knowledge entities in Neo4j */ export class KnowledgeService { /** * Add a new knowledge item * @param knowledge Input data, potentially including domain and citations for relationship creation * @returns The created knowledge item (without domain/citations properties) */ static async addKnowledge(knowledge) { const session = await neo4jDriver.getSession(); try { const projectExists = await Neo4jUtils.nodeExists(NodeLabels.Project, 'id', knowledge.projectId); if (!projectExists) { throw new Error(`Project with ID ${knowledge.projectId} not found`); } const knowledgeId = knowledge.id || `know_${generateId()}`; const now = Neo4jUtils.getCurrentTimestamp(); // Input validation for domain if (!knowledge.domain || typeof knowledge.domain !== 'string' || knowledge.domain.trim() === '') { throw new Error('Domain is required to create a knowledge item.'); } // Create knowledge node and relationship to project // Removed domain and citations properties from CREATE const query = ` MATCH (p:${NodeLabels.Project} {id: $projectId}) CREATE (k:${NodeLabels.Knowledge} { id: $id, projectId: $projectId, text: $text, tags: $tags, createdAt: $createdAt, updatedAt: $updatedAt }) CREATE (p)-[r:${RelationshipTypes.CONTAINS_KNOWLEDGE}]->(k) // Create domain relationship MERGE (d:${NodeLabels.Domain} {name: $domain}) ON CREATE SET d.createdAt = $createdAt CREATE (k)-[:${RelationshipTypes.BELONGS_TO_DOMAIN}]->(d) // Return only the properties defined in Neo4jKnowledge RETURN k.id as id, k.projectId as projectId, k.text as text, k.tags as tags, k.createdAt as createdAt, k.updatedAt as updatedAt `; const params = { id: knowledgeId, projectId: knowledge.projectId, text: knowledge.text, tags: knowledge.tags || [], domain: knowledge.domain, // Domain needed for MERGE Domain node createdAt: now, updatedAt: now }; const result = await session.executeWrite(async (tx) => { const result = await tx.run(query, params); return result.records; }); const createdKnowledgeRecord = result[0]; if (!createdKnowledgeRecord) { throw new Error('Failed to create knowledge item or retrieve its properties'); } // Construct the Neo4jKnowledge object from the returned record const createdKnowledge = { id: createdKnowledgeRecord.get('id'), projectId: createdKnowledgeRecord.get('projectId'), text: createdKnowledgeRecord.get('text'), tags: createdKnowledgeRecord.get('tags') || [], createdAt: createdKnowledgeRecord.get('createdAt'), updatedAt: createdKnowledgeRecord.get('updatedAt') }; // Process citations using the input 'knowledge' object const inputCitations = knowledge.citations; if (inputCitations && Array.isArray(inputCitations) && inputCitations.length > 0) { await this.addCitations(knowledgeId, inputCitations); } logger.info('Knowledge item created successfully', { knowledgeId: createdKnowledge.id, projectId: knowledge.projectId }); // Return the object matching the Neo4jKnowledge interface return createdKnowledge; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Error creating knowledge item', { error: errorMessage, knowledgeInput: knowledge }); // Log input separately throw error; } finally { await session.close(); } } /** * Link two knowledge items with a specified relationship type. * @param sourceId ID of the source knowledge item * @param targetId ID of the target knowledge item * @param relationshipType The type of relationship to create (e.g., 'RELATED_TO', 'IS_SUBTOPIC_OF') - Validation needed * @returns True if the link was created successfully, false otherwise */ static async linkKnowledgeToKnowledge(sourceId, targetId, relationshipType) { // TODO: Validate relationshipType against allowed types or RelationshipTypes enum const session = await neo4jDriver.getSession(); logger.debug(`Attempting to link knowledge ${sourceId} to ${targetId} with type ${relationshipType}`); try { const sourceExists = await Neo4jUtils.nodeExists(NodeLabels.Knowledge, 'id', sourceId); const targetExists = await Neo4jUtils.nodeExists(NodeLabels.Knowledge, 'id', targetId); if (!sourceExists || !targetExists) { logger.warn(`Cannot link knowledge: Source (${sourceId} exists: ${sourceExists}) or Target (${targetId} exists: ${targetExists}) not found.`); return false; } // Escape relationship type for safety const escapedType = `\`${relationshipType.replace(/`/g, '``')}\``; const query = ` MATCH (source:${NodeLabels.Knowledge} {id: $sourceId}) MATCH (target:${NodeLabels.Knowledge} {id: $targetId}) MERGE (source)-[r:${escapedType}]->(target) RETURN r `; const result = await session.executeWrite(async (tx) => { const runResult = await tx.run(query, { sourceId, targetId }); return runResult.records; }); const linkCreated = result.length > 0; if (linkCreated) { logger.info(`Successfully linked knowledge ${sourceId} to ${targetId} with type ${relationshipType}`); } else { logger.warn(`Failed to link knowledge ${sourceId} to ${targetId} (MERGE returned no relationship)`); } return linkCreated; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Error linking knowledge items', { error: errorMessage, sourceId, targetId, relationshipType }); throw error; } finally { await session.close(); } } /** * Get a knowledge item by ID, including its domain and citations via relationships. * @param id Knowledge ID * @returns The knowledge item with domain and citations added, or null if not found. */ static async getKnowledgeById(id) { const session = await neo4jDriver.getSession(); try { // Fetch domain and citations via relationships const query = ` MATCH (k:${NodeLabels.Knowledge} {id: $id}) OPTIONAL MATCH (k)-[:${RelationshipTypes.BELONGS_TO_DOMAIN}]->(d:${NodeLabels.Domain}) OPTIONAL MATCH (k)-[:${RelationshipTypes.CITES}]->(c:${NodeLabels.Citation}) RETURN k.id as id, k.projectId as projectId, k.text as text, k.tags as tags, d.name as domainName, // Fetch domain name collect(DISTINCT c.source) as citationSources, // Collect distinct citation sources k.createdAt as createdAt, k.updatedAt as updatedAt `; const result = await session.executeRead(async (tx) => { const result = await tx.run(query, { id }); return result.records; }); if (result.length === 0) { return null; } const record = result[0]; // Construct the base Neo4jKnowledge object const knowledge = { id: record.get('id'), projectId: record.get('projectId'), text: record.get('text'), tags: record.get('tags') || [], createdAt: record.get('createdAt'), updatedAt: record.get('updatedAt') }; // Add domain and citations fetched via relationships const domain = record.get('domainName'); const citations = record.get('citationSources').filter((c) => c !== null); // Filter nulls if no citations found return { ...knowledge, domain: domain, // Can be null if no domain relationship exists citations: citations }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Error getting knowledge by ID', { error: errorMessage, id }); throw error; } finally { await session.close(); } } /** * Update a knowledge item, including domain and citation relationships. * @param id Knowledge ID * @param updates Updates including optional domain and citations * @returns The updated knowledge item (without domain/citations properties) */ static async updateKnowledge(id, updates) { const session = await neo4jDriver.getSession(); try { const exists = await Neo4jUtils.nodeExists(NodeLabels.Knowledge, 'id', id); if (!exists) { throw new Error(`Knowledge with ID ${id} not found`); } const updateParams = { id, updatedAt: Neo4jUtils.getCurrentTimestamp() }; let setClauses = ['k.updatedAt = $updatedAt']; const allowedProperties = ['projectId', 'text', 'tags']; // Define properties that can be updated // Add update clauses for allowed properties defined in Neo4jKnowledge for (const [key, value] of Object.entries(updates)) { // Check if the key is one of the allowed properties and value is defined if (value !== undefined && allowedProperties.includes(key)) { updateParams[key] = value; setClauses.push(`k.${key} = $${key}`); } } // Handle domain update using relationships let domainUpdateClause = ''; const domainUpdateValue = updates.domain; if (domainUpdateValue) { if (typeof domainUpdateValue !== 'string' || domainUpdateValue.trim() === '') { throw new Error('Domain update value cannot be empty.'); } updateParams.domain = domainUpdateValue; domainUpdateClause = ` // Update domain relationship WITH k // Ensure k is in scope OPTIONAL MATCH (k)-[oldDomainRel:${RelationshipTypes.BELONGS_TO_DOMAIN}]->(:${NodeLabels.Domain}) DELETE oldDomainRel MERGE (newDomain:${NodeLabels.Domain} {name: $domain}) ON CREATE SET newDomain.createdAt = $updatedAt // Set timestamp if domain is new CREATE (k)-[:${RelationshipTypes.BELONGS_TO_DOMAIN}]->(newDomain) `; } // Construct the main update query const query = ` MATCH (k:${NodeLabels.Knowledge} {id: $id}) ${setClauses.length > 0 ? `SET ${setClauses.join(', ')}` : ''} ${domainUpdateClause} // Return basic properties defined in Neo4jKnowledge RETURN k.id as id, k.projectId as projectId, k.text as text, k.tags as tags, k.createdAt as createdAt, k.updatedAt as updatedAt `; const result = await session.executeWrite(async (tx) => { const result = await tx.run(query, updateParams); return result.records; }); const updatedKnowledgeRecord = result[0]; if (!updatedKnowledgeRecord) { throw new Error('Failed to update knowledge item or retrieve result'); } // Update citations if provided in the input 'updates' object const inputCitations = updates.citations; if (inputCitations && Array.isArray(inputCitations)) { // Remove existing CITES relationships first await session.executeWrite(async (tx) => { await tx.run(` MATCH (k:${NodeLabels.Knowledge} {id: $id})-[r:${RelationshipTypes.CITES}]->(:${NodeLabels.Citation}) DELETE r `, { id }); }); // Add new CITES relationships if (inputCitations.length > 0) { await this.addCitations(id, inputCitations); } } // Construct the final return object matching Neo4jKnowledge const finalUpdatedKnowledge = { id: updatedKnowledgeRecord.get('id'), projectId: updatedKnowledgeRecord.get('projectId'), text: updatedKnowledgeRecord.get('text'), tags: updatedKnowledgeRecord.get('tags') || [], createdAt: updatedKnowledgeRecord.get('createdAt'), updatedAt: updatedKnowledgeRecord.get('updatedAt') }; logger.info('Knowledge item updated successfully', { knowledgeId: id }); return finalUpdatedKnowledge; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Error updating knowledge item', { error: errorMessage, id, updates }); throw error; } finally { await session.close(); } } /** * Delete a knowledge item * @param id Knowledge ID * @returns True if deleted, false if not found */ static async deleteKnowledge(id) { const session = await neo4jDriver.getSession(); try { const exists = await Neo4jUtils.nodeExists(NodeLabels.Knowledge, 'id', id); if (!exists) { return false; } // Use DETACH DELETE to remove the node and all its relationships const query = ` MATCH (k:${NodeLabels.Knowledge} {id: $id}) DETACH DELETE k `; await session.executeWrite(async (tx) => { await tx.run(query, { id }); }); logger.info('Knowledge item deleted successfully', { knowledgeId: id }); return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Error deleting knowledge item', { error: errorMessage, id }); throw error; } finally { await session.close(); } } /** * Get knowledge items for a project with optional filtering and server-side pagination. * Returns domain and citations via relationships. * @param options Filter and pagination options * @returns Paginated list of knowledge items including domain and citations */ static async getKnowledge(options) { const session = await neo4jDriver.getSession(); try { let conditions = []; // projectId filter moved to MATCH const params = { projectId: options.projectId }; let domainMatchClause = ''; if (options.domain) { params.domain = options.domain; // Match the relationship for filtering domainMatchClause = `MATCH (k)-[:${RelationshipTypes.BELONGS_TO_DOMAIN}]->(d:${NodeLabels.Domain} {name: $domain})`; } else { // Optionally match domain to return it domainMatchClause = `OPTIONAL MATCH (k)-[:${RelationshipTypes.BELONGS_TO_DOMAIN}]->(d:${NodeLabels.Domain})`; } // Handle tags filtering if (options.tags && options.tags.length > 0) { const tagQuery = Neo4jUtils.generateArrayInListQuery('k', 'tags', 'tagsList', options.tags); if (tagQuery.cypher) { conditions.push(tagQuery.cypher); Object.assign(params, tagQuery.params); } } // Handle text search (using regex - consider full-text index later) if (options.search) { // Use case-insensitive regex params.search = `(?i).*${options.search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*`; conditions.push('k.text =~ $search'); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; // Calculate pagination parameters const page = Math.max(options.page || 1, 1); const limit = Math.min(Math.max(options.limit || 20, 1), 100); const skip = (page - 1) * limit; // Add pagination params using neo4j.int params.skip = int(skip); params.limit = int(limit); // Query for total count matching filters const countQuery = ` MATCH (k:${NodeLabels.Knowledge} {projectId: $projectId}) // Filter projectId here ${whereClause} // Apply filters to the knowledge node 'k' first WITH k // Pass the filtered knowledge nodes ${domainMatchClause} // Now match domain relationship if needed for filtering RETURN count(DISTINCT k) as total // Count distinct knowledge nodes `; // Query for paginated data const dataQuery = ` MATCH (k:${NodeLabels.Knowledge} {projectId: $projectId}) // Filter projectId here ${whereClause} // Apply filters to the knowledge node 'k' first WITH k // Pass the filtered knowledge nodes ${domainMatchClause} // Match domain relationship OPTIONAL MATCH (k)-[:${RelationshipTypes.CITES}]->(c:${NodeLabels.Citation}) // Match citations WITH k, d, collect(DISTINCT c.source) as citationSources // Collect citations RETURN k.id as id, k.projectId as projectId, k.text as text, k.tags as tags, d.name as domainName, // Return domain name from relationship citationSources, // Return collected citations k.createdAt as createdAt, k.updatedAt as updatedAt ORDER BY k.createdAt DESC SKIP $skip LIMIT $limit `; // Execute count query const totalResult = await session.executeRead(async (tx) => { // Need to remove skip/limit from params for count query const countParams = { ...params }; delete countParams.skip; delete countParams.limit; const result = await tx.run(countQuery, countParams); // The driver seems to return a standard number for count(), use ?? 0 for safety return result.records[0]?.get('total') ?? 0; }); // totalResult is now the standard number returned by executeRead const total = totalResult; // Execute data query const dataResult = await session.executeRead(async (tx) => { const result = await tx.run(dataQuery, params); // Use params with skip/limit return result.records; }); // Map results including domain and citations const knowledgeItems = dataResult.map(record => { const baseKnowledge = { id: record.get('id'), projectId: record.get('projectId'), text: record.get('text'), tags: record.get('tags') || [], createdAt: record.get('createdAt'), updatedAt: record.get('updatedAt') }; const domain = record.get('domainName'); const citations = record.get('citationSources').filter((c) => c !== null); return { ...baseKnowledge, domain: domain, citations: citations }; }); // Return paginated result structure const totalPages = Math.ceil(total / limit); return { data: knowledgeItems, total, page, limit, totalPages }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Error getting knowledge items', { error: errorMessage, options }); throw error; } finally { await session.close(); } } /** * Get all available domains with item counts * @returns Array of domains with counts */ static async getDomains() { const session = await neo4jDriver.getSession(); try { // This query correctly uses the relationship already const query = ` MATCH (d:${NodeLabels.Domain})<-[:${RelationshipTypes.BELONGS_TO_DOMAIN}]-(k:${NodeLabels.Knowledge}) RETURN d.name AS name, count(k) AS count ORDER BY count DESC, name `; const result = await session.executeRead(async (tx) => { const result = await tx.run(query); return result.records; }); return result.map(record => ({ name: record.get('name'), count: record.get('count').toNumber() // Convert Neo4j int })); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Error getting domains', { error: errorMessage }); throw error; } finally { await session.close(); } } /** * Get all unique tags used across knowledge items with counts * @param projectId Optional project ID to filter tags * @returns Array of tags with counts */ static async getTags(projectId) { const session = await neo4jDriver.getSession(); try { let whereClause = ''; const params = {}; if (projectId) { whereClause = 'WHERE k.projectId = $projectId'; params.projectId = projectId; } // This query is fine as it only reads the tags property const query = ` MATCH (k:${NodeLabels.Knowledge}) ${whereClause} UNWIND k.tags AS tag RETURN tag, count(*) AS count ORDER BY count DESC, tag `; const result = await session.executeRead(async (tx) => { const result = await tx.run(query, params); return result.records; }); return result.map(record => ({ tag: record.get('tag'), count: record.get('count').toNumber() // Convert Neo4j int })); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Error getting tags', { error: errorMessage, projectId }); throw error; } finally { await session.close(); } } /** * Add CITES relationships from a knowledge item to new Citation nodes. * @param knowledgeId Knowledge ID * @param citations Array of citation source strings * @returns The IDs of the created Citation nodes * @private */ static async addCitations(knowledgeId, citations) { if (!citations || citations.length === 0) { return []; } const session = await neo4jDriver.getSession(); try { const citationData = citations.map(source => ({ id: `cite_${generateId()}`, source: source, createdAt: Neo4jUtils.getCurrentTimestamp() })); const query = ` MATCH (k:${NodeLabels.Knowledge} {id: $knowledgeId}) UNWIND $citationData as citationProps CREATE (c:${NodeLabels.Citation}) SET c = citationProps CREATE (k)-[:${RelationshipTypes.CITES}]->(c) RETURN c.id as citationId `; const result = await session.executeWrite(async (tx) => { const res = await tx.run(query, { knowledgeId, citationData }); return res.records.map(r => r.get('citationId')); }); logger.debug(`Added ${result.length} citations for knowledge ${knowledgeId}`); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Error adding citations', { error: errorMessage, knowledgeId, citations }); throw error; } finally { await session.close(); } } }