Skip to content

feat(mcp): serve the vault index to MCP clients over stdio - #746

Merged
hamidfzm merged 14 commits into
mainfrom
feat/mcp-server
Sep 13, 2026
Merged

hamidfzm merged 14 commits into
mainfrom
feat/mcp-server

Conversation

@hamidfzm

@hamidfzm hamidfzm commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Summary

glyph mcp serves the vault index to an MCP client over stdio, from the existing binary: no window, no port, no token.

Every tool answers something an agent with cat and grep either cannot get or gets wrong:

  • wikilinks resolved the way the app resolves them, with the losing candidates when a name is ambiguous
  • backlinks, and the link graph some hops out
  • broken links and orphans
  • tags by Glyph's rules, and frontmatter parsed with the renderer's scalar rules
  • heading sections sliced the way embeds slice them
  • canvas boards
  • what is open in the app
  • opening a note in Glyph, and Glyph's own export pipeline

The agent chooses the vault on each call. Without --vault, the server offers the vaults open in Glyph, and any other folder the agent names by absolute path is served once the user accepts the MCP client's prompt (elicitation), for the rest of the session. --vault pins the server to exactly the folders it names and turns asking off.

It also lands the parts #223 deferred to this issue:

  • link detail: the heading fragment, the alias text, and whether the link embeds
  • a Rust heading parser and section slicer, held to the TypeScript ones by a shared fixture
  • neighbours with depth and direction
  • the other candidates from the resolver

Changes

Index (src-tauri/src/vault/)

  • note.rs: each link keeps its #heading and |alias exactly as written, untrimmed, so a rename can rewrite the target and keep the rest byte for byte. It also records whether the link is an ![[embed]]. A canvas file card carries its subpath as the heading.
  • headings.rs:
    • parse_headings, ported rule for rule from markdownHeadings.ts, including JavaScript's \s and . semantics
    • github-slugger's stateless slug()
    • the section slicer from headingSection.ts
  • slug_table.rs: the code points github-slugger strips, generated from its regex.js by scripts/gen-slug-table.mjs.
    • A port on Unicode properties disagrees with that regex on 14,804 code points, because the package was generated from older Unicode data (U+0870 to U+089F, for one), so the table is the only exact port.
    • A Vitest case regenerates it from the installed package and fails when an upgrade changes it.
  • resolve.rs: resolution now reports the winner, the other candidates in tie-break order, what matched (name, path, alias) and which rule broke the tie (sameDirectory, shortestPath). The same-directory winner is still the first in walk order, as before.
  • graph.rs and queries.rs: undirected adjacency becomes outgoing and incoming, and neighbors(path, depth, direction) walks breadth first. Degree and orphan status are unchanged.
  • index.rs and commands/walk.rs: the walk records each file's modified time and size. Vault::sync() walks again and re-reads only the files that appeared, vanished or changed, through the existing apply_changes path. The stamps come from the walk alone, so two walks compare like with like. Only indexed files keep a stamp, so a file refused at the cap is tried again and a note a capped walk no longer reaches stays known until it is gone, and a file written in the last two seconds is re-read, since a stamp inside the filesystem's clock resolution cannot tell two saves apart.
  • store.rs: VaultStore, with_vault, apply_changes and forget move out of commands.rs, joined by with_synced_vault for callers that no watcher keeps current.
  • vault_neighbors and vault_canvas are deleted. They were kept for this server, which reads the core in-process, so they would be IPC surface with no caller.

