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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/desktop-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions internal/cli/commands_tasks_tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
53 changes: 53 additions & 0 deletions internal/cli/tasks_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
51 changes: 51 additions & 0 deletions internal/mcp/list_tasks_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions internal/mcp/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 10 additions & 5 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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."},
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1020,14 +1022,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 = "<server-url>"
}
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
Expand Down
Loading