@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.
536 lines (489 loc) • 23.7 kB
text/typescript
import { Session, int } from 'neo4j-driver'; // Import int
import { logger } from '../../utils/logger.js';
import { neo4jDriver } from './driver.js';
import {
NodeLabels,
PaginatedResult,
RelationshipTypes, // Import RelationshipTypes
SearchOptions
} from './types.js';
import { Neo4jUtils } from './utils.js';
/**
* Type for search result items - Made generic
*/
export type SearchResultItem = {
id: string;
type: string; // Node label
entityType?: string; // Optional: Specific classification (e.g., taskType, domain)
title: string; // Best guess title (name, title, truncated text)
description?: string; // Optional: Full description or text
matchedProperty: string;
matchedValue: string; // Potentially truncated
createdAt?: string; // Optional
updatedAt?: string; // Optional
projectId?: string; // Optional
projectName?: string; // Optional
score: number;
};
/**
* Service for unified search functionality across all entity types
*/
export class SearchService {
/**
* Perform a unified search across multiple entity types (node labels).
* Searches common properties like name, title, description, text.
* Applies pagination after combining and sorting results from individual label searches.
* @param options Search options
* @returns Paginated search results
*/
static async search(options: SearchOptions): Promise<PaginatedResult<SearchResultItem>> {
try {
const {
property = '',
value,
entityTypes = ['project', 'task', 'knowledge'],
caseInsensitive = true,
fuzzy = false,
taskType,
page = 1,
limit = 20 // This limit will be applied *after* combining results
} = options;
if (!value || value.trim() === '') {
throw new Error('Search value cannot be empty');
}
const targetLabels = Array.isArray(entityTypes) ? entityTypes : [entityTypes];
if (targetLabels.length === 0) {
logger.warn("Unified search called with empty entityTypes array. Returning empty results.");
return Neo4jUtils.paginateResults([], { page, limit });
}
// Prepare search value once
const normalizedProperty = property ? property.toLowerCase() : '';
const escapedValue = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Escape original value
const caseFlag = caseInsensitive ? '(?i)' : ''; // Determine case flag based on input option
// Prepare regex value for Cypher based on fuzzy flag
const cypherSearchValue = fuzzy
? `${caseFlag}.*${escapedValue}.*` // Fuzzy contains (case flag applied)
: `${caseFlag}^${escapedValue}$`; // Non-fuzzy exact match (case flag applied)
const allResults: SearchResultItem[] = [];
const searchPromises: Promise<SearchResultItem[]>[] = [];
// Define a reasonable upper bound for results fetched per label before final pagination
// This prevents fetching *everything* but allows enough data for good sorting.
const perLabelLimit = Math.max(limit * 2, 50); // Fetch more than the final limit per label
for (const label of targetLabels) {
if (!label || typeof label !== 'string') {
logger.warn(`Skipping invalid label in entityTypes: ${label}`);
continue;
}
// Call helper, passing the prepared cypherSearchValue and perLabelLimit
searchPromises.push(
this.searchSingleLabel(
label,
cypherSearchValue, // Pass prepared value
normalizedProperty,
// Pass taskType filter only if applicable
(label.toLowerCase() === 'project' || label.toLowerCase() === 'task') ? taskType : undefined,
perLabelLimit // Limit results per label search
)
);
}
const settledResults = await Promise.allSettled(searchPromises);
settledResults.forEach((result, index) => {
const label = targetLabels[index];
if (result.status === 'fulfilled' && result.value && Array.isArray(result.value)) {
allResults.push(...result.value);
} else if (result.status === 'rejected') {
logger.error(`Search promise rejected for label "${label}":`, { reason: result.reason });
} else if (result.status === 'fulfilled') {
logger.warn(`Search promise fulfilled with non-array value for label "${label}":`, { value: result.value });
}
});
// Sort combined results by score, then by date
allResults.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const dateA = a.updatedAt || a.createdAt || '1970-01-01T00:00:00.000Z';
const dateB = b.updatedAt || b.createdAt || '1970-01-01T00:00:00.000Z';
return new Date(dateB).getTime() - new Date(dateA).getTime();
});
// Apply final pagination using the utility function on the combined & sorted results
return Neo4jUtils.paginateResults(allResults, { page, limit });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Error performing unified search', { error: errorMessage, options });
throw error;
}
}
/**
* Helper to search within a single node label with sorting and limit.
* Acquires and closes its own session.
* @private
*/
private static async searchSingleLabel(
labelInput: string,
cypherSearchValue: string, // Use pre-calculated regex/lucene value
normalizedProperty: string,
taskTypeFilter?: string,
limit: number = 50 // Default limit per label search
): Promise<SearchResultItem[]> {
let session: Session | null = null;
try {
session = await neo4jDriver.getSession();
let actualLabel: NodeLabels | undefined;
switch (labelInput.toLowerCase()) {
case 'project': actualLabel = NodeLabels.Project; break;
case 'task': actualLabel = NodeLabels.Task; break;
case 'knowledge': actualLabel = NodeLabels.Knowledge; break;
default:
logger.warn(`Unsupported label provided to searchSingleLabel: ${labelInput}`);
return [];
}
const correctlyEscapedLabel = `\`${actualLabel}\``;
const params: Record<string, any> = {
searchValue: cypherSearchValue,
label: actualLabel,
limit: int(limit) // Use neo4j integer for limit
};
let whereConditions: string[] = [];
if (taskTypeFilter) {
whereConditions.push('n.taskType = $taskTypeFilter');
params.taskTypeFilter = taskTypeFilter;
}
// Determine the property/properties to search
let searchProperty: string | null = null;
if (normalizedProperty) {
searchProperty = normalizedProperty; // Use specified property
} else {
// Default property based on label if none specified
switch (actualLabel) {
case NodeLabels.Project: searchProperty = 'name'; break;
case NodeLabels.Task: searchProperty = 'title'; break;
case NodeLabels.Knowledge: searchProperty = 'text'; break;
}
}
if (!searchProperty) {
logger.warn(`Could not determine a default search property for label ${actualLabel}. Returning empty results.`);
return [];
}
// Add the search condition for the determined property
const propExistsCheck = `n.\`${searchProperty}\` IS NOT NULL`;
let searchPart: string;
// Special handling for array properties like 'tags'
if (searchProperty === 'tags') {
// For arrays, use ANY predicate for case-insensitive check.
// Extract the original value for comparison.
params.exactTagValueLower = params.searchValue.replace(/^\(\?i\)\.\*(.*)\.\*$/, '$1').toLowerCase(); // Ensure lowercase
searchPart = `ANY(tag IN n.\`${searchProperty}\` WHERE toLower(tag) = $exactTagValueLower)`;
} else {
// For strings, use regex matching (already case-insensitive via (?i))
searchPart = `n.\`${searchProperty}\` =~ $searchValue`;
}
whereConditions.push(`(${propExistsCheck} AND ${searchPart})`);
const whereClause = `WHERE ${whereConditions.join(' AND ')}`;
// Simplified Scoring based on the single search property - Remove toString()
// Adjust scoring logic slightly for tags using ANY and toLower()
const scoreValueParam = '$searchValue';
const scoreExactTagValueLowerParam = '$exactTagValueLower'; // Use lowercase param name
const scoringLogic = `
CASE
WHEN n.\`${searchProperty}\` IS NOT NULL THEN
CASE
WHEN '${searchProperty}' = 'tags' AND ANY(tag IN n.\`${searchProperty}\` WHERE toLower(tag) = ${scoreExactTagValueLowerParam}) THEN 8 // Case-insensitive tag match
WHEN n.\`${searchProperty}\` =~ ${scoreValueParam} THEN 8 // Regex match for strings
ELSE 5
END
ELSE 5
END AS score
`;
// Simplified RETURN clause focusing on the searched property - Remove toString()
const valueParam = '$searchValue'; // Re-declare for clarity in this scope
const returnClause = `
RETURN
n.id AS id,
$label AS type,
// Fetch domain via relationship for Knowledge nodes
CASE $label WHEN '${NodeLabels.Knowledge}' THEN d.name ELSE COALESCE(n.taskType, '') END AS entityType,
COALESCE(n.name, n.title, CASE WHEN n.text IS NOT NULL AND size(toString(n.text)) > 50 THEN left(toString(n.text), 50) + '...' ELSE toString(n.text) END, n.id) AS title,
COALESCE(n.description, n.text) AS description,
'${searchProperty}' AS matchedProperty, // Directly use the searched property name
CASE
WHEN n.\`${searchProperty}\` IS NOT NULL THEN
CASE
// For tags, find and show the first matched tag (case-insensitive)
WHEN '${searchProperty}' = 'tags' AND ANY(t IN n.\`${searchProperty}\` WHERE toLower(t) = ${scoreExactTagValueLowerParam}) THEN
HEAD([tag IN n.\`${searchProperty}\` WHERE toLower(tag) = ${scoreExactTagValueLowerParam}]) // Get the actual matched tag
// For strings, show truncated original value if matched by regex
WHEN n.\`${searchProperty}\` =~ ${valueParam} THEN
CASE
WHEN size(toString(n.\`${searchProperty}\`)) > 100 THEN left(toString(n.\`${searchProperty}\`), 100) + '...'
ELSE toString(n.\`${searchProperty}\`) // Ensure string conversion here for safety
END
ELSE ''
END
ELSE ''
END AS matchedValue,
n.createdAt AS createdAt,
n.updatedAt AS updatedAt,
// Use COALESCE for projectId as Knowledge nodes might not have it directly
COALESCE(n.projectId, k_proj.id) AS projectId,
// Use COALESCE for projectName
COALESCE(p.name, k_proj.name) AS projectName,
${scoringLogic}
`;
// Add OPTIONAL MATCH for project and domain based on label
let optionalMatches = `OPTIONAL MATCH (p:${NodeLabels.Project} {id: n.projectId})`; // For Task/Project
if (actualLabel === NodeLabels.Knowledge) {
// For Knowledge, match its project and domain
optionalMatches = `
OPTIONAL MATCH (k_proj:${NodeLabels.Project} {id: n.projectId})
OPTIONAL MATCH (n)-[:${RelationshipTypes.BELONGS_TO_DOMAIN}]->(d:${NodeLabels.Domain})
`;
}
// Construct final query with ORDER BY and LIMIT
// Apply WHERE before OPTIONAL MATCH
const query = `
MATCH (n:${correctlyEscapedLabel})
${whereClause} // Apply primary filters first
WITH n // Pass filtered nodes
${optionalMatches} // Now match optional related nodes
${returnClause} // Return results
ORDER BY score DESC, COALESCE(n.updatedAt, n.createdAt) DESC
LIMIT $limit
`;
logger.debug(`Executing search query for label ${actualLabel}`, { query, params });
const result = await session.executeRead(async (tx: any) => (await tx.run(query, params)).records);
return result.map((record: any) => {
const data = record.toObject();
const scoreValue = data.score;
const score = typeof scoreValue === 'number' ? scoreValue :
(scoreValue && typeof scoreValue.toNumber === 'function' ? scoreValue.toNumber() : 5);
const description = typeof data.description === 'string' ? data.description : undefined;
return {
...data,
score,
description,
entityType: data.entityType || undefined,
createdAt: data.createdAt || undefined,
updatedAt: data.updatedAt || undefined,
projectId: data.projectId || undefined,
projectName: data.projectName || undefined,
} as SearchResultItem;
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Error searching label ${labelInput}`, { error: errorMessage, searchValue: cypherSearchValue, normalizedProperty });
return [];
} finally {
if (session) {
await session.close();
}
}
}
// --- Full-text search method ---
// This method still uses a different approach (CALL db.index...) and applies pagination at the end.
// Optimizing this further for Community Edition might require separate count queries per index.
// For now, leaving it as is, but acknowledging it fetches more data than necessary before pagination.
/**
* Perform a full-text search using Neo4j's built-in full-text search capabilities
* Requires properly set up full-text indexes (project_fulltext, task_fulltext, knowledge_fulltext)
* @param searchValue Value to search for (supports Lucene syntax)
* @param options Search options
* @returns Paginated search results
*/
static async fullTextSearch(
searchValue: string,
options: Omit<SearchOptions, 'value' | 'fuzzy' | 'caseInsensitive'> = {}
): Promise<PaginatedResult<SearchResultItem>> {
// Remove single session acquisition from here
try {
const {
entityTypes = ['project', 'task', 'knowledge'],
taskType,
page = 1,
limit = 20
} = options;
if (!searchValue || searchValue.trim() === '') {
throw new Error('Search value cannot be empty');
}
const rawEntityTypes = options.entityTypes;
const defaultEntityTypes = ['project', 'task', 'knowledge'];
const typesToUse = rawEntityTypes && Array.isArray(rawEntityTypes) && rawEntityTypes.length > 0
? rawEntityTypes
: defaultEntityTypes;
const targetLabels = typesToUse.map(l => l.toLowerCase());
const searchResults: SearchResultItem[] = [];
// Remove searchPromises array
// --- Run searches sequentially ---
// Project full-text search
if (targetLabels.includes('project')) {
let projectSession: Session | null = null;
try {
projectSession = await neo4jDriver.getSession();
const query = `
CALL db.index.fulltext.queryNodes("project_fulltext", $searchValue)
YIELD node AS p, score
${taskType ? 'WHERE p.taskType = $taskType' : ''}
RETURN
p.id AS id, 'project' AS type, p.taskType AS entityType,
p.name AS title, p.description AS description,
'full-text' AS matchedProperty,
CASE
WHEN score > 2 THEN p.name
WHEN size(toString(p.description)) > 100 THEN left(toString(p.description), 100) + '...'
ELSE toString(p.description)
END AS matchedValue,
p.createdAt AS createdAt, p.updatedAt AS updatedAt,
p.id as projectId,
p.name as projectName,
score * 2 AS adjustedScore
`;
// Execute directly, not pushing to promise array
// Use projectSession here
await projectSession.executeRead(async (tx) => {
const result = await tx.run(query, { searchValue, ...(taskType && { taskType }) });
const items = result.records.map(record => {
const data = record.toObject();
const scoreValue = data.adjustedScore;
const score = typeof scoreValue === 'number' ? scoreValue : 5;
return {
...data,
score,
description: typeof data.description === 'string' ? data.description : undefined,
entityType: data.entityType || undefined,
createdAt: data.createdAt || undefined,
updatedAt: data.updatedAt || undefined,
projectId: data.projectId || undefined, // Added projectId
projectName: data.projectName || undefined, // Added projectName
} as SearchResultItem;
});
searchResults.push(...items);
});
} catch(err) {
logger.error('Error during project full-text search query', { error: err, searchValue });
// Optionally re-throw or just log and continue
} finally {
if (projectSession) await projectSession.close();
}
}
// Task full-text search
if (targetLabels.includes('task')) {
let taskSession: Session | null = null;
try {
taskSession = await neo4jDriver.getSession();
const query = `
CALL db.index.fulltext.queryNodes("task_fulltext", $searchValue)
YIELD node AS t, score
${taskType ? 'WHERE t.taskType = $taskType' : ''}
MATCH (p:${NodeLabels.Project} {id: t.projectId})
RETURN
t.id AS id, 'task' AS type, t.taskType AS entityType,
t.title AS title, t.description AS description,
'full-text' AS matchedProperty,
CASE
WHEN score > 2 THEN t.title
WHEN size(toString(t.description)) > 100 THEN left(toString(t.description), 100) + '...'
ELSE toString(t.description)
END AS matchedValue,
t.createdAt AS createdAt, t.updatedAt AS updatedAt,
t.projectId AS projectId, p.name AS projectName, // Include project info
score * 1.5 AS adjustedScore
`;
// Execute directly, use taskSession
await taskSession.executeRead(async (tx) => {
const result = await tx.run(query, { searchValue, ...(taskType && { taskType }) });
const items = result.records.map(record => {
const data = record.toObject();
const scoreValue = data.adjustedScore;
const score = typeof scoreValue === 'number' ? scoreValue : 5;
return {
...data,
score,
description: typeof data.description === 'string' ? data.description : undefined,
entityType: data.entityType || undefined,
createdAt: data.createdAt || undefined,
updatedAt: data.updatedAt || undefined,
projectId: data.projectId || undefined,
projectName: data.projectName || undefined,
} as SearchResultItem;
});
searchResults.push(...items);
});
} catch(err) {
logger.error('Error during task full-text search query', { error: err, searchValue });
} finally {
if (taskSession) await taskSession.close();
}
}
// Knowledge full-text search
if (targetLabels.includes('knowledge')) {
let knowledgeSession: Session | null = null;
try {
knowledgeSession = await neo4jDriver.getSession();
const query = `
CALL db.index.fulltext.queryNodes("knowledge_fulltext", $searchValue)
YIELD node AS k, score
// Match project for projectName and domain via relationship
MATCH (p:${NodeLabels.Project} {id: k.projectId})
OPTIONAL MATCH (k)-[:${RelationshipTypes.BELONGS_TO_DOMAIN}]->(d:${NodeLabels.Domain})
RETURN
k.id AS id, 'knowledge' AS type, d.name AS entityType, // Use domain name
CASE
WHEN k.text IS NULL THEN 'Untitled Knowledge'
WHEN size(toString(k.text)) <= 50 THEN toString(k.text)
ELSE substring(toString(k.text), 0, 50) + '...'
END AS title,
k.text AS description,
'text' AS matchedProperty,
CASE
WHEN size(toString(k.text)) > 100 THEN left(toString(k.text), 100) + '...'
ELSE toString(k.text)
END AS matchedValue,
k.createdAt AS createdAt, k.updatedAt AS updatedAt,
k.projectId AS projectId, p.name AS projectName, // Include project info
score AS adjustedScore
`;
// Execute directly, use knowledgeSession
await knowledgeSession.executeRead(async (tx) => {
const result = await tx.run(query, { searchValue });
const items = result.records.map(record => {
const data = record.toObject();
const scoreValue = data.adjustedScore;
const score = typeof scoreValue === 'number' ? scoreValue : 5;
return {
...data,
score,
description: typeof data.description === 'string' ? data.description : undefined,
entityType: data.entityType || undefined, // Domain name
createdAt: data.createdAt || undefined,
updatedAt: data.updatedAt || undefined,
projectId: data.projectId || undefined,
projectName: data.projectName || undefined,
} as SearchResultItem;
});
searchResults.push(...items);
});
} catch(err) {
logger.error('Error during knowledge full-text search query', { error: err, searchValue });
} finally {
if (knowledgeSession) await knowledgeSession.close();
}
}
// Remove Promise.all
// await Promise.all(searchPromises); // No longer needed
searchResults.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const dateA = a.updatedAt || a.createdAt || '1970-01-01T00:00:00.000Z';
const dateB = b.updatedAt || b.createdAt || '1970-01-01T00:00:00.000Z';
return new Date(dateB).getTime() - new Date(dateA).getTime();
});
// Apply final pagination after combining and sorting
return Neo4jUtils.paginateResults(searchResults, { page, limit });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Error performing full-text search', { error: errorMessage, searchValue, options });
if (errorMessage.includes("Unable to find index")) {
logger.warn("Full-text index might not be configured correctly or supported in this Neo4j version.");
throw new Error(`Full-text search failed: Index not found or query error. (${errorMessage})`);
}
throw error;
} // Remove finally block that closes the single session
}
}