Commit graph

650 commits

Author SHA1 Message Date
dependabot[bot]
9f2cab0122
ci: bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 09:10:21 +00:00
Aaron Bockelie
4359153d0b chore: Bump version to 0.11.42 2026-07-13 18:48:38 -05:00
Aaron Bockelie
67bbb3fb39
Merge pull request #266 from aaronsb/fix/retrieval-signal-honesty
fix(retrieval): stop the score labels lying, and name both blind spots
2026-07-13 18:47:57 -05:00
Aaron Bockelie
8e09a3871f fix(retrieval): stop the score labels lying, and name both blind spots
Three defects that each cause an agent to trust a signal further than it holds.
All were surfaced by agents working the same retrieval task and reported
independently, twice each.

Score labels asserted a judgement the number cannot support
interpretScore mapped TF-IDF onto fixed bands — "Good" at >=1.0, "Low" at >0 —
but TF-IDF has no absolute scale; it means different things per query and per
corpus. On a vault where most notes mention the query term the scores compress:
measured, the single best-answering note scored 1.42 ("Good") while five
load-bearing notes scored 0.36-0.45 and were all labelled "Low". An agent
pruning at "Low" would have dropped the entire answer except the anchor. It said
so itself: "the graph was trustworthy; the ranking was not."

The number was never the problem — the adjective was. Scores now render bare,
against this run's top hit ("0.36, 25% of top hit"), which makes the compression
visible instead of hiding it behind a word. Results also carry an explicit
caution not to prune on score.

The graph's blind spot was undocumented, and it is the mirror of search's
The tool descriptions warned that search misses notes written in different
vocabulary. They did not warn that traversal only reaches what someone actually
linked. Both failure modes are real and they are opposite: in the test corpus a
note documenting a fifth consumer of a dependency hub was causally coupled to it
and NOT linked from it, so no traversal from the anchor could reach it — the
shell/ripgrep arm found it by content scan, the graph arm missed it entirely.
Meanwhile that same shell arm silently under-retrieved by 8 files because the
corpus spells the subject two ways, and "a partial match set is indistinguishable
from a complete one".

So they are complements, not substitutes: scan broadly to catch the unlinked,
follow links to catch the differently-worded, trust neither alone. The graph
description now says this, and points at backlinks as the way to find what
depends on a note — which its own text does not know.

"Summary view" read as a truncation warning
Every response closed with "_Summary view. For all metadata fields, use raw:
true._". On tools whose job is faithful retrieval, that reads as a warning that
the content was abridged, and callers spent real attention deciding whether the
note body was a lossy rendering of the argument. Only metadata is abbreviated;
genuine truncation already announces itself (a Pagination section, an explicit
"N more" line). The footer now names what it actually elides.

Also: search-traverse is now described with its cost, not just its brevity — it
returns snippets per node and prunes on scoreThreshold, so it finds WHICH notes
matter but is not the whole argument. Two agents declined to use it because the
description sold the saving without the trade, and chose more calls over less
certainty.

427 tests passing.
2026-07-13 18:45:26 -05:00
Aaron Bockelie
7276b65683 chore: Bump version to 0.11.41 2026-07-13 18:33:19 -05:00
Aaron Bockelie
f688a8a61f
Merge pull request #265 from aaronsb/fix/retrieval-affordances
fix(retrieval): make paths usable and steer scan-then-follow
2026-07-13 18:32:38 -05:00
Aaron Bockelie
fba27c77e1 fix(retrieval): make paths usable and steer scan-then-follow
Measured against two vaults with a paired A/B harness (plugin tools vs. a shell
with ripgrep, same question, isolated agents). On a graph-shaped question over a
densely linked vault — the plugin's home field — it LOST: 90.3k tokens vs 70.1k,
20 calls vs 16, 3 dead ends vs 2. Not because the graph failed. Because the
plugin's own output made the graph unusable, and the agent spent the difference
working around it.

Paths were not usable as identifiers
vault.search elided long paths to `first/.../last`, and vault.list printed only
the basename. Both destroy the one field an agent must have verbatim: the value
it feeds to the very next call. An agent that joined the listed directory to the
name it was given got a path that does not exist and a hard "File not found".

This is the mechanism behind the repeated-search behaviour we keep seeing.
Scan-then-follow requires handing a search hit to graph.neighbors; if the hit's
path is elided, that handoff is impossible, so the only move left that still
works is another search. The tool was training the behaviour. Paths are now
emitted verbatim everywhere, and formatPath — a helper whose sole purpose was to
damage them — is deleted.

vault.fragments ignored its own `path` parameter
`path` was read only as a fallback *query* string, never as a scope, so asking
for fragments from one note returned passages from several, top-ranked hit
first. An agent reasoning "these are the key passages in the note I named" would
attribute content to the wrong file. Now scoped, over-fetching candidates first
so scoping does not silently return nothing when other notes outrank the target.

graph.statistics threw on the vault-wide call
No sourcePath is returned for the vault-wide shape; the formatter read it
unconditionally, threw, and fell back to raw JSON behind a "Formatter error"
banner. Now formatted — and it reports link density, because that is the number
that tells the caller whether following links will beat searching.

Steering, since the affordance was invisible
Both experiment arms independently concluded the graph was the strongest signal
available and both nearly missed it: "graph.neighbors was the MVP and I nearly
didn't reach for it"; and from the ripgrep arm, "I had a graph and was forced to
walk it as a pile of bytes." Search results now name graph.neighbors with the
top hit's path filled in, and the vault/graph tool descriptions teach the
pattern: a couple of broad scans, then follow links — rather than many narrow
searches that re-run the same ranking.

vault.read printed the frontmatter twice
A truncated "## Frontmatter" summary above a body that already opens with the
raw block. Suppressed when the body carries it; retained for fragment reads,
which do not.

Fragment strategy `semantic` renamed to `structure`
It cuts on headings and paragraphs. It has never been embedding or vector
search, but "semantic" in a retrieval slot reads as exactly that to any model
trained on the last decade of the literature — so callers selected it expecting
matching this index does not do. `semantic` still works as a deprecated alias.
All four strategies are now described; previously the enum shipped with no gloss
at all and the agent chose blind.

Tests: 415 passing. New suites cover path fidelity (including the fabricated
path an agent would construct), fragment scoping, the vault-wide stats shape,
and the frontmatter duplication.
2026-07-13 18:29:29 -05:00
Aaron Bockelie
ec4fac8d98
Merge pull request #264 from aaronsb/fix/reported-bug-bundle
fix: .mcpignore semantics, TLS 1.3 startup, rename extension (#250, #252, #253)
2026-07-13 17:51:49 -05:00
Aaron Bockelie
8bb580cc56 fix: .mcpignore semantics, TLS 1.3 startup, rename extension (#250, #252, #253)
Bundles the three reported bugs plus an unreported under-blocking bug found
while writing the tests for #250, and adds the coverage tooling that would
have caught all four.

#250 — .mcpignore negation was inverted
The parser stripped the leading '!' before Minimatch saw it, so .negate was
always false and the branch reading it was dead code. '!folder/*' compiled
into a positive exclusion: negation did not merely fail, it inverted.

Under-blocking (found while testing the above, not reported)
Patterns were handed to Minimatch raw, so two gitignore rules were never
implemented: a slash-less pattern matches at any depth ('*.secret' must hide
'a/b/creds.secret'), and a directory match covers its contents ('private/'
must hide 'private/notes.md'). No consumer of isExcluded() checks ancestors —
every caller passes a full file path — so nothing compensated downstream. The
plugin's own shipped .mcpignore template promises both behaviours, so a user
following it believed private files were hidden while they were being served
to the model. Patterns are now translated into the globs that implement
gitignore semantics, and negation is interpreted here rather than by Minimatch.

#252 — min TLS 1.3 crashed the server
secureProtocol mapped 1.3 to 'TLSv1_3_method', which OpenSSL never shipped, so
context creation threw "Unknown method: TLSv1_3_method". Switched to minVersion,
which also fixes a second latent bug: secureProtocol PINS one version, so
"minimum TLS 1.2" was silently refusing TLS 1.3 clients. The setting now
behaves as the floor it is labelled.

#253 — rename dropped the extension
'newName' was concatenated onto the source directory verbatim, so renaming
'note.md' to 'renamed' produced an extension-less file that drops out of
markdown views. The source extension is now carried over when newName omits
one, before the overwrite guard so it checks the right path.

Coverage + test contract
All four bugs shipped through code paths with zero coverage. Adds `make
coverage` / `coverage-map` / `coverage-gate`, a ratcheting coverageThreshold
(security boundary held to a far higher floor than global, since a hole there
leaks vault content), and tests/test-contract.test.ts enforcing that a green
run means something: no .only, no .skip, every file asserts, no tautologies.
The contract immediately caught an `expect(true).toBe(true)` placeholder in the
path-validator suite, now replaced with real assertions.

BREAKING (.mcpignore, both intended):
- The double-'!' workaround for #250 now correctly parses as a double negation
  (net exclude) and will stop working.
- A bare '*' now excludes at every depth, per gitignore, rather than top-level
  only. This is what makes the '*' + '!keep/**' whitelist idiom work.
2026-07-13 17:46:50 -05:00
Aaron Bockelie
2349a2b13d chore: Bump version to 0.11.40 2026-07-05 13:15:57 -05:00
Aaron Bockelie
f58c66fdd6 fix(manifest): drop "Obsidian" from plugin description
Obsidian's portal review (0.11.39 scan, 2026-07-04) flagged the
description as a gating Error: it must not contain the word "Obsidian"
— redundant in the plugin-directory context. Reword via the SoT
(package.json → sync to manifest.json), keeping the "built-in MCP
server, no external server to run" framing without naming Obsidian.

Claude-Session: https://claude.ai/code/session_01DgYBhBUkeLzqm6BLrnWL6t
2026-07-05 13:15:41 -05:00
Aaron Bockelie
df9b913ee1 chore: Bump version to 0.11.39 2026-07-04 00:05:17 -05:00
Aaron Bockelie
6ce8864ffa
Merge pull request #258 from aaronsb/docs/mcpb-description
docs(mcpb): align .mcpb connector description with new positioning
2026-07-03 22:05:03 -07:00
Aaron Bockelie
2951ec9d4e docs(mcpb): align the .mcpb connector description with the new positioning
The .mcpb bundle's description shows in Claude Desktop's connector UI — a
separate surface from the Obsidian directory one-liner (hand-maintained; not
touched by `make set-description`). It still read the old flat "Connect Claude
Desktop to your Obsidian vault via the Obsidian MCP plugin." Rewrite it
verb-first and consistent with the README + directory copy, while staying
accurate to the bundle's actual role — it's the bridge to the MCP server that
runs inside the plugin, not the server itself.
2026-07-04 00:03:48 -05:00
Aaron Bockelie
b6a54dae04 chore: Bump version to 0.11.38 2026-07-03 23:31:34 -05:00
Aaron Bockelie
725ee8e703
Merge pull request #257 from aaronsb/docs/short-description
docs: rewrite community-directory short description
2026-07-03 21:31:02 -07:00
Aaron Bockelie
4f697dd7a9 docs: rewrite community-directory short description — verbs + native edge, drop "semantic"
The short description is the one-liner shown in Obsidian's in-app community
browser search — the highest-leverage line for cold discovery, and where the
install base actually comes from. The old copy led with "semantic access" and
spelled out "Model Context Protocol (MCP)" — jargon in the most valuable
150 chars. New copy matches the repositioned README: concrete verbs first, the
native/no-external-server edge stated, MCP-explainer dropped.

Set via `make set-description` (SoT: package.json → synced to manifest.json).
2026-07-03 23:29:43 -05:00
Aaron Bockelie
f9fc75a9f5
Merge pull request #256 from aaronsb/docs/readme-positioning
docs(readme): lead with concrete verbs + the native/no-server edge
2026-07-03 21:25:49 -07:00
Aaron Bockelie
f72b460ab2 docs(readme): finish de-jargoning — headings, feature labels, fix stale name
Carry the "drop semantic jargon" pass through the whole README, not just the
opening:
- "## Why Semantic MCP?" → "## Why It's Different"
- "## The Semantic Advantage" → "## In Practice" (the section is examples;
  the old title also near-duplicated "Why It's Different")
- "### Semantic Search" → "### Full-Text Search" — the section describes
  operators/regex/relevance ranking, i.e. full-text, not embedding-semantic;
  the old label was both jargon and inaccurate
- "Search across all notes semantically" → "Search and rank across all notes"
- "Concept Discovery: Semantic search finds..." → "Search and graph traversal
  surface related ideas..."
- "AI uses semantic tools to:" → "AI uses these tools to:"
- Fix stale settings reference: "Semantic MCP" → "Semantic Notes Vault MCP"
  (the actual manifest name shown in Obsidian's plugin list)

"Semantic" now survives only as the plugin's own name and the
semantic-vault-mcp slug / cert paths — its established identity, unchanged.
2026-07-03 23:11:03 -05:00
Aaron Bockelie
7a7faa84a7 docs(readme): drop "semantic" jargon, foreground permissions + Bases
Per feedback: "semantic" is insider framing nobody searches for. Lead the
capability line with concrete value instead — 8 powerful tools, first-class
Dataview + Bases support, and (the real trust differentiator) that every
operation respects the permissions you set: read-only mode, per-operation
controls, and path allow/block lists. Competitors mostly hand the AI
unrestricted read/write; this one doesn't. All claims verified against the
shipped VaultSecurityManager + readOnlyMode settings.
2026-07-03 23:03:37 -05:00
Aaron Bockelie
7c1774bcbe docs(readme): lead with concrete verbs + the native/no-server edge
Repositioning the README opening after a competitive prose analysis of the
top Obsidian-MCP projects. The old opening led with an abstract tagline
("Give AI semantic agency over your knowledge graph") and spent prime real
estate explaining what MCP is — to people who searched for MCP — while our
single biggest differentiator (a native in-Obsidian server, no external Node
process, no separate REST-API plugin) went unstated. A competitor was already
using our own "no external dependencies" angle.

Changes to the top-of-README positioning only (setup/tools/docs untouched):
- Verb-first tagline: "Read, write, search, and traverse your Obsidian vault
  from any AI assistant — through an MCP server that runs inside Obsidian."
- State the native/no-server edge in the first sentence, framed positively
  (our own architecture, not a competitor put-down); connect it to why the
  drag-drop `.mcpb` setup is possible.
- Ground the "8 semantic tools" claim with a concrete, verified example
  (the vault tool alone handles 13 actions) instead of an abstraction.
- Relocate the "what is MCP" explainer to a short "New to MCP?" aside.
- Preserve the drag-drop onboarding hero — the strongest story in the field.
2026-07-03 23:01:25 -05:00
Aaron Bockelie
a406294cc9
Merge pull request #255 from aaronsb/fix/dataview-groupby-live-findings
fix(dataview): recover rows for implicit LIST GROUP BY; label whitespace group keys
2026-07-03 19:48:53 -07:00
Aaron Bockelie
1fdcbc3a8b fix(dataview): recover rows for implicit LIST GROUP BY; label whitespace group keys
Two findings from testing 0.11.37 live against Dataview 0.5.68 in the app —
neither reachable through the pre-existing mock fixtures, both real:

1. Implicit `LIST ... GROUP BY` returned bare group keys, no rows. Dataview's
   programmatic query() API drops grouped rows when nothing in the query
   references `rows` (unlike rendered markdown), so `LIST FROM "x" GROUP BY
   file.folder` came back as just the folder names. normalizeListGroupByQuery
   injects a default `rows.file.link` output expression for exactly that shape
   — a LIST with GROUP BY and no explicit output expression — so proper
   {key, rows} groups come back. Queries that already name an output
   expression, and non-LIST / non-grouped queries, pass through untouched; the
   user-facing echoed query stays the original input.

2. `TASK ... GROUP BY status` groups incomplete tasks under a space-character
   key, which rendered as an empty bold header (`**** (n)`). groupKeyLabel now
   treats whitespace-only keys the same as empty → `(no group)`.

The test mock is made faithful to the live behavior: a grouped LIST that
references `rows` returns list-pair wrappers, one that doesn't collapses to bare
keys — so the injection is actually observable rather than passing vacuously.
Adds normalization unit tests, an end-to-end implicit-grouped-LIST recovery
test, and a whitespace-key label test. build + lint + test green, 355/355.
2026-07-03 21:47:21 -05:00
Aaron Bockelie
88f0ce5efd chore: Bump version to 0.11.37 2026-07-03 21:30:21 -05:00
Aaron Bockelie
a7a7c7e208
Merge pull request #254 from aaronsb/chore/deps-tooling-upgrades
chore(deps): upgrade dev toolchain — eslint 10, typescript 6, grouped bumps
2026-07-03 19:15:15 -07:00
Aaron Bockelie
fc47349e18 chore(deps): upgrade dev toolchain — eslint 10, typescript 6, and grouped bumps
Incorporates the open dependabot dev-dependency PRs as one coherent, tightly
verified upgrade rather than five separate rebase/CI cycles, since they interact
across the build/lint toolchain:

- eslint 9.39.2 → 10.5.0 and @eslint/js 9.39.2 → 10.0.1 (#235 + #236 — these are
  coupled: @eslint/js@10 peer-requires eslint@10, so #236 could only ever resolve
  alongside #235; neither lands alone)
- typescript 5.9.3 → 6.0.3 (#237)
- typescript-eslint / @typescript-eslint/utils 8.60.1 → 8.62.0, esbuild
  0.28.0 → 0.28.1, globals 17.6.0 → 17.7.0, obsidian 1.13.0 → 1.13.1 (#246,
  which also subsumes the standalone esbuild bump in #233)
- actions/checkout v6 → v7 across all workflows (#240)

TypeScript 6 turns two tsconfig deprecations into errors. Fixed by modernizing
the config, not suppressing — Obsidian's community-plugin source review forbids
`ignoreDeprecations`-style escape hatches and rewards tight configs:

- dropped `baseUrl: "."` (TS5101) — unused; every local import is relative
- `moduleResolution: "node"` → `"bundler"` (TS5107) — the correct modern value
  for an esbuild-bundled project with extensionless imports (node16/nodenext
  would force `.js` extensions and break every relative import)

Also ran `npm audit fix` (security fixes are hold-exempt) clearing two dev/test
transitive advisories (@babel/core file-read, js-yaml merge-key DoS) that were
pre-existing on main via the jest/istanbul chain — `npm audit` now reports 0.

Verified tight: `npm run build && npm run lint && npm test` all green, 352/352
tests pass, no rule suppressions added.
2026-07-03 21:13:32 -05:00
Aaron Bockelie
70058a31dc
Merge pull request #249 from laplaque/fix/dataview-list-metadata-formatters
fix(dataview): render list/metadata, GROUP BY, and TABLE cells (#220)
2026-07-03 19:00:58 -07:00
Aaron Bockelie
b9a05e5aa1 fix(dataview): harden GROUP BY group detection from maintainer review (#220)
Maintainer review-fix commit on top of the contributor's work in PR #249,
addressing three low-severity robustness findings from a security/quality
review of the formatter changes:

- F1: the group branches switched all-or-nothing on `groups.every(...)`, so a
  single unrecognized group in a GROUP BY result dropped every group back to
  the flat renderer (re-surfacing the #220 raw-wrapper symptom). Render grouped
  when *any* element is a group and emit a stray non-group element as a plain
  row, so one bad group can't collapse the whole result.
- F2: `asGroup` (formatter) accepted only plain-array inner collections while
  the producer's `unwrapGroup` also accepted `.array()`-able DataArrays. They
  reconcile today only because the producer flattens first; align `asGroup`'s
  predicate and flatten so an unflattened wrapper reaching the formatter still
  renders instead of leaking.
- F5: guard `toISO()`'s return with a `typeof === 'string'` check so a
  non-string return can't propagate into `truncate`.

Happy-path GROUP BY behavior unchanged; all 352 tests pass. The mixed-group
and unflattened-group paths are defensive (the current producer can't emit
those shapes), so they are hardening rather than newly-reachable behavior.
2026-07-03 20:59:19 -05:00
Earl Plak
3cb2ad1f90 fix(dataview): coerce TABLE Link/date cells instead of dumping raw JSON (#220)
Per the maintainer's #220 comment: TABLE cells that are Dataview Link objects rendered as raw JSON ({"path":...}) and Luxon DateTime values as quoted ISO strings. Adds a coerceCell helper (Link -> display/path, DateTime -> toISO(), native Date -> toISOString(), primitives as-is) and routes formatDataviewTable cells through it. Adds a TABLE regression test with a Link cell and a DateTime cell (DataArray-wrapped row).
2026-06-27 20:42:29 +02:00
Earl Plak
1c4183116a fix(dataview): render GROUP BY results (list + task) instead of leaking group wrappers (#220)
GROUP BY nests rows under a group wrapper — {key, rows}, or a list-pair {$widget:'dataview:list-pair', key, value} for LIST. The producer's list branch passed wrappers through (the formatter then rendered them raw) and the task branch mangled each group into an empty task, so grouped rows never showed.

formatQueryResult now unwraps groups (flattening inner DataArrays to plain arrays) in the list and task branches, and the list/task formatters render each group's key and rows. Adds DataArray-wrapped GROUP BY fixtures + tests through executeQuery -> formatResponse (mock-faithful).

LIST GROUP BY verified live on Dataview 0.5.68; TASK GROUP BY covered by synthetic fixtures (no grouped-task data in the test vault). TABLE GROUP BY is outside #220's stated scope.
2026-06-27 19:51:23 +02:00
Earl Plak
5436548972 fix(dataview): dedicated list/metadata formatters so non-raw output renders (#220)
dataview.list and dataview.metadata non-raw output always rendered "No results found": the dispatcher force-cast their {success,count,pages} and {success,path,metadata} producer shapes into the {values} query formatter (formatDataviewQuery reads response.values), which always hit the empty branch. raw:true was unaffected.

A page list and a single page's metadata aren't query results, so add dedicated formatDataviewPages and formatDataviewMetadata and route the two dispatcher cases to them instead of formatDataviewQuery. Both surface producer-side failures (bad source, page-not-found) instead of a misleading empty result.

Adds mock-faithful regression tests through formatResponse() covering both happy paths and failure paths (the #216 mock-faithfulness lesson). Verified live against Dataview 0.5.68.
2026-06-27 18:35:52 +02:00
Aaron Bockelie
065a62f638
Merge pull request #248 from aaronsb/ci/release-notes-mcpb-callout
ci(release): note Claude Desktop .mcpb re-import in generated release notes
2026-06-24 09:06:24 -07:00
Aaron Bockelie
9fe222e9a7 ci(release): note Claude Desktop .mcpb re-import in generated release notes
The .mcpb bridge ships inside the GitHub release bundle, and BRAT updates
only the Obsidian plugin — never the Claude Desktop connector. Users had no
signal that a plugin update doesn't deliver bridge-side fixes (#243/#247).
Add a standing "Claude Desktop (.mcpb connector)" section to the release
workflow's notes template so every release tells .mcpb users to re-import,
rather than depending on per-release notes remembering to.

Claude-Session: https://claude.ai/code/session_011yowKCMFCBp61DRBu5xQm4
2026-06-24 11:04:03 -05:00
Aaron Bockelie
6e1e7b6137 chore: Bump version to 0.11.36 2026-06-24 10:50:54 -05:00
Aaron Bockelie
d7e5e70168
Merge pull request #245 from aaronsb/dependabot/npm_and_yarn/hono-4.12.27
chore(deps): bump hono from 4.12.23 to 4.12.27
2026-06-24 08:50:06 -07:00
Aaron Bockelie
9dc74f063f chore: Bump version to 0.11.35 2026-06-24 10:08:42 -05:00
Aaron Bockelie
aec72a63a2
Merge pull request #247 from aaronsb/fix/bridge-bootstrap-desktop-loader
fix(bridge): start under Claude Desktop's loader, not just `node server.js`
2026-06-24 08:08:16 -07:00
Aaron Bockelie
7198a3342b refactor(bridge): bridge-owned autostart opt-out; address #247 review
Replace the JEST_WORKER_ID bootstrap guard with a bridge-owned
MCP_BRIDGE_NO_AUTOSTART opt-out. JEST_WORKER_ID is a jest-worker
artifact that isn't reliably set under --runInBand, so an in-band run
could let bridge-self-heal's in-process require() fire main() and
process.exit(1) — the same boot-trap class this fix exists to kill.
The bridge now declares its own boot contract: autostart unless a
caller explicitly opts out. bridge-self-heal sets the var before
requiring; bridge-bootstrap strips it from spawned child env (in case
a sibling test set it in the worker) so real launches still autostart.

Test robustness (review nits): parse only a complete newline-
terminated stdout line (a mid-line chunk split would throw on partial
JSON and flake); route every terminal path through a `finish` helper
that clears the timer and kills the child, so no failure mode orphans
a process. Expand the guard comment to note the manifest launches
`node server.js` (where require.main would be true) while Desktop's
built-in-Node path is the one that breaks.

Refs #243.

Claude-Session: https://claude.ai/code/session_011yowKCMFCBp61DRBu5xQm4
2026-06-24 10:06:39 -05:00
Aaron Bockelie
d79d4b0666 fix(bridge): start under Claude Desktop's loader, not just node server.js
0.11.34 shipped a broken bridge: #243 gated startup on
`require.main === module`, but Claude Desktop runs the .mcpb with its
built-in Node ("Using built-in Node.js for MCP server") via a loader
that does NOT make server.js the main module. So main() never ran,
stdin was never read, the client's `initialize` went unanswered, and
Desktop timed out after 60s ("Request timed out", -32001) — the
extension "couldn't connect at all". (Local `node mcpb/server.js`
worked, which is why it passed review: that path makes
require.main === module true.)

Gate on the absence of Jest's worker env instead. main() then runs in
every real launch — system `node server.js` and Desktop's built-in
Node alike — while the require()-based test seam stays inert under
Jest. Reproduced via `node -e "require('./mcpb/server.js')"` (hangs
before, responds after).

Add tests/bridge-bootstrap.test.ts: spawns the bridge against a stub
HTTP server and asserts it answers `initialize` in BOTH launch shapes
(`node server.js` AND non-main require). The existing unit tests
`require()` the module — which by design skips main() — so they could
never catch a boot failure; this is that missing coverage. Verified
non-vacuous: reverting the guard fails the non-main case.

Refs #243. The GitHub 0.11.34 release .mcpb has this bug and must not
be promoted; ship the fix in 0.11.35.

Claude-Session: https://claude.ai/code/session_011yowKCMFCBp61DRBu5xQm4
2026-06-24 10:00:41 -05:00
dependabot[bot]
34c4813b8d
chore(deps): bump hono from 4.12.23 to 4.12.27
Bumps [hono](https://github.com/honojs/hono) from 4.12.23 to 4.12.27.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.23...v4.12.27)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.27
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-24 14:28:36 +00:00
Aaron Bockelie
d8860751fb chore: Bump version to 0.11.34 2026-06-24 09:27:19 -05:00
Aaron Bockelie
e3ff9b21b3
Merge pull request #244 from aaronsb/fix/sse-noise-dead-transport-handlers
fix(mcp): stop reaping SSE notify stream; drop dead transport handlers
2026-06-24 00:01:45 -07:00
Aaron Bockelie
926b8fb363 refactor(mcp): address review of #244 — document SSE backstop, test teardown
Code review refinements (no behavior change):
- Make the SSE socket-exemption trade-off explicit: with setTimeout(0)
  there is no socket-layer idle reap, so a truly-dead SSE socket is
  bounded by the SessionManager's 1h idle eviction (which calls
  transport.close()) and the 32-session cap, not leaked unbounded. Note
  the finite-timeout alternative is deferred to live validation.
- Clarify the comment: the exemption matches any GET, but non-SSE GETs
  are short-lived so it's a harmless no-op for them.
- Add afterEach teardown disposing the SessionManager + connection-pool
  intervals (server.stop() short-circuits when never started), removing
  the test's leaked-timer warning.
- Strengthen the missing-socket test to assert the request still routes
  to the spec §2 400, not just that it doesn't throw.

Claude-Session: https://claude.ai/code/session_011yowKCMFCBp61DRBu5xQm4
2026-06-24 02:00:33 -05:00
Aaron Bockelie
0b9d5b09dc fix(mcp): stop reaping SSE notify stream; drop dead transport handlers
Two transport/session-lifecycle hygiene fixes surfaced while tracing
the #221/#238 session cluster.

SSE notify-stream churn: `GET /mcp` opens the standalone SSE
notification stream, which is long-lived and idle by design (this
server pushes no server-initiated notifications). The server-wide
120s idle socket timeout set in start() reaped it every ~2 min,
surfacing as the "stream terminated" reconnect churn and noisy logs
in bridge reports (#221). handleMCPRequest now exempts only the GET
(SSE) socket from the idle timeout via req.socket.setTimeout(0); POST
request sockets keep the server-wide default.

Dead code: removed attachTransportHandlers, which wired
transport.on('close'|'error'). SDK 1.29's StreamableHTTPServerTransport
is not an EventEmitter and has no `.on`, so it never fired — dead and
misleading. Transport cleanup is already covered by the SessionManager
`session-evicted` handler (close + map delete; every transport maps to
a manager-tracked session that is eventually idle-evicted, so none
leaks) and the explicit DELETE /mcp handler. Replaced the helper with a
comment documenting those two paths.

Adds tests/sse-socket-timeout.test.ts (GET exempts its socket, POST
does not, missing-socket tolerated).

Refs #221. Behavioral SSE change; verify on a live Obsidian instance
before release.

Claude-Session: https://claude.ai/code/session_011yowKCMFCBp61DRBu5xQm4
2026-06-24 01:45:25 -05:00
Aaron Bockelie
7722be4d6f
Merge pull request #243 from aaronsb/fix/bridge-self-heal-404
fix(bridge): self-heal on session expiry — reinitialize + replay (#238)
2026-06-23 23:34:06 -07:00
Aaron Bockelie
dbf0fb1045 fix(bridge): recover concurrent 404s; address review of #243
Code review of the self-heal path surfaced a concurrency correctness
gap and some observability refinements.

Correctness: the 404 self-heal was gated on `response.status === 404
&& sessionId`. When two tool calls 404 concurrently on a dead
session, the first call's reinitialize() synchronously nulls
sessionId before the second call's 404 is handled, so the sibling
failed the `&& sessionId` guard, skipped self-heal, and surfaced
-32603 "Bridge: HTTP 404" instead of recovering. Drop the guard: a
404 on /mcp always means a session id was presented (the server
answers 400 when none is), so any non-initialize 404 is a self-heal
trigger regardless of current bridge state. Both concurrent calls now
coalesce onto the single in-flight reinit and replay under the fresh
session. Added a concurrent-dispatch regression test proving one
reinit + both replays under the new session id.

Observability: log a non-2xx (or thrown) notifications/initialized
during reinit so a required-but-failing handshake notification is
diagnosable from stderr; soften the comment to match its best-effort
reality; note the startNotifyStream fire-and-forget intent. Align the
test's __state() type with the seam.

Claude-Session: https://claude.ai/code/session_011yowKCMFCBp61DRBu5xQm4
2026-06-24 01:32:51 -05:00
Aaron Bockelie
add60a43ef fix(bridge): self-heal on session expiry — reinitialize + replay (#238)
The .mcpb stdio bridge cleared its session on a 404 and emitted
-32000 "please reinitialize", relying on the client to send a fresh
initialize. Clients that ignore that signal (Claude Desktop) then
sent the next tool call with no session → HTTP 400, and the
connection dead-ended until a manual restart. This is the #221/#238
"worked yesterday, broke this morning, tools time out" symptom: a
server restart (Obsidian reload / plugin update) or the 1-hour idle
session GC orphans the bridge's in-memory session.

The bridge now caches the initialize handshake and, on a 404 for a
non-initialize call, transparently re-initializes (fresh synthetic
id, result discarded) + completes the handshake with
notifications/initialized + replays the original message once, then
emits that real result. Recovery is invisible to the client and no
client-side fix is required. An isReplay flag caps it at a single
attempt so a session that dies again can't spin a reinit loop; the
existing `initializing` gate serializes concurrent 404s onto one
re-init.

Made the bridge require()-able (bootstrap guarded by
require.main === module, dispatch/state exported) so the self-heal
path is unit-testable without spawning a process. Adds
tests/bridge-self-heal.test.ts covering reinit+replay success, the
give-up path when re-init fails (no loop), and initialize caching.

Closes #238. Addresses #221 for users on the bundled .mcpb bridge
(third-party bridges like supergateway are unaffected; the server's
404 signal remains spec-compliant per ADR-106).

Claude-Session: https://claude.ai/code/session_011yowKCMFCBp61DRBu5xQm4
2026-06-24 01:11:20 -05:00
Aaron Bockelie
4f319b300d
Merge pull request #242 from aaronsb/ui/settings-connect-first
feat(settings): surface connect/getting-started section at top
2026-06-23 22:51:17 -07:00
Aaron Bockelie
e282f36489 feat(settings): surface connect/getting-started section at top
Move createProtocolInfoSection to render first, ahead of Connection
status and the config sections, so the one-click MCPB bundle, Claude
Code config, and advanced client config are the first thing a user
sees instead of being buried at the bottom of the tab. Rename its
heading "Mcp protocol information" -> "Getting started — connect a
client" to match the content.

Stays on the sanctioned display()->render() fallback (minAppVersion
1.6.6); declarative-API migration remains tracked in #224.

Claude-Session: https://claude.ai/code/session_011yowKCMFCBp61DRBu5xQm4
2026-06-24 00:50:03 -05:00
Aaron Bockelie
b49ba191e9
Merge pull request #231 from aaronsb/docs/obsidian-review-no-suppress
docs: capture 'fix, never suppress' for the Obsidian source-code review
2026-06-09 13:00:20 -05:00