Server (src-tauri/src/mcp/)

  • glyph mcp [--vault <folder>]... is handled in run() beside --version and --help, before any Tauri builder exists.
    • --vault can repeat.
    • A file, a missing folder, a positional argument or any other flag is a usage error. A relative folder is taken from the working directory only, never from its parent as the dev fallback for other arguments does.
    • A folder named ./mcp still opens normally.
  • registry.rs: ToolDef { name, title, description, input_schema, effect }, list() and dispatch(name, args, &Session).
    • Session carries the grants, the vault store and an OpenState.
    • Handlers never touch stdio or exit.
    • A handler that panics, or an answer over 1 MiB, becomes a refusal rather than the end of the session. A panic under the store's lock clears the store rather than poisoning it, so the next call rebuilds from disk.
  • stdio.rs: a hand-rolled newline-delimited JSON-RPC loop, with no new dependency.
    • It advertises 2025-11-25 and echoes 2025-06-18 when a client asks for it.
    • Batches, lines over 1 MiB, malformed messages and unknown methods get JSON-RPC errors.
    • A tool that refuses returns an isError result the model can read and correct from.
    • When the client declared elicitation, a tool can put a yes-or-no question to the user mid-call (elicitation/create). Whatever the client sends while the user decides is held and handled in order once the call returns, and only what arrives during this question counts against the cap. A notifications/cancelled for the call ends the wait, and so does the client closing the stream.
    • A cancelled call gets no reply, and its question is withdrawn with notifications/cancelled so the client can take the prompt down. A client that sends more than 64 messages while the user decides has the question withdrawn too.
  • refs.rs, the vault a call reads: a vault that names no served root, given as an absolute path, is resolved and put to the user by its real path.
    • On accept, the server grants exactly that resolved path (GrantRegistry::grant_resolved_workspace), so a link swapped in while the user decides leads nowhere. Later calls list the folder.
    • A relative path, a missing folder, a file, or a path holding a character that could disguise it (a control, a line separator, a bidi mark) is refused without asking.
    • Nothing is looked up on disk unless the client can ask and the server was started without --vault. vault_context reports this as canAskForVaults, and the advice in every refusal follows it.
    • On Windows, a network share or device path is refused before any lookup, since resolving one connects to the host. The check lives in GrantRegistry, ahead of every ensure_* and of the settings seed, so a renderer command gets the same refusal as a tool argument; a share a grant already sits on stays allowed.
  • session.rs: vault_context reads settings.json and workspace-sessions.json, re-read on every call, and reports only what the grants admit. An active graph tab is no active note, and a folder the user allowed is listed once however the app spells it.
    • Whether the app is running comes from an OS lock that interactive launches hold on instance.lock in the data directory. The OS releases it on exit or crash. Export and serve launches do not take it.
    • The app retries the lock briefly, so a probe landing at the wrong instant cannot hide it. A filesystem that cannot lock at all is reported once on stderr instead of retried.
  • launch.rs:
    • open_in_glyph spawns the binary detached on Windows and Linux, where the single-instance plugin forwards it. On macOS it uses open -b, which LaunchServices delivers as RunEvent::Opened. From an AppImage it spawns the AppImage itself, not the binary inside its mount.
    • export runs glyph export with an explicit --out: beside the note by default, and always inside the vault it reads. It passes the child's stderr through verbatim.
    • On Windows the server clears inheritance on its own stdio handles. Otherwise a launched Glyph would hold the client's stdout open and the client would never see the end of the stream.
  • data_dir.rs: finds the app's data directory without Tauri's path resolver: dirs::data_dir() then dirs::config_dir(), joined with the identifier, which is exactly what Tauri 2.11 does. build.rs emits the identifier from tauri.conf.json, and setup.rs now seeds grants through the same helper.

Docs and CI

  • glyph --help documents mcp and --vault.
  • README:
    • client setup under Command-line usage, with no --vault in the example
    • one Features line
    • the "Automation & agents" comparison table
    • the Obsidian CLI cell
  • docs/security/threat-model.md describes the MCP boundary and drops the two deleted commands.
  • ci-build.yml: every Build leg starts the release binary the way an MCP client does (scripts/mcp-smoke.mjs), with no --vault as the README configures it, including asking for a temporary folder and answering the question the server puts. A server that dies with a request pending fails the step with its exit code at once. Linux runs it with no xvfb; Windows runs the GUI-subsystem executable.

Departures from the plan, for review

  • Headings are parsed from the note on demand, not stored per note in the index. Only note_info and read_note read them, and both have the file open anyway. Storing them would make the app's index carry every heading of every note for nothing, which is why feat(vault): index the workspace in Rust #728 left them out.
  • mcp is planned by cli::mcp_plan, not a CliLaunch::Mcp variant. run() serves it before the app exists, so a variant could only reach setup.rs as an unreachable arm.
  • open_in_glyph takes no heading, and the issue body was updated to match. Neither the single-instance forward nor the macOS open event carries anything but a path.
  • A vault argument can add a folder, with the user's approval through the client. The issue first said an argument never adds a root. The maintainer asked for the agent to choose the vault, and the issue body was updated to match. Asking happens only without --vault, which keeps the flag a hard boundary for anyone who wants one.

Risk classification

  • Persistence / data loss
  • Asynchronous ordering / races
  • Destructive lifecycle (close, unmount, workspace switch, app exit)
  • Filesystem / IPC surface
  • Untrusted rendering (Markdown, plugins, links)
  • Secrets / credentials
  • Network
  • Migrations / persisted-format changes
  • Accessibility
  • Bundle size / startup
  • No risk areas touched

