package cmd import ( "context" "encoding/json" "fmt" "math" "strings" "github.com/david/knox/internal/db" "github.com/david/knox/internal/index" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) type detailLevel int const ( detailBrief detailLevel = iota detailNormal detailFull ) func parseDetail(s string) detailLevel { switch strings.ToLower(s) { case "brief": return detailBrief case "full": return detailFull default: return detailNormal } } func NewMCPServer(kdb *db.KnoxDB) *server.MCPServer { s := server.NewMCPServer( "knox-knowledge", "0.2.0", server.WithResourceCapabilities(true, true), server.WithPromptCapabilities(true), server.WithLogging(), ) // ─── knox_search ────────────────────────────────────────── searchTool := mcp.NewTool("knox_search", mcp.WithDescription("Search the knowledge index. Supports zoom levels: brief (lean), normal (default), full (verbose). Scope filters: all, skills, sessions, logs."), mcp.WithString("query", mcp.Required(), mcp.Description("Search query")), mcp.WithNumber("limit", mcp.Description("Max results (default 10)")), mcp.WithString("detail", mcp.Description("Zoom level: brief, normal, or full (default normal)")), mcp.WithString("scope", mcp.Description("Source scope: all, skills, sessions, logs (default all)")), ) s.AddTool(searchTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { query, err := requiredStringArg(req, "query") if err != nil { return errorResult(err.Error()), nil } limit, err := optionalIntArg(req, "limit", 10, 1, 50) if err != nil { return errorResult(err.Error()), nil } detail := parseDetail(getString(req, "detail", "normal")) scope := getString(req, "scope", "all") results, err := kdb.Search(query, limit) if err != nil { return errorResult(err.Error()), nil } results = filterByScope(results, scope) if len(results) == 0 { return mcp.NewToolResultText(fmt.Sprintf("No results for %q in scope %q", query, scope)), nil } return mcp.NewToolResultText(formatEntries(results, detail, kdb)), nil }) // ─── knox_recent ─────────────────────────────────────────── recentTool := mcp.NewTool("knox_recent", mcp.WithDescription("Show recent knowledge entries with zoom levels: brief, normal (default), full."), mcp.WithNumber("limit", mcp.Description("Max results (default 10)")), mcp.WithString("detail", mcp.Description("Zoom level: brief, normal, or full (default normal)")), mcp.WithString("scope", mcp.Description("Source scope: all, skills, sessions, logs (default all)")), ) s.AddTool(recentTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { limit, err := optionalIntArg(req, "limit", 10, 1, 50) if err != nil { return errorResult(err.Error()), nil } detail := parseDetail(getString(req, "detail", "normal")) scope := getString(req, "scope", "all") entries, err := kdb.RecentEntries(limit) if err != nil { return errorResult(err.Error()), nil } entries = filterByScope(entries, scope) if len(entries) == 0 { return mcp.NewToolResultText("No entries yet."), nil } return mcp.NewToolResultText(formatEntries(entries, detail, kdb)), nil }) // ─── knox_get ────────────────────────────────────────────── getTool := mcp.NewTool("knox_get", mcp.WithDescription("Get full detail for a specific entry by fingerprint (drill-down from search results)."), mcp.WithString("fingerprint", mcp.Required(), mcp.Description("Entry fingerprint (full or first 8 chars)")), ) s.AddTool(getTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { fp, err := requiredStringArg(req, "fingerprint") if err != nil { return errorResult(err.Error()), nil } entry, err := kdb.FindEntry(fp) if err != nil { return errorResult(err.Error()), nil } if entry == nil { // Try fingerprint prefix match entries, err := kdb.FindEntryByPrefix(fp, 5) if err != nil { return errorResult(err.Error()), nil } if len(entries) == 1 { entry = &entries[0] } else if len(entries) > 1 { return mcp.NewToolResultText(fmt.Sprintf("Multiple matches for %q — use full fingerprint:\n%s", fp, formatEntriesBrief(entries))), nil } else { return mcp.NewToolResultText(fmt.Sprintf("No entry found for fingerprint %q", fp)), nil } } // Full detail: entry + observations + provenance chain var b strings.Builder b.WriteString(formatEntryFull(entry)) obs, _ := kdb.ObservationsByFingerprint(entry.Fingerprint, 20) if len(obs) > 0 { b.WriteString(fmt.Sprintf("\nObservations (%d):\n", len(obs))) for _, o := range obs { b.WriteString(fmt.Sprintf(" [%s] trigger=%s confidence=%.1f line=%d ver=%s\n", o.CollectedAt, o.Trigger, o.Confidence, o.LineEnd, o.IngesterVersion)) } } chain, _ := kdb.ProvenanceChain(entry.Fingerprint, 3) if len(chain) > 0 { b.WriteString(fmt.Sprintf("\nProvenance links (%d):\n", len(chain))) for _, l := range chain { b.WriteString(fmt.Sprintf(" %s --[%s]--> %s (hop %d)\n", shortFP(l.Fingerprint), l.Relation, shortFP(entry.Fingerprint), l.HopDistance)) } } return mcp.NewToolResultText(b.String()), nil }) // ─── knox_stats ──────────────────────────────────────────── statsTool := mcp.NewTool("knox_stats", mcp.WithDescription("Get knowledge index statistics"), ) s.AddTool(statsTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { stats, err := kdb.Stats() if err != nil { return errorResult(err.Error()), nil } b, _ := json.MarshalIndent(stats, "", " ") return mcp.NewToolResultText(string(b)), nil }) // ─── knox_count ──────────────────────────────────────────── countTool := mcp.NewTool("knox_count", mcp.WithDescription("Return hit count matching a query, grouped by source. Lightweight — no result bodies, minimal tokens."), mcp.WithString("query", mcp.Required(), mcp.Description("Search query")), ) s.AddTool(countTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { query, err := requiredStringArg(req, "query") if err != nil { return errorResult(err.Error()), nil } results, err := kdb.Search(query, 500) if err != nil { return errorResult(err.Error()), nil } total := len(results) bySource := make(map[string]int) for _, r := range results { bySource[r.SourceID]++ } var b strings.Builder b.WriteString(fmt.Sprintf("Matches for %q: %d total\n", query, total)) for src, count := range bySource { b.WriteString(fmt.Sprintf(" %-20s %d\n", src+":", count)) } return mcp.NewToolResultText(b.String()), nil }) // ─── knox_reflect ────────────────────────────────────────── reflectTool := mcp.NewTool("knox_reflect", mcp.WithDescription("Process pending sessions for reflection and gap analysis"), ) s.AddTool(reflectTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { reflector := index.NewReflector(kdb) results, err := reflector.Reflect() if err != nil { return errorResult(err.Error()), nil } if len(results) == 0 { return mcp.NewToolResultText("No pending sessions to reflect on."), nil } return mcp.NewToolResultText(reflector.FormatResult(results)), nil }) // ─── knox_thread_list ─────────────────────────────────────── threadListTool := mcp.NewTool("knox_thread_list", mcp.WithDescription("List knowledge development threads with their motivation, status, and linked entry count."), mcp.WithString("status", mcp.Description("Filter: active, stalled, resolved (default all)")), ) s.AddTool(threadListTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { status := getString(req, "status", "") threads, err := kdb.ListThreads(status) if err != nil { return errorResult(err.Error()), nil } if len(threads) == 0 { return mcp.NewToolResultText("No threads."), nil } var b strings.Builder for _, t := range threads { b.WriteString(fmt.Sprintf("#%d %-40s [%s/%s] %d entries\n", t.ID, t.Title, t.Status, t.Priority, t.EntryCount)) if t.Motivation != "" { b.WriteString(fmt.Sprintf(" %s\n", t.Motivation)) } } return mcp.NewToolResultText(b.String()), nil }) // ─── knox_thread_create ───────────────────────────────────── threadCreateTool := mcp.NewTool("knox_thread_create", mcp.WithDescription("Create a new knowledge development thread to track intent/motivation behind observations."), mcp.WithString("title", mcp.Required(), mcp.Description("Thread title")), mcp.WithString("motivation", mcp.Description("Why this thread exists (the 'so what')")), mcp.WithString("priority", mcp.Description("high, medium, or low (default medium)")), mcp.WithString("provenance", mcp.Description("JSON provenance metadata: {\"trigger\":\"...\",\"session_id\":\"...\"}")), ) s.AddTool(threadCreateTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { title, err := requiredStringArg(req, "title") if err != nil { return errorResult(err.Error()), nil } motivation := getString(req, "motivation", "") priority := getString(req, "priority", "medium") provenance := getString(req, "provenance", "{}") id, err := kdb.CreateThread(title, motivation, priority, "[]", provenance) if err != nil { return errorResult(err.Error()), nil } return mcp.NewToolResultText(fmt.Sprintf("Created thread #%d: %s", id, title)), nil }) // ─── knox_thread_update ───────────────────────────────────── threadUpdateTool := mcp.NewTool("knox_thread_update", mcp.WithDescription("Rewrite a thread's editorial fields (title/motivation/priority/tags). Used to polish auto-generated threads."), mcp.WithNumber("thread_id", mcp.Required(), mcp.Description("Thread ID to update")), mcp.WithString("title", mcp.Description("New title (leave empty to keep)")), mcp.WithString("motivation", mcp.Description("New motivation (leave empty to keep)")), mcp.WithString("priority", mcp.Description("New priority: high/medium/low (empty to keep)")), mcp.WithString("tags", mcp.Description("New space-separated tags (empty to keep)")), ) s.AddTool(threadUpdateTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, err := requiredIntArg(req, "thread_id", 1, 999999) if err != nil { return errorResult(err.Error()), nil } title := getString(req, "title", "") motivation := getString(req, "motivation", "") priority := getString(req, "priority", "") tags := getString(req, "tags", "") changed, err := kdb.UpdateThread(id, title, motivation, priority, tags) if err != nil { return errorResult(err.Error()), nil } if !changed { return mcp.NewToolResultText(fmt.Sprintf("Thread #%d unchanged (no edits differed).", id)), nil } return mcp.NewToolResultText(fmt.Sprintf("Updated thread #%d", id)), nil }) // ─── knox_thread_draft ────────────────────────────────────── // Emits auto-threaded candidates with enough context (sources, linked // observations) for an agent to write tight titles/motivations. threadDraftTool := mcp.NewTool("knox_thread_draft", mcp.WithDescription("List auto-threaded candidates needing LLM polish. Returns each heuristic thread's current title/motivation and its linked observations so you can rewrite them via knox_thread_update."), mcp.WithNumber("limit", mcp.Description("Max candidates to return (default 20)")), ) s.AddTool(threadDraftTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { limit, err := optionalIntArg(req, "limit", 20, 1, 200) if err != nil { return errorResult(err.Error()), nil } threads, err := kdb.ListThreads("") if err != nil { return errorResult(err.Error()), nil } selected := make([]db.Thread, 0, len(threads)) for _, t := range threads { if strings.Contains(t.Provenance, "auto_thread") || strings.HasPrefix(t.Motivation, "Auto-detected") { selected = append(selected, t) } if len(selected) >= limit { break } } if len(selected) == 0 { return mcp.NewToolResultText("No auto-threaded candidates. Run `knox thread auto` or wait for the watch tick first."), nil } var b strings.Builder for _, t := range selected { obs, _ := kdb.ThreadObservations(t.ID) fmt.Fprintf(&b, "THREAD #%d [%s] %d obs\n title: %s\n motivation: %s\n", t.ID, t.Priority, t.EntryCount, t.Title, t.Motivation) if t.Tags != "[]" { fmt.Fprintf(&b, " tags: %s\n", t.Tags) } b.WriteString(" linked observations:\n") for _, o := range obs { line := strings.TrimSpace(o.Title) if o.Summary != "" { line += " — " + strings.TrimSpace(o.Summary) } fmt.Fprintf(&b, " • [%s] %s\n", o.SourceID, truncateStr(line, 160)) } b.WriteString("\n") } return mcp.NewToolResultText(b.String()), nil }) // ─── knox_thread_link ─────────────────────────────────────── threadLinkTool := mcp.NewTool("knox_thread_link", mcp.WithDescription("Link an observation to a thread by observation ID. Use knox_get first to find observation IDs."), mcp.WithNumber("thread_id", mcp.Required(), mcp.Description("Thread ID")), mcp.WithNumber("observation_id", mcp.Required(), mcp.Description("Observation ID to link")), mcp.WithString("relevance", mcp.Description("Why this observation matters to the thread")), ) s.AddTool(threadLinkTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { threadID, err := requiredIntArg(req, "thread_id", 1, 999999) if err != nil { return errorResult(err.Error()), nil } obsID, err := requiredIntArg(req, "observation_id", 1, 999999) if err != nil { return errorResult(err.Error()), nil } relevance := getString(req, "relevance", "") if err := threadExists(kdb, threadID); err != nil { return errorResult(err.Error()), nil } if err := kdb.LinkObservationToThread(threadID, obsID, relevance); err != nil { return errorResult(err.Error()), nil } return mcp.NewToolResultText(fmt.Sprintf("Linked observation #%d to thread #%d", obsID, threadID)), nil }) // ─── knox_thread_search ───────────────────────────────────── threadSearchTool := mcp.NewTool("knox_thread_search", mcp.WithDescription("Search threads by motivation, title, or tags. Find in-flight knowledge work by intent."), mcp.WithString("query", mcp.Required(), mcp.Description("Search motivation, title, or tags")), ) s.AddTool(threadSearchTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { query, err := requiredStringArg(req, "query") if err != nil { return errorResult(err.Error()), nil } threads, err := kdb.SearchThreadsByMotivation(query) if err != nil { return errorResult(err.Error()), nil } if len(threads) == 0 { return mcp.NewToolResultText(fmt.Sprintf("No threads matching %q", query)), nil } var b strings.Builder for _, t := range threads { b.WriteString(fmt.Sprintf("#%d %-40s [%s/%s] %d entries\n", t.ID, t.Title, t.Status, t.Priority, t.EntryCount)) if t.Motivation != "" { b.WriteString(fmt.Sprintf(" %s\n", t.Motivation)) } } return mcp.NewToolResultText(b.String()), nil }) // ─── knox_thread_link_entry ───────────────────────────────── threadLinkEntryTool := mcp.NewTool("knox_thread_link_entry", mcp.WithDescription("Link an entry to a thread via provenance graph. Use after knox_search to connect findings to intent."), mcp.WithNumber("thread_id", mcp.Required(), mcp.Description("Thread ID")), mcp.WithString("fingerprint", mcp.Required(), mcp.Description("Entry fingerprint")), mcp.WithString("relation", mcp.Description("Relation type: produced (default), references, motivated")), ) s.AddTool(threadLinkEntryTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { threadID, err := requiredIntArg(req, "thread_id", 1, 999999) if err != nil { return errorResult(err.Error()), nil } fp, err := requiredStringArg(req, "fingerprint") if err != nil { return errorResult(err.Error()), nil } relation := getString(req, "relation", "produced") if err := threadExists(kdb, threadID); err != nil { return errorResult(err.Error()), nil } if err := kdb.LinkEntryToThread(threadID, fp, relation); err != nil { return errorResult(err.Error()), nil } return mcp.NewToolResultText(fmt.Sprintf("Linked %s to thread #%d [%s]", shortFP(fp), threadID, relation)), nil }) // ─── knox_thread_relate ───────────────────────────────────── threadRelateTool := mcp.NewTool("knox_thread_relate", mcp.WithDescription("Relate two threads (parent spawns child, threads merge, etc.). Builds the thread-inquiry graph."), mcp.WithNumber("parent_id", mcp.Required(), mcp.Description("Parent thread ID")), mcp.WithNumber("child_id", mcp.Required(), mcp.Description("Child thread ID")), mcp.WithString("relation", mcp.Description("Relation: spawned (default), merged_into, references, supersedes")), ) s.AddTool(threadRelateTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { parentID, err := requiredIntArg(req, "parent_id", 1, 999999) if err != nil { return errorResult(err.Error()), nil } childID, err := requiredIntArg(req, "child_id", 1, 999999) if err != nil { return errorResult(err.Error()), nil } relation := getString(req, "relation", "spawned") if err := threadExists(kdb, parentID); err != nil { return errorResult(err.Error()), nil } if err := threadExists(kdb, childID); err != nil { return errorResult(err.Error()), nil } if err := kdb.LinkEntryToThread(childID, db.ThreadFP(parentID), relation); err != nil { return errorResult(err.Error()), nil } return mcp.NewToolResultText(fmt.Sprintf("Related thread #%d -> #%d [%s]", parentID, childID, relation)), nil }) // ─── knox_thread_golden ───────────────────────────────────── goldenTool := mcp.NewTool("knox_thread_golden", mcp.WithDescription("Set, get, or clear the golden thread. The golden thread represents your current focus — new observations auto-link to it."), mcp.WithNumber("thread_id", mcp.Description("Thread ID to set as golden. Omit to query current. Pass 0 to clear.")), ) s.AddTool(goldenTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Absent thread_id: query the current golden thread. if _, ok := req.Params.Arguments["thread_id"]; !ok { current, err := kdb.GoldenThreadID() if err != nil { return errorResult(err.Error()), nil } if current == 0 { return mcp.NewToolResultText("No golden thread set."), nil } t, err := kdb.GetThread(current) if err != nil { return errorResult(err.Error()), nil } if t == nil { return errorResult(fmt.Sprintf("Golden thread #%d no longer exists", current)), nil } return mcp.NewToolResultText(fmt.Sprintf("Golden thread: #%d %s [%s]\n %s", t.ID, t.Title, t.Status, t.Motivation)), nil } threadID, err := requiredIntArg(req, "thread_id", 0, 999999) if err != nil { return errorResult(err.Error()), nil } if threadID == 0 { // Explicit 0 clears the golden thread. current, err := kdb.GoldenThreadID() if err != nil { return errorResult(err.Error()), nil } if current == 0 { return mcp.NewToolResultText("No golden thread set."), nil } if err := kdb.SetGoldenThread(0); err != nil { return errorResult(err.Error()), nil } t, err := kdb.GetThread(current) if err != nil { return errorResult(err.Error()), nil } name := "?" if t != nil { name = t.Title } return mcp.NewToolResultText(fmt.Sprintf("Golden thread cleared (was #%d: %s)", current, name)), nil } if err := threadExists(kdb, threadID); err != nil { return errorResult(err.Error()), nil } if err := kdb.SetGoldenThread(threadID); err != nil { return errorResult(err.Error()), nil } t, err := kdb.GetThread(threadID) if err != nil { return errorResult(err.Error()), nil } name := "?" if t != nil { name = t.Title } return mcp.NewToolResultText(fmt.Sprintf("Golden thread set to #%d: %s", threadID, name)), nil }) // ─── knox_topics ─────────────────────────────────────────── topicsTool := mcp.NewTool("knox_topics", mcp.WithDescription("List auto-detected topic clusters from TF-IDF analysis. Shows what topics appear across all sources."), mcp.WithNumber("limit", mcp.Description("Max topics (default 10)")), ) s.AddTool(topicsTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { limit, err := optionalIntArg(req, "limit", 10, 1, 30) if err != nil { return errorResult(err.Error()), nil } entries, _ := kdb.RecentEntries(2000) tfidf := index.BuildTFIDF(entries) clusters := tfidf.Cluster(2, limit) if len(clusters) == 0 { return mcp.NewToolResultText("No topic clusters detected."), nil } var b strings.Builder for i, c := range clusters { total := 0 for _, v := range c.BySource { total += v } b.WriteString(fmt.Sprintf("%d. %s (%d entries)\n", i+1, c.Name, total)) var sources []string for src, count := range c.BySource { sources = append(sources, fmt.Sprintf("%s:%d", src, count)) } b.WriteString(fmt.Sprintf(" Sources: %s\n", strings.Join(sources, ", "))) } return mcp.NewToolResultText(b.String()), nil }) // ─── knox_gaps ───────────────────────────────────────────── gapsTool := mcp.NewTool("knox_gaps", mcp.WithDescription("Find topics with browser activity but no skill coverage. Identifies candidates for new skill creation."), ) s.AddTool(gapsTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { entries, _ := kdb.RecentEntries(2000) skillKeywords := make(map[string]bool) for _, e := range entries { if e.SourceID == "skills-catalog" { for _, w := range index.Tokenize(e.Title + " " + e.Summary) { skillKeywords[w] = true } } } tfidf := index.BuildTFIDF(entries) clusters := tfidf.Cluster(2, 15) gaps := tfidf.Gaps(clusters, skillKeywords) if len(gaps) == 0 { return mcp.NewToolResultText("No gaps found."), nil } var b strings.Builder b.WriteString(fmt.Sprintf("Found %d topics with browser activity but NO skill coverage:\n\n", len(gaps))) for i, g := range gaps { browserCount := g.BySource["browser-history"] b.WriteString(fmt.Sprintf("%d. %s — %d browser visits\n", i+1, g.Name, browserCount)) } return mcp.NewToolResultText(b.String()), nil }) // ─── knox_git_status ─────────────────────────────────────── gitStatusTool := mcp.NewTool("knox_git_status", mcp.WithDescription("List local git repository status captured by Knox (branch, clean/dirty, ahead/behind, HEAD, last commit). Pass project to filter."), mcp.WithString("project", mcp.Description("Optional project/repo name substring filter")), ) s.AddTool(gitStatusTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { entries, err := kdb.EntriesBySource("git") if err != nil { return errorResult(err.Error()), nil } filter, _ := req.Params.Arguments["project"].(string) if len(entries) == 0 { return mcp.NewToolResultText("No git status entries yet. Run `knox git` or wait for the daemon's git timer."), nil } var b strings.Builder shown := 0 for _, e := range entries { if filter != "" && !strings.Contains(strings.ToLower(e.Project), strings.ToLower(filter)) { continue } b.WriteString(fmt.Sprintf("%-24s %s\n", e.Project, e.Summary)) shown++ } b.WriteString(fmt.Sprintf("\n%d / %d repos", shown, len(entries))) return mcp.NewToolResultText(b.String()), nil }) return s } // ─── Format helpers ────────────────────────────────────────── func formatEntries(entries []db.Entry, detail detailLevel, kdb *db.KnoxDB) string { switch detail { case detailBrief: return formatEntriesBrief(entries) case detailFull: var b strings.Builder for i, e := range entries { if i > 0 { b.WriteString("---\n") } b.WriteString(formatEntryFull(&e)) } return b.String() default: var b strings.Builder for _, e := range entries { b.WriteString(formatEntryNormal(&e)) b.WriteString("\n") } return b.String() } } func formatEntriesBrief(entries []db.Entry) string { var b strings.Builder for _, e := range entries { b.WriteString(fmt.Sprintf("%-8s | %-40s | %-14s | %s\n", shortFP(e.Fingerprint), truncateStr(e.Title, 38), e.SourceID, e.Project)) } return b.String() } func formatEntryNormal(e *db.Entry) string { return fmt.Sprintf("%-8s %-40s [%-14s %-4s] seen:%d conf:%.1f\n %s", shortFP(e.Fingerprint), truncateStr(e.Title, 38), e.SourceID, e.Project, e.RefCount, e.LastConfidence, truncateStr(e.Summary, 90)) } func formatEntryFull(e *db.Entry) string { return fmt.Sprintf(`fingerprint: %s title: %s source: %s path: %s project: %s type: %s first_seen: %s last_seen: %s ref_count: %d confidence: %.1f summary: %s`, e.Fingerprint, e.Title, e.SourceID, e.SourcePath, e.Project, e.ContentType, e.FirstSeen, e.LastSeen, e.RefCount, e.LastConfidence, e.Summary) } // ─── Scope filtering ───────────────────────────────────────── func filterByScope(entries []db.Entry, scope string) []db.Entry { switch strings.ToLower(scope) { case "skills": return filterSource(entries, "skills-catalog") case "sessions": return filterSource(entries, "opencode-session") case "logs": return filterSource(entries, "opencode-log") default: return entries } } func filterSource(entries []db.Entry, sourceID string) []db.Entry { var filtered []db.Entry for _, e := range entries { if e.SourceID == sourceID { filtered = append(filtered, e) } } return filtered } // ─── Argument helpers ──────────────────────────────────────── func getString(req mcp.CallToolRequest, key, def string) string { if v, ok := req.Params.Arguments[key].(string); ok { return v } return def } // requiredStringArg returns a non-empty string argument or a descriptive error. // Empty/missing/wrong-type values are rejected rather than silently defaulted: // LLM clients routinely omit or zero value params, and a silent default writes // to the wrong thread or reports false success. func requiredStringArg(req mcp.CallToolRequest, key string) (string, error) { v, _ := req.Params.Arguments[key].(string) v = strings.TrimSpace(v) if v == "" { return "", fmt.Errorf("%s is required and must be a non-empty string", key) } return v, nil } // requiredIntArg returns an integer argument validated against [min, max]. func requiredIntArg(req mcp.CallToolRequest, key string, min, max int64) (int64, error) { v, ok := req.Params.Arguments[key].(float64) if !ok || v != math.Trunc(v) { return 0, fmt.Errorf("%s is required and must be an integer", key) } n := int64(v) if n < min || n > max { return 0, fmt.Errorf("%s must be in [%d..%d], got %d", key, min, max, n) } return n, nil } // optionalIntArg validates a present numeric argument, defaulting when absent. func optionalIntArg(req mcp.CallToolRequest, key string, def, min, max int) (int, error) { v, ok := req.Params.Arguments[key].(float64) if !ok { return def, nil } if v != math.Trunc(v) { return 0, fmt.Errorf("%s must be an integer", key) } n := int(v) if n < min || n > max { return 0, fmt.Errorf("%s must be in [%d..%d], got %d", key, min, max, n) } return n, nil } // threadExists is a guard for write tools: link/relate/set-golden must not // silently accept ids with no matching thread. func threadExists(kdb *db.KnoxDB, id int64) error { t, err := kdb.GetThread(id) if err != nil { return err } if t == nil { return fmt.Errorf("thread #%d not found", id) } return nil } func shortFP(fp string) string { if len(fp) > 8 { return fp[:8] } return fp } func truncateStr(s string, n int) string { runes := []rune(s) if len(runes) <= n { return s } return string(runes[:n]) + "..." } func errorResult(msg string) *mcp.CallToolResult { return &mcp.CallToolResult{ IsError: true, Content: []mcp.Content{mcp.NewTextContent(msg)}, } }