Skip to content

zn v0.13.0: zn mcp can share part of a vault, read-only if asked - #24

Merged
adibhanna merged 7 commits into
mainfrom
zn-0.13.0
Oct 30, 2026
Merged

adibhanna merged 7 commits into
mainfrom
zn-0.13.0

Conversation

@adibhanna

@adibhanna adibhanna commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

zn 0.13.0 on top of #23 (assistants stay inside the note folders, already on main).

  • zn mcp can share part of a vault, read-only if asked (Support scoped note access and permissions for MCP #21). Four flags scope the MCP server: --allow-path and --deny-path (folders or notes, repeatable), --read-only, and --access read|comment|write. A non-empty allow list shows only what it names, and deny hides paths even inside it. The server enforces the scope on every tool (listings, search, tags, tasks, backlinks, assets, comments, errors), and a hidden note answers exactly like a missing one. Tools above the access level are not listed, and every tool carries read-only and destructive hints.
  • No creating through a link. A scoped create, move or folder rename into a segment the listings do not know must have nothing on disk there, so a symlink from a shared folder into a hidden one cannot be written through.
  • One contract for both engines. internal/mcp/testdata/mcp-scope.json is byte-identical to the desktop's packages/bridge-contract/fixtures/mcp-scope.json from ZenNotes/zennotes 7be9e993 (sha256 076d5448...), and scope_contract_test.go runs it here, so zn mcp and the desktop's bundled server give the same answers.
  • zn base add joins the picks of the view it opens on. Since desktop 2.79.0 a table view can show picked rows only; a row zn added never joined them, so it did not show in the app. The TUI's a had the same gap.
  • Docs. The README's MCP section documents the flags, zn help mcp says what they do, docs/desktop-integration.md covers workspaces and the desktop engine, and the design doc's status reflects what ships.

Verified: gofmt, go vet ./... and go test ./... clean on this branch; the contract fixture compared with cmp against the desktop commit; the built binary driven over stdio with --allow-path Projects --read-only (12 tools, a hidden note and a path through a link both Note not found, write_note unknown). Windows CI caught two harness bugs, fixed here: the fixture now checks out with LF like the other shared fixtures, and the contract finds the vault root in JSON results, where Windows doubles its backslashes.

…ns on

Since desktop 2.79.0 a table view can show only picked rows
(pickedRowIds in schema.json), and + Row in the app's grid adds the new
row to the picks of the view on screen, or it would vanish the moment
it was made. zn knew nothing of picks. `zn base add` kept pickedRowIds
when it rewrote schema.json but never added its row, so a database that
opens on a picks view did not show the row in the app. The TUI's `a`
had the same gap, on a table or a board.

Doc.AddRowInView is the app's addRowInView (shared-domain
database-records.ts, desktop commit 502d45d4): when the view has a
pickedRowIds list, the new row is appended to it, and picksChanged says
schema.json has to be written with the row. An empty list counts, since
it is a view of no rows; null or no key is a view of every row. The
list is edited in place in the ordered sidecar, so schema.json keeps
every other byte: an add with picks matches the app's
JSON.stringify(schema, null, 2) byte for byte, an option minted in the
same write included.

`zn base add` uses the view the app would open on, the database's
activeViewId, as the desktop CLI does. When that view has picks the row
joins them; when it has none, nothing about the views changes and
schema.json is not rewritten. The text output says "added to the picked
rows of the <View> view", before the page line, and --json carries
pickedInView. The TUI uses the view on screen. Either way other views
keep their picks: a curated view nobody is looking at does not collect
every row added elsewhere.

It does not teach the TUI to show picks. Its views still list every row
and cannot pick or unpick; only the row it adds now stays in the app's
view.

Tests: AddRowInView joins only the named view's list (empty list, null
and an unknown view too); `zn base add` on a picks view rewrites
schema.json with nothing but the new id and reports pickedInView; on a
view of every row it leaves schema.json byte-identical and emits no
pickedInView; the text line and a --page record land together; the TUI's
`a` joins Shelf, then Later after switching views, and changes nothing
on All. The picks tests fail without this change. The README, `zn help`
and the TUI's help say so.

go vet and go test ./... clean.
zn mcp handed every client the whole vault. Connecting a personal vault
to an assistant meant sharing all of it, or keeping a second vault, and
the only brake was the client's own tool permissions (#21, @lewxdev).

Four flags now scope the server: --allow-path and --deny-path (folders or
notes, repeatable), --read-only, and --access read|comment|write. A
non-empty allow list shows only what it names; deny hides paths even
inside it. The server enforces the scope itself, in a backend wrapper
every tool call goes through, so listings, search, tags, tasks,
backlinks, assets and comments all agree, and a handler added later gets
it for free.