Invariants at stake and evidence

INV-5 (all external input is untrusted) and INV-6 (the backend is the boundary)

Every path-taking argument passes GrantRegistry before anything is read or written:

  • a ref that names a path, and resolve_link's from, go through ensure_readable
  • read_canvas's path goes through the same check
  • export's out goes through ensure_writable, and must also sit inside the vault being read

Roots come from --vault or the persisted settings. A vault argument picks one of them, or names another folder. That folder is served only after the client answers accept to an elicitation/create question showing its resolved path. The client answers that question, never the model. Tests:

  • a_path_outside_the_vault_is_refused_by_every_tool: 12 refusals, none of which leaks content
  • a_folder_the_user_allows_is_served_by_its_real_path: a path spelled with .. is put to the user resolved
  • a_folder_the_user_refuses_stays_closed: no grant is left behind
  • only_an_existing_folder_named_absolutely_is_put_to_the_user: a relative path, a missing folder and a file are refused without asking
  • a_folder_swapped_while_the_user_decides_is_not_served: the folder becomes a symlink (Unix) or a junction (Windows) to another one mid-question, and nothing of the other one is read
  • a_path_that_could_disguise_itself_is_never_put_to_the_user (Unix)
  • a_client_that_cannot_ask_learns_nothing_of_the_disk: an existing folder and a missing one get the same refusal
  • network_and_device_paths_are_never_looked_up (Windows)
  • a_network_or_device_path_is_refused_before_it_resolves (Windows): the grant registry refuses the same paths for every check, the document asset check included, and for the settings seed
  • a_folder_allowed_once_is_served_for_the_rest_of_the_session: the whole server over streams, asking once
  • a_vault_flag_turns_asking_off
  • a_symlink_out_of_the_vault_is_refused (Unix)
  • a_junction_out_of_the_vault_is_refused (Windows)
  • export_stays_inside_the_vault_it_reads_whatever_else_is_granted: a workspace that stays granted after the app closed it
  • several_vaults_need_the_caller_to_pick_one
  • the_vault_of_the_note_in_front_of_the_user_is_the_default
  • a_folder_is_not_a_note
  • mcp_refuses_anything_but_existing_folders

The protocol is hostile input too. Tests:

  • malformed_messages_are_answered_and_the_loop_goes_on
  • an_oversized_line_is_refused_without_ending_the_session
  • an_oversized_line_at_the_end_of_input_is_skipped_in_pieces
  • notifications_and_responses_get_no_reply
  • malformed_arguments_are_refused_with_a_reason
  • a_panic_or_an_outsized_answer_is_a_refusal_not_the_end
  • the_users_answer_decides_the_call: accept, decline, dismiss, and an error in place of an answer
  • a_client_that_cannot_ask_is_never_asked and only_a_client_that_declares_forms_can_be_asked
  • a_question_the_client_cannot_take_is_a_refusal

So is note content: hostile_notes_are_bounded_and_never_crash_a_call covers frontmatter nested past the parser's depth cap and a note over 5 MB.

Stdout:

  • Only stdio::send writes to stdout.
  • Every stdio test parses each output line as JSON-RPC, and the CI smoke step does the same against the release binary.
  • The inherited-handle leak on Windows was found and fixed during the live check. After the fix the server's stdout closed 2 ms after stdin, with the launched app still running.

INV-7 (partial results are explicit)

  • Every listing is { items, total, cut } at 200 rows.
  • Note text is cut at 50,000 characters, with cut and totalChars.
  • Every vault result carries ScanStatus.
  • Dispatch refuses any answer over 1 MiB.

Tests:

  • long_results_are_cut_and_say_so
  • a_sync_reports_the_cap_the_walk_hit_and_lifts_it_when_room_returns
  • the status assertions in backlinks_agree_with_the_backlinks_panel

INV-3 (stale results never win)

The server has no watcher: each call walks the vault and re-reads what changed through apply_changes, which is already asserted equal to a rebuild. Tests:

  • a_sync_catches_the_index_up_with_the_disk: a create, an edit, a delete and a rename, each checked against a rebuild
  • a_file_that_displaces_the_last_walked_note_is_not_mistaken_for_it and a_same_length_edit_the_clock_cannot_tell_apart_is_still_read
  • a_change_on_disk_is_in_the_next_call
  • a_vault_deleted_mid_session_is_an_error_not_a_crash
  • a_sync_over_a_deleted_root_is_an_error_not_an_empty_vault

