Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions internal/cli/commands_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func cmdServer(ctx context.Context, action string, args Args) error {
if args.Bool("json") {
emitJSON(status)
} else {
emitLine(fmt.Sprintf("%s %s — %s\nActive: %t Healthy: %t\nVault: %s", name, status.Version, status.URL, status.Active, status.Healthy, status.Vault))
emitLine(fmt.Sprintf("%s %s at %s\nActive: %t Healthy: %t\nVault: %s", name, status.Version, status.URL, status.Active, status.Healthy, status.Vault))
if status.Error != "" {
emitLine(status.Error)
}
Expand Down Expand Up @@ -122,7 +122,7 @@ func cmdServer(ctx context.Context, action string, args Args) error {
if args.Bool("json") {
emitJSON(i)
} else {
emitOK(fmt.Sprintf("%s: %s complete — version %s, %s", i.Name, action, i.Version, i.URL()))
emitOK(fmt.Sprintf("%s: %s complete (version %s, %s)", i.Name, action, i.Version, i.URL()))
}
return nil
}
164 changes: 164 additions & 0 deletions internal/mcp/agentedits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package mcp

import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"

"github.com/BurntSushi/toml"

"github.com/ZenNotes/tui/internal/backend"
"github.com/ZenNotes/tui/internal/config"
"github.com/ZenNotes/tui/internal/vault"
)

// Keeping what a note said before an assistant changed it, so the user can
// review the change in the desktop app (ZenNotes/zennotes
// docs/ideas/agent-edit-review.md). Every tool that rewrites a note's text
// runs through runRecording: it writes the record BEFORE the edit, lets the
// tool work, then notes how the note looks now. Mirrors
// apps/desktop/src/mcp/agent-edit-recorder.ts in ZenNotes/zennotes: same
// tools, same order, same record.

// recordedTools are the tools that change a note's text, and where each one
// names the note. Creating, moving, renaming and deleting notes are not
// recorded: a new note has nothing to compare against, and Trash keeps the rest.
var recordedTools = map[string]func(a args) string{
"write_note": pathArg,
"append_to_note": pathArg,
"prepend_to_note": pathArg,
"insert_at_line": pathArg,
"replace_in_note": pathArg,
"toggle_task": func(a args) string {
id, _ := a["id"].(string)
hash := strings.LastIndex(id, "#")
if hash <= 0 {
return ""
}
return id[:hash]
},
}

func pathArg(a args) string {
rel, _ := a["path"].(string)
return strings.TrimSpace(rel)
}

// clientDisplayNames mirrors CLIENT_DISPLAY_NAMES in the desktop's MCP server,
// so a record names the assistant the way its comments do.
var clientDisplayNames = map[string]string{
"claude-ai": "Claude",
"claude-code": "Claude Code",
"claude-desktop": "Claude",
"codex-cli": "Codex",
"codex": "Codex",
}

// agentForClient is the display name for an MCP client's initialize name.
func agentForClient(name string) string {
raw := strings.TrimSpace(name)
if raw == "" {
return "Assistant"
}
if known, ok := clientDisplayNames[strings.ToLower(raw)]; ok {
return known
}
words := strings.FieldsFunc(raw, func(r rune) bool { return r == '-' || r == '_' || r == ' ' })
for i, w := range words {
words[i] = strings.ToUpper(w[:1]) + w[1:]
}
return strings.Join(words, " ")
}

// reviewAgentEditsEnabled reads Settings → MCP → "Review agent edits" from the
// portable config the app writes (`[mcp] review_agent_edits`). On when the
// file or the key is missing, matching the app's default.
func reviewAgentEditsEnabled() bool {
raw, err := os.ReadFile(config.ConfigTomlPath())
if err != nil {
return true
}
var doc struct {
MCP struct {
ReviewAgentEdits *bool `toml:"review_agent_edits"`
} `toml:"mcp"`
}
if _, err := toml.Decode(string(raw), &doc); err != nil || doc.MCP.ReviewAgentEdits == nil {
return true
}
return *doc.MCP.ReviewAgentEdits
}

// Tool calls are served concurrently, and two edits to one note racing on its
// record would lose one of them, so edits to the same note take turns.
var noteLocks sync.Map

func lockNote(key string) func() {
value, _ := noteLocks.LoadOrStore(key, &sync.Mutex{})
mu := value.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}

func noteText(ctx context.Context, b Backend, rel string) (string, bool) {
content, err := b.ReadNote(ctx, rel)
if err != nil {
return "", false
}
return content.Body, true
}

// runRecording runs a tool, recording its edit when it rewrites a note of a
// local vault. Recording never stands in the way of the edit the user asked
// for: if the record cannot be written, the tool still runs and the change is
// simply not offered for review.
func runRecording(ctx context.Context, tool string, a args, b Backend, agent string, run func() (any, error)) (any, error) {
noteOf, ok := recordedTools[tool]
// A remote vault's notes are written by its server; recording there needs
// the server's own change.
if !ok || b.Kind() != backend.KindLocal {
return run()
}
rel := noteOf(a)
if rel == "" {
return run()
}
unlock := lockNote(b.Root() + "\x00" + rel)
defer unlock()

if !reviewAgentEditsEnabled() {
return run()
}
before, ok := noteText(ctx, b, rel)
// Not a note we can read (the tool reports its own error), or one too
// large to keep copies of.
if !ok || !vault.AgentEditRecordable(before) {
return run()
}
existing := vault.ReadAgentEdit(b.Root(), rel)
started := vault.BeginAgentEdit(existing, before)
if err := vault.WriteAgentEdit(b.Root(), rel, started); err != nil {
fmt.Fprintf(os.Stderr, "zn mcp: could not record the edit to %s: %v\n", rel, err)
return run()
}

result, runErr := run()

after, ok := noteText(ctx, b, rel)
var finish error
if !ok || after == before {
// Nothing changed (no match, a task that moved, a failed write): the
// record goes back to what it was.
finish = vault.WriteAgentEdit(b.Root(), rel, existing)
} else {
entry := vault.AgentEditEntry{At: time.Now().UnixMilli(), Agent: agent, Tool: tool}
finish = vault.WriteAgentEdit(b.Root(), rel, vault.FinishAgentEdit(started, after, entry))
}
if finish != nil {
fmt.Fprintf(os.Stderr, "zn mcp: could not finish the record for %s: %v\n", rel, finish)
}
return result, runErr
}
150 changes: 150 additions & 0 deletions internal/mcp/agentedits_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package mcp

import (
"context"
"os"
"path/filepath"
"testing"

sdk "github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/ZenNotes/tui/internal/backend"
"github.com/ZenNotes/tui/internal/vault"
)

// Agent edit records written through the real MCP server, as Claude Code
// would drive it. Mirrors apps/desktop/src/mcp/agent-edit-recorder.test.ts
// in ZenNotes/zennotes.

const planNote = "inbox/Plan.md"
const planBody = "# Plan\n\n- [ ] Ship the beta\nThe migration runs at night.\n"

// serveAs is runServer with a client name, so the record can name it.
func serveAs(t *testing.T, client string, root string) *sdk.ClientSession {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
serverT, clientT := sdk.NewInMemoryTransports()
done := make(chan struct{})
go func() {
defer close(done)
_ = Run(ctx, Options{
ResolveTarget: func() (backend.Target, error) { return backend.Target{Kind: backend.KindLocal, Root: root}, nil },
OpenBackend: func(t backend.Target) (Backend, error) { return backend.New(t, backend.Options{}) },
Transport: serverT,
})
}()
cs, err := sdk.NewClient(&sdk.Implementation{Name: client, Version: "0"}, nil).Connect(ctx, clientT, nil)
if err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
_ = cs.Close()
cancel()
<-done
})
return cs
}

