From 49817c6482071dc2b4dee6caaf32d74382f00c02 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 08:54:51 -0500 Subject: [PATCH 1/2] Copy(mcp): the token hint names the desktop's Server token setting ZenNotes desktop 2.69 can hand the token it connects to a server with over to zn and the MCP servers itself: Settings > MCP > Server token > Let assistants use it writes the line `zn connect --no-default` would write to credentials.toml (ZenNotes/zennotes#830). An assistant the server turns away is now told that first, before `zn connect` and ZENNOTES_REMOTE_TOKEN, matching the MCP server bundled with the app. The hint names the version, since a zn installed with Homebrew can be newer than the app, and vault_info's note for a server without a token uses the same text. The README and the desktop integration guide mention the setting beside `zn connect`. Checked: go vet and go test ./... (the 401 test now asks for the setting by name), and the built zn mcp against a token-protected server while following an app whose config carries no token. --- README.md | 10 ++++++---- docs/desktop-integration.md | 6 +++++- internal/mcp/run_test.go | 4 ++++ internal/mcp/server.go | 9 ++++++--- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a7b1505..dac08c5 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ explicitly; `--vault` and `--server` still select a specific destination. The TU uses its own saved selection unless `--workspace-source app` is explicitly passed. When the desktop app is on a ZenNotes server, desktop-managed commands and `zn mcp` authenticate with the token `zn connect` saved for that server's URL: the app keeps -its own copy in the OS secret store, which zn cannot read. +its own copy in the OS secret store, which zn cannot read. Since desktop 2.69, +Settings > MCP > Server token > Let assistants use it saves that same entry for you. If an older desktop build reports **Unknown command** for `zn tui`, check `type -a zn`. A Homebrew installation can be run directly with @@ -814,9 +815,10 @@ new vault. Nothing an assistant planned in one vault lands in the other. A vault on a ZenNotes server that needs a token gets it the way every command does: `--token`, then `ZENNOTES_REMOTE_TOKEN`, then the token `zn connect` saved for the server's URL. The desktop app keeps its copy in -the OS secret store, which zn cannot read, so run this once and the MCP -server authenticates whenever the app is connected to that server, with no -token in any client config: +the OS secret store, which zn cannot read. Since desktop 2.69, Settings > MCP > +Server token > Let assistants use it writes the saved token for you; otherwise +run this once. Either way the MCP server authenticates whenever the app is +connected to that server, with no token in any client config: ```bash zn connect https://notes.example.com --no-default diff --git a/docs/desktop-integration.md b/docs/desktop-integration.md index 8b6ced6..86ffced 100644 --- a/docs/desktop-integration.md +++ b/docs/desktop-integration.md @@ -67,7 +67,11 @@ The desktop keeps server tokens in the OS secret store, which zn cannot read. Wh neither `--token`, `ZENNOTES_REMOTE_TOKEN` nor the desktop profile supplies one, app mode uses the token `zn connect` saved for the same server URL. The lookup is keyed by the URL the desktop selected, so it authenticates that server and never selects -a different one. +a different one. Since desktop 2.69 the app can write that entry itself, on request: +Settings > MCP > Server token > Let assistants use it. It writes the same +`credentials.toml` line `zn connect --no-default` would, removes it again on Stop +sharing or when the server is forgotten, and never touches a token saved with +`zn connect`. `zn tui` defaults to the separately saved terminal workspace. Explicit selectors and `--workspace-source app|terminal` take precedence. MCP resolves the workspace diff --git a/internal/mcp/run_test.go b/internal/mcp/run_test.go index 412a63f..059f928 100644 --- a/internal/mcp/run_test.go +++ b/internal/mcp/run_test.go @@ -191,6 +191,10 @@ func TestRunNamesTheZnConnectCommandOnA401(t *testing.T) { if !isErr || !strings.Contains(text, "zn connect "+server.URL+" --no-default") || !strings.Contains(text, "ZENNOTES_REMOTE_TOKEN") { t.Fatalf("401 hint: %s", text) } + // The desktop app can hand its token over itself (ZenNotes/zennotes#830). + if !strings.Contains(text, "Let assistants use it under Settings > MCP > Server token") { + t.Fatalf("401 hint does not name the desktop's Server token setting: %s", text) + } } // A stale ZENNOTES_REMOTE_TOKEN outranks any token `zn connect` saves, so diff --git a/internal/mcp/server.go b/internal/mcp/server.go index e26eadd..d1a3719 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -1020,14 +1020,17 @@ func DescribeToolError(err error, b Backend) string { return msg } -// tokenHint says how to hand zn a server token. `--no-default` matters: a -// plain `zn connect` also makes the server zn's own default, and an MCP +// tokenHint says how to hand zn a server token. Since 2.69 the desktop app +// does it itself: Settings > MCP > Server token writes the entry `zn connect` +// would (ZenNotes/zennotes#830), so that comes first, with the version for a +// zn that is newer than the app. On the terminal way `--no-default` matters: +// a plain `zn connect` also makes the server zn's own default, and an MCP // server in terminal mode would then stop following the app. func tokenHint(serverURL string) string { if serverURL == "" { serverURL = "" } - return "run `zn connect " + serverURL + " --no-default` once in a terminal (it saves the token where zn and this MCP server look, and the next call uses it), or set ZENNOTES_REMOTE_TOKEN in the MCP server's environment." + return "in the ZenNotes desktop app (2.69 or later), choose Let assistants use it under Settings > MCP > Server token, or run `zn connect " + serverURL + " --no-default` once in a terminal. Either saves the token where zn and this MCP server look, and the next call uses it. Setting ZENNOTES_REMOTE_TOKEN in the MCP server's environment works too." } // Payload renders a tool result the way the desktop server does: a string From 91bb33b42a3d156c2e577762b1f7ce1feb02d586 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 13 Oct 2026 09:13:26 -0500 Subject: [PATCH 2/2] Fix(tasks): list_tasks and zn task list leave cancelled tasks out of open The MCP list_tasks tool (default status "open") and zn task list, by default and with --unchecked, returned `[-]` cancelled tasks next to the actionable ones. The desktop app treats a cancelled task as not open (isTaskOpen, #450 and #476 in ZenNotes/zennotes), and its calendar and board leave them out, so an assistant asked what is open or due this week listed abandoned tasks as to-dos. ZenNotes/zennotes#929 (by @vishalhabib99) fixed the desktop's bundled server and its Node CLI for 2.69.0; this mirrors it here, where the same filters missed the same field. status "all" and --all still return cancelled tasks, and the status description matches the desktop's word for word: "open = not done, cancelled or waiting". Checked: go vet and go test ./..., with two new tests on a real vault (list_tasks over the MCP transport, and zn task list by default, with --unchecked and with --all) that fail without the fix. --- internal/cli/commands_tasks_tags.go | 7 ++-- internal/cli/tasks_test.go | 53 +++++++++++++++++++++++++++++ internal/mcp/list_tasks_test.go | 51 +++++++++++++++++++++++++++ internal/mcp/server.go | 6 ++-- 4 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 internal/cli/tasks_test.go create mode 100644 internal/mcp/list_tasks_test.go diff --git a/internal/cli/commands_tasks_tags.go b/internal/cli/commands_tasks_tags.go index e496cf8..ff43a2b 100644 --- a/internal/cli/commands_tasks_tags.go +++ b/internal/cli/commands_tasks_tags.go @@ -25,12 +25,13 @@ func cmdTaskList(ctx context.Context, b backend.Backend, args Args) error { if !showAll { // A forwarded record's live copy sits in the destination note // and lists on its own, so showing both doubled every carried - // task. `--all` still surfaces the records. + // task. A `[-]` cancelled task is abandoned, so it is not open + // either (ZenNotes/zennotes#929). `--all` still surfaces both. if onlyUnchecked { - if t.Checked || t.Forwarded { + if t.Checked || t.Forwarded || t.Cancelled { continue } - } else if t.Checked || t.Waiting || t.Forwarded { + } else if t.Checked || t.Waiting || t.Forwarded || t.Cancelled { continue } } diff --git a/internal/cli/tasks_test.go b/internal/cli/tasks_test.go new file mode 100644 index 0000000..0f832fe --- /dev/null +++ b/internal/cli/tasks_test.go @@ -0,0 +1,53 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "slices" + "testing" +) + +// A `[-]` cancelled task is abandoned, so `zn task list` leaves it out by +// default and with --unchecked, as the desktop app does +// (ZenNotes/zennotes#929); --all still lists it. +func TestTaskListLeavesCancelledTasksOut(t *testing.T) { + isolatedCLI(t) + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o755); err != nil { + t.Fatal(err) + } + note := "- [ ] Buy milk\n- [-] Cancelled plan\n- [x] Done thing\n" + if err := os.WriteFile(filepath.Join(root, "inbox", "todo.md"), []byte(note), 0o644); err != nil { + t.Fatal(err) + } + listed := func(flags ...string) []string { + args := append([]string{"task", "list", "--json", "--vault", root}, flags...) + out := captureOutput(t, func() { + if code := Main(args); code != 0 { + t.Errorf("zn %v exited %d", args, code) + } + }) + var tasks []struct { + Content string `json:"content"` + } + if err := json.Unmarshal([]byte(out), &tasks); err != nil { + t.Fatalf("zn %v: %v\n%s", args, err, out) + } + contents := []string{} + for _, task := range tasks { + contents = append(contents, task.Content) + } + return contents + } + if got := listed(); !reflect.DeepEqual(got, []string{"Buy milk"}) { + t.Fatalf("default: %v", got) + } + if got := listed("--unchecked"); !reflect.DeepEqual(got, []string{"Buy milk"}) { + t.Fatalf("--unchecked: %v", got) + } + if got := listed("--all"); !slices.Contains(got, "Cancelled plan") { + t.Fatalf("--all: %v", got) + } +} diff --git a/internal/mcp/list_tasks_test.go b/internal/mcp/list_tasks_test.go new file mode 100644 index 0000000..6f9bc62 --- /dev/null +++ b/internal/mcp/list_tasks_test.go @@ -0,0 +1,51 @@ +package mcp + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "slices" + "testing" + + "github.com/ZenNotes/tui/internal/backend" +) + +// A `[-]` cancelled task is abandoned, so list_tasks leaves it out of open, +// as the desktop app does (ZenNotes/zennotes#929); status "all" keeps it. +func TestListTasksLeavesCancelledTasksOutOfOpen(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o755); err != nil { + t.Fatal(err) + } + note := "- [ ] Buy milk\n- [-] Cancelled plan\n- [x] Done thing\n" + if err := os.WriteFile(filepath.Join(root, "inbox", "todo.md"), []byte(note), 0o644); err != nil { + t.Fatal(err) + } + cs := runServer(t, func() (backend.Target, error) { + return backend.Target{Kind: backend.KindLocal, Root: root}, nil + }) + contents := func(args map[string]any) []string { + text, isErr := callTool(t, cs, "list_tasks", args) + if isErr { + t.Fatalf("list_tasks %v: %s", args, text) + } + var tasks []struct { + Content string `json:"content"` + } + if err := json.Unmarshal([]byte(text), &tasks); err != nil { + t.Fatalf("list_tasks %v: %v\n%s", args, err, text) + } + out := []string{} + for _, task := range tasks { + out = append(out, task.Content) + } + return out + } + if got := contents(map[string]any{}); !reflect.DeepEqual(got, []string{"Buy milk"}) { + t.Fatalf("open tasks: %v", got) + } + if got := contents(map[string]any{"status": "all"}); !slices.Contains(got, "Cancelled plan") { + t.Fatalf("status all: %v", got) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index d1a3719..0756f7a 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -729,7 +729,7 @@ func tools() []toolDef { name: "list_tasks", description: "Return every task (markdown checkbox) in live notes with parsed metadata: due date, priority, @waiting, tags. Filter by status, priority, date range, tag, or folder.", schema: `{"type":"object","properties":{ -"status":{"type":"string","enum":["open","done","waiting","all"],"description":"open = unchecked & not waiting (default). done = checked. waiting = has @waiting. all = everything."}, +"status":{"type":"string","enum":["open","done","waiting","all"],"description":"open = not done, cancelled or waiting (default). done = checked. waiting = has @waiting. all = everything."}, "priority":{"type":"string","enum":["high","med","low"],"description":"Only tasks at this priority."}, "dueBefore":{"type":"string","description":"YYYY-MM-DD exclusive upper bound."}, "dueAfter":{"type":"string","description":"YYYY-MM-DD inclusive lower bound."}, @@ -782,7 +782,9 @@ func tools() []toolDef { if folder != "" && t.NoteFolder != folder { continue } - if status == "open" && (t.Checked || t.Waiting || t.Forwarded) { + // A `[-]` cancelled task is abandoned, so it is not open either, + // as in the desktop app (ZenNotes/zennotes#929). + if status == "open" && (t.Checked || t.Waiting || t.Forwarded || t.Cancelled) { continue } if status == "done" && !t.Checked {