Skip to main content
Glama

COA Goldfish MCP

by anortham
SearchService.csโ€ข23.4 kB
using Microsoft.Extensions.Logging; using Microsoft.EntityFrameworkCore; using COA.Goldfish.McpServer.Services.Storage; using COA.Goldfish.McpServer.Models; namespace COA.Goldfish.McpServer.Services; // FTS result classes for raw SQL queries public class CheckpointFtsResult { public string Id { get; set; } = string.Empty; public string WorkspaceId { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public string WorkContext { get; set; } = string.Empty; public string Highlights { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } public string SessionId { get; set; } = string.Empty; public bool IsGlobal { get; set; } public string ActiveFiles { get; set; } = string.Empty; public string GitBranch { get; set; } = string.Empty; } public class PlanFtsResult { public string Id { get; set; } = string.Empty; public string WorkspaceId { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public string Items { get; set; } = string.Empty; public string Discoveries { get; set; } = string.Empty; public DateTime UpdatedAt { get; set; } public string Status { get; set; } = string.Empty; public string Category { get; set; } = string.Empty; public string Priority { get; set; } = string.Empty; } public class TodoListFtsResult { public string Id { get; set; } = string.Empty; public string WorkspaceId { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public DateTime UpdatedAt { get; set; } public bool IsActive { get; set; } public string Tags { get; set; } = string.Empty; } public class ChronicleEntryFtsResult { public string Id { get; set; } = string.Empty; public string WorkspaceId { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public DateTime Timestamp { get; set; } public string Type { get; set; } = string.Empty; public string RelatedPlanId { get; set; } = string.Empty; public string RelatedTodoId { get; set; } = string.Empty; public string RelatedCheckpointId { get; set; } = string.Empty; public string Tags { get; set; } = string.Empty; } public interface ISearchService { Task<SearchResult> SearchAsync(string query, string? workspaceId = null, int limit = 10, string? since = null); Task<SearchResult> SearchCheckpointsAsync(string query, string? workspaceId = null, int limit = 10); Task<SearchResult> SearchPlansAsync(string query, string? workspaceId = null, int limit = 10); Task<SearchResult> SearchTodosAsync(string query, string? workspaceId = null, int limit = 10); Task<SearchResult> SearchChronicleAsync(string query, string? workspaceId = null, int limit = 10); } public class SearchService : ISearchService { private readonly GoldfishDbContext _context; private readonly ILogger<SearchService> _logger; private readonly WorkspaceService _workspaceService; public SearchService(GoldfishDbContext context, ILogger<SearchService> 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 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) { // Use FTS5 for full-text search directly (simple approach) object[] parameters = since.HasValue ? new object[] { query, workspaceId, since.Value } : new object[] { query, workspaceId }; var ftsResults = await _context.Database .SqlQueryRaw<CheckpointFtsResult>(@" SELECT fts.Id, fts.WorkspaceId, fts.Description, fts.WorkContext, fts.Highlights, c.CreatedAt, c.SessionId, c.IsGlobal, c.ActiveFiles, c.GitBranch FROM CheckpointsFts fts JOIN Checkpoints c ON fts.Id = c.Id WHERE fts MATCH {0} AND fts.WorkspaceId = {1} " + (since.HasValue ? " AND c.CreatedAt >= {2}" : "") + @" ORDER BY bm25(fts), c.CreatedAt DESC LIMIT {" + (since.HasValue ? "3" : "2") + "}", parameters) .ToListAsync(); var checkpoints = ftsResults.Select(r => new Checkpoint { Id = r.Id, WorkspaceId = r.WorkspaceId, Description = r.Description, WorkContext = r.WorkContext, Highlights = System.Text.Json.JsonSerializer.Deserialize<List<string>>(r.Highlights) ?? new List<string>(), CreatedAt = r.CreatedAt, SessionId = r.SessionId, IsGlobal = r.IsGlobal, ActiveFiles = System.Text.Json.JsonSerializer.Deserialize<List<string>>(r.ActiveFiles) ?? new List<string>(), GitBranch = r.GitBranch }).ToList(); 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) { // Use FTS5 for full-text search object[] parameters = since.HasValue ? new object[] { query, workspaceId, since.Value } : new object[] { query, workspaceId }; var ftsResults = await _context.Database .SqlQueryRaw<PlanFtsResult>(@" SELECT p.Id, p.WorkspaceId, p.Title, p.Description, p.Items, p.Discoveries, p.UpdatedAt, p.Status, p.Category, p.Priority FROM PlansFts fts JOIN Plans p ON fts.Id = p.Id WHERE fts MATCH {0} AND p.WorkspaceId = {1} " + (since.HasValue ? " AND p.CreatedAt >= {2}" : "") + @" ORDER BY bm25(fts), p.UpdatedAt DESC LIMIT {" + (since.HasValue ? "3" : "2") + "}", parameters) .ToListAsync(); var plans = ftsResults.Select(r => new Plan { Id = r.Id, WorkspaceId = r.WorkspaceId, Title = r.Title, Description = r.Description, Items = System.Text.Json.JsonSerializer.Deserialize<List<string>>(r.Items) ?? new List<string>(), Discoveries = System.Text.Json.JsonSerializer.Deserialize<List<string>>(r.Discoveries) ?? new List<string>(), UpdatedAt = r.UpdatedAt, Status = Enum.TryParse<PlanStatus>(r.Status, out var planStatus) ? planStatus : PlanStatus.Draft, Category = r.Category, Priority = r.Priority }).ToList(); 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) { // Use FTS5 for full-text search object[] parameters = since.HasValue ? new object[] { query, workspaceId, since.Value } : new object[] { query, workspaceId }; var ftsResults = await _context.Database .SqlQueryRaw<TodoListFtsResult>(@" SELECT tl.Id, tl.WorkspaceId, tl.Title, tl.Description, tl.UpdatedAt, tl.IsActive, tl.Tags FROM TodoListsFts fts JOIN TodoLists tl ON fts.Id = tl.Id WHERE fts MATCH {0} AND tl.WorkspaceId = {1} " + (since.HasValue ? " AND tl.CreatedAt >= {2}" : "") + @" ORDER BY bm25(fts), tl.UpdatedAt DESC LIMIT {" + (since.HasValue ? "3" : "2") + "}", parameters) .ToListAsync(); var todoIds = ftsResults.Select(r => r.Id).ToList(); var todoLists = await _context.TodoLists .Include(tl => tl.Items) .Where(tl => todoIds.Contains(tl.Id)) .OrderByDescending(tl => tl.UpdatedAt) .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) { // Use FTS5 for full-text search object[] parameters = since.HasValue ? new object[] { query, workspaceId, since.Value } : new object[] { query, workspaceId }; var ftsResults = await _context.Database .SqlQueryRaw<ChronicleEntryFtsResult>(@" SELECT ce.Id, ce.WorkspaceId, ce.Description, ce.Timestamp, ce.Type, ce.RelatedPlanId, ce.RelatedTodoId, ce.RelatedCheckpointId, ce.Tags FROM ChronicleEntriesFts fts JOIN ChronicleEntries ce ON fts.Id = ce.Id WHERE fts MATCH {0} AND ce.WorkspaceId = {1} " + (since.HasValue ? " AND ce.Timestamp >= {2}" : "") + @" ORDER BY bm25(fts), ce.Timestamp DESC LIMIT {" + (since.HasValue ? "3" : "2") + "}", parameters) .ToListAsync(); var chronicleEntries = ftsResults.Select(r => new ChronicleEntry { Id = r.Id, WorkspaceId = r.WorkspaceId, Description = r.Description, Timestamp = r.Timestamp, Type = Enum.TryParse<ChronicleEntryType>(r.Type, out var entryType) ? entryType : ChronicleEntryType.Note, RelatedPlanId = r.RelatedPlanId, RelatedTodoId = r.RelatedTodoId, RelatedCheckpointId = r.RelatedCheckpointId, Tags = System.Text.Json.JsonSerializer.Deserialize<List<string>>(r.Tags) ?? new List<string>() }).ToList(); 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) + "..."; } } public class SearchResult { public string Query { get; set; } = string.Empty; public string WorkspaceId { get; set; } = string.Empty; public string? EntityType { get; set; } public List<SearchResultItem> Results { get; set; } = new List<SearchResultItem>(); public int TotalCount { get; set; } public int LimitedCount { get; set; } public string? Error { get; set; } } public class SearchResultItem { public string Id { get; set; } = string.Empty; public string EntityType { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public string Content { get; set; } = string.Empty; public string Snippet { get; set; } = string.Empty; public DateTime Timestamp { get; set; } public double Score { get; set; } public Dictionary<string, object> Metadata { get; set; } = new Dictionary<string, object>(); }

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