func seedPlan(t *testing.T) string {
t.Helper()
t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir())
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "inbox", "Plan.md"), []byte(planBody), 0o644); err != nil {
t.Fatal(err)
}
return root
}

func readPlan(t *testing.T, root string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(root, "inbox", "Plan.md"))
if err != nil {
t.Fatal(err)
}
return string(data)
}

func TestMCPRecordsAnAssistantsEditsForReview(t *testing.T) {
root := seedPlan(t)
cs := serveAs(t, "claude-code", root)
if _, isErr := callTool(t, cs, "replace_in_note", map[string]any{"path": planNote, "find": "at night", "replace": "on Sunday"}); isErr {
t.Fatal("replace_in_note failed")
}
if _, isErr := callTool(t, cs, "append_to_note", map[string]any{"path": planNote, "text": "A new line."}); isErr {
t.Fatal("append_to_note failed")
}
rec := vault.ReadAgentEdit(root, planNote)
if rec == nil {
t.Fatal("no record")
}
if rec.Before != planBody || rec.After == nil || *rec.After != readPlan(t, root) {
t.Fatalf("record texts = %q / %v", rec.Before, rec.After)
}
if len(rec.Edits) != 2 || rec.Edits[0].Agent != "Claude Code" || rec.Edits[0].Tool != "replace_in_note" || rec.Edits[1].Tool != "append_to_note" {
t.Fatalf("edits = %+v", rec.Edits)
}
}