INV-1 (user edits are never silently discarded)

export cannot replace a note: out must carry the format's extension and sit inside the vault. Test: export_refuses_a_target_it_must_not_write.

Asynchronous ordering

  • a_probe_in_flight_does_not_cost_the_app_its_lock covers the instance lock racing the server's probe.
  • messages_held_for_an_earlier_question_do_not_count_against_the_next and a_poisoned_store_recovers_on_the_next_call.
  • A question out to the user holds every other message until the call returns, then handles them in arrival order: what_arrives_while_the_user_decides_waits_its_turn. A cancellation or a closed stream ends the wait: a_cancelled_call_stops_waiting_for_the_answer and a_client_that_leaves_mid_question_ends_the_session.
  • A call replayed from behind a question, whose cancellation is already held, never asks: a_call_cancelled_while_another_waits_never_asks. A flood while the user decides is bounded: a_client_that_floods_a_question_gets_it_withdrawn.
  • A sync applies removals before additions, so a file that took a deleted one's place is not refused at the cap.

Untrusted rendering

Checked because note content is now parsed on a new path: headings, links and frontmatter. No rendering path changes.

Bundle size and startup

  • No new crate: dirs 6 was already in the lock file.
  • An interactive launch now opens instance.lock and takes its lock, retrying for at most 100 ms past a probe that happens to be in flight.
  • The binary grows by the slug table and the server.

Testing

  • Gates, on Windows:

    • pnpm typecheck && pnpm check && pnpm test: 413 files, 3932 tests
    • pnpm test:coverage
    • cargo fmt --check and cargo clippy --all-targets -- -D warnings
    • cargo test --lib: 811 passed, 1 ignored
  • Parity:

    • vault-headings.json is asserted by both vault::tests::headings_match_the_shared_expectation and the two TypeScript suites.
    • vault-frontmatter.json covers the new note.
    • The slug table guard regenerates the table.
  • Live on Windows, against a release build from pnpm tauri build --no-bundle. The session stores were moved aside for the run and restored afterwards.

    • Handshake against the debug and the release binary, spawned with pipes the way a client does. The release executable is PE subsystem 2 (GUI).
    • open_in_glyph with no app running started one. A second call was forwarded to the same process (same PID). vault_context then reported appRunning: true, the new active note and both tabs.
    • export: the HTML it writes is byte-identical to glyph export's. The DOCX differs only in the three members that also differ between two plain CLI runs: generated ids and timestamps.
    • Asking for a folder: scripts/mcp-smoke.mjs against a debug build started with no --vault. The server asked for samples and for a temporary folder no app had open, read both once allowed, and exited cleanly.
  • Not verified: open_in_glyph on Linux and macOS, so that criterion stays open on Implement an MCP server that exposes Glyph's vault index #301. Please check both before merging.

    • Linux takes the detached spawn path the Unix unit tests drive.
    • macOS runs open -b com.hamidfzm.glyph <path>, which reaches the app as RunEvent::Opened.
  • Tested on macOS

  • Tested on Windows

  • Tested on Linux

Known gaps, each tracked for a follow-up

  • Protocol revision 2026-07-28 is not implemented: no major client sends it by default yet, and supporting it later is purely additive.
  • An export blocks other calls until it finishes rendering.
  • A client that neither answers a question nor cancels the call leaves the server waiting. Clients cancel a call when their own tool timeout passes.
  • The folder question is checked against the protocol, the unit tests and the smoke client, not yet inside a desktop chat client, whose prompts differ.

Screenshots

No UI change.

Closes #301

`glyph mcp [--vault <folder>]...` answers from the existing binary with no
window, port or token: resolved wikilinks with their ambiguity candidates,
backlinks, graph neighbours by depth and direction, broken links and
orphans, tags, frontmatter, heading sections, canvas boards, what is open
in the app, opening a note in Glyph, and the export pipeline.

The index gains link detail (heading, alias, embed), a heading parser and
section slicer held to the TypeScript ones by a shared fixture, a slug
table generated from github-slugger, directed adjacency, resolver
candidates, and a stamp-based sync for callers no watcher keeps current.
Grants outlive the vaults a session serves, so export's out is checked against the vault it reads. The app retries the instance lock past a probe in flight, dispatch turns a panic or an oversized answer into a refusal, sync takes stamps from the walk alone, and resolve_link candidates and expanded folders are capped.
@github-actions github-actions Bot added the enhancement New feature or request label Sep 10, 2026
@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.69%. Comparing base (b56bf3c) to head (c0cf3c5).
✅ All tests successful. No failed tests found.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #746      +/-   ##
==========================================
+ Coverage   99.66%   99.69%   +0.03%     
==========================================
  Files         536      548      +12     
  Lines       20433    22584    +2151     
  Branches     2467     2467              
