Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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()
}