func TestMCPRecordsATaskToggledByID(t *testing.T) {
root := seedPlan(t)
cs := serveAs(t, "codex-cli", root)
if _, isErr := callTool(t, cs, "toggle_task", map[string]any{"id": planNote + "#0"}); isErr {
t.Fatal("toggle_task failed")
}
rec := vault.ReadAgentEdit(root, planNote)
if rec == nil || rec.Before != planBody || len(rec.Edits) != 1 || rec.Edits[0].Agent != "Codex" || rec.Edits[0].Tool != "toggle_task" {
t.Fatalf("record = %+v", rec)
}
}

func TestMCPLeavesNoRecordWhenNothingChanged(t *testing.T) {
root := seedPlan(t)
cs := serveAs(t, "claude-code", root)
callTool(t, cs, "replace_in_note", map[string]any{"path": planNote, "find": "nowhere in the note", "replace": "x"})
callTool(t, cs, "read_note", map[string]any{"path": planNote})
if vault.ReadAgentEdit(root, planNote) != nil {
t.Fatal("no edit, no record")
}
if _, err := os.Stat(filepath.Join(root, ".zennotes", "agent-edits")); !os.IsNotExist(err) {
t.Fatalf("no agent-edits folder should be left: %v", err)
}
}

func TestMCPRecordsNothingWhenReviewIsOff(t *testing.T) {
root := seedPlan(t)
if err := os.WriteFile(filepath.Join(os.Getenv("ZENNOTES_CONFIG_DIR"), "config.toml"), []byte("[mcp]\nreview_agent_edits = false\n"), 0o644); err != nil {
t.Fatal(err)
}
cs := serveAs(t, "claude-code", root)
callTool(t, cs, "append_to_note", map[string]any{"path": planNote, "text": "Unrecorded."})
if vault.ReadAgentEdit(root, planNote) != nil {
t.Fatal("review is off: nothing may be recorded")
}
}

func TestMCPCannotWriteIntoZennotes(t *testing.T) {
root := seedPlan(t)
cs := serveAs(t, "claude-code", root)
forged := ".zennotes/agent-edits/inbox/Plan.md.json"
if _, isErr := callTool(t, cs, "write_note", map[string]any{"path": forged, "body": "{}"}); !isErr {
t.Fatal("write_note into .zennotes must fail")
}
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(forged))); !os.IsNotExist(err) {
t.Fatalf("nothing may land in .zennotes: %v", err)
}
}

func TestAgentForClientMatchesTheDesktop(t *testing.T) {
for in, want := range map[string]string{
"claude-code": "Claude Code", "Claude-Code": "Claude Code", "codex": "Codex",
"": "Assistant", "my_agent-tool": "My Agent Tool",
} {
if got := agentForClient(in); got != want {
t.Errorf("agentForClient(%q) = %q, want %q", in, got, want)
}
}
}
Loading
Loading