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.
Parallel edit.window/append/patch/at_line/from_buffer calls against the
same file (the recommended batched-tool pattern for MCP clients) ran
overlapping read-modify-write cycles: every call returned "Edit
successful" but only one edit survived, with no error surfaced — silent
data loss.
A new SemanticRouter is constructed per request, so a per-instance lock
cannot serialize concurrent requests. Add a process-wide FileLockManager
singleton (same pattern as ContentBufferManager) that serializes
operations on the *same* file path in arrival order while leaving
different paths fully concurrent. executeEditOperation runs its whole
action switch inside withLock(params.path, …).
- src/utils/file-lock.ts: promise-chain-per-path lock; chains drain so
the map stays bounded; a rejected holder doesn't break later waiters.
- router.ts: wrap executeEditOperation in the per-file lock; also move
the window-edit dynamic import into the two cases that use it (window,
from_buffer) instead of eagerly on every edit action — append/patch/
at_line no longer load it, and the path is unit-testable.
- tests/file-lock-edit-serialization.test.ts: FileLockManager unit
behaviour + a router-level #139 repro (3 concurrent appends; without
the lock the read-modify-write MockAPI loses 2 of 3 — with it all
land) + a different-files-stay-concurrent assertion.
Scope: edit.* actions (the #139 report). No router extraction (that is
#199's own PR). make check green (build + lint 0 errors + 231/231).
Stale/evicted MCP sessions were unrecoverable without a client restart
(#128): a non-initialize request bearing an evicted Mcp-Session-Id hit a
server-side synthetic `initialize` that cannot drive SDK 1.29's
web-standard transport to an initialized state (proven in #190 — non-stream
compatReq, hono RequestError→400, post-hoc accept header lost→406), "failed
open", and returned a non-spec 400 -32000 "Server not initialized" that no
client treats as session-expiry → infinite 400 loop.
Emit the Streamable HTTP spec's session-lifecycle signal and let the client
re-initialize itself:
- evicted session + non-initialize → HTTP 404 + Mcp-Session-Id (spec §3);
compliant client/bridge re-inits per §4, next request opens a fresh
session — no restart (fixes#128)
- no session id + non-initialize → HTTP 400 (spec §2)
- initialize (fresh or recreate) → unchanged, never short-circuited
Delete the synthetic compat-initialize block, requireInitializeNotice
machinery, and the createNullRes/NullResponse shim. New
sendSessionTerminated() helper centralises the spec response.
ADR-106 records the decision, the restated acceptance (404-signal +
2xx-reinit pair), and the known client caveat (spec §4 is the client's
obligation; mcp-remote reaches it via connectToRemoteServer recursion).
Regression harness tests/mcp-session-reinit.test.ts drives the request
pair through handleMCPRequest deterministically (paths return before any
SDK transport — the thing #190 showed cannot be driven synthetically).
ADR numbered 106 (not 105) to avoid collision with the #197 branch's
ADR-105; contiguous once both merge. make check green (build + lint 0
errors + 229/229 tests).
The worker-thread pool ratified/extended by ADR-104 has never executed a
single request since introduction (v0.5.8b, 22742bd): ConnectionPool's
action-level workerOps gate (tool.vault.search, …) never matched the
operation-level method strings the pool emits (tool.vault, tool.edit, …),
so processWithWorker was unreachable. Independently, the worker built to
dist/workers/workers/semantic-worker.js while WorkerManager loaded
dist/workers/semantic-worker.js — load would have failed anyway.
Verified dead against current main; no CPU-starvation field reports.
Fixing forward was rejected: it would ship an untested concurrency path
that widens the #139 parallel-edit race via a new TOCTOU window for an
unproven perf need (see #197 analysis).
- delete src/workers/semantic-worker.ts, src/utils/worker-manager.ts,
build-worker.js; drop build:worker + the build-worker.js build step
- strip worker wiring from ConnectionPool (retained as a main-thread
bounded queue — the only behaviour that ever ran) and the dead
prepareWorkerContext/workerScript from mcp-server.ts
- ADR-105 records the removal as a partial reversal of ADR-104; ADR-104
stays Accepted (SSE-route deconfliction half, shipped #196, still
governs) with a banner pointing to ADR-105
Behaviour-preserving by construction: the deleted path never ran.
make check green (build + lint 0 errors + 225/225 tests).
#192 replaced the Claude Code copy/paste command with a hand-edit-this-JSON
blob plus a warning against `claude mcp add --header`. That was a real UX
regression, and the security premise was wrong for THIS transport:
- The argv / macOS-unified-log exposure applies to **stdio** transports
(a spawned `npx mcp-remote --header ...` child), NOT native **HTTP**
transport — there is no spawned child carrying the header in argv. #143's
threat model was conflated with the mcp-remote setup it was also removing.
- Cleartext-at-rest in ~/.claude.json / .mcp.json is identical whether the
entry is added via the CLI or hand-edited, so "edit the file instead"
bought no at-rest improvement either.
(Confirmed against current Claude Code docs via claude-code-guide.)
Restores the single `claude mcp add --transport http ...` command as the
Claude Code path in the Settings UI, README, and the issue-32 template.
Removes the inaccurate warning and its now-dead `.mcp-security-warning` CSS
and the eslint-disable it required.
Kept from #192 (those parts were legit): native HTTP transport over the
deprecated mcp-remote, and the self-signed-cert / NODE_EXTRA_CA_CERTS
trust docs.
ADR-104's context has a passing line calling the `--header` form
deprecated; left as-is (accepted ADRs are immutable records of what we
believed then) — this commit is the corrective record.
Reimplemented from PR #126 (djsplice, who also reported #125), reduced to
the parts that are verified-clean. The worker-offload half of #126 is split
out to a tracking issue (inert as wired + correctness divergences — see PR
discussion / the follow-up issue).
SSE route deconfliction
In @modelcontextprotocol/sdk@1.29.0 (the pinned version), `GET /mcp` is
the standalone server->client SSE stream for server-initiated messages —
verified in webStandardStreamableHttp.js:handleGetRequest (opens
`_GET_stream`, Content-Type text/event-stream, gated on
Accept: text/event-stream + session + protocol version). A debug route
returning application/json on `GET /mcp` shadowed it, so any client that
opens the standalone stream (mcp-remote-class bridges — #128's logs show
one in use here) received JSON instead of an SSE stream and treated it as
failed. Moved debug to `GET /mcp-info`; `GET /mcp` and `POST /mcp` both
reach handleMCPRequest. Unlike #126's `app.all('/mcp')`, GET/POST are
registered individually so the existing explicit `app.delete('/mcp')`
session-close handler is preserved.
Scope note: this restores the server-initiated-notification channel (a
real latent defect on its own). It is NOT claimed to fix#128 — per the
#190 investigation that is a separate SDK-1.29 compat-init problem — and
#125's "SSE reconnection loop" is the reporter's un-reproduced diagnosis.
Two-row Levenshtein
fuzzy-match.ts rewritten from a full (m+1)x(n+1) matrix to a two-row
formulation (O(n) memory, no per-line array-of-arrays allocation) plus
length heuristics and a 0.95 early-exit. Pure CPU/memory win on the
main-thread edit.window path; behaviour-preserving. Adds the first
fuzzy-match tests (classic Levenshtein distances, symmetry,
heuristic-preservation).
build.yml: PR-status-comment step set continue-on-error so it cannot red an
otherwise-green build on fork/limited-token PRs.
Co-authored-by: djsplice <barrows.jeff@gmail.com>
Code review (PR #193) caught that the inline branch returned
`sourceFiles: paths` (caller input order) while `content` is built from
the local sourceFiles array *after* sortFiles() mutates it in place. With
sortBy set — exactly the sorted multi-file retrieval case this feature
targets — a consumer mapping content sections back to files got the wrong
order. Return the post-sort order instead.
Adds a router-level regression test (the gap the reviewer flagged: the
formatter-only tests could not have caught this).
Co-authored-by: Earl Plak <5597016+laplaque@users.noreply.github.com>
`vault combine` without a `destination` now returns the combined content
inline in the response instead of erroring. Read-only consumers can use
combine for multi-file retrieval with no side effects.
- router.ts: no-destination path returns { inline, content, ... }; the
destination-exists guard is skipped when there is no destination.
- formatters/vault.ts: FileCombineResponse gains optional destination +
inline/content; formatFileCombine renders an inline block.
- semantic-tools.ts: combine counts as a write-op (blocked in read-only
mode) only when a destination is given; param description updated.
- tests: formatter inline/destination/fallback coverage.
Reimplemented from PR #146 against current `main` (code only); supersedes
and closes out #146. Dropped the original PR's
`path: destination || '_inline_'` validator workaround — the batch.combine
validators (BatchLimitValidator, PathArrayValidator) never inspect `path`,
so the hack was unnecessary. The README/troubleshooting cert-trust docs
that #146 also carried are covered by the #143 reimplementation instead, to
avoid duplicate divergent copies.
Co-authored-by: Earl Plak <5597016+laplaque@users.noreply.github.com>
`claude mcp add --header "Authorization: Bearer <token>"` defeats
secret-at-rest protection: the CLI resolves and echoes the header value to
stdout (captured by any parent process, incl. AI agents) and on macOS the
spawned MCP child argv is written to the unified log. Editing the MCP config
file directly avoids both vectors.
- Settings UI: new shared `renderClaudeCodeConnection()` renders a
ready-to-paste JSON config + a security warning for the authenticated
path, and the safe plain CLI command only when auth is disabled. Used by
both the initial render and the live-refresh handler so they cannot drift
(the previous two copies had already diverged).
- README / SECURITY.md / troubleshooting.md / issue-32 response template:
drop `mcp-remote` + `NODE_TLS_REJECT_UNAUTHORIZED=0`, document native HTTP
transport and proper self-signed-cert trust (macOS Keychain +
NODE_EXTRA_CA_CERTS for Bun-based runtimes), add the `--header` warning.
- styles.css: `.mcp-security-warning`.
Reimplemented from PR #143 against current `main` (the settings UI had been
restructured since the PR was opened); supersedes and closes out #143.
ADR-100 left untouched — accepted ADRs are immutable records; the `--header`
deprecation is recorded in ADR-104's context instead.
Co-authored-by: Earl Plak <5597016+laplaque@users.noreply.github.com>
PR2 of 2 for #180 (ADR-201). Closes the arbitrary-code-execution vector
reachable through a synced/shared `.base` file.
- Drop `new Function('context', 'with(context){return ${expr}}')` (and its
no-implied-eval eslint-disable) from expression-evaluator.ts.
- Parse with expression-eval@5.0.1 (jsep grammar: no eval/Function/new, no
global scope; member denylist built in). Pinned exact; jsep 0.3.5 is its
audited transitive parser. Both >7d old; npm audit 0 vulns.
- Defense-in-depth AST pre-walk (assertNoForbiddenAccess): rejects
constructor/__proto__/prototype member access (Identifier AND computed
["constructor"] forms) and ThisExpression, independent of the library's
internal list — ADR-201's explicit, tested no-globals property lives in
our code, not a transitive dep.
- createEvalContext and all helpers untouched → behavioural parity.
Differential corpus (tests/bases-expression-evaluator.test.ts, from PR1):
57 EVAL_CASES still green = expression-eval == old new Function for every
realistic Bases construct; the formerly-executing escapes (2/42/99 under
new Function) now fail closed; SECURITY set never reaches a function, the
real global, or a value.
No behavioural change for legitimate `.base` expressions. Breaking only by
design: the RCE vectors, `this`, and prototype-chain access are now
refused. Clears the Obsidian "Dynamic Code Execution" finding.
209/209 tests; build + lint clean. Refs #180, ADR-201
Code review found the date-safety doc cited the wrong line (date() global,
not the real reconciliation). Correct it: (1) Obsidian metadata cache is the
primary frontmatter source, this parser is only a fallback; (2) when the
fallback runs, expression-evaluator auto-coerces date-like keys via
new Date(); (3) .base docs have no date scalars.
Surface the masked divergences instead of hiding them behind toBeFalsy:
- empty doc: yaml→null vs js-yaml→undefined — asserted concretely, proven
neutralized by parseFrontmatter's typeof/!==null guard.
- merge keys: js-yaml resolves <<:*anchor, yaml keeps literal — asserted as
a known, accepted boundary (.base/frontmatter never use merge keys).
- input anchors/aliases: confirmed both resolve structurally.
Documented all in yaml-bridge.ts. 149/149 green.
Closes#178. updateStatusBar() previously remove()'d and re-addStatusBarItem()'d
on every call. It fires multiple times during async startup (port-conflict
paths, server-start callbacks, settings); concurrent calls could each add an
element while only the last was tracked, orphaning a transient 'Mcp: error'
element that persisted until the next Obsidian reload.
Create the element exactly once and mutate it thereafter (setText + class
swap). showConnectionStatus=false now hides the single element via the
existing mcp-hidden utility instead of leaving/removing DOM. Structurally
eliminates duplicate/orphaned indicators regardless of call ordering.
Closes#174. Obsidian source-code review flagged js-yaml (unmaintained) for
replacement. Rewire bases-api.ts's 5 call sites onto yaml-bridge; drop
js-yaml + @types/js-yaml. yaml pinned to 2.8.4 (published 2026-05-02, clears
the project's 7-day supply-chain hold; 2.9.0 was 5 days old). Existing
overrides.yaml reconciled to $yaml so the direct dep governs transitive
resolution. Behaviour-equivalence proven by the differential corpus.
Introduces src/utils/yaml-bridge.ts as the single YAML parse/serialize seam
for the Bases subsystem, and a reusable .base corpus + differential tests
that run every fixture through both js-yaml (still resolvable transitively as
the behavioural oracle) and the new yaml-backed bridge, asserting
equivalence. The one intentional difference — js-yaml coerced bare/ISO dates
to Date, yaml keeps strings — is asserted explicitly and proven downstream-
safe (expression-evaluator already does new Date(value) on non-Date input).
Corpus is structured for ADR-201/#180 reuse.
Closes#163. The literal only existed in DEFAULT_SETTINGS and was passed to
the inbound https.createServer, which never sets requestCert — so Node ignored
it. certificate-manager already defaults the effective value to true via
`!== false`, so removing the default is behavior-preserving and removes the
exact token Obsidian's scorecard pattern-matches as 'disables SSL verification'.
Adds tests/security/tls-cert-verification.test.ts as a regression guard:
fails if the flagged literal returns or the secure default flips.
The plugin code already calls APIs introduced in newer Obsidian versions
(FileManager.trashFile in 1.6.6, Workspace.getLeaf in 0.16.0,
Vault.createFolder in 1.4.0, ToggleComponent.setDisabled in 1.2.3,
AbstractTextComponent.setDisabled in 1.2.3, ButtonComponent.setTooltip
in 1.1.0). Declaring minAppVersion 0.15.0 was misleading — anyone
between 0.15 and 1.5 would have the plugin load and then crash at
runtime. Set the floor to 1.6.6 to match the highest API actually used.
The 0.3.0 release adds typed rules (no-plugin-as-component, no-view-references-in-plugin,
no-unsupported-api, prefer-file-manager-trash-file, prefer-instanceof) and wires
them into the hybrid recommended config without a **/*.ts files scope. As a
result they also try to run against package.json — which uses ESLint's JSON
language and has no @typescript-eslint parser services — and ESLint aborts
the whole run with "Error while loading rule 'obsidianmd/no-plugin-as-component'".
- eslint.config.mts: disable those five typed rules on JSON files, and
ignore the data files the recommended config doesn't wire a JSON parser
for (tsconfig.json, package-lock.json, manifest.json, versions.json,
src/config/**/*.json, .claude/**).
- Keep the autofix for `setTimeout` → `window.setTimeout` and
`globalThis` → `window` in code paths that only run inside Obsidian
(main.ts, main-complex.ts, worker-manager.ts, connection-pool.ts,
tools/fetch.ts) — that's the popout-window-compat behaviour the rule
exists to enforce.
- Revert the autofix in code paths imported by Jest tests where `window`
is undefined: session-manager.ts and obsidian-api.ts background timers,
semantic/router.ts active-file timeout, debug.ts console reference.
Each retains a `// eslint-disable-next-line` with the reason
(background timer / Node test compat).
Closes#115. Closes#123.
The Dataview API returns Luxon DateTime objects (which expose toISO(), not
toISOString()) for file.ctime/mtime, so listPages() and getPageMetadata()
crashed with "toISOString is not a function" the moment any real Dataview
result reached them. The existing tests passed only because the mocks used
native Date, which exposes toISOString().
The query path had a separate envelope bug: executeQuery() hard-coded
success: true whenever dataviewAPI.query() didn't throw, while Dataview
itself returns { successful: false, error: "..." } for malformed queries
without throwing. The formatter then read response.successful (undefined),
hit the failure branch, and rendered every result as "❌ Query failed:
Unknown error" — even when the query had succeeded.
- Add toIsoOptional() that prefers Luxon's toISO() and falls back to
Date.toISOString(); apply at every page.file.ctime/mtime serialization
site and inside convertDataviewValue().
- Propagate result.successful and result.error from the inner Dataview
response to the outer tool envelope.
- Add a dataview.query normalizer case in the formatter dispatcher that
flattens result.{type,values,headers} to the top level and maps
success → successful, matching the formatter's contract.
- Regression tests for both bugs, including a Luxon-shaped mock distinct
from the existing Date-shaped one.
Pagination was silently a no-op for the MCP-facing surface. MCP
clients send numeric arguments (page, pageSize, snippetLength) as
JSON numbers; paramStr returns undefined for non-string values, so
parseInt(paramStr(...) ?? '1') always collapsed to defaults.
Caught while live-verifying the #158 pagination work shipped in
0.11.19: a call with page=2 pageSize=50 returned page 1 pageSize 20.
The router was discarding the user's values before the call ever
reached listFilesPaginated.
Three call sites fixed in src/semantic/router.ts:
- vault.list (line 174-175) — the immediate verifier
- vault.search (line 286-287)
- vault.search snippet length (line 306)
paramNum already existed at line 44; this commit just wires it in.
Code-reviewer caught a real bug: listFiles (recursive flat) sorts by
full path, but listFilesPaginated in recursive mode was sorting
folders-first-then-by-basename. So the agent's "use page=2 pageSize=50"
hint anchored to a path-sorted universe, but page 2 returned a
basename-sorted universe — overlaps and gaps for any caller trying to
walk the listing.
Recursive paginated mode now sorts by full path, matching listFiles.
Level-only mode (the default for internal callers — cp recursion,
isDirectory probe) keeps the folder-first-then-basename order it
needs for tree navigation.
New test (`paged universe matches the flat listFiles universe`)
concatenates every page and asserts equality with the flat call —
exactly the agent-facing contract the formatter's next-page hint
promises.
Router comment also clarifies why root paginated stays recursive=false
(getAllLoadedFiles is already recursive — the path collapses to the
same answer either way).
Refs PR #158 review.
When the formatter truncated a directory listing with "... and N more",
the agent had no usable next call. \`page=2 pageSize=50\` would have
returned a fundamentally different shape (level-only folder entries
from listFilesPaginated, not the next 50 files from the recursive
listFiles), so any hint to "paginate" would have led the agent
somewhere broken.
This change makes pagination semantically consistent and gives the
agent an explicit, working escape hatch:
- listFilesPaginated gains a recursive flag (default false to
preserve existing internal callers — cp at router.ts:1049 and the
isDirectory probe at router.ts:1021 still get level-only).
- vault.list passes recursive=true when paginating with a non-root
directory. Page N of vault.list('foo') now returns the Nth slice
of the same flat universe vault.list('foo') (unpaginated) would
return — no shape switch.
- The flat-list formatter, when it truncates at 50, now tells the
agent exactly what to call next: page=2 pageSize=50, or raw:true
for the full list in one response.
- The structured-response formatter shows "Page X of Y" and, when
there are more pages, surfaces the next-page call with the
current pageSize baked in.
- normalizeResponse gains a vault.list case translating the API
response's \`type: 'file'|'folder'\` to the formatter's \`isFolder\`
boolean. Previously the structured branch's filter on f.isFolder
always failed silently, lumping every entry into "Files" even when
half were folders.
Two new tests in tests/list-files-recursive.test.ts:
- recursive paginated mode produces the expected slice with no
cross-page overlap
- recursive=false (default) preserves the level-only behavior the
internal callers depend on
Refs #154 (extends the agent-facing fix).
vault.list(directory) returned an empty array whenever the target
folder's direct children were all subfolders — listFiles used
folder.children and then filtered to TFile, dropping every
descendant. Meanwhile the root case used vault.getAllLoadedFiles()
which is recursive, so root and directory listings diverged.
listFiles now walks the folder tree when a directory is provided,
matching the recursive semantics of the root case. Callers that
were already iterating result paths get the same shape — full
vault-rooted paths to .md files — they just no longer hit the
empty-list cliff for folder-of-folders inputs.
The synchronous throw on missing directory is preserved; existing
try/catch sites at router.ts:538 and :1030 use it as a "does this
exist as a directory" probe.
Adds tests/list-files-recursive.test.ts with regression coverage:
- recursing through a folder of subfolders returns all descendant files
- recurses through arbitrary nesting depth
- still throws on a missing directory
- root call (no directory) still returns the full vault
Test fixtures: tests/__mocks__/obsidian.ts gained a TFolder class
and a stat field on TFile.
Closes#154.
Default view now shows only the two common paths: Claude Desktop
MCPB download and Claude Code CLI string. Everything else moves into
a <details> windowshade ("Advanced — other MCP clients, multi-vault,
custom bundles"), collapsed by default:
- JSON mcpServers block (for Cline, Continue, custom integrations,
and multi-vault setups)
- Pointer to scripts/make-mcpb.mjs for custom-named bundles per vault
Cleaner default surface, advanced paths still one click away. The
maker-script note moved out of the MCPB section header so the
primary path is just "download, paste, install" with no asterisks.
The Settings UI was building a versioned releases/download/<version>/
URL that would 404 for any plugin version not yet associated with a
GitHub release — the exact case for users on a fresh BRAT install
running ahead of the release pipeline.
build-mcpb.mjs now emits both the versioned filename (for archival)
and an unversioned obsidian-mcp.mcpb alias. release.yml uploads both.
The Settings UI links to releases/latest/download/obsidian-mcp.mcpb,
which GitHub resolves to the most recent release's asset by name —
stable regardless of plugin version.
Makefile clean target updated to remove the new artifact.
Refs PR #155 review.
Reorders the protocol-information section to surface the easiest
onboarding path first. New section order:
1. Claude desktop (.mcpb — one-click install) ← primary
2. Claude code (CLI) ← unchanged
3. Other mcp clients (JSON config) ← demoted, renamed
The MCPB section shows a versioned download link that resolves to
the GitHub release asset and a values panel (URL + API key with
copy buttons) for the install prompt. A footnote points multi-vault
users at scripts/make-mcpb.mjs for custom-named bundles.
eslint config now ignores mcpb/ and scripts/ — they ship as zip
content and standalone Node CLIs respectively, not plugin code.
Refs ADR-102.
The fetch tool returns errors as {content: [{type:'text', text:'...'}], isError: true}
but formatWebFetch expected content to be a plain string. When an array hit a
template literal it rendered as [object Object]. Now extracts the text field
from MCP content arrays before formatting.
system info always reported httpPort even when only HTTPS was bound.
Now exposes httpEnabled, httpsEnabled, httpPort, and httpsPort in
both the API response and the settings UI. The formatter renders
both listeners with enabled/disabled status. Legacy single-port
responses still handled for backward compatibility.
Users can now control which MCP tools are exposed to connecting agents
via a tri-state tree UI in the plugin settings panel. Disabled tools
are excluded from the MCP tools/list enumeration — agents cannot
discover or call them. Includes defense-in-depth runtime check.
Also adds ADR-101 (tool visibility gating) and ADR-200 (CLI parity gaps).