Skip to content

Commit 35a77be

Browse files
committed
Fix(mcp): zn mcp follows the app to another vault, and never carries work across
zn mcp resolved its vault at the first tool call and kept it for the life of the process. Switching the app from a ZenNotes server back to a local vault left the agent reading and writing the server until the MCP client restarted it, while a fresh zn followed the switch at once (Kevin on Discord, 2026-09-25, zn 0.4.0 against a 2.56.0 server). The pin was deliberate: an agent that read notes in one vault and writes after the user switched would change a note at the same path in the other vault. The server now resolves the vault before every tool call, the way a fresh zn does, and keeps that protection another way. Once the vault changes, every tool except vault_info stops with an error that names the old and the new vault and runs nothing. vault_info moves the session to the new vault, its notes say which vault the session left, and the calls after it run in the new one. go-sdk runs tool calls concurrently, so the check sits under the session's lock and a whole batch of parallel calls stops, not only its first call. A new token or profile name for the same vault reopens the backend quietly, and switching back before vault_info is no switch at all. SameVault compares servers by normalized URL and directories by stat, so a symlink or a trailing slash is the same vault. Options.Transport lets the tests run the real server over an in-memory pair: after a switch between two vaults, a write is refused and never reaches the new vault, a read is refused too, vault_info reports the move, and list_notes then lists the new vault. Session tests cover a parallel batch, a switch and back, and failures that leave the session where it was. Verified against the dev desktop app driven through its own UI (Settings > Vault > Quick Connect, then the sidebar's Switch vault) with a headless Claude Code agent attached across the switch: it hit the refusal, called vault_info on its own and carried on in the local vault.
1 parent ad95b48 commit 35a77be

9 files changed

Lines changed: 508 additions & 37 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,13 @@ For other clients, register the command `zn` with the argument `mcp`:
335335
The tools match the desktop's MCP server one for one: notes, folders,
336336
search, tasks, assets, comments, archive and trash.
337337

338+
The vault is resolved again before every tool call, so the server follows
339+
the desktop app (or `zn use`) the way a fresh `zn` does. A switch never
340+
redirects work silently: once the vault changes, every tool call stops with a
341+
message naming the old and the new vault until the assistant calls
342+
`vault_info`, which confirms the switch, and the calls after it run in the
343+
new vault. Nothing an assistant planned in one vault lands in the other.
344+
338345
## Demo recordings
339346

340347
`docs/demo/` holds the seeded vaults and the keystroke choreography;

‎docs/desktop-integration.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ vault/profile names, credentials and current selection. A terminal workspace wit
1111
the same name cannot redirect an existing desktop script or MCP client.
1212

1313
`zn tui` defaults to the separately saved terminal workspace. Explicit selectors
14-
and `--workspace-source app|terminal` take precedence. MCP retains the first
15-
successfully resolved workspace until its process restarts.
14+
and `--workspace-source app|terminal` take precedence. MCP resolves the workspace
15+
again before every tool call and follows a desktop switch once `vault_info`
16+
confirms it. Until then every other tool stops with a message naming the old and
17+
new workspace, so a switch never redirects in-flight edits.
1618

1719
## Creation dates
1820

‎internal/backend/target.go‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"errors"
99
"fmt"
1010
"os"
11+
"path/filepath"
1112
"regexp"
1213
"strings"
1314

@@ -70,6 +71,36 @@ func ResolveAuthTokenForSource(baseURL, flagToken, profileToken, source string)
7071
return ResolveAuthTokenFor(baseURL, flagToken, profileToken)
7172
}
7273

