@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.
638 lines (556 loc) • 24.5 kB
text/typescript
import { logger } from '../../utils/logger.js';
import { neo4jDriver } from './driver.js';
import { generateId } from './helpers.js';
import {
KnowledgeFilterOptions,
Neo4jKnowledge, // This type no longer has domain/citations
NodeLabels,
PaginatedResult,
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: Omit<Neo4jKnowledge, 'id' | 'createdAt' | 'updatedAt'> & { id?: string; domain?: string; citations?: string[] }): Promise<Neo4jKnowledge> {
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: Neo4jKnowledge = {
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: string, targetId: string, relationshipType: string): Promise<boolean> {
// 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: string): Promise<(Neo4jKnowledge & { domain: string | null; citations: string[] }) | null> {
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: Neo4jKnowledge = {
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: string | null): c is string => 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: string, updates: Partial<Omit<Neo4jKnowledge, 'id' | 'projectId' | 'createdAt' | 'updatedAt'>> & { domain?: string; citations?: string[] }): Promise<Neo4jKnowledge> {
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: Record<string, any> = {
id,
updatedAt: Neo4jUtils.getCurrentTimestamp()
};
let setClauses = ['k.updatedAt = $updatedAt'];
const allowedProperties: (keyof Neo4jKnowledge)[] = ['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 as keyof Neo4jKnowledge)) {
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: Neo4jKnowledge = {
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: string): Promise<boolean> {
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: KnowledgeFilterOptions): Promise<PaginatedResult<Neo4jKnowledge & { domain: string | null; citations: string[] }>> {
const session = await neo4jDriver.getSession();
try {
let conditions: string[] = []; // projectId filter moved to MATCH
const params: Record<string, any> = {
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: Neo4jKnowledge = {
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: string | null): c is string => 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(): Promise<Array<{ name: string; count: number }>> {
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?: string): Promise<Array<{ tag: string; count: number }>> {
const session = await neo4jDriver.getSession();
try {
let whereClause = '';
const params: Record<string, any> = {};
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
*/
private static async addCitations(knowledgeId: string, citations: string[]): Promise<string[]> {
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();
}
}
}