a06f331eb8
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
340 lines
12 KiB
Go
340 lines
12 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/persistence"
|
|
)
|
|
|
|
// registerNotesTools wires the session-memory triplet:
|
|
//
|
|
// - save_note — create or update a note (auto-linked to symbols
|
|
// mentioned in the body)
|
|
// - query_notes — list / search notes by symbol / file / tag /
|
|
// session / text
|
|
// - distill_session — fold a session's notes into a digest the agent
|
|
// can paste back into context after compaction
|
|
//
|
|
// Each handler is scope-aware: notes inherit the session's workspace
|
|
// boundary, and `query_notes` never returns notes from a different
|
|
// workspace.
|
|
func (s *Server) registerNotesTools() {
|
|
s.addTool(
|
|
mcp.NewTool("save_note",
|
|
mcp.WithDescription("Persist an agent-authored note for the current session. Optionally attach to a symbol or file; the body is scanned for symbol references and auto-linked. Use to record decisions, gotchas, follow-ups, or context the agent will need after a compaction. Pass `id` to update an existing note."),
|
|
mcp.WithString("body", mcp.Description("Free-form note text. Symbol IDs (file/path.go::Name) and bare identifier names are auto-linked when they resolve in the graph.")),
|
|
mcp.WithString("symbol_id", mcp.Description("Primary symbol the note attaches to (optional)")),
|
|
mcp.WithString("file_path", mcp.Description("Primary file the note attaches to (optional)")),
|
|
mcp.WithString("tags", mcp.Description("Comma-separated labels — e.g. 'decision,bug,follow-up'. Notes tagged 'decision' are surfaced separately in distill_session.")),
|
|
mcp.WithString("links", mcp.Description("Comma-separated symbol IDs to attach explicitly (merged with auto-detected links)")),
|
|
mcp.WithBoolean("pinned", mcp.Description("Pinned notes are always returned by distill_session and never evicted by the store cap.")),
|
|
mcp.WithString("id", mcp.Description("Existing note ID — passing it switches the call from create to update.")),
|
|
mcp.WithBoolean("no_autolink", mcp.Description("Skip the body→symbol auto-linker (set true when the body intentionally mentions identifiers that should not be linked).")),
|
|
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
|
|
),
|
|
s.handleSaveNote,
|
|
)
|
|
|
|
s.addTool(
|
|
mcp.NewTool("query_notes",
|
|
mcp.WithDescription("Search saved notes for the current session / workspace. Filter by symbol, file, tag, free-text body match, session, or recency."),
|
|
mcp.WithString("symbol_id", mcp.Description("Return notes attached or auto-linked to this symbol")),
|
|
mcp.WithString("file_path", mcp.Description("Return notes attached to this file path")),
|
|
mcp.WithString("tag", mcp.Description("Return notes carrying this tag (case-insensitive)")),
|
|
mcp.WithString("text", mcp.Description("Case-insensitive substring filter on the body")),
|
|
mcp.WithString("session_id", mcp.Description("Limit to a specific session. Defaults to the current session; pass 'all' to query every session.")),
|
|
mcp.WithString("since", mcp.Description("Only return notes updated at or after this RFC-3339 timestamp")),
|
|
mcp.WithNumber("limit", mcp.Description("Cap the result set (default: 50)")),
|
|
mcp.WithBoolean("pinned_only", mcp.Description("Return only pinned notes")),
|
|
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
|
|
),
|
|
s.handleQueryNotes,
|
|
)
|
|
|
|
s.addTool(
|
|
mcp.NewTool("distill_session",
|
|
mcp.WithDescription("Aggregate a session's saved notes into a digest: top symbols / files / tags, pinned notes, recent excerpts, and a short summary. Use after a context compaction to recover what the agent was doing."),
|
|
mcp.WithString("session_id", mcp.Description("Session to distill. Defaults to the current session; pass 'all' to distill every session for the current workspace.")),
|
|
mcp.WithNumber("max_symbols", mcp.Description("Cap on the top-symbols list (default: 10)")),
|
|
mcp.WithNumber("max_files", mcp.Description("Cap on the top-files list (default: 10)")),
|
|
mcp.WithNumber("max_tags", mcp.Description("Cap on the top-tags list (default: 10)")),
|
|
mcp.WithNumber("max_recent", mcp.Description("Number of recent note excerpts to include (default: 8)")),
|
|
mcp.WithNumber("excerpt_chars", mcp.Description("Body excerpt cap in bytes (default: 240)")),
|
|
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
|
|
),
|
|
s.handleDistillSession,
|
|
)
|
|
}
|
|
|
|
// handleSaveNote — create-or-update entry point. Update mode is
|
|
// selected by passing an existing `id`; everything else is a create.
|
|
func (s *Server) handleSaveNote(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
if s.notes == nil {
|
|
return mcp.NewToolResultError("notes storage not initialised"), nil
|
|
}
|
|
|
|
id := strings.TrimSpace(req.GetString("id", ""))
|
|
body := req.GetString("body", "")
|
|
symbolID := strings.TrimSpace(req.GetString("symbol_id", ""))
|
|
filePath := strings.TrimSpace(req.GetString("file_path", ""))
|
|
tags := splitCSV(req.GetString("tags", ""))
|
|
links := splitCSV(req.GetString("links", ""))
|
|
pinned := req.GetBool("pinned", false)
|
|
noAutolink := req.GetBool("no_autolink", false)
|
|
|
|
if id == "" && body == "" && symbolID == "" && filePath == "" {
|
|
return mcp.NewToolResultError("save_note requires at least one of: body, symbol_id, file_path"), nil
|
|
}
|
|
|
|
// Update path. body / tags / links default to "leave alone" when
|
|
// the caller didn't supply them. pinned is always re-applied
|
|
// (agents should pass it explicitly on updates that care).
|
|
if id != "" {
|
|
var bodyPtr *string
|
|
if body != "" {
|
|
bodyPtr = &body
|
|
}
|
|
pinnedPtr := &pinned
|
|
var addLinks []string
|
|
if !noAutolink && body != "" {
|
|
addLinks = append(addLinks, autoLinkBody(body, s.graph, sessionWorkspaceIDOrEmpty(s, ctx), defaultAutoLinkOptions())...)
|
|
}
|
|
addLinks = append(addLinks, links...)
|
|
updated, err := s.notes.Update(id, bodyPtr, tags, pinnedPtr, addLinks)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("update note: %v", err)), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, noteEntryToWire(updated))
|
|
}
|
|
|
|
// Create path. Stamp scope from session + (when available) the
|
|
// attached symbol's node, so notes inherit the workspace boundary
|
|
// without forcing the caller to think about it.
|
|
workspaceID, projectID, _ := s.sessionScope(ctx)
|
|
repoPrefix, _ := s.sessionLocality(ctx)
|
|
|
|
if symbolID != "" {
|
|
if node := s.graph.GetNode(symbolID); node != nil {
|
|
if workspaceID == "" {
|
|
workspaceID = node.WorkspaceID
|
|
}
|
|
if projectID == "" {
|
|
projectID = node.ProjectID
|
|
}
|
|
if repoPrefix == "" {
|
|
repoPrefix = node.RepoPrefix
|
|
}
|
|
}
|
|
}
|
|
|
|
autoLinks := links
|
|
if !noAutolink && body != "" {
|
|
autoLinks = append(autoLinks, autoLinkBody(body, s.graph, workspaceID, defaultAutoLinkOptions())...)
|
|
}
|
|
// Always ensure the explicit symbol_id ends up on the link list
|
|
// so query_notes by symbol_id matches even when the body did not
|
|
// mention it.
|
|
if symbolID != "" {
|
|
autoLinks = append(autoLinks, symbolID)
|
|
}
|
|
|
|
entry := persistence.NoteEntry{
|
|
SessionID: SessionIDFromContext(ctx),
|
|
ClientName: sessionClientName(s, ctx),
|
|
Body: body,
|
|
SymbolID: symbolID,
|
|
FilePath: filePath,
|
|
RepoPrefix: repoPrefix,
|
|
WorkspaceID: workspaceID,
|
|
ProjectID: projectID,
|
|
Tags: tags,
|
|
AutoLinks: autoLinks,
|
|
Pinned: pinned,
|
|
}
|
|
|
|
newID, err := s.notes.Save(entry)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("save note: %v", err)), nil
|
|
}
|
|
entry.ID = newID
|
|
saved, _ := s.notes.Get(newID)
|
|
return s.respondJSONOrTOON(ctx, req, noteEntryToWire(saved))
|
|
}
|
|
|
|
// handleQueryNotes — multi-filter listing. Honours the session
|
|
// workspace boundary: every result is required to live inside the
|
|
// session's workspace (or carry no workspace when the session is
|
|
// unbound).
|
|
func (s *Server) handleQueryNotes(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
if s.notes == nil {
|
|
return mcp.NewToolResultError("notes storage not initialised"), nil
|
|
}
|
|
|
|
limit := req.GetInt("limit", 50)
|
|
filter := NoteQueryFilter{
|
|
SymbolID: strings.TrimSpace(req.GetString("symbol_id", "")),
|
|
FilePath: strings.TrimSpace(req.GetString("file_path", "")),
|
|
Tag: strings.TrimSpace(req.GetString("tag", "")),
|
|
TextSearch: req.GetString("text", ""),
|
|
Limit: limit,
|
|
}
|
|
|
|
sess := strings.TrimSpace(req.GetString("session_id", ""))
|
|
switch sess {
|
|
case "":
|
|
filter.SessionID = SessionIDFromContext(ctx)
|
|
case "all", "*":
|
|
filter.SessionID = ""
|
|
default:
|
|
filter.SessionID = sess
|
|
}
|
|
|
|
if since := strings.TrimSpace(req.GetString("since", "")); since != "" {
|
|
if ts, err := time.Parse(time.RFC3339, since); err == nil {
|
|
filter.Since = ts
|
|
} else {
|
|
return mcp.NewToolResultError(fmt.Sprintf("invalid `since` timestamp: %v", err)), nil
|
|
}
|
|
}
|
|
if req.GetBool("pinned_only", false) {
|
|
yes := true
|
|
filter.Pinned = &yes
|
|
}
|
|
|
|
if workspaceID, _, bound := s.sessionScope(ctx); bound {
|
|
filter.WorkspaceID = workspaceID
|
|
}
|
|
|
|
notes := s.notes.Query(filter)
|
|
wire := make([]map[string]any, 0, len(notes))
|
|
for _, n := range notes {
|
|
wire = append(wire, noteEntryToWire(n))
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"notes": wire,
|
|
"total": len(wire),
|
|
})
|
|
}
|
|
|
|
// handleDistillSession — assemble the per-session digest.
|
|
func (s *Server) handleDistillSession(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
if s.notes == nil {
|
|
return mcp.NewToolResultError("notes storage not initialised"), nil
|
|
}
|
|
|
|
opts := defaultDistillOptions()
|
|
if v := req.GetInt("max_symbols", 0); v > 0 {
|
|
opts.MaxSymbols = v
|
|
}
|
|
if v := req.GetInt("max_files", 0); v > 0 {
|
|
opts.MaxFiles = v
|
|
}
|
|
if v := req.GetInt("max_tags", 0); v > 0 {
|
|
opts.MaxTags = v
|
|
}
|
|
if v := req.GetInt("max_recent", 0); v > 0 {
|
|
opts.MaxRecent = v
|
|
}
|
|
if v := req.GetInt("excerpt_chars", 0); v > 0 {
|
|
opts.ExcerptCap = v
|
|
}
|
|
|
|
sessionID := ""
|
|
switch strings.TrimSpace(req.GetString("session_id", "")) {
|
|
case "":
|
|
sessionID = SessionIDFromContext(ctx)
|
|
case "all", "*":
|
|
sessionID = ""
|
|
default:
|
|
sessionID = req.GetString("session_id", "")
|
|
}
|
|
|
|
var workspaceID, projectID string
|
|
if ws, proj, bound := s.sessionScope(ctx); bound {
|
|
workspaceID = ws
|
|
projectID = proj
|
|
}
|
|
|
|
res := s.notes.DistillSession(sessionID, workspaceID, projectID, opts, func(id string) *graph.Node {
|
|
if s.graph == nil {
|
|
return nil
|
|
}
|
|
return s.graph.GetNode(id)
|
|
})
|
|
return s.respondJSONOrTOON(ctx, req, res)
|
|
}
|
|
|
|
// noteEntryToWire shapes a stored note for inclusion in JSON / GCX /
|
|
// TOON responses.
|
|
func noteEntryToWire(e persistence.NoteEntry) map[string]any {
|
|
m := map[string]any{
|
|
"id": e.ID,
|
|
"timestamp": e.Timestamp.UTC().Format(time.RFC3339),
|
|
"updated_at": e.UpdatedAt.UTC().Format(time.RFC3339),
|
|
"body": e.Body,
|
|
}
|
|
if e.SessionID != "" {
|
|
m["session_id"] = e.SessionID
|
|
}
|
|
if e.ClientName != "" {
|
|
m["client_name"] = e.ClientName
|
|
}
|
|
if e.SymbolID != "" {
|
|
m["symbol_id"] = e.SymbolID
|
|
}
|
|
if e.FilePath != "" {
|
|
m["file_path"] = e.FilePath
|
|
}
|
|
if e.WorkspaceID != "" {
|
|
m["workspace_id"] = e.WorkspaceID
|
|
}
|
|
if e.ProjectID != "" {
|
|
m["project_id"] = e.ProjectID
|
|
}
|
|
if e.RepoPrefix != "" {
|
|
m["repo_prefix"] = e.RepoPrefix
|
|
}
|
|
if len(e.Tags) > 0 {
|
|
m["tags"] = e.Tags
|
|
}
|
|
if len(e.AutoLinks) > 0 {
|
|
m["links"] = e.AutoLinks
|
|
}
|
|
if e.Pinned {
|
|
m["pinned"] = true
|
|
}
|
|
return m
|
|
}
|
|
|
|
// sessionWorkspaceIDOrEmpty returns the session's workspace ID,
|
|
// falling back to "" for unbound sessions. Helper because the
|
|
// autoLinkBody signature takes a single string.
|
|
func sessionWorkspaceIDOrEmpty(s *Server, ctx context.Context) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
ws, _, bound := s.sessionScope(ctx)
|
|
if !bound {
|
|
return ""
|
|
}
|
|
return ws
|
|
}
|
|
|
|
// sessionClientName pulls the captured MCP clientInfo.name for the
|
|
// current session. Empty until the dispatcher snoops `initialize`.
|
|
func sessionClientName(s *Server, ctx context.Context) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
sess := s.sessionFor(ctx)
|
|
if sess == nil {
|
|
return ""
|
|
}
|
|
return sess.snapshotClientName()
|
|
}
|