Skip to main content
Glama

atlas-mcp-server

knowledgeService.ts28.3 kB
import { logger, requestContextService } from "../../utils/index.js"; // Updated import path import { neo4jDriver } from "./driver.js"; import { generateId, escapeRelationshipType } 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, including its domain and citations. */ static async addKnowledge( knowledge: Omit<Neo4jKnowledge, "id" | "createdAt" | "updatedAt"> & { id?: string; domain?: string; citations?: string[]; }, ): Promise<Neo4jKnowledge & { domain: string | null; citations: string[] }> { 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 properties including domain name WITH k, d 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, d.name as domainName `; 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 baseKnowledge: 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"), }; const domainName = createdKnowledgeRecord.get("domainName") as | string | null; let actualCitations: string[] = []; // Process citations using the input 'knowledge' object const inputCitations = knowledge.citations; if ( inputCitations && Array.isArray(inputCitations) && inputCitations.length > 0 ) { await this.addCitations(knowledgeId, inputCitations); actualCitations = inputCitations; // Assume these are the citations for the response } const reqContext = requestContextService.createRequestContext({ operation: "addKnowledge", knowledgeId: baseKnowledge.id, projectId: knowledge.projectId, }); logger.info("Knowledge item created successfully", reqContext); // Return the extended object with domain and citations return { ...baseKnowledge, domain: domainName || knowledge.domain || null, // Fallback to input domain if query somehow misses it citations: actualCitations, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const errorContext = requestContextService.createRequestContext({ operation: "addKnowledge.error", knowledgeInput: knowledge, }); logger.error("Error creating knowledge item", error as Error, { ...errorContext, detail: errorMessage, }); 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(); const reqContext = requestContextService.createRequestContext({ operation: "linkKnowledgeToKnowledge", sourceId, targetId, relationshipType, }); logger.debug( `Attempting to link knowledge ${sourceId} to ${targetId} with type ${relationshipType}`, reqContext, ); try { const sourceExists = await Neo4jUtils.nodeExists( NodeLabels.Knowledge, "id", sourceId, ); const targetExists = await Neo4jUtils.nodeExists( NodeLabels.Knowledge, "id", targetId, ); if (!sourceExists || !targetExists) { logger.warning( `Cannot link knowledge: Source (${sourceId} exists: ${sourceExists}) or Target (${targetId} exists: ${targetExists}) not found.`, { ...reqContext, sourceExists, targetExists }, ); return false; } // Escape relationship type for safety const escapedType = escapeRelationshipType(relationshipType); 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}`, reqContext, ); } else { logger.warning( `Failed to link knowledge ${sourceId} to ${targetId} (MERGE returned no relationship)`, reqContext, ); } return linkCreated; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error("Error linking knowledge items", error as Error, { ...reqContext, detail: errorMessage, }); 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); const reqContext = requestContextService.createRequestContext({ operation: "getKnowledgeById.error", knowledgeId: id, }); logger.error("Error getting knowledge by ID", error as Error, { ...reqContext, detail: errorMessage, }); 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"), }; const reqContext_update = requestContextService.createRequestContext({ operation: "updateKnowledge", knowledgeId: id, }); logger.info("Knowledge item updated successfully", reqContext_update); return finalUpdatedKnowledge; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const errorContext = requestContextService.createRequestContext({ operation: "updateKnowledge.error", knowledgeId: id, updatesApplied: updates, }); logger.error("Error updating knowledge item", error as Error, { ...errorContext, detail: errorMessage, }); 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 }); }); const reqContext_delete = requestContextService.createRequestContext({ operation: "deleteKnowledge", knowledgeId: id, }); logger.info("Knowledge item deleted successfully", reqContext_delete); return true; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const errorContext = requestContextService.createRequestContext({ operation: "deleteKnowledge.error", knowledgeId: id, }); logger.error("Error deleting knowledge item", error as Error, { ...errorContext, detail: errorMessage, }); 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[] = []; const params: Record<string, any> = {}; // Initialize empty params // Conditionally add projectId to params if it's not '*' if (options.projectId && options.projectId !== "*") { 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); // Construct the base MATCH clause conditionally const projectIdMatchFilter = options.projectId && options.projectId !== "*" ? "{projectId: $projectId}" : ""; const baseMatch = `MATCH (k:${NodeLabels.Knowledge} ${projectIdMatchFilter})`; // Query for total count matching filters const countQuery = ` ${baseMatch} // Use conditional base match ${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 = ` ${baseMatch} // Use conditional base match ${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); const reqContext_get = requestContextService.createRequestContext({ operation: "getKnowledge.error", filterOptions: options, }); logger.error("Error getting knowledge items", error as Error, { ...reqContext_get, detail: errorMessage, }); 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); const reqContext_domains = requestContextService.createRequestContext({ operation: "getDomains.error", }); logger.error("Error getting domains", error as Error, { ...reqContext_domains, detail: 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); const reqContext_tags = requestContextService.createRequestContext({ operation: "getTags.error", projectId, }); logger.error("Error getting tags", error as Error, { ...reqContext_tags, detail: errorMessage, }); 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")); }); const reqContext_addCite = requestContextService.createRequestContext({ operation: "addCitations", knowledgeId, citationCount: result.length, }); logger.debug( `Added ${result.length} citations for knowledge ${knowledgeId}`, reqContext_addCite, ); return result; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const errorContext = requestContextService.createRequestContext({ operation: "addCitations.error", knowledgeId, citations, }); logger.error("Error adding citations", error as Error, { ...errorContext, detail: errorMessage, }); throw error; } finally { await session.close(); } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/cyanheads/atlas-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server