mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-12 04:55:18 +00:00
Initial commit
This commit is contained in:
@@ -0,0 +1,562 @@
|
||||
// Package mcp provides the MCP (Model Context Protocol) server for claude-mnemonic.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/lukaszraczylo/claude-mnemonic/internal/search"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Server is the MCP server that exposes search tools.
|
||||
type Server struct {
|
||||
searchMgr *search.Manager
|
||||
version string
|
||||
stdin io.Reader
|
||||
stdout io.Writer
|
||||
}
|
||||
|
||||
// NewServer creates a new MCP server.
|
||||
func NewServer(searchMgr *search.Manager, version string) *Server {
|
||||
return &Server{
|
||||
searchMgr: searchMgr,
|
||||
version: version,
|
||||
stdin: os.Stdin,
|
||||
stdout: os.Stdout,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID any `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *Error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Error represents a JSON-RPC error.
|
||||
type Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"inputSchema"`
|
||||
}
|
||||
|
||||
// Run starts the MCP server loop.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
scanner := bufio.NewScanner(s.stdin)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := json.Unmarshal([]byte(line), &req); err != nil {
|
||||
s.sendError(nil, -32700, "Parse error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
resp := s.handleRequest(ctx, &req)
|
||||
s.sendResponse(resp)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("scanner error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRequest dispatches the request to the appropriate handler.
|
||||
func (s *Server) handleRequest(ctx context.Context, req *Request) *Response {
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
return s.handleInitialize(req)
|
||||
case "tools/list":
|
||||
return s.handleToolsList(req)
|
||||
case "tools/call":
|
||||
return s.handleToolsCall(ctx, req)
|
||||
default:
|
||||
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 (ChromaDB).",
|
||||
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: "decisions",
|
||||
Description: "Semantic shortcut for finding architectural, design, and implementation decisions.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"query"},
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Natural language query for finding decisions"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"limit": map[string]any{"type": "number", "default": 20, "minimum": 1, "maximum": 100},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "changes",
|
||||
Description: "Semantic shortcut for finding code changes, refactorings, and modifications.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"query"},
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Natural language query for finding changes"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"limit": map[string]any{"type": "number", "default": 20, "minimum": 1, "maximum": 100},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "how_it_works",
|
||||
Description: "Semantic shortcut for understanding system architecture, design patterns, and implementation details.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"query"},
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Natural language query for understanding how something works"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"limit": map[string]any{"type": "number", "default": 20, "minimum": 1, "maximum": 100},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "find_by_concept",
|
||||
Description: "Find observations tagged with specific concepts.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"concepts"},
|
||||
"properties": map[string]any{
|
||||
"concepts": map[string]any{"type": "string", "description": "Concept tag(s) to filter by"},
|
||||
"type": map[string]any{"type": "string"},
|
||||
"files": map[string]any{"type": "string"},
|
||||
"project": map[string]any{"type": "string"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"orderBy": map[string]any{"type": "string", "enum": []string{"date_desc", "date_asc"}, "default": "date_desc"},
|
||||
"limit": map[string]any{"type": "number", "default": 20},
|
||||
"offset": map[string]any{"type": "number", "default": 0},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "find_by_file",
|
||||
Description: "Find observations related to specific file paths.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"files"},
|
||||
"properties": map[string]any{
|
||||
"files": map[string]any{"type": "string", "description": "File path(s) to filter by"},
|
||||
"type": map[string]any{"type": "string"},
|
||||
"concepts": map[string]any{"type": "string"},
|
||||
"project": map[string]any{"type": "string"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"orderBy": map[string]any{"type": "string", "enum": []string{"date_desc", "date_asc"}, "default": "date_desc"},
|
||||
"limit": map[string]any{"type": "number", "default": 20},
|
||||
"offset": map[string]any{"type": "number", "default": 0},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "find_by_type",
|
||||
Description: "Find observations of specific types.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"type"},
|
||||
"properties": map[string]any{
|
||||
"type": map[string]any{"type": "string", "description": "Observation type(s) to filter by"},
|
||||
"concepts": map[string]any{"type": "string"},
|
||||
"files": map[string]any{"type": "string"},
|
||||
"project": map[string]any{"type": "string"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"orderBy": map[string]any{"type": "string", "enum": []string{"date_desc", "date_asc"}, "default": "date_desc"},
|
||||
"limit": map[string]any{"type": "number", "default": 20},
|
||||
"offset": map[string]any{"type": "number", "default": 0},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_recent_context",
|
||||
Description: "Get recent session context for timeline display.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"project": map[string]any{"type": "string"},
|
||||
"type": map[string]any{"type": "string"},
|
||||
"concepts": map[string]any{"type": "string"},
|
||||
"files": map[string]any{"type": "string"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"limit": map[string]any{"type": "number", "default": 30, "minimum": 1, "maximum": 100},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_context_timeline",
|
||||
Description: "Get timeline of observations around a specific observation ID.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"anchor_id"},
|
||||
"properties": map[string]any{
|
||||
"anchor_id": map[string]any{"type": "number", "description": "Observation ID to use as anchor point"},
|
||||
"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"},
|
||||
"type": map[string]any{"type": "string"},
|
||||
"concepts": map[string]any{"type": "string"},
|
||||
"files": map[string]any{"type": "string"},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_timeline_by_query",
|
||||
Description: "Combined search + timeline tool. First searches for observations matching the query, then returns timeline around the best match.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"query"},
|
||||
"properties": map[string]any{
|
||||
"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"},
|
||||
"type": map[string]any{"type": "string"},
|
||||
"concepts": map[string]any{"type": "string"},
|
||||
"files": map[string]any{"type": "string"},
|
||||
"dateStart": map[string]any{"type": []string{"string", "number"}},
|
||||
"dateEnd": map[string]any{"type": []string{"string", "number"}},
|
||||
"format": map[string]any{"type": "string", "enum": []string{"index", "full"}, "default": "index"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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, ¶ms); 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 {
|
||||
return &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Error: &Error{
|
||||
Code: -32000,
|
||||
Message: "Tool error",
|
||||
Data: err.Error(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
Result: map[string]any{
|
||||
"content": []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": result,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// callTool dispatches to the appropriate tool handler.
|
||||
func (s *Server) callTool(ctx context.Context, name string, args json.RawMessage) (string, error) {
|
||||
var params search.SearchParams
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
var result *search.UnifiedSearchResult
|
||||
var err error
|
||||
|
||||
switch name {
|
||||
case "search":
|
||||
result, err = s.searchMgr.UnifiedSearch(ctx, params)
|
||||
case "timeline":
|
||||
result, err = s.handleTimeline(ctx, args)
|
||||
case "decisions":
|
||||
result, err = s.searchMgr.Decisions(ctx, params)
|
||||
case "changes":
|
||||
result, err = s.searchMgr.Changes(ctx, params)
|
||||
case "how_it_works":
|
||||
result, err = s.searchMgr.HowItWorks(ctx, params)
|
||||
case "find_by_concept":
|
||||
params.Type = "observations"
|
||||
result, err = s.searchMgr.UnifiedSearch(ctx, params)
|
||||
case "find_by_file":
|
||||
params.Type = "observations"
|
||||
result, err = s.searchMgr.UnifiedSearch(ctx, params)
|
||||
case "find_by_type":
|
||||
params.Type = "observations"
|
||||
result, err = s.searchMgr.UnifiedSearch(ctx, params)
|
||||
case "get_recent_context":
|
||||
result, err = s.searchMgr.UnifiedSearch(ctx, params)
|
||||
case "get_context_timeline":
|
||||
result, err = s.handleTimeline(ctx, args)
|
||||
case "get_timeline_by_query":
|
||||
result, err = s.handleTimelineByQuery(ctx, args)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown tool: %s", name)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
output, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal result: %w", err)
|
||||
}
|
||||
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// TimelineParams represents parameters for timeline operations.
|
||||
type TimelineParams struct {
|
||||
AnchorID int64 `json:"anchor_id"`
|
||||
Query string `json:"query"`
|
||||
Before int `json:"before"`
|
||||
After int `json:"after"`
|
||||
Project string `json:"project"`
|
||||
ObsType string `json:"obs_type"`
|
||||
Concepts string `json:"concepts"`
|
||||
Files string `json:"files"`
|
||||
DateStart int64 `json:"dateStart"`
|
||||
DateEnd int64 `json:"dateEnd"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
// handleTimeline handles timeline requests.
|
||||
func (s *Server) handleTimeline(ctx context.Context, args json.RawMessage) (*search.UnifiedSearchResult, error) {
|
||||
var params TimelineParams
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid timeline params: %w", err)
|
||||
}
|
||||
|
||||
if params.Before <= 0 {
|
||||
params.Before = 10
|
||||
}
|
||||
if params.After <= 0 {
|
||||
params.After = 10
|
||||
}
|
||||
|
||||
// If query provided, first find anchor
|
||||
if params.Query != "" && params.AnchorID == 0 {
|
||||
searchParams := search.SearchParams{
|
||||
Query: params.Query,
|
||||
Type: "observations",
|
||||
Project: params.Project,
|
||||
Limit: 1,
|
||||
}
|
||||
result, err := s.searchMgr.UnifiedSearch(ctx, searchParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(result.Results) > 0 {
|
||||
params.AnchorID = result.Results[0].ID
|
||||
}
|
||||
}
|
||||
|
||||
if params.AnchorID == 0 {
|
||||
return &search.UnifiedSearchResult{Results: []search.SearchResult{}}, nil
|
||||
}
|
||||
|
||||
// Fetch observations around anchor
|
||||
searchParams := search.SearchParams{
|
||||
Type: "observations",
|
||||
Project: params.Project,
|
||||
ObsType: params.ObsType,
|
||||
Concepts: params.Concepts,
|
||||
Files: params.Files,
|
||||
Limit: params.Before + params.After + 1,
|
||||
Format: params.Format,
|
||||
}
|
||||
|
||||
return s.searchMgr.UnifiedSearch(ctx, searchParams)
|
||||
}
|
||||
|
||||
// handleTimelineByQuery handles combined search + timeline requests.
|
||||
func (s *Server) handleTimelineByQuery(ctx context.Context, args json.RawMessage) (*search.UnifiedSearchResult, error) {
|
||||
var params TimelineParams
|
||||
if err := json.Unmarshal(args, ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("invalid timeline params: %w", err)
|
||||
}
|
||||
|
||||
if params.Query == "" {
|
||||
return nil, fmt.Errorf("query is required")
|
||||
}
|
||||
|
||||
// First search
|
||||
searchParams := search.SearchParams{
|
||||
Query: params.Query,
|
||||
Type: "observations",
|
||||
Project: params.Project,
|
||||
DateStart: params.DateStart,
|
||||
DateEnd: params.DateEnd,
|
||||
Limit: 1,
|
||||
}
|
||||
|
||||
result, err := s.searchMgr.UnifiedSearch(ctx, searchParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(result.Results) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Now get timeline around that result
|
||||
params.AnchorID = result.Results[0].ID
|
||||
return s.handleTimeline(ctx, args)
|
||||
}
|
||||
|
||||
// sendResponse sends a JSON-RPC response.
|
||||
func (s *Server) sendResponse(resp *Response) {
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to marshal response")
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(s.stdout, string(data))
|
||||
}
|
||||
|
||||
// sendError sends a JSON-RPC error response.
|
||||
func (s *Server) sendError(id any, code int, message string, data any) {
|
||||
resp := &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Error: &Error{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Data: data,
|
||||
},
|
||||
}
|
||||
s.sendResponse(resp)
|
||||
}
|
||||
Reference in New Issue
Block a user