Skip to content

feat(mcp): single-source the tool surface, add read-only mode, paid-operation policy, and concurrency tests - #768

Merged
manoahLinks merged 4 commits into
mind-vault-1:mainfrom
kaluuba-org:feat/mcp-readonly-paid-confirm-drift-concurrency
Aug 31, 2026
Merged

feat(mcp): single-source the tool surface, add read-only mode, paid-operation policy, and concurrency tests#768
manoahLinks merged 4 commits into
mind-vault-1:mainfrom
kaluuba-org:feat/mcp-readonly-paid-confirm-drift-concurrency

Conversation

@kaluuba-org

@kaluuba-org kaluuba-org commented Aug 30, 2026

Copy link
Copy Markdown

Closes #591, Closes #593, Closes #594, Closes #596

All four issues are scoped to mcp/.


⚠️ Read this first: mcp/ does not build on main

Before any of the four issues could be worked on — or even tested — the package had to compile. It does not. pnpm --filter @mindvault/mcp build fails on main today, and pnpm test collects zero tests from 15 suites because the modules they import will not parse.

Three separate bad merges left duplicated declarations behind:

Where What
auditLog.ts Two emit implementations — one writing to the file sink, the other stamping the correlation ID. tsc rejects the module, so every suite importing it collects nothing.
index.ts Merge branch 'main' into feat/mcp-545-catalog-resources (in #757) concatenated both sides of eight conflicting hunks: two Mutex imports (one from async-mutex, which is not a dependency), two safeErrorMessage imports, two browse definitions, two toolMetrics, and a ToolOutcome/string return-type clash in the dispatcher.
preview() Lost its #556 offline-snapshot fallback when #582's preview limits landed on top of it, leaving index.test.ts asserting a cache path the function no longer had.
toolSchemaSnapshots Snapshots written before #553 added output schemas to TOOL_DEFINITIONS and never regenerated — 25 cases expecting null where a schema is now declared.

The first commit repairs this. For index.ts I re-ran the three-way merge properly rather than patching symptoms; the result is the pre-merge main file plus exactly the four additions #545 contributed (the resources capability, the two ListResources/ReadResource handlers, and their imports). Nothing from either branch is dropped.

This commit is separable. If you would rather land it on its own first, drop the other commits and I will rebase.


#596 — list_tools contract drift check

tools.ts says of itself that it is "the single source of truth for the tool surface advertised to agent clients", and scripts/generate-tool-docs.ts builds docs/mcp-tool-reference.md on that promise.

It was not true. The ListTools handler in index.ts carried its own hand-maintained ~490-line copy of the list, and the two had drifted apart in every direction at once:

  • Six implemented, validated, documented tools were missing from the copy and therefore undiscoverable by any agent: mindvault_update_metadata, mindvault_set_price, mindvault_transfer_ownership, mindvault_set_listed, mindvault_export_receipts, mindvault_recover_catalog_cache.
  • mindvault_publish_status and mindvault_purchase_history existed only in the copy, so the generated reference never listed them and the schema snapshots never covered them.
  • mindvault_reset could never be confirmed. It advertised a confirm argument, resetGuard.isResetConfirmed reads confirm, and TOOL_ARGUMENT_SPECS did not declare it — so the validator rejected every confirmed reset as an unknown argument and the tool was permanently stuck returning its preview:
    Invalid arguments for mindvault_reset: confirm is not a recognized
    argument for mindvault_reset. Accepted arguments: all, confirmMainnet.
    
  • mindvault_publish and mindvault_buy had stopped advertising dryRun and maxAutoPayUsdc, and several schemas had lost their per-field descriptions and examples.

The cause is removed: toolSurface.ts derives the ListTools payload from TOOL_DEFINITIONS, and index.ts sheds 586 lines. The category is covered by listToolsContract.test.ts.

A tool is really four declarations — definition, argument spec, dispatch case, output schema — and any one can be added without the others with nothing failing. The test asserts all four agree, against the response a real client receives over a real transport rather than against the arrays that produce it. I verified it fails on three injected regressions before trusting it:

Injected drift Caught by
A definition with no handler and no spec 5 tests
An argument the validator rejects but the schema advertises 2 tests
A tool dropped from the surface with its wiring left behind 2 tests

mindvault_set_tags is defined and validated but has no dispatch case — a known gap that docs/mcp-structured-output.md already records. I did not implement it (out of scope). It is withheld from ListTools rather than advertised, since an agent that calls an advertised tool and gets Unknown tool learns nothing it can act on. TOOLS_WITHOUT_HANDLERS is asserted to name exactly the tools in that state, in both directions — so implementing it fails the suite until the entry is removed.

EXTRA_TOOL_ANNOTATIONS and EXTRA_OUTPUT_SCHEMAS existed only to patch over the two tools missing from TOOL_DEFINITIONS. Both are now dead and removed.

Net effect for agents: 30 → 36 advertised tools, with fuller schemas, and mindvault_reset works.

#593 — read-only mode for catalog browsing

MINDVAULT_READ_ONLY=1 turns the server into a catalog browser.

It is deliberately unlike the mainnet guardrail: that one is network-scoped (testnet is wide open) and per-call — confirmMainnet: true lifts it from inside the very call you wanted to prevent. This is operator-scoped, set once on the process, and no tool argument can override it.

Two things change together, and both are load-bearing:

  1. ListTools advertises only tools declaring readOnlyHint — so an agent never plans around a tool it cannot use.
  2. Dispatch refuses the rest — because a client with a cached tool list, or one guessing a name, still calls it. This is the gate that makes the mode a guarantee rather than a hint, and the tests exercise it directly.

Classification reads each tool's own readOnlyHint annotation, so a tool added later is gated by whatever it declares about itself and this cannot fall out of sync with the surface.

#594 — paid-operation confirmation policy

MINDVAULT_CONFIRM_PAID_OPERATIONS=off|usdc|all requires confirmPaid: true before a tool spends.

This is a third axis, not a replacement. The mainnet guardrail asks where a spend happens and never fires on testnet; the auto-pay ceiling asks how much and happily permits a hundred cheap purchases. This asks whether the caller meant to spend at all.

Value Effect
off (default) Nothing changes.
usdc mindvault_publish, mindvault_buy
all …plus register_onchain, update_metadata, set_price, transfer_ownership, set_listed

Design notes:

  • Default off, so an upgrade changes no existing deployment. A guardrail that breaks working setups gets switched off wholesale, which is worse than not shipping it.
  • Dry runs are exempt — gating one would mean confirming a spend in order to discover what the spend would be.
  • mindvault_setup_wallet is not gated — the sponsored-account service funds it, so the agent's own wallet pays nothing and claiming otherwise would be a lie about what the call costs.
  • An unrecognized value raises rather than falling back to off. A typo in a safety setting that silently disables it is worse than one that fails loudly, because the operator believes they are protected.
  • Tests pin that confirmPaid and confirmMainnet cannot substitute for each other, and that a call subject to both must satisfy both.

#591 — concurrent state write regression test

Several tools do a read-modify-write against the module-level profiles map and then persist it. The window between read and write is not theoretical — mindvault_import_wallet awaits a dynamic import("@stellar/stellar-sdk") in the middle of it:

activeProfileName = target;        // read/modify
…await…                            // another call runs here
activeProfile().wallet = {};    // modify, against whatever
saveState();                       // activeProfileName now says

STATE_MUTATING_TOOLS + stateMutex.runExclusive close that window; these tests exist so the closing stays closed. They drive real concurrent dispatchTool calls and assert on the bytes that land on diskmutex.test.ts already covers the primitive in isolation, which is not the same thing.

The failure being guarded against is not a lost write but a misdirected one. Interleaved calls do not drop a profile; they write one call's wallet into another call's profile, which persists cleanly, reads back as valid JSON, and hands the wrong agent a wallet. "Every profile is present" passes while that happens — so the assertions check each profile holds the key actually meant for it.

Covered: twelve overlapping imports checked for correct per-profile assignment; the state file parseable throughout the run; 0600 preserved across concurrent rewrites; lock release after a failed mutation (a leaked lock would deadlock every later mutating tool and look like a hang rather than a fault); reads interleaved with writes; and a state file left read-only by an earlier run.

A final pair models importWallet's exact shape against a local mirror to show precisely which interleaving does the damage — both bodies reach their await before either resumes, so both resume reading the last writer's activeProfileName. Two successful calls, no error raised, one profile silently gone. The interleaving is forced with microtask yields rather than timers, so it is deterministic and cannot flake.

Keypairs are generated rather than inlined: they only need to be valid and distinct, and a committed S… literal reads like a leaked credential to every scanner that meets it. Nothing here is funded or submitted.


Also included: a pre-existing flaky test

correlation.test.ts > produces distinct ids in a tight loop minted 500 ids from the real clock and Math.random and asserted all were distinct. It failed roughly one run in fourteen — enough to make CI red on unrelated PRs.

The failure was correct; the assertion was wrong. A 500-iteration loop finishes inside one millisecond, so the time component is constant and all 500 ids come from the suffix alone — 36**4 = 1,679,616 values, which by the birthday bound collides ~7% of the time.

The obvious fix (a uniqueness counter) is unavailable: the test directly above pins that newCorrelationId is deterministic in (clock, random), which exists so other tests can assert exact ids. So the loop is replaced by the properties the design does guarantee, and the docstring's claim that a same-millisecond collision is "effectively impossible" is corrected to state the actual size and why a counter is not an option.

Also separable if you would prefer it split out.


Verification

Check Result
make validate All checks passed
pnpm test (workspace) registry-client 22 ✓ · server 273 ✓ · web 72 ✓ · mcp 2260 ✓
tsc --noEmit (mcp) clean
pnpm --filter @mindvault/mcp build
pnpm --filter @mindvault/mcp smoke:install ✓ 7 checks against dist/index.js
eslint 0 errors (16 warnings, all pre-existing — one fewer than main)
prettier clean
Flake check 20 consecutive full-suite runs + 6 with --sequence.shuffle, all green

For reference, mcp/ on main today: 15 suites collecting 0 tests, 25 snapshot failures, build broken.

Commits

Each is self-contained and passes the suite on its own:

  1. fix(mcp) — repair the merge damage (prerequisite)
  2. feat(mcp) — single-source the tool surface, read-only mode, paid-operation policy ([MCP] Add list_tools contract drift check #596, [MCP] Add read-only mode for catalog browsing #593, [MCP] Add paid-operation confirmation policy #594 — these three share toolSurface.ts, tools.ts and the dispatcher, so they land together)
  3. test(mcp) — concurrent state write regression tests ([MCP] Add concurrent state write regression test #591)
  4. test(mcp) — fix the flaky correlation-id assertion

Docs

docs/environment-variables.md gains both new variables plus a section each; mcp/.env.example gains commented entries; docs/mcp-tool-reference.md is regenerated (the staleness guard in toolDescriptions.test.ts enforces this).

The mcp package does not build or test on main. Three separate bad merges
left duplicated declarations and a stale snapshot file behind:

- auditLog.ts had two `emit` implementations — one writing to the file sink,
  the other stamping the correlation ID — so `tsc` rejected the module and
  every suite importing it collected zero tests. Merged into one exit point
  that does both, and imported the missing `currentCorrelationId`.

- index.ts came out of "Merge branch 'main' into feat/mcp-545-catalog-resources"
  with both sides of eight conflicting hunks concatenated: two `Mutex`
  imports (one from `async-mutex`, which is not a dependency), two
  `safeErrorMessage` imports, two `browse` definitions, two `toolMetrics`,
  and a `ToolOutcome`/`string` return-type clash in the dispatcher. Redoing
  that three-way merge yields the pre-merge main file plus exactly the four
  additions mind-vault-1#545 contributed (the resources capability, the two
  ListResources/ReadResource handlers, and their imports), which is what
  this commit applies.

- preview() lost its mind-vault-1#556 offline-snapshot fallback when mind-vault-1#582's preview
  limits landed on top of it, leaving `index.test.ts` asserting a cache
  path the function no longer had. Restored as `previewData`, composed with
  `applyPreviewLimits` so both behaviours hold.

- toolSchemaSnapshots snapshots were written before mind-vault-1#553 added output
  schemas to TOOL_DEFINITIONS and never regenerated, so 25 cases expected
  `null` where a schema is now declared. Regenerated.

mcp: 67 files / 2112 tests pass, `tsc --noEmit` is clean, and
`smoke:install` drives the built server over stdio.
…aid-operation policy

Closes mind-vault-1#596, mind-vault-1#593, mind-vault-1#594.

## mind-vault-1#596 — list_tools contract drift check

tools.ts calls itself "the single source of truth for the tool surface
advertised to agent clients", and scripts/generate-tool-docs.ts builds
docs/mcp-tool-reference.md on that promise. It was not true: the ListTools
handler in index.ts carried its own ~490-line literal copy, and the two had
drifted apart in every direction at once.

  - Six implemented, validated, documented tools were missing from the copy
    and so undiscoverable: mindvault_update_metadata, _set_price,
    _transfer_ownership, _set_listed, _export_receipts, _recover_catalog_cache.
  - mindvault_publish_status and mindvault_purchase_history existed only in
    the copy, so the generated reference never listed them and the schema
    snapshots never covered them.
  - mindvault_reset advertised a `confirm` argument that resetGuard reads and
    TOOL_ARGUMENT_SPECS did not declare, so the validator rejected it as an
    unknown argument. The tool could never be confirmed — it was permanently
    stuck returning its preview.
  - mindvault_publish and mindvault_buy had stopped advertising `dryRun` and
    `maxAutoPayUsdc`, and several schemas had lost their field descriptions
    and examples.

toolSurface.ts now derives the ListTools payload from TOOL_DEFINITIONS, which
removes the cause; listToolsContract.test.ts covers the category. A tool is
four declarations — definition, argument spec, dispatch case, output schema —
and any one can be added without the others. The test checks all four agree
against the response a real client receives over a real transport, and was
verified to fail on three injected regressions (a definition with no handler,
an argument the validator rejects, a tool dropped from the surface).

mindvault_set_tags is defined and validated but has no dispatch case, as
docs/mcp-structured-output.md records. It is withheld rather than advertised —
an agent that calls an advertised tool and gets `Unknown tool` learns nothing
it can act on — and TOOLS_WITHOUT_HANDLERS is asserted to name exactly the
tools in that state, in both directions.

EXTRA_TOOL_ANNOTATIONS and EXTRA_OUTPUT_SCHEMAS existed only to patch over the
two tools missing from TOOL_DEFINITIONS. Both are gone.

## mind-vault-1#593 — read-only mode for catalog browsing

MINDVAULT_READ_ONLY=1 restricts the server to catalog browsing. Unlike the
mainnet guardrail it is operator-scoped rather than network-scoped, and cannot
be lifted by a tool argument — confirmMainnet is supplied from inside the very
call you wanted to prevent.

ListTools advertises only tools declaring readOnlyHint, and dispatch refuses
the rest. Both are needed: the listing keeps an agent from planning around a
tool it cannot use, but a client with a cached list still calls it, so the
gate that enforces the mode is the one in the dispatcher. The refusal names
what is still available. Classification reads each tool's own readOnlyHint,
so a tool added later is covered by what it declares about itself.

## mind-vault-1#594 — paid-operation confirmation policy

MINDVAULT_CONFIRM_PAID_OPERATIONS=off|usdc|all requires confirmPaid: true
before a tool spends. It is a third axis, not a replacement: the mainnet
guardrail asks *where* a spend happens and never fires on testnet; the
auto-pay ceiling asks *how much* and permits a hundred cheap purchases. This
asks whether the caller meant to spend at all.

Default is off, so an upgrade changes no existing deployment. Dry runs are
exempt — gating one would mean confirming a spend to discover what the spend
would be. mindvault_setup_wallet is not gated: the sponsored-account service
funds it, not the agent's wallet. An unrecognized value raises rather than
falling back to off, because a typo that silently disables a safety setting
leaves the operator believing they are protected. Tests pin that confirmPaid
and confirmMainnet cannot substitute for each other.

## Verification

mcp: 71 files / 2258 tests pass, including 31 consecutive runs and 6 with
--sequence.shuffle. Workspace `pnpm test`, eslint (0 errors), prettier, the
mcp build, and smoke:install all pass. index.ts drops 586 lines.
Closes mind-vault-1#591.

Several MCP tools do a read-modify-write against the module-level `profiles`
map and then persist the whole map to ~/.mindvault/state.json. The window
between the read and the write is not theoretical — mindvault_import_wallet
awaits a dynamic import("@stellar/stellar-sdk") in the middle of it:

    activeProfileName = target;        // read/modify
    …await…                            // another call runs here
    activeProfile().wallet = { … };    // modify, against whatever
    saveState();                       // activeProfileName now says

STATE_MUTATING_TOOLS + stateMutex.runExclusive close that window. These tests
exist so the closing stays closed. They drive real concurrent dispatchTool
calls and assert on the bytes that land on disk; mutex.test.ts already covers
the primitive in isolation, which is not the same thing.

The failure being guarded against is not a lost write but a misdirected one.
Interleaved calls do not drop a profile — they write one call's wallet into
another call's profile, which persists cleanly, reads back as valid JSON, and
hands the wrong agent a wallet. "Every profile is present" passes while that
happens, so the assertions check each profile holds the key meant for it.

Twelve overlapping imports are checked for correct per-profile assignment, a
parseable state file throughout the run, 0600 preserved across concurrent
rewrites, lock release after a failed mutation (a leaked lock would deadlock
every later mutating tool and look like a hang rather than a fault), reads
interleaved with writes, and a state file left read-only by an earlier run.

A final pair models importWallet's exact shape against a local mirror to show
which interleaving does the damage: both bodies run to their await before
either resumes, so both resume reading the *last* writer's activeProfileName.
Two successful calls, no error raised, one profile silently gone. The
interleaving is forced with microtask yields rather than timers, so it is
deterministic and cannot flake.

Keypairs are generated rather than inlined — they only need to be valid and
distinct, and a committed S… literal reads like a leaked credential to every
scanner that meets it. Nothing here is funded or submitted.
`newCorrelationId > produces distinct ids in a tight loop` minted 500 ids from
the real clock and Math.random and asserted all 500 were distinct. It failed
roughly one run in fourteen, which was enough to make CI red on unrelated PRs.

The failure was correct and the assertion was wrong. A 500-iteration loop
completes inside one millisecond, so the time component is constant and all
500 ids are drawn from the suffix alone — 36**4 = 1,679,616 values. By the
birthday bound that collides about 7% of the time, which matches the observed
rate.

The obvious fix — a per-process uniqueness counter — is not available. The
test directly above it pins that `newCorrelationId` is deterministic in
(clock, random), which exists so other tests can assert exact ids, and a
counter would break it. The suffix is sized for real tool calls, each of which
does network I/O; a handful per millisecond is already an extreme burst.

So the loop is replaced by the two properties the design does guarantee: ids
across advancing milliseconds are all distinct, and distinct randomness within
one millisecond maps to distinct ids (the mapping is injective — collisions
come from Math.random repeating, never from the suffix losing information). A
third case pins the suffix at its full four digits, since narrowing it would
raise the collision rate, and a collision merges two concurrent calls' audit
trails — the failure this module exists to prevent.

The module docstring claimed a same-millisecond collision was "effectively
impossible". Corrected to state the actual size, what it is sized for, and why
a counter is not an option.

20 consecutive full-suite runs pass.
@drips-wave

drips-wave Bot commented Aug 30, 2026

Copy link
Copy Markdown

@kaluuba-org Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@manoahLinks
manoahLinks merged commit 12f71d7 into mind-vault-1:main Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants