The blanket 7-day hold delayed exactly the vuln patches Obsidian's plugin
review and npm audit flag — leaving us knowingly exposed to a named CVE to
guard against a hypothetical malicious republish. Split the rule: routine
version bumps still age 7 days; security fixes (dependabot security PRs,
npm audit advisories, Obsidian-review dependency warnings) land immediately
once green.
Obsidian's source-code review forbids disabling obsidianmd/prefer-window-timers
and obsidianmd/no-global-this (and flags undescribed directive comments). The
0.11.32 review failed on five such disables.
- router.ts, obsidian-api.ts, session-manager.ts: global set/clearInterval and
setTimeout → window.* equivalents.
- debug.ts: drop the globalThis console fallback → window.console.
- tests/setup.ts: alias window to globalThis in the node test env so the
window.* timers/console resolve under Jest (Obsidian always provides window
at runtime).
- session-manager cleanupInterval typed number (window.setInterval's return).
All five eslint-disable directives removed. Lint clean, build green, 336 tests pass.
The dev-dependency bump tightened lint and exposed pre-existing debt.
Resolved without blanket rule suppression:
- no-unnecessary-type-assertion (15): removed redundant assertions
(eslint --fix) across formatters, mcp-server, security, utils, validation.
- no-base-to-string (6): the removed assertions un-masked String(unknown)
calls. All were already guarded (object→JSON.stringify, primitive→String),
so not bugs — narrowed via a typed primitive local, which also satisfies
no-unnecessary-type-assertion (whose receiver String() accepts anything).
- no-deprecated (15): obsidian 1.13.0 deprecates PluginSettingTab.display()
in favor of getSettingDefinitions(). Kept display() as the supported
cross-version fallback and routed internal re-renders through a new
render() method, so no deprecated symbol is referenced and no future
deprecation in main.ts is masked. Full declarative migration: #224.
- no-deprecated (1, ButtonComponent.setWarning): the replacement
setDestructive() requires 1.13.0 > our minAppVersion 1.6.6, so it's kept
with one scoped eslint-disable + comment. Revisit with #224.
- no-unused-vars (2): dropped now-unused http type imports in mcp-server.
- prefer-active-doc (5): document → activeDocument for popout-window
compatibility (main settings DOM queries, image-handler canvas).
Lint clean, build green, 336 tests pass.
Supersedes #217 (dependabot). Brings the dev toolchain current:
@types/node 25.3→25.9, @typescript-eslint/utils + typescript-eslint
8.56→8.60, esbuild 0.27→0.28, globals 17.3→17.6, jest 30.2→30.4,
jiti 2.6→2.7, obsidian 1.12.2→1.13.0, ts-jest 29.1→29.4.
The typescript-eslint 8.60 and obsidian 1.13.0 type bumps surface new
lint findings; cleared in the follow-up commit.
The '/' traverse summary reported Math.min(10, getFiles().length) using the
unfiltered file count, while the traversal actually seeds from the
.mcpignore-filtered set (graph-traversal.ts). With exclusions enabled this
overcounted and leaked that hidden files exist via an inflated number. Count
the same filtered set the seed logic uses.
Move Quick Start above the conceptual sections so install is the first
thing readers hit, and relocate 'Why Semantic MCP?' down to before Core
Tools. Add an inline-emoji hero banner (box → robot) for the .mcpb
drag-into-Claude-Desktop flow — glyphs render on the Obsidian listing
where embedded images get stripped, making the one-click path obvious
to non-power-users.
Graph operations (neighbors/backlinks/forwardlinks/traverse/path/
statistics) read metadataCache.resolvedLinks and vault.getFiles()
directly and never consulted the MCPIgnoreManager. With path
exclusions enabled, .mcpignore-ignored paths surfaced as neighbors/
edges of non-ignored queries, and querying an ignored note directly
disclosed its relationships.
- GraphSearchTool rejects an excluded query root (treated as
"File not found", matching ObsidianAPI's read-path behavior).
- GraphTraversal filters excluded paths out of the link primitives and
every file enumeration (neighbors, root traversal, tag connections,
vault statistics, all-nodes).
- Add ObsidianAPI.getIgnoreManager() accessor + regression tests.
No behavior change when path exclusions are disabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dataview's `query()` resolves to a `Result` monad —
`{ successful, value: { type, values, headers }, error }` — where
`value.values` is a plain array. The tool modelled the return as a flat
`{ type, values }` object with a wrapped `DataArray`, so:
- `formatQueryResult()` switched on `result.type` (undefined → `result.value.type`),
collapsing every query to the `unknown` branch → `# Dataview: UNKNOWN`.
- it called `result.values?.array()` (undefined → `result.value.values`,
already a plain array) → `[]` → "No results found".
`dataview.status` had a separate shape mismatch: the detector returns
`{ installed, enabled, apiReady, version }` but `formatDataviewStatus`
reads `available`, so it always rendered "not available".
Fixes:
- Re-model `DataviewQueryResult` as the real monad; read payload from `.value`.
- Add `toPlainArray()` helper (accepts plain arrays and wrapped DataArrays).
- Add a `dataview.status` normalizer mapping apiReady → available.
- Correct the test mock, which encoded the same wrong flat+wrapped shape —
the reason CI stayed green while real Dataview v0.5.68 failed.
- Add formatted-output regression tests through formatResponse() for
query (LIST/TABLE) and status.
Before: graph.statistics threw 'Source path is required' when called
without sourcePath, blocking the baseline use case (vault health
snapshots, dashboards). Users had to pick an arbitrary seed and reason
about its neighborhood — not representative.
After: when sourcePath is omitted, return a new vaultStatistics shape:
totalNotes — count of .md files in the vault
totalLinks — sum of resolved link occurrences (Obsidian
semantics: A→B referenced 3× counts as 3)
orphanCount — singletons (no links in either direction)
averageDegree — 2 * totalLinks / totalNotes
largestComponentSize — biggest connected subgraph (undirected)
isolatedClusters — total connected-component count,
inclusive of singletons (so non-trivial =
isolatedClusters - orphanCount)
Per-node statistics behaviour is unchanged when sourcePath is provided.
Implementation: GraphTraversal.getVaultStatistics does one O(V+E)
pass over the resolvedLinks adjacency, plus BFS for components.
Treats the graph as undirected for component analysis (a link from A
to B means A and B are in the same component) while keeping link
counts directed (matches how metadataCache exposes them).
Tests: tests/graph-statistics-vault-wide.test.ts covers the
vault-wide path (the topology described above), per-node fallback,
empty vault, repeated occurrences, non-md filtering, and the
directed-edges-undirected-components invariant.
PR #207 (merged 2026-05-25) switched forwardlinks node titling from
`file.name.replace(/\.md$/, '')` to `getNodeTitle(file)`, which reads
`file.basename`. This PR was authored before that landed, so the test
mock set `name` but not `basename` and now produces `title: undefined`
on integration. Add `basename` to the mock to match the real TFile API.
- vault.update content-guard hint pointed at vault.patch, which does not
exist — patch lives at edit.patch. Bad recovery hint is worse than no
hint.
- edit.window guard test claimed "before searching the file" but only
asserted on mutations; the mock's 'original' content doesn't contain
'undefined' so even a leaked guard wouldn't show a write. Add a reads
recorder and assert getFile is never called, making the test honest
about what it proves.
- Strip a stray space from the lock-key guard comment.
#210 reported that `vault.update` with no `content` writes the literal
9-byte string `"undefined"` to the target file — `String(undefined)`
coerced silently, the validator accepted the 9-byte string, and the
formatter rendered the misleading heading `✓ Updated: undefined`.
The same `String(params.x)` footgun existed at every vault-mutating
dispatch site: `edit.append` (would append "undefined"), `edit.window`
(would search for or replace with "undefined"), plus path-only cases
that produced cryptic "File not found: undefined" errors instead of a
clean dispatch-level rejection.
Fix the class, not just the instance:
- Add `requireParamStr(params, key, action, hint?)` in operations/shared.
Throws at the boundary when a required string param is missing or
wrong-typed, so a malformed MCP call cannot reach a vault sink.
- Convert the corruption-class sites (`vault.update`, `vault.create`,
`vault.delete`, `edit.append`, `edit.window`) and the noisy path-only
sites (`edit.patch`, `edit.at_line`, `edit.from_buffer`, `view.file`,
`view.window`, `view.open_in_obsidian`) to use the helper.
- `vault.update`'s `content` guard carries a hint pointing at
`vault.patch` / `edit.window` — that's the wrong-tool path that
produced the #210 corruption in the wild.
- Lift `edit.*`'s path guard above `FileLockManager.withLock` so a
missing path can't take a lock on the literal string "undefined".
- `updateFile` / `deleteFile` now return `{ success, path }` so
`formatFileWrite` renders `Updated: <path>` — belt-and-suspenders for
the second issue called out in #210.
Tests: `tests/dispatch-param-guards.test.ts` records every mutation a
mock API receives; for each malformed call we assert the route rejects
AND no mutation was recorded, proving the guard runs before any sink.
The previous template inlined ${{ inputs.release_notes }} directly into
a double-quoted bash NOTES="..." assignment, so backticks (or $vars) in
the notes were evaluated by the shell as command substitution. Today's
0.11.30 release hit this — backticks in the ADR-107 release notes blew
up the heredoc and failed the workflow before tag creation.
Switch the templated values to env-var bindings (RELEASE_NAME,
RELEASE_VERSION, RELEASE_NOTES_INPUT) and assemble the markdown via
printf so the notes are treated as data, not code.
Blocking — userSuppliedCert discriminator:
- Was: certificateConfig.selfSigned === false (never set false in UI;
rows 5/7/9 of the ADR-107 table unreachable, defaulting to over-warn).
- Now: !!(certPath && keyPath) — actual cert provenance signal.
Fixed in src/mcp-server.ts and src/main.ts settings UI badge.
Non-blocking #1 — migration Notice race:
- Was: isExistingPreBindModeInstall() re-read data.json after
loadSettings() may have already persisted bindMode='loopback' as
part of the apiKey auto-save.
- Now: snapshot raw loadData() before loadSettings(), test the snapshot.
Non-blocking #2 — loopback host matcher tightened:
- Was: startsWith('127.') accepted '127.evil.com'.
- Now: strict dotted-quad regex /^127(\.\d{1,3}){3}$/ with octet range.
New tests cover hostile inputs (127.evil.com, 127.0.0.1.attacker.tld,
127.300.0.1, 1270.0.0.1).
Non-blocking #3 — Reconfigure hint:
- ADR-107 specifies the 🔴 badge includes a Reconfigure hint;
implementation now renders 'Reconfigure: switch the bind address
below to Loopback, or enable HTTPS.' beneath the verdict reason.
Non-blocking #4 — empty custom host UX:
- When bindMode='custom' && customBindHost is empty, render an
explicit Notice that the server will fall back to loopback until a
host is entered (resolves the silent 🟢 OK in that state).
Lint clean, 296/296 tests pass.
Replaces the implicit 0.0.0.0 Node-default bind with a deliberate,
classified network exposure model.
Server: MCPHttpServer.start() now resolves the listen host from
settings (bindMode + customBindHost), passes it to listen(), and
classifies the combined (protocol × bind × certSource) state. On 🔴
fires a 15s Notice + Debug.error; on 🟡 logs Debug.warn; on 🟢 silent.
The verdict-derived agent-visible warning is pushed into MCPServerPool
which conditionally injects it into the MCP initialize.instructions
field for every session created after bind, so an LLM client sees the
warning on its first turn against the server. node-mcp-server.ts (the
unused fallback) is hardcoded to loopback for consistency.
Settings: MCPPluginSettings gains bindMode ('loopback' | 'all' |
'custom') default 'loopback', customBindHost default '', and a
one-shot hasShownBindMigrationNotice flag. Existing installs with a
data.json that predates bindMode see a one-time 20s Notice on next
load pointing them at the new setting; fresh installs are silent.
UI: a new "Network binding" section between server config and the
HTTPS section, with a live colored verdict badge that re-classifies
on every change, the bind dropdown (Loopback / All interfaces /
Custom), an inline red caution under "All interfaces" whose wording
adapts to whether HTTPS is enabled, and a custom-host text field
that normalizes loopback/wildcard aliases on blur (typing 127.0.0.1
collapses the dropdown to Loopback; 0.0.0.0 collapses to All).
Tests: 7 integration assertions verifying the source wires listen()
to the resolved host, pushes verdict-derived instructions into the
pool, conditionally injects on 🔴 only, and that the pool spread-
pattern omits the field on ok/warn.
Inspired by PR #208 (fa1k3) — preserves the security instinct while
keeping the LAN/remote escape hatch the hardcoded approach removed.
Pure functions consumed by the bind-time guard, settings UI, and MCP
initialize.instructions injector: classify(NetworkState) → Verdict,
resolveListenHost(), resolveBindAxis(), normalizeBindInput(),
classifyFromSettings(), agentInstructionsForVerdict(). Single source
of truth for "how exposed is the current network configuration?".
The 9-cell ADR-107 table maps (protocol × bind × certSource) → one of
🟢 ok / 🟡 warn / 🔴 jail. Custom-input normalization collapses
loopback aliases (127.x.x.x / localhost / ::1) and wildcard aliases
(0.0.0.0 / ::) to the corresponding explicit BindMode so the same
intent expressed three ways converges to one state.
40 unit tests cover all 9 table cells, all normalization paths, the
defensive empty-custom fallback, and the agent-instructions formatter.
Models the combined (protocol × bind × cert) state as a 9-cell table
with three verdicts (🟢 OK / 🟡 WARN / 🔴 JAIL). Server always starts;
🔴 surfaces a Notice + log + MCP `initialize` instructions warning so
the LLM client also sees it. New settings: `bindMode` discriminated
enum + `customBindHost`. Migration flips the implicit `0.0.0.0` to a
real `127.0.0.1` default, making ADR-103's "loopback by default"
premise actually true. Inspired by community PR #208 (fa1k3) —
preserves the security instinct while keeping the LAN/remote escape
hatch the hardcoded approach would have removed.
formatFileRead truncated the string branch to a 50-line preview; since raw
defaults false, the DEFAULT vault.read was that truncated view — an agent
reading any >50-line file could not derive a byte-matching edit.window
oldText without raw:true, the exact #133 friction ADR-203 retires. The data
layer was already byte-faithful; the presentation default undercut it.
- string branch: emit full content verbatim, no 50-line slice, no wrapping
code fence (body may contain fences — a wrapper would corrupt round-trip)
- add non-text/binary passthrough guard (closes a pre-existing latent
formatter-error path; reviewer non-blocking note)
- test now asserts the FORMATTED (raw:false) output is byte-exact, not just
not.toThrow
make check green: 0 errors, baseline 5 warnings, 244/244.
Default vault.read no longer fragments-and-flattens. New contract:
- fits READ_PAGE_CHARS (50000) → whole file, byte-exact, one load (common case)
- exceeds → verbatim page 1, single contiguous block, with line bookends
(lineStart/lineEnd/totalLines + nextPage); page=N to continue. Absolute
line numbers preserved so edit.at_line still works on large files.
- returnFullFile:true → entire file verbatim (explicit large override; param
retained & repurposed, not retired)
- query/strategy/maxFragments → semantic fragments (unchanged)
- structured envelope no longer double-encodes the body (metadata sans body)
Budget is char-based on purpose: line count is an invalid proxy for context
cost; only bookends are line-based (for at_line). Hard invariant: a default
read must never hand the agent a context-breaking raw dump.
Also fixes the latent formatFileRead crash (_Formatter error_) on full-file
shapes; formatter now renders verbatim + a Pagination section.
src/utils/file-reader.ts rewritten; vault.ts threads `page`; formatter
hardened; tool description + CHANGELOG (breaking) updated.
make check green (0 errors, baseline 5 warnings, 243/243 incl. 8 new
ADR-203 round-trip/pagination/fidelity tests).
Refined through design dialogue:
- paginate/no-paginate boundary is a CHARACTER budget (READ_PAGE_CHARS,
default 50000), not a line count (line count is an invalid proxy for
context cost — 1500 short vs 1500 long lines differ by orders of magnitude)
- bookends stay line-based (lineStart/lineEnd/totalLines) so edit.at_line
on a large file still works; a page = longest whole-line run within the
char budget
- fits budget → whole file verbatim, one load (common case, no ceremony)
- exceeds → bookended page 1 (single contiguous block, not chunk array) +
nextPage hint; sequential paging via new `page` param
- returnFullFile:true RETAINED and repurposed as the explicit
whole-large-file override (not retired)
- hard invariant recorded: a default read must never hand the agent a
context-breaking raw dump
Alternatives added: no-guard full dump, line-count budget, chunk-array — all rejected with rationale.
Synthesizes #133's intent (machine-usable default for a machine protocol)
with the project instinct (concise formatted tool results, but unaltered
content for editing). Empirically grounded: default vault.read is lossy
(fragments + flattens newlines → broken read→edit round-trip), the raw
envelope double-encodes the body (~2x), and returnFullFile-without-raw
crashes the formatter.
Decision (two-bucket contract): content reads faithful/verbatim by default
(fragmentation = explicit opt-in via existing strategy/maxFragments);
action/result tools stay concise-formatted by default; stop double-encoding
the body; verbatim path must not route through the broken formatter.
#133's global-raw-flip remedy explicitly declined; its intent satisfied by
bucket 1. Status: Proposed (design of record; implementation tracked
separately, not in this ADR).
splitContent/sortFiles don't touch RouterContext — the uniform ctx-first
param was a mechanical-extraction artifact. Removed from both defs and
their two call sites. Indentation nit deliberately NOT addressed: the
retained class-method indentation is what makes the extraction a
byte-verifiable behaviour-preserving move (per the review's normalized
diff); reflowing would destroy that property for zero functional gain.
tsc clean, lint baseline, 235/235.
router.ts was a 2,228-line monolith (~2.8x the 800-line threshold). Extract
the largest unit — executeVaultOperation + its helpers — into
src/semantic/operations/vault.ts behind a RouterContext interface.
- operations/router-context.ts: RouterContext (api/app/fragmentRetriever/
validator); SemanticRouter implements it and passes itself, so shared
state propagates with no indirection
- operations/shared.ts: Params, SearchResultItem, paramStr/Num/Bool
(router-private → shared by all handlers)
- operations/vault.ts: executeVaultOperation + splitContent/sortFiles/
copyFile/copyDirectoryRecursive; mechanical this.→ctx. (tsc is the
safety net — a missed rewrite fails compile)
- router fields api/app/fragmentRetriever/validator → public readonly
- removed a fully unreachable pre-existing dead subtree (combineSearchResults,
isDirectory, performFileBasedSearch, indexVaultFiles, getSearchWorkflowHints,
extractContext, getFileType — zero call sites anywhere; eslint doesn't flag
unused class methods, which hid them). ~290 dead lines gone.
router.ts 2228→988; vault.ts 948. Behaviour-preserving: tsc clean, lint at
the exact pre-existing baseline (0 errors, 5 unrelated warnings), 235/235
tests unchanged.
Staged per the issue's "extract vault first in isolation" guidance: #199
stays open for the remaining handlers (edit/view/graph/system/bases →
router thin dispatcher <300 lines). ADR-202.