Skip to main content
Glama

COA Goldfish MCP

by anortham
TestSearchService.csโ€ข16.4 kB
using Microsoft.Extensions.Logging; using Microsoft.EntityFrameworkCore; using COA.Goldfish.McpServer.Services.Storage; using COA.Goldfish.McpServer.Models; using COA.Goldfish.McpServer.Services; namespace COA.Goldfish.IntegrationTests; /// <summary> /// Test-specific SearchService that uses LIKE queries instead of FTS5 /// to avoid complex FTS5 setup in tests while maintaining same interface /// </summary> public class TestSearchService : ISearchService { private readonly GoldfishDbContext _context; private readonly ILogger<TestSearchService> _logger; private readonly WorkspaceService _workspaceService; public TestSearchService(GoldfishDbContext context, ILogger<TestSearchService> logger, WorkspaceService workspaceService) { _context = context; _logger = logger; _workspaceService = workspaceService; } public async Task<SearchResult> SearchAsync(string query, string? workspaceId = null, int limit = 10, string? since = null) { try { var resolvedWorkspaceId = workspaceId ?? _workspaceService.GetCurrentWorkspace(); var sinceDate = ParseSinceParameter(since); _logger.LogDebug("Starting comprehensive search for query '{Query}' in workspace '{WorkspaceId}'", query, resolvedWorkspaceId); // Search across all entity types using LIKE queries var checkpointResults = await SearchCheckpointsInternalAsync(query, resolvedWorkspaceId, limit / 4 + 1, sinceDate); var planResults = await SearchPlansInternalAsync(query, resolvedWorkspaceId, limit / 4 + 1, sinceDate); var todoResults = await SearchTodosInternalAsync(query, resolvedWorkspaceId, limit / 4 + 1, sinceDate); var chronicleResults = await SearchChronicleInternalAsync(query, resolvedWorkspaceId, limit / 4 + 1, sinceDate); // Combine and rank results var allResults = new List<SearchResultItem>(); allResults.AddRange(checkpointResults); allResults.AddRange(planResults); allResults.AddRange(todoResults); allResults.AddRange(chronicleResults); // Sort by relevance (higher scores first) and then by date (newer first) var rankedResults = allResults .OrderByDescending(r => r.Score) .ThenByDescending(r => r.Timestamp) .Take(limit) .ToList(); return new SearchResult { Query = query, WorkspaceId = resolvedWorkspaceId, Results = rankedResults, TotalCount = allResults.Count, LimitedCount = rankedResults.Count }; } catch (Exception ex) { _logger.LogError(ex, "Error during comprehensive search for query '{Query}'", query); return new SearchResult { Query = query, WorkspaceId = workspaceId ?? "unknown", Results = new List<SearchResultItem>(), TotalCount = 0, LimitedCount = 0, Error = ex.Message }; } } public async Task<SearchResult> SearchCheckpointsAsync(string query, string? workspaceId = null, int limit = 10) { try { var resolvedWorkspaceId = workspaceId ?? _workspaceService.GetCurrentWorkspace(); var results = await SearchCheckpointsInternalAsync(query, resolvedWorkspaceId, limit); return new SearchResult { Query = query, WorkspaceId = resolvedWorkspaceId, Results = results, TotalCount = results.Count, LimitedCount = results.Count, EntityType = "checkpoint" }; } catch (Exception ex) { _logger.LogError(ex, "Error searching checkpoints for query '{Query}'", query); return new SearchResult { Query = query, WorkspaceId = workspaceId ?? "unknown", Results = new List<SearchResultItem>(), Error = ex.Message }; } } public async Task<SearchResult> SearchPlansAsync(string query, string? workspaceId = null, int limit = 10) { try { var resolvedWorkspaceId = workspaceId ?? _workspaceService.GetCurrentWorkspace(); var results = await SearchPlansInternalAsync(query, resolvedWorkspaceId, limit); return new SearchResult { Query = query, WorkspaceId = resolvedWorkspaceId, Results = results, TotalCount = results.Count, LimitedCount = results.Count, EntityType = "plan" }; } catch (Exception ex) { _logger.LogError(ex, "Error searching plans for query '{Query}'", query); return new SearchResult { Query = query, WorkspaceId = workspaceId ?? "unknown", Results = new List<SearchResultItem>(), Error = ex.Message }; } } public async Task<SearchResult> SearchTodosAsync(string query, string? workspaceId = null, int limit = 10) { try { var resolvedWorkspaceId = workspaceId ?? _workspaceService.GetCurrentWorkspace(); var results = await SearchTodosInternalAsync(query, resolvedWorkspaceId, limit); return new SearchResult { Query = query, WorkspaceId = resolvedWorkspaceId, Results = results, TotalCount = results.Count, LimitedCount = results.Count, EntityType = "todo" }; } catch (Exception ex) { _logger.LogError(ex, "Error searching todos for query '{Query}'", query); return new SearchResult { Query = query, WorkspaceId = workspaceId ?? "unknown", Results = new List<SearchResultItem>(), Error = ex.Message }; } } public async Task<SearchResult> SearchChronicleAsync(string query, string? workspaceId = null, int limit = 10) { try { var resolvedWorkspaceId = workspaceId ?? _workspaceService.GetCurrentWorkspace(); var results = await SearchChronicleInternalAsync(query, resolvedWorkspaceId, limit); return new SearchResult { Query = query, WorkspaceId = resolvedWorkspaceId, Results = results, TotalCount = results.Count, LimitedCount = results.Count, EntityType = "chronicle" }; } catch (Exception ex) { _logger.LogError(ex, "Error searching chronicle for query '{Query}'", query); return new SearchResult { Query = query, WorkspaceId = workspaceId ?? "unknown", Results = new List<SearchResultItem>(), Error = ex.Message }; } } private async Task<List<SearchResultItem>> SearchCheckpointsInternalAsync(string query, string workspaceId, int limit, DateTime? since = null) { var checkpointsQuery = _context.Checkpoints .Where(c => c.WorkspaceId == workspaceId) .Where(c => c.Description.ToLower().Contains(query.ToLower()) || (c.WorkContext != null && c.WorkContext.ToLower().Contains(query.ToLower()))); if (since.HasValue) checkpointsQuery = checkpointsQuery.Where(c => c.CreatedAt >= since.Value); var checkpoints = await checkpointsQuery .OrderByDescending(c => c.CreatedAt) .Take(limit) .ToListAsync(); var results = checkpoints.Select(c => new SearchResultItem { Id = c.Id, EntityType = "checkpoint", Title = TruncateText(c.Description, 100), Content = c.WorkContext ?? "", Snippet = CreateSnippet(c.Description + " " + (c.WorkContext ?? ""), query, 200), Timestamp = c.CreatedAt, Score = CalculateRelevanceScore(c.Description + " " + (c.WorkContext ?? ""), query), Metadata = new Dictionary<string, object> { ["sessionId"] = c.SessionId ?? "", ["isGlobal"] = c.IsGlobal, ["activeFiles"] = c.ActiveFiles, ["highlights"] = c.Highlights } }).ToList(); return results; } private async Task<List<SearchResultItem>> SearchPlansInternalAsync(string query, string workspaceId, int limit, DateTime? since = null) { var plansQuery = _context.Plans .Where(p => p.WorkspaceId == workspaceId) .Where(p => p.Title.ToLower().Contains(query.ToLower()) || p.Description.ToLower().Contains(query.ToLower())); if (since.HasValue) plansQuery = plansQuery.Where(p => p.CreatedAt >= since.Value); var plans = await plansQuery .OrderByDescending(p => p.UpdatedAt) .Take(limit) .ToListAsync(); var results = plans.Select(p => new SearchResultItem { Id = p.Id, EntityType = "plan", Title = p.Title, Content = p.Description, Snippet = CreateSnippet(p.Title + " " + p.Description, query, 200), Timestamp = p.UpdatedAt, Score = CalculateRelevanceScore(p.Title + " " + p.Description, query), Metadata = new Dictionary<string, object> { ["status"] = p.Status, ["category"] = p.Category ?? "", ["priority"] = p.Priority ?? "", ["items"] = p.Items, ["discoveries"] = p.Discoveries } }).ToList(); return results; } private async Task<List<SearchResultItem>> SearchTodosInternalAsync(string query, string workspaceId, int limit, DateTime? since = null) { var todoListsQuery = _context.TodoLists .Where(tl => tl.WorkspaceId == workspaceId) .Where(tl => tl.Title.ToLower().Contains(query.ToLower()) || (tl.Description != null && tl.Description.ToLower().Contains(query.ToLower()))); if (since.HasValue) todoListsQuery = todoListsQuery.Where(tl => tl.CreatedAt >= since.Value); var todoLists = await todoListsQuery .Include(tl => tl.Items) .OrderByDescending(tl => tl.UpdatedAt) .Take(limit) .ToListAsync(); var results = todoLists.Select(tl => new SearchResultItem { Id = tl.Id, EntityType = "todo", Title = tl.Title, Content = tl.Description ?? "", Snippet = CreateSnippet(tl.Title + " " + (tl.Description ?? ""), query, 200), Timestamp = tl.UpdatedAt, Score = CalculateRelevanceScore(tl.Title + " " + (tl.Description ?? ""), query), Metadata = new Dictionary<string, object> { ["isActive"] = tl.IsActive, ["itemCount"] = tl.Items.Count, ["pendingCount"] = tl.Items.Count(i => i.Status == TodoItemStatus.Pending), ["completedCount"] = tl.Items.Count(i => i.Status == TodoItemStatus.Done), ["tags"] = tl.Tags } }).ToList(); return results; } private async Task<List<SearchResultItem>> SearchChronicleInternalAsync(string query, string workspaceId, int limit, DateTime? since = null) { var chronicleQuery = _context.ChronicleEntries .Where(ce => ce.WorkspaceId == workspaceId) .Where(ce => ce.Description.ToLower().Contains(query.ToLower())); if (since.HasValue) chronicleQuery = chronicleQuery.Where(ce => ce.Timestamp >= since.Value); var chronicleEntries = await chronicleQuery .OrderByDescending(ce => ce.Timestamp) .Take(limit) .ToListAsync(); var results = chronicleEntries.Select(ce => new SearchResultItem { Id = ce.Id, EntityType = "chronicle", Title = TruncateText(ce.Description, 100), Content = ce.Description, Snippet = CreateSnippet(ce.Description, query, 200), Timestamp = ce.Timestamp, Score = CalculateRelevanceScore(ce.Description, query), Metadata = new Dictionary<string, object> { ["type"] = ce.Type, ["relatedPlanId"] = ce.RelatedPlanId ?? "", ["relatedTodoId"] = ce.RelatedTodoId ?? "", ["relatedCheckpointId"] = ce.RelatedCheckpointId ?? "", ["tags"] = ce.Tags } }).ToList(); return results; } private static DateTime? ParseSinceParameter(string? since) { if (string.IsNullOrWhiteSpace(since)) return null; var now = DateTime.UtcNow; return since.ToLowerInvariant() switch { "1h" or "hour" => now.AddHours(-1), "1d" or "day" or "today" => now.AddDays(-1), "3d" => now.AddDays(-3), "1w" or "week" => now.AddDays(-7), "2w" => now.AddDays(-14), "1m" or "month" => now.AddDays(-30), _ when DateTime.TryParse(since, out var parsed) => parsed, _ => null }; } private static double CalculateRelevanceScore(string text, string query) { if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(query)) return 0.0; var lowerText = text.ToLowerInvariant(); var lowerQuery = query.ToLowerInvariant(); var queryTerms = lowerQuery.Split(' ', StringSplitOptions.RemoveEmptyEntries); double score = 0.0; foreach (var term in queryTerms) { // Exact match gets highest score if (lowerText.Contains(term)) { score += 1.0; // Bonus for word boundary matches if (lowerText.Contains(" " + term + " ") || lowerText.StartsWith(term + " ") || lowerText.EndsWith(" " + term)) { score += 0.5; } } } // Normalize by query term count return queryTerms.Length > 0 ? score / queryTerms.Length : 0.0; } private static string CreateSnippet(string text, string query, int maxLength) { if (string.IsNullOrWhiteSpace(text)) return ""; var lowerText = text.ToLowerInvariant(); var lowerQuery = query.ToLowerInvariant(); // Try to find the query in the text var index = lowerText.IndexOf(lowerQuery); if (index == -1) { // If exact query not found, look for first query term var queryTerms = lowerQuery.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (queryTerms.Length > 0) { index = lowerText.IndexOf(queryTerms[0]); } } if (index == -1) { // No match found, return beginning of text return TruncateText(text, maxLength); } // Create snippet around the match var start = Math.Max(0, index - maxLength / 3); var length = Math.Min(maxLength, text.Length - start); var snippet = text.Substring(start, length); if (start > 0) snippet = "..." + snippet; if (start + length < text.Length) snippet = snippet + "..."; return snippet; } private static string TruncateText(string text, int maxLength) { if (string.IsNullOrWhiteSpace(text) || text.Length <= maxLength) return text ?? ""; return text.Substring(0, maxLength) + "..."; } }

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/anortham/coa-goldfish-mcp'

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