==========================================
+ Hits        20365    22516    +2151     
  Misses         17       17              
  Partials       51       51              
Flag Coverage Δ
frontend 99.41% <ø> (ø)
rust 99.92% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src-tauri/src/cli.rs 100.00% <100.00%> (ø)
src-tauri/src/cli_help.rs 100.00% <100.00%> (ø)
src-tauri/src/commands/walk.rs 100.00% <100.00%> (ø)
src-tauri/src/data_dir.rs 100.00% <100.00%> (ø)
src-tauri/src/grants.rs 100.00% <100.00%> (ø)
src-tauri/src/mcp/launch.rs 100.00% <100.00%> (ø)
src-tauri/src/mcp/link_tools.rs 100.00% <100.00%> (ø)
src-tauri/src/mcp/mod.rs 100.00% <100.00%> (ø)
src-tauri/src/mcp/note_tools.rs 100.00% <100.00%> (ø)
src-tauri/src/mcp/refs.rs 100.00% <100.00%> (ø)
... and 16 more

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 40 bytes (0.0%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
glyph-frontend-esm 19.18MB 40 bytes (0.0%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: glyph-frontend-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/index-*.css 40 bytes 94.32kB 0.04%

…ints

lib.rs now hands mcp::run the process's stdin and stdout, and setup.rs
passes the app data directory to hold_instance_lock. What remains in mcp
and data_dir takes its streams and directory as arguments, so the tests
drive the same functions the app calls.
A tool's `vault` can now name any folder by absolute path. Without
--vault, the server asks the user through the client (MCP elicitation)
and serves the folder for the rest of the session only on accept.

- stdio: a question goes out mid-call, and what arrives meanwhile is
  held and replayed in order. A cancellation withdraws the question and
  the call gets no reply; a flood past 64 messages withdraws it too.
- The grant is the exact resolved path the user saw, so a link swapped
  in while they decide leads nowhere.
- Nothing is looked up on disk unless the client can ask, and
  vault_context reports canAskForVaults. --vault turns asking off.
- On Windows, network share and device paths in any tool argument are
  refused before any lookup.
- A path holding a character that could disguise it is never shown.
@hamidfzm hamidfzm self-assigned this Sep 11, 2026
# Conflicts:
#	src-tauri/src/cli_help.rs
#	src-tauri/src/vault/tests.rs
- sync() keeps a stamp only for indexed files, so a note a capped walk
  drops stays known until it is gone and a refused file is tried again;
  a file written in the last two seconds is re-read
- the network-path refusal moves into GrantRegistry, ahead of every
  ensure_* check and the settings seed
- an active graph tab is no active note, and an allowed folder is
  listed once however the app spells it
- headings: a hash-only heading closes as the renderer's does, lines
  split on CR too, and one fence tracker serves headings, links and tags
- note_info reports frontmatter the way read_note does
- the instance lock stops retrying on a hard error and says so
- --vault never falls back to the parent directory
- a question's held-message cap counts only its own messages
- a panic under the store lock clears the store instead of poisoning it
- the smoke client reports a server that dies mid-request at once
- the server tests run against an empty data directory
Headings trim the text before checking it for a line terminator, as the
renderer does, so a trailing U+2028 is whitespace. The data directory
lookup tries the XDG defaults after the variables, since an MCP client
may start the server with other ones than the app was started with. The
slug table script finds the table from its own location, and the threat
model says that a requested folder is resolved once to match it against
the open vaults.

Cleanups: the dead enabled flag on tools goes; each line is parsed once
and each result serialized once; notes are found by binary search and a
batch of changes is applied in one pass instead of a splice per path;
neighbours sort by id; the junction helper and the test harness are
shared; the 5 MB figure is derived from the walk cap; the read_text test
lives beside read_text.
The document asset check main gained refuses a network or device path
before it resolves one, as every other path the grant registry checks
does here. The threat model keeps its row for that check beside this
branch's vault command row, and the security rule names both the MCP
elicitation grant and the asset scope mirror.
@hamidfzm
hamidfzm merged commit 720bc43 into main Sep 13, 2026
28 checks passed
@hamidfzm
hamidfzm deleted the feat/mcp-server branch September 13, 2026 21:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement an MCP server that exposes Glyph's vault index

1 participant