Paths are never trusted as given. Each one is looked up in the vault's
own note and folder listings, which walk real directories and regular
files only, so a symlink from a shared folder into a hidden one, a case
variant of a hidden folder on a case-insensitive disk, or .zennotes has
no match. A hidden note answers exactly like a missing one, and neither
errors nor vault_info show the vault's absolute location. Search filters
before it limits, so hidden matches can neither crowd out shared ones nor
be counted by a short page. Tools above the access level are not listed;
every tool now carries read-only and destructive hints for clients.

Writes stay inside the scope: notes are created, moved and restored only
in allowed folders, never through a subpath into a system, asset or
internal directory under any case. Archiving and trashing a visible note
are allowed as exits even when the archive or trash is not shared. A
rename rewrites links only in notes the client can see, through a new
vault.RenameNoteRewritingLinksIn; a server rewrites links in every note
itself, so a scoped client cannot rename there. A folder moves or goes
as a whole only when nothing inside it is hidden, and empty_trash is
never offered once paths are restricted.

A --deny-path that matches nothing in the vault shares nothing at all:
the folder may have been renamed outside ZenNotes, and what it hid would
otherwise reappear under the new name. stderr names the entry for the
client's logs; the model never sees it.

The design, the leak analysis, the decisions and what comes next (the
desktop's bundled server, then per-vault profiles chosen from the app)
are in docs/ideas/mcp-scoped-access.md.
The scope resolves every path against the vault's listings, which skip
symbolic links, so a destination segment they did not know was taken as a
new folder. Under --allow-path Projects, create_note, create_folder,
move_note and rename_folder into Projects/inner, a link to a hidden folder,
wrote into that folder. On a local vault the first segment the listings do
not know must now have nothing on disk; anything else gets the usual
refusal. A server's vault is left to the server's walker.
…ss contract

internal/mcp/testdata/mcp-scope.json is a copy of the fixture the desktop's
bundled MCP server runs (ZenNotes/zennotes bridge-contract/fixtures), with
its provenance hash: flag refusals, the tools each scope lists and their
annotations, every tool's answer for shared, hidden, linked, case- and
accent-variant paths in root, inbox and remapped vaults, and the disk
afterwards. The docs now say the bundled server takes the same flags, and
the design doc records how Phase 1b was built and where the two engines
still differ.
`zn help mcp` now says what the scope flags do, not only their names,
and the README's feature list says zn mcp can share part of a vault.

The design doc's status no longer calls Phase 1a and 1b uncommitted:
zn 0.13.0 carries Phase 0 (#23) and Phase 1a with the link fix and the
shared contract test, and Phase 1b is 7be9e993 in ZenNotes/zennotes,
landing in desktop 2.80.0. Decision 10 records that Phase 0 merged on
its own and then shipped with 1a.
TestSharedScopeContract checksums internal/mcp/testdata/mcp-scope.json
against the desktop's copy, but Windows runners check text out with
CRLF, so the checksum differed and both contract tests failed there.
The folder now gets the eol=lf rule the other shared fixtures already
have.
The fixture's full-access vault_info case expects the result to contain
$VAULT, and two scoped cases expect it not to. The test replaced $VAULT
with the root as is, but a result object is JSON, where a Windows root's
backslashes are doubled, so the full-access case failed on Windows and
the scoped ones could not have caught a leaked root there. A needle now
matches the root as is (error text) or JSON-escaped (result objects).
The fixture is unchanged.
@adibhanna
adibhanna merged commit cae76af into main Oct 30, 2026
5 checks passed
adibhanna added a commit to ZenNotes/zennotes that referenced this pull request Oct 30, 2026
The bundled CLI is the floor a desktop build guarantees, and 0.13.0 is
the newest release (ZenNotes/tui#24). It answers ZenNotes/tui#21: zn mcp
takes --allow-path, --deny-path, --read-only and --access and enforces
them on every tool, the same contract this app's bundled MCP server
follows since 969f50e. It also keeps every assistant out of hidden and
system folders through moves, renames and subpaths (ZenNotes/tui#23),
and puts a row zn base add makes into the picked rows of the view the
database opens on, as + Row does in the grid.

The pin is the release's terminal-release.json copied byte for byte
after it verified against the zn-release-1 key this app trusts
(cli-update-core.ts), naming source commit cae76aff (the v0.13.0 tag),
integration protocol 1, and four archives that match both the manifest
and checksums.txt. Staged through `npm run terminal:stage`; the native
darwin-arm64 binary reports zn v0.13.0. The in-app manual's CLI card
says 0.13.0 and what it adds.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant