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

218 lines
7.9 KiB
Go

package mcp
import (
"context"
"fmt"
"strings"
"time"
"github.com/mark3labs/mcp-go/mcp"
)
// notebookTimeFormat is RFC3339Nano so consecutive saves within
// the same second remain distinguishable on wire output.
const notebookTimeFormat = time.RFC3339Nano
// registerNotebookTools wires the repository-local persistent
// notebook — the third memory axis below session notes
// (per-session) and cross-session memories (workspace-wide, in the
// cache dir). Notebook entries live at .gortex/notebook/<id>.md so
// they can be committed to git and reviewed in PRs.
//
// Surface:
// notebook_save — create or update an entry
// notebook_find — case-insensitive substring search over
// title / body / tags
// notebook_list — every entry sorted Updated DESC
// notebook_show — return a single entry by ID
// notebook_used — bump UsedCount + LastUsed (resets TTL)
//
// 30-day TTL pruner runs at every save: entries unused (or never
// touched after Updated) for longer than that age out.
func (s *Server) registerNotebookTools() {
s.addTool(
mcp.NewTool("notebook_save",
mcp.WithDescription("Persist a repository-local notebook entry to .gortex/notebook/<id>.md. Entries are checked into git, travel with the repo, and survive daemon restarts. Use for agent-authored notes that should be visible in PR review (vs cross-session memories which live in the cache dir). Pass `id` to update an existing entry; omit to create with a fresh ID."),
mcp.WithString("body", mcp.Description("Markdown body. Required for create; optional for update.")),
mcp.WithString("title", mcp.Description("Short caption for the entry list.")),
mcp.WithString("tags", mcp.Description("Comma-separated tags.")),
mcp.WithString("id", mcp.Description("Existing entry ID — passing it switches the call from create to update.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleNotebookSave,
)
s.addTool(
mcp.NewTool("notebook_find",
mcp.WithDescription("Case-insensitive substring search over notebook entries' title / body / tags. Empty query returns every entry sorted Updated DESC. Pairs with notebook_show for full-body retrieval after a hit."),
mcp.WithString("query", mcp.Description("Substring to match (case-insensitive). Empty = list all.")),
mcp.WithNumber("limit", mcp.Description("Cap on the result set (default: 50).")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleNotebookFind,
)
s.addTool(
mcp.NewTool("notebook_list",
mcp.WithDescription("List every notebook entry sorted Updated DESC. Returns metadata only — call notebook_show for the body. Pairs with notebook_find when you want to filter."),
mcp.WithNumber("limit", mcp.Description("Cap on the result set (default: 50).")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleNotebookList,
)
s.addTool(
mcp.NewTool("notebook_show",
mcp.WithDescription("Return a single notebook entry by ID with its full markdown body. Use after notebook_find / notebook_list to drill into a hit."),
mcp.WithString("id", mcp.Description("Notebook entry ID.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleNotebookShow,
)
s.addTool(
mcp.NewTool("notebook_used",
mcp.WithDescription("Mark a notebook entry as 'in active use' — increments UsedCount and resets LastUsed, which also resets the TTL pruner's clock so load-bearing entries don't age out. Pin a draft you'll come back to without copying it into memories."),
mcp.WithString("id", mcp.Description("Notebook entry ID.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleNotebookUsed,
)
}
// notebookEntryToWire shapes an entry for JSON / GCX / TOON output.
// Timestamps render in RFC3339Nano so consecutive saves within the
// same second remain distinguishable in test assertions and audit
// logs.
func notebookEntryToWire(e notebookEntry, includeBody bool) map[string]any {
m := map[string]any{
"id": e.ID,
"title": e.Title,
"tags": e.Tags,
"created": e.Created.UTC().Format(notebookTimeFormat),
"updated": e.Updated.UTC().Format(notebookTimeFormat),
}
if !e.LastUsed.IsZero() {
m["last_used"] = e.LastUsed.UTC().Format(notebookTimeFormat)
}
if e.UsedCount > 0 {
m["used_count"] = e.UsedCount
}
if includeBody {
m["body"] = e.Body
}
return m
}
func (s *Server) handleNotebookSave(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.notebook == nil {
return mcp.NewToolResultError("notebook not initialised (call InitNotebook)"), nil
}
body := req.GetString("body", "")
title := strings.TrimSpace(req.GetString("title", ""))
tags := splitCSV(req.GetString("tags", ""))
id := strings.TrimSpace(req.GetString("id", ""))
entry := notebookEntry{
ID: id,
Title: title,
Tags: tags,
Body: body,
}
// Preserve creation timestamp + used_count on update.
if id != "" {
if existing, ok := s.notebook.Get(id); ok {
entry.Created = existing.Created
entry.UsedCount = existing.UsedCount
entry.LastUsed = existing.LastUsed
if body == "" {
entry.Body = existing.Body
}
if title == "" {
entry.Title = existing.Title
}
if len(tags) == 0 {
entry.Tags = existing.Tags
}
}
}
if entry.Body == "" && entry.Title == "" && len(entry.Tags) == 0 {
return mcp.NewToolResultError("notebook_save requires at least one of: body, title, tags"), nil
}
saved, err := s.notebook.Save(entry)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("save notebook: %v", err)), nil
}
return s.respondJSONOrTOON(ctx, req, notebookEntryToWire(saved, true))
}
func (s *Server) handleNotebookFind(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.notebook == nil {
return mcp.NewToolResultError("notebook not initialised"), nil
}
query := req.GetString("query", "")
limit := max(req.GetInt("limit", 50), 1)
hits := s.notebook.Find(query)
if len(hits) > limit {
hits = hits[:limit]
}
wire := make([]map[string]any, 0, len(hits))
for _, e := range hits {
wire = append(wire, notebookEntryToWire(e, false))
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"entries": wire,
"total": len(wire),
"query": query,
})
}
func (s *Server) handleNotebookList(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.notebook == nil {
return mcp.NewToolResultError("notebook not initialised"), nil
}
limit := max(req.GetInt("limit", 50), 1)
all := s.notebook.List()
if len(all) > limit {
all = all[:limit]
}
wire := make([]map[string]any, 0, len(all))
for _, e := range all {
wire = append(wire, notebookEntryToWire(e, false))
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"entries": wire,
"total": len(wire),
})
}
func (s *Server) handleNotebookShow(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.notebook == nil {
return mcp.NewToolResultError("notebook not initialised"), nil
}
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError("id is required"), nil
}
entry, ok := s.notebook.Get(id)
if !ok {
return mcp.NewToolResultError(fmt.Sprintf("notebook entry %q not found", id)), nil
}
return s.respondJSONOrTOON(ctx, req, notebookEntryToWire(entry, true))
}
func (s *Server) handleNotebookUsed(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.notebook == nil {
return mcp.NewToolResultError("notebook not initialised"), nil
}
id, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError("id is required"), nil
}
entry, mErr := s.notebook.MarkUsed(id)
if mErr != nil {
return mcp.NewToolResultError(fmt.Sprintf("mark used: %v", mErr)), nil
}
return s.respondJSONOrTOON(ctx, req, notebookEntryToWire(entry, false))
}