74+
// Label names a target the way Backend.Label names an open one: the
75+
// directory, or the server's profile name and URL. Never the token.
76+
func (t Target) Label() string {
77+
if t.Kind == KindRemote {
78+
if t.Name != "" {
79+
return fmt.Sprintf("%s (%s)", t.Name, t.BaseURL)
80+
}
81+
return t.BaseURL
82+
}
83+
return t.Root
84+
}
85+
86+
// SameVault says two targets reach the same vault: the same server URL, or
87+
// the same directory however its path is spelled. Tokens and profile names
88+
// do not matter.
89+
func SameVault(a, b Target) bool {
90+
if a.Kind != b.Kind {
91+
return false
92+
}
93+
if a.Kind == KindRemote {
94+
return strings.EqualFold(remote.NormalizeBaseURL(a.BaseURL), remote.NormalizeBaseURL(b.BaseURL))
95+
}
96+
if filepath.Clean(a.Root) == filepath.Clean(b.Root) {
97+
return true
98+
}
99+
aInfo, aErr := os.Stat(a.Root)
100+
bInfo, bErr := os.Stat(b.Root)
101+
return aErr == nil && bErr == nil && os.SameFile(aInfo, bInfo)
102+
}
103+
73104
// TargetForWorkspace resolves one of zn's own saved workspaces by name.
74105
func TargetForWorkspace(ws config.Workspaces, name, flagToken string) (Target, bool) {
75106
if v := ws.FindVault(name); v != nil {

‎internal/backend/target_test.go‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package backend
22

33
import (
4+
"os"
45
"path/filepath"
56
"testing"
67

@@ -45,3 +46,51 @@ func TestDefaultTargetPrefersZnWorkspaces(t *testing.T) {
4546
t.Fatalf("remember: %+v", ws)
4647
}
4748
}
49+
50+
func TestSameVaultComparesTheVaultNotTheCredentials(t *testing.T) {
51+
dir := t.TempDir()
52+
work, home := filepath.Join(dir, "Work"), filepath.Join(dir, "Home")
53+
for _, root := range []string{work, home} {
54+
if err := os.MkdirAll(root, 0o755); err != nil {
55+
t.Fatal(err)
56+
}
57+
}
58+
// Windows only allows symlinks with developer mode or elevation.
59+
link := filepath.Join(dir, "WorkLink")
60+
if err := os.Symlink(work, link); err != nil {
61+
link = work
62+
}
63+
local := func(root string) Target { return Target{Kind: KindLocal, Root: root} }
64+
server := func(url, name, token string) Target {
65+
return Target{Kind: KindRemote, BaseURL: url, Name: name, AuthToken: token}
66+
}
67+
for _, tc := range []struct {
68+
name string
69+
a, b Target
70+
want bool
71+
}{
72+
{"same directory", local(work), local(work), true},
73+
{"unclean spelling", local(work), local(filepath.Join(home, "..", "Work") + "/"), true},
74+
{"symlink", local(work), local(link), true},
75+
{"other directory", local(work), local(home), false},
76+
{"missing directories", local(filepath.Join(dir, "a")), local(filepath.Join(dir, "b")), false},
77+
{"token and name differ", server("https://notes.example.com", "home", "old"), server("https://notes.example.com", "", "new"), true},
78+
{"URL spelling", server("https://Notes.Example.com/", "", ""), server("https://notes.example.com", "", ""), true},
79+
{"bare host:port", server("notes.example.com:7878", "", ""), server("http://notes.example.com:7878", "", ""), true},
80+
{"other port", server("http://notes.example.com:7878", "", ""), server("http://notes.example.com:7879", "", ""), false},
81+
{"local and remote", local(work), server("https://notes.example.com", "", ""), false},
82+
} {
83+
if got := SameVault(tc.a, tc.b); got != tc.want {
84+
t.Errorf("%s: SameVault = %v, want %v", tc.name, got, tc.want)
85+
}
86+
}
87+
if got := server("https://notes.example.com", "home", "secret").Label(); got != "home (https://notes.example.com)" {
88+
t.Errorf("named server label %q", got)
89+
}
90+
if got := server("https://notes.example.com", "", "secret").Label(); got != "https://notes.example.com" {
91+
t.Errorf("bare server label %q", got)
92+
}
93+
if got := local(work).Label(); got != work {
94+
t.Errorf("local label %q", got)
95+
}
96+
}

‎internal/cli/commands_mcp_tui.go‎

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,14 @@ import (
1111
)
1212

1313
// cmdMCP runs the MCP stdio server for as long as the client keeps the
14-
// pipe open. The server pins the first successfully resolved backend for the
15-
// session and retries resolution failures, so starting before configuration
16-
// works without letting later desktop switches redirect in-flight edits.
14+
// pipe open. The vault is resolved again for every tool call, so the server
15+
// follows the desktop app the way a fresh zn does and starting before
16+
// anything is configured works; after a switch, calls stop until vault_info
17+
// confirms the new vault, so a switch never redirects in-flight edits.
1718
func cmdMCP(ctx context.Context, args Args) error {
1819
return mcp.Run(ctx, mcp.Options{
19-
ResolveBackend: func() (mcp.Backend, error) {
20-
target, err := ResolveTargetFromArgs(args)
21-
if err != nil {
22-
return nil, err
23-
}
24-
return OpenBackend(target)
25-
},
20+
ResolveTarget: func() (backend.Target, error) { return ResolveTargetFromArgs(args) },
21+
OpenBackend: OpenBackend,
2622
})
2723
}
2824

‎internal/mcp/run_test.go‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"sync"
10+
"testing"
11+
12+
sdk "github.com/modelcontextprotocol/go-sdk/mcp"
13+
14+
"github.com/ZenNotes/tui/internal/backend"
15+
)
16+
17+
// runServer serves Run over an in-memory transport and returns a connected
18+
// client; resolve is consulted before every tool call, like the app's config.
19+
func runServer(t *testing.T, resolve func() (backend.Target, error)) *sdk.ClientSession {
20+
t.Helper()
21+
t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir())
22+
ctx, cancel := context.WithCancel(context.Background())
23+
serverT, clientT := sdk.NewInMemoryTransports()
24+
done := make(chan struct{})
25+
go func() {
26+
defer close(done)
27+
_ = Run(ctx, Options{
28+
ResolveTarget: resolve,
29+
OpenBackend: func(t backend.Target) (Backend, error) { return backend.New(t, backend.Options{}) },
30+
Transport: serverT,
31+
})
32+
}()
33+
client := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "0"}, nil)
34+
cs, err := client.Connect(ctx, clientT, nil)
35+
if err != nil {
36+
cancel()
37+
t.Fatal(err)
38+
}
39+
t.Cleanup(func() {
40+
_ = cs.Close()
41+
cancel()
42+
<-done
43+
})
44+
return cs
45+
}
46+
47+
func callTool(t *testing.T, cs *sdk.ClientSession, name string, args map[string]any) (string, bool) {
48+
t.Helper()
49+
res, err := cs.CallTool(context.Background(), &sdk.CallToolParams{Name: name, Arguments: args})
50+
if err != nil {
51+
t.Fatalf("%s: %v", name, err)
52+
}
53+
var text strings.Builder
54+
for _, c := range res.Content {
55+
if tc, ok := c.(*sdk.TextContent); ok {
56+
text.WriteString(tc.Text)
57+
}
58+
}
59+
return text.String(), res.IsError
60+
}
61+
62+
func seedVault(t *testing.T, note string) string {
63+
t.Helper()
64+
root := t.TempDir()
65+
if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o755); err != nil {
66+
t.Fatal(err)
67+
}
68+
if err := os.WriteFile(filepath.Join(root, "inbox", note), []byte("# "+strings.TrimSuffix(note, ".md")+"\n"), 0o644); err != nil {
69+
t.Fatal(err)
70+
}
71+
return root
72+
}
73+
74+
func TestRunFollowsAVaultSwitchOnlyAfterVaultInfo(t *testing.T) {
75+
first, second := seedVault(t, "First.md"), seedVault(t, "Second.md")
76+
var mu sync.Mutex
77+
current := backend.Target{Kind: backend.KindLocal, Root: first}
78+
cs := runServer(t, func() (backend.Target, error) {
79+
mu.Lock()
80+
defer mu.Unlock()
81+
return current, nil
82+
})
83+
84+
if text, isErr := callTool(t, cs, "list_notes", nil); isErr || !strings.Contains(text, "inbox/First.md") {
85+
t.Fatalf("before the switch: %s", text)
86+
}
87+
mu.Lock()
88+
current = backend.Target{Kind: backend.KindLocal, Root: second}
89+
mu.Unlock()
90+
91+
// A write planned against the first vault must not land in the second.
92+
text, isErr := callTool(t, cs, "write_note", map[string]any{"path": "inbox/First.md", "content": "planned for the first vault"})
93+
if !isErr || !strings.Contains(text, "The ZenNotes vault changed") || !strings.Contains(text, first) || !strings.Contains(text, second) {
94+
t.Fatalf("write after the switch: error=%v %s", isErr, text)
95+
}
96+
if _, err := os.Stat(filepath.Join(second, "inbox", "First.md")); !os.IsNotExist(err) {
97+
t.Fatalf("the refused write reached the second vault: %v", err)
98+
}
99+
if text, isErr := callTool(t, cs, "list_notes", nil); !isErr || !strings.Contains(text, "vault_info") {
100+
t.Fatalf("reads stop too until vault_info: %s", text)
101+
}
102+
103+
text, isErr = callTool(t, cs, "vault_info", nil)
104+
if isErr {
105+
t.Fatalf("vault_info: %s", text)
106+
}
107+
var info struct {
108+
VaultRoot string `json:"vaultRoot"`
109+
Notes string `json:"notes"`
110+
}
111+
if err := json.Unmarshal([]byte(text), &info); err != nil {
112+
t.Fatalf("vault_info payload: %v\n%s", err, text)
113+
}
114+
if info.VaultRoot != second || !strings.HasPrefix(info.Notes, "The vault changed: until this call the session worked in the local vault "+first) {
115+
t.Fatalf("vault_info after the switch: %+v", info)
116+
}
117+
118+
if text, isErr := callTool(t, cs, "list_notes", nil); isErr || !strings.Contains(text, "inbox/Second.md") || strings.Contains(text, "First.md") {
119+
t.Fatalf("after vault_info: %s", text)
120+
}
121+
if text, _ := callTool(t, cs, "vault_info", nil); strings.Contains(text, "The vault changed") {
122+
t.Fatalf("the switch note repeats: %s", text)
123+
}
124+
}

0 commit comments

Comments
 (0)