feat(mcp): serve the vault index to MCP clients over stdio - #746
Merged
Merged
Conversation
`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.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ 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
Flags with carried forward coverage won't be shown. Click here to find out more.
|
Bundle ReportChanges will increase total bundle size by 40 bytes (0.0%) ⬆️. This is within the configured threshold ✅ Detailed changes
Affected Assets, Files, and Routes:view changes for bundle: glyph-frontend-esmAssets Changed:
|
14 tasks
…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.
# 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
glyph mcpserves 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
catandgrepeither cannot get or gets wrong: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.--vaultpins the server to exactly the folders it names and turns asking off.It also lands the parts #223 deferred to this issue:
Changes
Index (
src-tauri/src/vault/)note.rs: each link keeps its#headingand|aliasexactly 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 frommarkdownHeadings.ts, including JavaScript's\sand.semanticsslug()headingSection.tsslug_table.rs: the code points github-slugger strips, generated from itsregex.jsbyscripts/gen-slug-table.mjs.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.rsandqueries.rs: undirected adjacency becomes outgoing and incoming, andneighbors(path, depth, direction)walks breadth first. Degree and orphan status are unchanged.index.rsandcommands/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 existingapply_changespath. 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_changesandforgetmove out ofcommands.rs, joined bywith_synced_vaultfor callers that no watcher keeps current.vault_neighborsandvault_canvasare 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 inrun()beside--versionand--help, before any Tauri builder exists.--vaultcan repeat../mcpstill opens normally.registry.rs:ToolDef { name, title, description, input_schema, effect },list()anddispatch(name, args, &Session).Sessioncarries the grants, the vault store and anOpenState.stdio.rs: a hand-rolled newline-delimited JSON-RPC loop, with no new dependency.2025-11-25and echoes2025-06-18when a client asks for it.isErrorresult the model can read and correct from.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. Anotifications/cancelledfor the call ends the wait, and so does the client closing the stream.notifications/cancelledso 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: avaultthat names no served root, given as an absolute path, is resolved and put to the user by its real path.GrantRegistry::grant_resolved_workspace), so a link swapped in while the user decides leads nowhere. Later calls list the folder.--vault.vault_contextreports this ascanAskForVaults, and the advice in every refusal follows it.GrantRegistry, ahead of everyensure_*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_contextreadssettings.jsonandworkspace-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.instance.lockin the data directory. The OS releases it on exit or crash. Export and serve launches do not take it.launch.rs:open_in_glyphspawns the binary detached on Windows and Linux, where the single-instance plugin forwards it. On macOS it usesopen -b, which LaunchServices delivers asRunEvent::Opened. From an AppImage it spawns the AppImage itself, not the binary inside its mount.exportrunsglyph exportwith an explicit--out: beside the note by default, and always inside the vault it reads. It passes the child's stderr through verbatim.data_dir.rs: finds the app's data directory without Tauri's path resolver:dirs::data_dir()thendirs::config_dir(), joined with the identifier, which is exactly what Tauri 2.11 does.build.rsemits the identifier fromtauri.conf.json, andsetup.rsnow seeds grants through the same helper.Docs and CI
glyph --helpdocumentsmcpand--vault.--vaultin the exampledocs/security/threat-model.mddescribes 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--vaultas 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
note_infoandread_noteread 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.mcpis planned bycli::mcp_plan, not aCliLaunch::Mcpvariant.run()serves it before the app exists, so a variant could only reachsetup.rsas an unreachable arm.open_in_glyphtakes 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.vaultargument 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
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
GrantRegistrybefore anything is read or written:refthat names a path, andresolve_link'sfrom, go throughensure_readableread_canvas'spathgoes through the same checkexport'soutgoes throughensure_writable, and must also sit inside the vault being readRoots come from
--vaultor the persisted settings. Avaultargument picks one of them, or names another folder. That folder is served only after the client answersacceptto anelicitation/createquestion 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 contenta_folder_the_user_allows_is_served_by_its_real_path: a path spelled with..is put to the user resolveda_folder_the_user_refuses_stays_closed: no grant is left behindonly_an_existing_folder_named_absolutely_is_put_to_the_user: a relative path, a missing folder and a file are refused without askinga_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 reada_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 refusalnetwork_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 seeda_folder_allowed_once_is_served_for_the_rest_of_the_session: the whole server over streams, asking oncea_vault_flag_turns_asking_offa_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 itseveral_vaults_need_the_caller_to_pick_onethe_vault_of_the_note_in_front_of_the_user_is_the_defaulta_folder_is_not_a_notemcp_refuses_anything_but_existing_foldersThe protocol is hostile input too. Tests:
malformed_messages_are_answered_and_the_loop_goes_onan_oversized_line_is_refused_without_ending_the_sessionan_oversized_line_at_the_end_of_input_is_skipped_in_piecesnotifications_and_responses_get_no_replymalformed_arguments_are_refused_with_a_reasona_panic_or_an_outsized_answer_is_a_refusal_not_the_endthe_users_answer_decides_the_call: accept, decline, dismiss, and an error in place of an answera_client_that_cannot_ask_is_never_askedandonly_a_client_that_declares_forms_can_be_askeda_question_the_client_cannot_take_is_a_refusalSo is note content:
hostile_notes_are_bounded_and_never_crash_a_callcovers frontmatter nested past the parser's depth cap and a note over 5 MB.Stdout:
stdio::sendwrites to stdout.INV-7 (partial results are explicit)
{ items, total, cut }at 200 rows.cutandtotalChars.ScanStatus.Tests:
long_results_are_cut_and_say_soa_sync_reports_the_cap_the_walk_hit_and_lifts_it_when_room_returnsstatusassertions inbacklinks_agree_with_the_backlinks_panelINV-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 rebuilda_file_that_displaces_the_last_walked_note_is_not_mistaken_for_itanda_same_length_edit_the_clock_cannot_tell_apart_is_still_reada_change_on_disk_is_in_the_next_calla_vault_deleted_mid_session_is_an_error_not_a_crasha_sync_over_a_deleted_root_is_an_error_not_an_empty_vaultINV-1 (user edits are never silently discarded)
exportcannot replace a note:outmust 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_lockcovers the instance lock racing the server's probe.messages_held_for_an_earlier_question_do_not_count_against_the_nextanda_poisoned_store_recovers_on_the_next_call.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_answeranda_client_that_leaves_mid_question_ends_the_session.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.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
dirs6 was already in the lock file.instance.lockand takes its lock, retrying for at most 100 ms past a probe that happens to be in flight.Testing
Gates, on Windows:
pnpm typecheck && pnpm check && pnpm test: 413 files, 3932 testspnpm test:coveragecargo fmt --checkandcargo clippy --all-targets -- -D warningscargo test --lib: 811 passed, 1 ignoredParity:
vault-headings.jsonis asserted by bothvault::tests::headings_match_the_shared_expectationand the two TypeScript suites.vault-frontmatter.jsoncovers the new note.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.open_in_glyphwith no app running started one. A second call was forwarded to the same process (same PID).vault_contextthen reportedappRunning: true, the new active note and both tabs.export: the HTML it writes is byte-identical toglyph export's. The DOCX differs only in the three members that also differ between two plain CLI runs: generated ids and timestamps.scripts/mcp-smoke.mjsagainst a debug build started with no--vault. The server asked forsamplesand for a temporary folder no app had open, read both once allowed, and exited cleanly.Not verified:
open_in_glyphon 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.open -b com.hamidfzm.glyph <path>, which reaches the app asRunEvent::Opened.Tested on macOS
Tested on Windows
Tested on Linux
Known gaps, each tracked for a follow-up
2026-07-28is not implemented: no major client sends it by default yet, and supporting it later is purely additive.exportblocks other calls until it finishes rendering.Screenshots
No UI change.
Closes #301