Files
claude-mnemonic/internal/mcp/server.go
T
f2886bbedb fix(mcp): do not end an idle stdio server by closing stdin (#71)
After 30 minutes without a message the idle monitor closed stdin and expected the read loop to end. On a real, blocking stdin a read already in progress is not woken by closing the file, so the server lived on, the client's next message completed that read and the one after failed with 'read /dev/stdin: file already closed', which ended the connection mid-call.

The idle shutdown is now off by default (a stdio server lives as long as its client; orphans are still caught by the parent check). CLAUDE_MNEMONIC_MCP_IDLE_TIMEOUT (a Go duration) turns it on, and then it cancels the loop's context so Run returns nil instead of closing stdin.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-10-04 23:16:27 +01:00

1645 lines
53 KiB
Go

// Package mcp provides the MCP (Model Context Protocol) server for claude-mnemonic.
package mcp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/rs/zerolog/log"
)
// Server is the MCP server that proxies tool calls to the worker HTTP API.
// Field order optimized for memory alignment (fieldalignment).
type Server struct {
stdin io.Reader
stdout io.Writer
client *http.Client
workerURL string
project string
version string
writeMu sync.Mutex
lastActivity atomic.Int64
// idleTimeout ends the server after this long without a message; 0 (the default) never does. A stdio server lives
// as long as its client: ending it while the client is still connected makes the client's next call fail.
idleTimeout time.Duration
// monitorInterval is how often the parent and idle checks run (30s when 0).
monitorInterval time.Duration
}
// NewServer creates a new MCP server that proxies to the worker HTTP API.
func NewServer(client *http.Client, workerURL, project, version string) *Server {
return &Server{
client: client,
workerURL: workerURL,
project: project,
version: version,
stdin: os.Stdin,
stdout: os.Stdout,
idleTimeout: idleTimeoutFromEnv(),
}
}
// IdleTimeoutEnv names the environment variable that turns the idle shutdown on, as a Go duration such as "2h".
// It is off by default.
const IdleTimeoutEnv = "CLAUDE_MNEMONIC_MCP_IDLE_TIMEOUT"
// idleTimeoutFromEnv reads IdleTimeoutEnv; an unset, invalid or non-positive value means no idle shutdown.
func idleTimeoutFromEnv() time.Duration {
d, err := time.ParseDuration(os.Getenv(IdleTimeoutEnv))
if err != nil || d <= 0 {
return 0
}
return d
}
// Request represents a JSON-RPC request.
type Request struct {
JSONRPC string `json:"jsonrpc"`
ID any `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// Response represents a JSON-RPC response.
type Response struct {
ID any `json:"id"`
Result any `json:"result,omitempty"`
Error *Error `json:"error,omitempty"`
JSONRPC string `json:"jsonrpc"`
}
// Error represents a JSON-RPC error.
type Error struct {
Data any `json:"data,omitempty"`
Message string `json:"message"`
Code int `json:"code"`
}
// ToolCallParams represents parameters for tools/call method.
type ToolCallParams struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
// Tool represents an MCP tool definition.
type Tool struct {
InputSchema map[string]any `json:"inputSchema"`
Name string `json:"name"`
Description string `json:"description"`
}
// Run starts the MCP server loop.
func (s *Server) Run(ctx context.Context) error {
// An idle shutdown cancels this context. Closing stdin does not do it: on a real (blocking) stdin the read stays
// blocked, the server lived on with a closed stdin, and the client's next message woke the read with "file already
// closed", which killed the connection mid-call.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var idleStopped atomic.Bool
scanner := bufio.NewScanner(s.stdin)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024) // 1MB max message size
lines := make(chan string)
scanErr := make(chan error, 1)
// Track last activity for idle timeout
s.lastActivity.Store(time.Now().Unix())
go func() {
defer close(lines)
for scanner.Scan() {
lines <- scanner.Text()
}
scanErr <- scanner.Err()
}()
// Monitor parent process liveness and, when one is set, the idle timeout.
// If the parent dies (ppid changes) or no messages arrive for idleTimeout, shut down.
parentPID := os.Getppid()
go func() {
interval := s.monitorInterval
if interval <= 0 {
interval = 30 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if os.Getppid() != parentPID {
log.Info().Msg("Parent process died, shutting down MCP server")
if closer, ok := s.stdin.(io.Closer); ok {
_ = closer.Close()
}
return
}
if s.idleTimeout > 0 && time.Since(time.Unix(s.lastActivity.Load(), 0)) > s.idleTimeout {
log.Info().Dur("idle", s.idleTimeout).Msg("MCP server idle timeout, shutting down")
idleStopped.Store(true)
cancel()
return
}
}
}
}()
// Semaphore limits concurrent request goroutines.
const maxConcurrent = 10
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for {
select {
case <-ctx.Done():
// Drain in-flight requests before returning.
wg.Wait()
if idleStopped.Load() {
return nil // an idle shutdown the operator asked for is a clean exit
}
return ctx.Err()
case line, ok := <-lines:
if !ok {
// Scanner finished — drain in-flight requests, then check for errors.
wg.Wait()
err := <-scanErr
if err != nil {
if errors.Is(err, bufio.ErrTooLong) {
log.Error().Msg("MCP message exceeded 1MB buffer limit")
}
return fmt.Errorf("scanner error: %w", err)
}
return nil
}
s.lastActivity.Store(time.Now().Unix())
if line == "" {
continue
}
var req Request
if err := json.Unmarshal([]byte(line), &req); err != nil {
// Parse errors are cheap — send inline, no goroutine needed.
if werr := s.sendError(nil, -32700, "Parse error", err.Error()); werr != nil {
return fmt.Errorf("write error: %w", werr)
}
continue
}
// Dispatch request to its own goroutine.
// Semaphore is acquired inside the goroutine so the main
// loop never blocks on a full semaphore (Fix #1).
wg.Add(1)
go func(r Request) {
defer wg.Done()
// Acquire semaphore inside goroutine, not blocking main loop
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
// Server shutting down — send error so caller isn't left waiting
if r.ID != nil {
_ = s.sendError(r.ID, -32000, "Server shutting down", nil)
}
return
}
resp := s.handleRequest(ctx, &r)
if resp != nil {
if werr := s.sendResponse(resp); werr != nil {
log.Error().Err(werr).Msg("Failed to send response")
}
}
}(req)
}
}
}
// handleRequest dispatches the request to the appropriate handler.
func (s *Server) handleRequest(ctx context.Context, req *Request) *Response {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
switch req.Method {
case "initialize":
return s.handleInitialize(req)
case "tools/list":
return s.handleToolsList(req)
case "tools/call":
return s.handleToolsCall(ctx, req)
case "notifications/initialized", "notifications/cancelled":
return nil // Notifications don't get responses
default:
if req.ID == nil {
// Notifications must not receive responses per JSON-RPC 2.0
return nil
}
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Error: &Error{
Code: -32601,
Message: "Method not found",
},
}
}
}
// handleInitialize handles the initialize request.
func (s *Server) handleInitialize(req *Request) *Response {
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{
"tools": map[string]any{},
},
"serverInfo": map[string]any{
"name": "claude-mnemonic",
"version": s.version,
},
},
}
}
// handleToolsList returns the list of available tools.
func (s *Server) handleToolsList(req *Request) *Response {
tools := []Tool{
{
Name: "search",
Description: "Unified search across all memory types (observations, sessions, and user prompts) using vector-first semantic search (sqlite-vec).",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string", "description": "Natural language search query for semantic ranking"},
"type": map[string]any{"type": "string", "enum": []string{"observations", "sessions", "prompts"}, "description": "Filter by document type"},
"project": map[string]any{"type": "string", "description": "Filter by project name"},
"obs_type": map[string]any{"type": "string", "description": "Filter observations by type"},
"concepts": map[string]any{"type": "string", "description": "Filter by concept tags"},
"files": map[string]any{"type": "string", "description": "Filter by file paths"},
"dateStart": map[string]any{"type": []string{"string", "number"}, "description": "Start date for filtering"},
"dateEnd": map[string]any{"type": []string{"string", "number"}, "description": "End date for filtering"},
"orderBy": map[string]any{"type": "string", "enum": []string{"relevance", "date_desc", "date_asc"}, "default": "date_desc"},
"limit": map[string]any{"type": "number", "default": 20, "minimum": 1, "maximum": 100},
"offset": map[string]any{"type": "number", "default": 0, "minimum": 0},
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
},
},
},
{
Name: "timeline",
Description: "Fetch timeline of observations around a specific point in time.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"anchor_id": map[string]any{"type": "number", "description": "Observation ID to use as anchor"},
"query": map[string]any{"type": "string", "description": "Natural language query to find anchor observation"},
"before": map[string]any{"type": "number", "default": 10, "minimum": 0, "maximum": 100},
"after": map[string]any{"type": "number", "default": 10, "minimum": 0, "maximum": 100},
"project": map[string]any{"type": "string"},
"concepts": map[string]any{"type": "string"},
"files": map[string]any{"type": "string"},
"obs_type": map[string]any{"type": "string"},
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
},
},
},
{
Name: "observation",
Description: "Manage individual observations. Set 'action' to choose the operation: get, edit, delete, supersede, boost, merge, related, similar, quality, relationships, scoring, tag, by_tag, batch_tag.",
InputSchema: map[string]any{
"type": "object",
"required": []string{"action"},
"properties": map[string]any{
"action": map[string]any{"type": "string", "enum": []string{"get", "edit", "delete", "supersede", "boost", "merge", "related", "similar", "quality", "relationships", "scoring", "tag", "by_tag", "batch_tag"}, "description": "Operation to perform"},
"id": map[string]any{"type": "number", "description": "Observation ID (get, edit, quality, relationships, scoring, related)"},
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "number"}, "description": "Observation IDs (delete, supersede, boost)"},
"title": map[string]any{"type": "string", "description": "edit: new title"},
"subtitle": map[string]any{"type": "string", "description": "edit: new subtitle"},
"narrative": map[string]any{"type": "string", "description": "edit: new narrative"},
"facts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "edit: new facts"},
"concepts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "edit: new concept tags"},
"files_read": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "edit: new files read"},
"files_modified": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "edit: new files modified"},
"scope": map[string]any{"type": "string", "enum": []string{"project", "global"}, "description": "edit: new scope"},
"query": map[string]any{"type": "string", "description": "similar: text to find similar observations for"},
"project": map[string]any{"type": "string", "description": "Filter by project (similar, by_tag, batch_tag)"},
"min_confidence": map[string]any{"type": "number", "minimum": 0.0, "maximum": 1.0, "description": "related: minimum confidence (default 0.5)"},
"min_similarity": map[string]any{"type": "number", "minimum": 0.0, "maximum": 1.0, "description": "similar: minimum similarity (default 0.7)"},
"limit": map[string]any{"type": "number", "description": "Max results (related, similar, by_tag, batch_tag)"},
"delete_vectors": map[string]any{"type": "boolean", "description": "delete: also delete vectors (default true)"},
"boost": map[string]any{"type": "number", "minimum": -1.0, "maximum": 1.0, "description": "boost: amount; merge: target boost"},
"source_id": map[string]any{"type": "number", "description": "merge: source observation ID (superseded)"},
"target_id": map[string]any{"type": "number", "description": "merge: target observation ID (kept)"},
"max_depth": map[string]any{"type": "number", "minimum": 1, "maximum": 5, "description": "relationships: traversal hops (default 2)"},
"tag": map[string]any{"type": "string", "description": "by_tag: tag/concept to search for"},
"tags": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "tag/batch_tag: tags to apply"},
"mode": map[string]any{"type": "string", "enum": []string{"add", "remove", "set"}, "description": "tag: add/remove/set (default add)"},
"pattern": map[string]any{"type": "string", "description": "batch_tag: search pattern to match"},
"dry_run": map[string]any{"type": "boolean", "description": "batch_tag: preview only (default true)"},
"max_matches": map[string]any{"type": "number", "description": "batch_tag: max observations to tag"},
},
},
},
{
Name: "memory_admin",
Description: "Memory system administration and analytics. Set 'action': stats, health, maintenance_stats, run_maintenance, importance, search_patterns, explain_ranking, temporal_trends, data_quality, export, suggest_consolidations, patterns.",
InputSchema: map[string]any{
"type": "object",
"required": []string{"action"},
"properties": map[string]any{
"action": map[string]any{"type": "string", "enum": []string{"stats", "health", "maintenance_stats", "run_maintenance", "importance", "search_patterns", "explain_ranking", "temporal_trends", "data_quality", "export", "suggest_consolidations", "patterns"}, "description": "Operation to perform"},
"project": map[string]any{"type": "string", "description": "Filter by project (importance, temporal_trends, data_quality, export, suggest_consolidations, patterns, explain_ranking)"},
"query": map[string]any{"type": "string", "description": "explain_ranking: query to analyze; patterns: search by name/description"},
"top_n": map[string]any{"type": "number", "description": "Top results (explain_ranking, search_patterns)"},
"days": map[string]any{"type": "number", "description": "Days to analyze (search_patterns, temporal_trends)"},
"group_by": map[string]any{"type": "string", "enum": []string{"day", "week", "hour_of_day"}, "description": "temporal_trends: grouping (default day)"},
"limit": map[string]any{"type": "number", "description": "Max results (importance, data_quality, export, suggest_consolidations, patterns)"},
"min_similarity": map[string]any{"type": "number", "minimum": 0.5, "maximum": 1.0, "description": "suggest_consolidations: grouping threshold (default 0.8)"},
"format": map[string]any{"type": "string", "enum": []string{"json", "jsonl", "markdown"}, "description": "export: output format (default json)"},
"date_start": map[string]any{"type": "number", "description": "export: filter start (epoch ms)"},
"date_end": map[string]any{"type": "number", "description": "export: filter end (epoch ms)"},
"obs_type": map[string]any{"type": "string", "description": "export: filter by observation type"},
"type": map[string]any{"type": "string", "enum": []string{"workflow", "preference", "best_practice", "anti_pattern", "tooling"}, "description": "patterns: filter by pattern type"},
"include_top_scored": map[string]any{"type": "boolean", "description": "importance: include top-scoring (default true)"},
"include_most_retrieved": map[string]any{"type": "boolean", "description": "importance: include most-retrieved (default true)"},
"include_concept_weights": map[string]any{"type": "boolean", "description": "importance: include concept weights (default true)"},
},
},
},
}
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]any{
"tools": tools,
},
}
}
// handleToolsCall handles tool invocations.
func (s *Server) handleToolsCall(ctx context.Context, req *Request) *Response {
var params ToolCallParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Error: &Error{
Code: -32602,
Message: "Invalid params",
Data: err.Error(),
},
}
}
result, err := s.callTool(ctx, params.Name, params.Arguments)
if err != nil {
// MCP spec: tool failures use Result with isError, not top-level Error
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]any{
"content": []map[string]any{
{"type": "text", "text": "Error: " + err.Error()},
},
"isError": true,
},
}
}
return &Response{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]any{
"content": []map[string]any{
{
"type": "text",
"text": result,
},
},
},
}
}
// searchArgs holds common search parameters used by many tools.
type searchArgs struct {
Query string `json:"query"`
Project string `json:"project"`
Type string `json:"type"`
ObsType string `json:"obs_type"`
Concepts string `json:"concepts"`
Files string `json:"files"`
DateStart any `json:"dateStart"`
DateEnd any `json:"dateEnd"`
OrderBy string `json:"orderBy"`
Format string `json:"format"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// observationActions maps the 'observation' tool's action values to the internal tool name that implements them.
var observationActions = map[string]string{
"get": "get_observation",
"edit": "edit_observation",
"delete": "bulk_delete_observations",
"supersede": "bulk_mark_superseded",
"boost": "bulk_boost_observations",
"merge": "merge_observations",
"related": "find_related_observations",
"similar": "find_similar_observations",
"quality": "get_observation_quality",
"relationships": "get_observation_relationships",
"scoring": "get_observation_scoring_breakdown",
"tag": "tag_observation",
"by_tag": "get_observations_by_tag",
"batch_tag": "batch_tag_by_pattern",
}
// adminActions maps the 'memory_admin' tool's action values to the internal tool name that implements them.
var adminActions = map[string]string{
"stats": "get_memory_stats",
"health": "check_system_health",
"maintenance_stats": "get_maintenance_stats",
"run_maintenance": "trigger_maintenance",
"importance": "analyze_observation_importance",
"search_patterns": "analyze_search_patterns",
"explain_ranking": "explain_search_ranking",
"temporal_trends": "get_temporal_trends",
"data_quality": "get_data_quality_report",
"export": "export_observations",
"suggest_consolidations": "suggest_consolidations",
"patterns": "get_patterns",
}
// dispatchAction routes a multiplexed tool call to its underlying implementation by reading the "action" field.
func (s *Server) dispatchAction(ctx context.Context, tool string, actions map[string]string, args json.RawMessage) (string, error) {
var a struct {
Action string `json:"action"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("%s: invalid arguments: %w", tool, err)
}
target, ok := actions[a.Action]
if !ok {
return "", fmt.Errorf("%s: unknown action %q", tool, a.Action)
}
return s.callTool(ctx, target, args)
}
// callTool dispatches to the appropriate tool handler by proxying to the worker HTTP API.
func (s *Server) callTool(ctx context.Context, name string, args json.RawMessage) (string, error) {
// Parse common search params used by many tools
var sa searchArgs
// Best-effort parse; individual handlers validate as needed
_ = json.Unmarshal(args, &sa)
// Default project from server config
if sa.Project == "" {
sa.Project = s.project
}
switch name {
// --- Search-based tools: proxy to GET /api/context/search ---
case "search":
return s.handleSearchProxy(ctx, sa)
case "observation":
return s.dispatchAction(ctx, "observation", observationActions, args)
case "memory_admin":
return s.dispatchAction(ctx, "memory_admin", adminActions, args)
case "decisions":
sa.ObsType = "decision"
return s.handleSearchProxy(ctx, sa)
case "changes":
sa.ObsType = "code_change"
return s.handleSearchProxy(ctx, sa)
case "how_it_works":
sa.ObsType = "architecture"
return s.handleSearchProxy(ctx, sa)
case "find_by_concept":
return s.handleSearchProxy(ctx, sa)
case "find_by_file":
return s.handleSearchProxy(ctx, sa)
case "find_by_type":
return s.handleSearchProxy(ctx, sa)
case "get_recent_context":
return s.handleSearchProxy(ctx, sa)
case "timeline", "get_context_timeline":
return s.handleTimelineProxy(ctx, args)
case "get_timeline_by_query":
return s.handleTimelineProxy(ctx, args)
// --- Observation endpoints ---
case "get_observation":
return s.handleGetObservationProxy(ctx, args)
case "edit_observation":
return s.handleEditObservationProxy(ctx, args)
case "find_related_observations":
return s.handleFindRelatedProxy(ctx, args)
case "find_similar_observations":
return s.handleFindSimilarProxy(ctx, args)
case "get_observation_quality":
return s.handleGetObservationQualityProxy(ctx, args)
case "get_observation_relationships":
return s.handleGetRelationshipsProxy(ctx, args)
case "get_observation_scoring_breakdown":
return s.handleGetScoringBreakdownProxy(ctx, args)
case "tag_observation":
return s.handleTagObservationProxy(ctx, args)
case "get_observations_by_tag":
return s.handleGetObservationsByTagProxy(ctx, args)
// --- Bulk operations ---
case "bulk_delete_observations":
return s.handleBulkStatusProxy(ctx, args, "delete")
case "bulk_mark_superseded":
return s.handleBulkStatusProxy(ctx, args, "supersede")
case "bulk_boost_observations":
return s.handleBulkStatusProxy(ctx, args, "boost")
case "merge_observations":
return s.handleMergeProxy(ctx, args)
// --- Pattern endpoints ---
case "get_patterns":
return s.handleGetPatternsProxy(ctx, args)
// --- Stats and analytics endpoints ---
case "get_memory_stats":
return s.proxyGetRaw(ctx, "/api/stats", map[string]string{
"project": s.project,
})
case "check_system_health":
return s.proxyGetRaw(ctx, "/api/selfcheck", nil)
case "get_maintenance_stats":
return s.proxyGetRaw(ctx, "/api/stats", map[string]string{
"project": s.project,
})
case "trigger_maintenance":
return s.proxyPostRaw(ctx, "/api/maintenance/run", nil)
case "analyze_observation_importance":
return s.handleAnalyzeImportanceProxy(ctx, args)
case "analyze_search_patterns":
return s.proxyGetRaw(ctx, "/api/search/analytics", nil)
case "explain_search_ranking":
return s.handleExplainSearchProxy(ctx, args)
case "get_temporal_trends":
return s.handleGetTemporalTrendsProxy(ctx, args)
case "get_data_quality_report":
return s.handleGetDataQualityProxy(ctx, args)
// --- Export and batch tag ---
case "export_observations":
return s.handleExportProxy(ctx, args)
case "suggest_consolidations":
return s.handleSuggestConsolidationsProxy(ctx, args)
case "batch_tag_by_pattern":
return s.handleBatchTagProxy(ctx, args)
default:
return "", fmt.Errorf("unknown tool: %s", name)
}
}
// =============================================================================
// HTTP PROXY HELPERS
// =============================================================================
// proxyGetRaw performs a GET request to the worker and returns the raw JSON response body.
func (s *Server) proxyGetRaw(ctx context.Context, path string, params map[string]string) (string, error) {
if s.client == nil {
return "", fmt.Errorf("worker unavailable at %s: http client not configured", s.workerURL)
}
req, err := http.NewRequestWithContext(ctx, "GET", s.workerURL+path, nil)
if err != nil {
return "", err
}
if len(params) > 0 {
q := req.URL.Query()
for k, v := range params {
if v != "" {
q.Set(k, v)
}
}
req.URL.RawQuery = q.Encode()
}
resp, err := s.client.Do(req)
if err != nil {
return "", fmt.Errorf("worker unavailable at %s: %w", s.workerURL, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read worker response: %w", err)
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("worker returned %d: %s", resp.StatusCode, string(body))
}
return string(body), nil
}
// proxyPostRaw performs a POST request to the worker and returns the raw JSON response body.
func (s *Server) proxyPostRaw(ctx context.Context, path string, payload any) (string, error) {
if s.client == nil {
return "", fmt.Errorf("worker unavailable at %s: http client not configured", s.workerURL)
}
var bodyReader io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, "POST", s.workerURL+path, bodyReader)
if err != nil {
return "", err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := s.client.Do(req)
if err != nil {
return "", fmt.Errorf("worker unavailable at %s: %w", s.workerURL, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read worker response: %w", err)
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("worker returned %d: %s", resp.StatusCode, string(body))
}
return string(body), nil
}
// proxyPutRaw performs a PUT request to the worker and returns the raw JSON response body.
func (s *Server) proxyPutRaw(ctx context.Context, path string, payload any) (string, error) {
if s.client == nil {
return "", fmt.Errorf("worker unavailable at %s: http client not configured", s.workerURL)
}
var bodyReader io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, "PUT", s.workerURL+path, bodyReader)
if err != nil {
return "", err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := s.client.Do(req)
if err != nil {
return "", fmt.Errorf("worker unavailable at %s: %w", s.workerURL, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read worker response: %w", err)
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("worker returned %d: %s", resp.StatusCode, string(body))
}
return string(body), nil
}
// anyToString converts an interface{} value to its string representation for query params.
func anyToString(v any) string {
if v == nil {
return ""
}
switch val := v.(type) {
case string:
return val
case float64:
return strconv.FormatInt(int64(val), 10)
case int:
return strconv.Itoa(val)
case int64:
return strconv.FormatInt(val, 10)
default:
return fmt.Sprintf("%v", v)
}
}
// =============================================================================
// TOOL HANDLER PROXIES
// =============================================================================
// handleSearchProxy proxies search requests to GET /api/context/search.
func (s *Server) handleSearchProxy(ctx context.Context, args searchArgs) (string, error) {
params := map[string]string{
"project": args.Project,
"query": args.Query,
}
if args.Limit > 0 {
params["limit"] = strconv.Itoa(args.Limit)
}
if args.ObsType != "" {
params["obs_type"] = args.ObsType
}
if args.Concepts != "" {
params["concepts"] = args.Concepts
}
if args.Files != "" {
params["files"] = args.Files
}
if args.DateStart != nil {
if ds := anyToString(args.DateStart); ds != "" {
params["dateStart"] = ds
}
}
if args.DateEnd != nil {
if de := anyToString(args.DateEnd); de != "" {
params["dateEnd"] = de
}
}
return s.proxyGetRaw(ctx, "/api/context/search", params)
}
// handleTimelineProxy proxies timeline requests. First searches for anchor, then fetches surrounding observations.
func (s *Server) handleTimelineProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
AnchorID int64 `json:"anchor_id"`
Query string `json:"query"`
Project string `json:"project"`
Before int `json:"before"`
After int `json:"after"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid timeline params: %w", err)
}
if params.Project == "" {
params.Project = s.project
}
if params.Before <= 0 {
params.Before = 10
}
if params.After <= 0 {
params.After = 10
}
// If query provided and no anchor, search for it first
if params.Query != "" && params.AnchorID == 0 {
searchResult, err := s.proxyGetRaw(ctx, "/api/context/search", map[string]string{
"project": params.Project,
"query": params.Query,
"limit": "1",
})
if err != nil {
return "", err
}
// Extract first observation ID from search results
var searchResp struct {
Observations []struct {
ID int64 `json:"id"`
} `json:"observations"`
}
if err := json.Unmarshal([]byte(searchResult), &searchResp); err == nil && len(searchResp.Observations) > 0 {
params.AnchorID = searchResp.Observations[0].ID
}
}
if params.AnchorID == 0 {
result, _ := json.Marshal(map[string]any{"observations": []any{}, "message": "no anchor found"})
return string(result), nil
}
// Get observations around the anchor
limit := params.Before + params.After + 1
return s.proxyGetRaw(ctx, "/api/observations", map[string]string{
"project": params.Project,
"limit": strconv.Itoa(limit),
})
}
// handleGetObservationProxy proxies get observation by ID.
func (s *Server) handleGetObservationProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.ID == 0 {
return "", fmt.Errorf("id is required")
}
return s.proxyGetRaw(ctx, fmt.Sprintf("/api/observations/%d", params.ID), nil)
}
// handleEditObservationProxy proxies observation edits.
func (s *Server) handleEditObservationProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
Title *string `json:"title,omitempty"`
Subtitle *string `json:"subtitle,omitempty"`
Narrative *string `json:"narrative,omitempty"`
Scope *string `json:"scope,omitempty"`
Facts []string `json:"facts,omitempty"`
Concepts []string `json:"concepts,omitempty"`
FilesRead []string `json:"files_read,omitempty"`
FilesModified []string `json:"files_modified,omitempty"`
ID int64 `json:"id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.ID == 0 {
return "", fmt.Errorf("id is required")
}
return s.proxyPutRaw(ctx, fmt.Sprintf("/api/observations/%d", params.ID), params)
}
// handleFindRelatedProxy proxies find related observations.
func (s *Server) handleFindRelatedProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
ID int64 `json:"id"`
MinConfidence float64 `json:"min_confidence"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.ID == 0 {
return "", fmt.Errorf("id is required")
}
qp := map[string]string{}
if params.MinConfidence > 0 {
qp["min_confidence"] = strconv.FormatFloat(params.MinConfidence, 'f', -1, 64)
}
if params.Limit > 0 {
qp["limit"] = strconv.Itoa(params.Limit)
}
return s.proxyGetRaw(ctx, fmt.Sprintf("/api/observations/%d/related", params.ID), qp)
}
// handleFindSimilarProxy proxies find similar observations via context search.
func (s *Server) handleFindSimilarProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
Query string `json:"query"`
Project string `json:"project"`
MinSimilarity float64 `json:"min_similarity"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.Query == "" {
return "", fmt.Errorf("query is required")
}
if params.Project == "" {
params.Project = s.project
}
if params.Limit == 0 {
params.Limit = 10
}
// Use context search which uses vector similarity
return s.proxyGetRaw(ctx, "/api/context/search", map[string]string{
"project": params.Project,
"query": params.Query,
"limit": strconv.Itoa(params.Limit),
})
}
// handleGetObservationQualityProxy proxies observation quality check.
// Fetches the observation and computes quality metrics locally (lightweight computation).
func (s *Server) handleGetObservationQualityProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.ID == 0 {
return "", fmt.Errorf("id is required")
}
// Fetch the observation
obsJSON, err := s.proxyGetRaw(ctx, fmt.Sprintf("/api/observations/%d", params.ID), nil)
if err != nil {
return "", err
}
// Parse observation for quality analysis
var obs struct {
Title string `json:"title"`
Narrative string `json:"narrative"`
Facts []string `json:"facts"`
Concepts []string `json:"concepts"`
FilesRead []string `json:"files_read"`
FilesModified []string `json:"files_modified"`
ImportanceScore float64 `json:"importance_score"`
RetrievalCount int `json:"retrieval_count"`
IsSuperseded bool `json:"is_superseded"`
}
if err := json.Unmarshal([]byte(obsJSON), &obs); err != nil {
return "", fmt.Errorf("parse observation: %w", err)
}
// Calculate completeness score
completenessScore := 0.0
maxScore := 5.0
var suggestions []string
if obs.Title != "" {
completenessScore += 1.0
} else {
suggestions = append(suggestions, "Add a descriptive title")
}
if len(obs.Narrative) > 50 {
completenessScore += 1.5
} else if obs.Narrative != "" {
completenessScore += 0.5
suggestions = append(suggestions, "Expand the narrative to provide more context (aim for 50+ characters)")
} else {
suggestions = append(suggestions, "Add a narrative explaining the observation")
}
if len(obs.Facts) >= 2 {
completenessScore += 1.0
} else if len(obs.Facts) == 1 {
completenessScore += 0.5
suggestions = append(suggestions, "Add more key facts (aim for 2+)")
} else {
suggestions = append(suggestions, "Add key facts to capture important details")
}
if len(obs.Concepts) >= 2 {
completenessScore += 0.75
} else if len(obs.Concepts) == 1 {
completenessScore += 0.25
suggestions = append(suggestions, "Add more concept tags for better discoverability")
} else {
suggestions = append(suggestions, "Add concept tags to categorize this observation")
}
if len(obs.FilesRead) > 0 || len(obs.FilesModified) > 0 {
completenessScore += 0.75
} else {
suggestions = append(suggestions, "Consider adding file references if applicable")
}
qualityTier := "poor"
switch {
case completenessScore >= 4.0:
qualityTier = "excellent"
case completenessScore >= 3.0:
qualityTier = "good"
case completenessScore >= 2.0:
qualityTier = "fair"
}
response := map[string]any{
"id": params.ID,
"completeness_score": completenessScore,
"max_score": maxScore,
"completeness_pct": (completenessScore / maxScore) * 100,
"quality_tier": qualityTier,
"importance_score": obs.ImportanceScore,
"retrieval_count": obs.RetrievalCount,
"is_superseded": obs.IsSuperseded,
"suggestions": suggestions,
"field_stats": map[string]any{
"has_title": obs.Title != "",
"has_narrative": obs.Narrative != "",
"narrative_length": len(obs.Narrative),
"facts_count": len(obs.Facts),
"concepts_count": len(obs.Concepts),
"files_read_count": len(obs.FilesRead),
"files_modified_count": len(obs.FilesModified),
},
}
output, err := json.Marshal(response)
if err != nil {
return "", fmt.Errorf("marshal response: %w", err)
}
return string(output), nil
}
// handleGetRelationshipsProxy proxies observation relationship graph requests.
func (s *Server) handleGetRelationshipsProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
ID int64 `json:"id"`
MaxDepth int `json:"max_depth"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid params: %w", err)
}
if params.ID <= 0 {
return "", fmt.Errorf("id is required and must be positive")
}
qp := map[string]string{}
if params.MaxDepth > 0 {
qp["max_depth"] = strconv.Itoa(params.MaxDepth)
}
return s.proxyGetRaw(ctx, fmt.Sprintf("/api/observations/%d/graph", params.ID), qp)
}
// handleGetScoringBreakdownProxy proxies observation scoring breakdown requests.
func (s *Server) handleGetScoringBreakdownProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.ID <= 0 {
return "", fmt.Errorf("id is required and must be positive")
}
return s.proxyGetRaw(ctx, fmt.Sprintf("/api/observations/%d/score", params.ID), nil)
}
// handleTagObservationProxy proxies tag operations on observations.
// Fetches current tags, computes new tag set, then updates via PUT.
func (s *Server) handleTagObservationProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
Mode string `json:"mode"`
Tags []string `json:"tags"`
ID int64 `json:"id"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.ID == 0 {
return "", fmt.Errorf("id is required")
}
if len(params.Tags) == 0 {
return "", fmt.Errorf("tags is required")
}
if params.Mode == "" {
params.Mode = "add"
}
if params.Mode != "add" && params.Mode != "remove" && params.Mode != "set" {
return "", fmt.Errorf("mode must be 'add', 'remove', or 'set'")
}
// Fetch current observation to compute new tags
obsJSON, err := s.proxyGetRaw(ctx, fmt.Sprintf("/api/observations/%d", params.ID), nil)
if err != nil {
return "", fmt.Errorf("get observation: %w", err)
}
var obs struct {
Concepts []string `json:"concepts"`
}
if err := json.Unmarshal([]byte(obsJSON), &obs); err != nil {
return "", fmt.Errorf("parse observation: %w", err)
}
// Compute new tags
var newTags []string
switch params.Mode {
case "set":
newTags = params.Tags
case "add":
tagSet := make(map[string]bool)
for _, t := range obs.Concepts {
tagSet[t] = true
newTags = append(newTags, t)
}
for _, t := range params.Tags {
if !tagSet[t] {
newTags = append(newTags, t)
}
}
case "remove":
removeSet := make(map[string]bool)
for _, t := range params.Tags {
removeSet[t] = true
}
for _, t := range obs.Concepts {
if !removeSet[t] {
newTags = append(newTags, t)
}
}
}
// Update via PUT
updatePayload := map[string]any{
"concepts": newTags,
}
result, err := s.proxyPutRaw(ctx, fmt.Sprintf("/api/observations/%d", params.ID), updatePayload)
if err != nil {
return "", fmt.Errorf("update observation: %w", err)
}
// Wrap response
response := map[string]any{
"id": params.ID,
"mode": params.Mode,
"tags_applied": params.Tags,
"result": json.RawMessage(result),
}
output, err := json.Marshal(response)
if err != nil {
return "", fmt.Errorf("marshal response: %w", err)
}
return string(output), nil
}
// handleGetObservationsByTagProxy proxies tag-based observation lookup.
func (s *Server) handleGetObservationsByTagProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
Tag string `json:"tag"`
Project string `json:"project"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.Tag == "" {
return "", fmt.Errorf("tag is required")
}
if params.Project == "" {
params.Project = s.project
}
if params.Limit == 0 {
params.Limit = 50
}
return s.proxyGetRaw(ctx, "/api/context/search", map[string]string{
"project": params.Project,
"query": params.Tag,
"concepts": params.Tag,
"limit": strconv.Itoa(params.Limit),
})
}
// handleBulkStatusProxy proxies bulk status update operations.
func (s *Server) handleBulkStatusProxy(ctx context.Context, args json.RawMessage, action string) (string, error) {
var params struct {
IDs []int64 `json:"ids"`
Boost float64 `json:"boost"`
DeleteVectors bool `json:"delete_vectors"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if len(params.IDs) == 0 {
return "", fmt.Errorf("ids is required")
}
payload := map[string]any{
"ids": params.IDs,
"action": action,
}
if action == "boost" {
payload["boost"] = params.Boost
}
if action == "delete" {
payload["delete_vectors"] = params.DeleteVectors
}
return s.proxyPostRaw(ctx, "/api/observations/bulk-status", payload)
}
// handleMergeProxy proxies merge observations request.
func (s *Server) handleMergeProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
SourceID int64 `json:"source_id"`
TargetID int64 `json:"target_id"`
Boost float64 `json:"boost"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.SourceID == 0 || params.TargetID == 0 {
return "", fmt.Errorf("source_id and target_id are required")
}
// Merge = mark source as superseded + boost target
// Step 1: Mark source as superseded
_, err := s.proxyPostRaw(ctx, "/api/observations/bulk-status", map[string]any{
"ids": []int64{params.SourceID},
"action": "supersede",
})
if err != nil {
return "", fmt.Errorf("mark source as superseded: %w", err)
}
// Step 2: Boost target via feedback
_, err = s.proxyPostRaw(ctx, fmt.Sprintf("/api/observations/%d/feedback", params.TargetID), map[string]any{
"feedback": "positive",
})
if err != nil {
log.Warn().Err(err).Msg("Failed to boost target observation after merge")
}
response := map[string]any{
"merged": true,
"source_id": params.SourceID,
"target_id": params.TargetID,
}
output, err := json.Marshal(response)
if err != nil {
return "", fmt.Errorf("marshal response: %w", err)
}
return string(output), nil
}
// handleGetPatternsProxy proxies pattern queries.
func (s *Server) handleGetPatternsProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
Type string `json:"type"`
Project string `json:"project"`
Query string `json:"query"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
qp := map[string]string{}
if params.Type != "" {
qp["type"] = params.Type
}
if params.Project != "" {
qp["project"] = params.Project
}
if params.Limit > 0 {
qp["limit"] = strconv.Itoa(params.Limit)
}
// Use search endpoint if query provided, otherwise get all
if params.Query != "" {
qp["query"] = params.Query
return s.proxyGetRaw(ctx, "/api/patterns/search", qp)
}
return s.proxyGetRaw(ctx, "/api/patterns", qp)
}
// handleAnalyzeImportanceProxy proxies importance analysis.
func (s *Server) handleAnalyzeImportanceProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
Project string `json:"project"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.Project == "" {
params.Project = s.project
}
if params.Limit == 0 {
params.Limit = 10
}
qp := map[string]string{
"project": params.Project,
"limit": strconv.Itoa(params.Limit),
}
// Combine results from multiple endpoints
response := map[string]any{
"project": params.Project,
}
// Get top-scoring observations
topScored, err := s.proxyGetRaw(ctx, "/api/observations/top", qp)
if err == nil {
response["top_scoring"] = json.RawMessage(topScored)
}
// Get most-retrieved observations
mostRetrieved, err := s.proxyGetRaw(ctx, "/api/observations/most-retrieved", qp)
if err == nil {
response["most_retrieved"] = json.RawMessage(mostRetrieved)
}
// Get scoring stats
scoringStats, err := s.proxyGetRaw(ctx, "/api/scoring/stats", qp)
if err == nil {
response["scoring_stats"] = json.RawMessage(scoringStats)
}
// Get concept weights
conceptWeights, err := s.proxyGetRaw(ctx, "/api/scoring/concepts", nil)
if err == nil {
response["concept_weights"] = json.RawMessage(conceptWeights)
}
output, err := json.Marshal(response)
if err != nil {
return "", fmt.Errorf("marshal response: %w", err)
}
return string(output), nil
}
// handleExplainSearchProxy proxies search ranking explanation.
func (s *Server) handleExplainSearchProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
Query string `json:"query"`
Project string `json:"project"`
TopN int `json:"top_n"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.Query == "" {
return "", fmt.Errorf("query is required")
}
if params.Project == "" {
params.Project = s.project
}
if params.TopN == 0 {
params.TopN = 5
}
return s.proxyGetRaw(ctx, "/api/context/search", map[string]string{
"project": params.Project,
"query": params.Query,
"limit": strconv.Itoa(params.TopN),
})
}
// handleGetTemporalTrendsProxy proxies temporal trend analysis.
func (s *Server) handleGetTemporalTrendsProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
Project string `json:"project"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.Project == "" {
params.Project = s.project
}
return s.proxyGetRaw(ctx, "/api/stats", map[string]string{
"project": params.Project,
})
}
// handleGetDataQualityProxy proxies data quality report.
func (s *Server) handleGetDataQualityProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
Project string `json:"project"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.Project == "" {
params.Project = s.project
}
return s.proxyGetRaw(ctx, "/api/stats", map[string]string{
"project": params.Project,
})
}
// handleExportProxy proxies observation export requests.
func (s *Server) handleExportProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
Format string `json:"format"`
Project string `json:"project"`
ObsType string `json:"obs_type"`
Limit int `json:"limit"`
DateStart int64 `json:"date_start"`
DateEnd int64 `json:"date_end"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
qp := map[string]string{}
if params.Format != "" {
qp["format"] = params.Format
}
if params.Project != "" {
qp["project"] = params.Project
} else {
qp["project"] = s.project
}
if params.ObsType != "" {
qp["obs_type"] = params.ObsType
}
if params.Limit > 0 {
qp["limit"] = strconv.Itoa(params.Limit)
}
if params.DateStart > 0 {
qp["date_start"] = strconv.FormatInt(params.DateStart, 10)
}
if params.DateEnd > 0 {
qp["date_end"] = strconv.FormatInt(params.DateEnd, 10)
}
return s.proxyGetRaw(ctx, "/api/observations/export", qp)
}
// handleSuggestConsolidationsProxy proxies consolidation suggestions via duplicates endpoint.
func (s *Server) handleSuggestConsolidationsProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
Project string `json:"project"`
MinSimilarity float64 `json:"min_similarity"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.Project == "" {
params.Project = s.project
}
qp := map[string]string{
"project": params.Project,
}
if params.MinSimilarity > 0 {
qp["min_similarity"] = strconv.FormatFloat(params.MinSimilarity, 'f', -1, 64)
}
if params.Limit > 0 {
qp["limit"] = strconv.Itoa(params.Limit)
}
return s.proxyGetRaw(ctx, "/api/observations/duplicates", qp)
}
// handleBatchTagProxy proxies batch tag operations.
// Searches for matching observations and applies tags via PUT endpoint.
func (s *Server) handleBatchTagProxy(ctx context.Context, args json.RawMessage) (string, error) {
var params struct {
Pattern string `json:"pattern"`
Project string `json:"project"`
Tags []string `json:"tags"`
MaxMatches int `json:"max_matches"`
DryRun bool `json:"dry_run"`
}
params.DryRun = true
params.MaxMatches = 100
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if params.Pattern == "" {
return "", fmt.Errorf("pattern is required")
}
if len(params.Tags) == 0 {
return "", fmt.Errorf("tags is required")
}
if params.Project == "" {
params.Project = s.project
}
// Search for matching observations
searchResult, err := s.proxyGetRaw(ctx, "/api/context/search", map[string]string{
"project": params.Project,
"query": params.Pattern,
"limit": strconv.Itoa(params.MaxMatches),
})
if err != nil {
return "", fmt.Errorf("search: %w", err)
}
var searchResp struct {
Observations []struct {
ID int64 `json:"id"`
Title string `json:"title"`
} `json:"observations"`
}
if err := json.Unmarshal([]byte(searchResult), &searchResp); err != nil {
return "", fmt.Errorf("parse search results: %w", err)
}
matches := make([]map[string]any, 0, len(searchResp.Observations))
var taggedCount int
for _, obs := range searchResp.Observations {
matches = append(matches, map[string]any{
"id": obs.ID,
"title": obs.Title,
})
if !params.DryRun {
tagArgs, _ := json.Marshal(map[string]any{
"id": obs.ID,
"tags": params.Tags,
"mode": "add",
})
_, tagErr := s.handleTagObservationProxy(ctx, tagArgs)
if tagErr == nil {
taggedCount++
}
}
}
response := map[string]any{
"pattern": params.Pattern,
"tags": params.Tags,
"dry_run": params.DryRun,
"matches_found": len(matches),
"matches": matches,
}
if !params.DryRun {
response["tagged_count"] = taggedCount
}
output, err := json.Marshal(response)
if err != nil {
return "", fmt.Errorf("marshal response: %w", err)
}
return string(output), nil
}
// sendResponse sends a JSON-RPC response. Returns an error if writing fails.
func (s *Server) sendResponse(resp *Response) error {
data, err := json.Marshal(resp)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal response")
// Send a fallback error so the caller isn't left waiting (Fix #3)
fallback, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": resp.ID,
"error": map[string]any{"code": -32603, "message": "internal marshal error"},
})
s.writeMu.Lock()
_, _ = fmt.Fprintln(s.stdout, string(fallback))
s.writeMu.Unlock()
return fmt.Errorf("marshal error: %w", err)
}
// Bound the write to prevent pipe deadlock (Fix #2)
done := make(chan error, 1)
go func() {
s.writeMu.Lock()
_, werr := fmt.Fprintln(s.stdout, string(data))
s.writeMu.Unlock()
done <- werr
}()
select {
case werr := <-done:
if werr != nil {
log.Error().Err(werr).Msg("Failed to write response to stdout")
return werr
}
return nil
case <-time.After(10 * time.Second):
log.Error().Msg("Stdout write timed out — pipe likely full")
return fmt.Errorf("write timeout: stdout pipe full")
}
}
// sendError sends a JSON-RPC error response. Returns an error if writing fails.
func (s *Server) sendError(id any, code int, message string, data any) error {
resp := &Response{
JSONRPC: "2.0",
ID: id,
Error: &Error{
Code: code,
Message: message,
Data: data,
},
}
return s.sendResponse(resp)
}