Skip to content

feature(api): read-only cache MCP tools#136

Merged
jamby77 merged 18 commits into
feature/cache-proposal-data-modelfrom
feature/cache-mcp-readonly-tools
Apr 29, 2026
Merged

feature(api): read-only cache MCP tools#136
jamby77 merged 18 commits into
feature/cache-proposal-data-modelfrom
feature/cache-mcp-readonly-tools

Conversation

@jamby77

@jamby77 jamby77 commented Apr 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

The 6 read-only MCP tools from spec-cache-mcp-readonly-tools.md. Stacked on #134 (cache proposal data model + service); base branch is `feature/cache-proposal-data-model` so it'll auto-rebase to `master` when #134 merges.

  • `cache_list` — enumerate `__betterdb:caches`, return hit rate + total ops + live/stale status per cache.
  • `cache_health` — discriminated by cache_type. Semantic returns category breakdown + uncertain_hit_rate; agent returns per-tool breakdown. No nullable-field fudging.
  • `cache_threshold_recommendation` — semantic only. Replicates `SemanticCache.thresholdEffectiveness()` by reading `{prefix}:__similarity_window` directly.
  • `cache_tool_effectiveness` — agent only. Reads `{prefix}:__stats` and applies the same TTL-recommendation rules as `AgentCache.toolEffectiveness()`.
  • `cache_similarity_distribution` — exactly 20 buckets of width 0.1 across the 0..2 cosine-distance range, with optional category + window-hours filters.
  • `cache_recent_changes` — recent proposals (any status) for a single cache, newest first, so agents can avoid re-proposing already-pending changes.

Per-type dispatch surfaces `INVALID_CACHE_TYPE` (HTTP 400) when a semantic-only or agent-only tool is called against the wrong cache type. `CACHE_NOT_FOUND` (HTTP 404) when the cache isn't in the discovery markers.

The cache-library methods (`thresholdEffectiveness`, `toolEffectiveness`) require `initialize()` which side-effect-creates the FT search index. Calling them from an HTTP request would mutate the cache as a side effect of an inspection. The service reads the underlying Valkey keys directly instead.

Test plan


Note

Medium Risk
Adds new endpoints and background expiry plus code that can delete cache keys and modify runtime cache config/TTL policies; mistakes could cause unintended invalidations or incorrect proposal state transitions.

Overview
Adds a cache proposal review + execution pipeline: new CacheProposalController endpoints to list pending/history, fetch details with audit, and approve/reject/edit-and-approve; CacheProposalService now enforces pending/expiry rules, records audit events with actor_source, supports proposal expiry, and (optionally) triggers apply via CacheApplyService.

Implements proposal application to Valkey via CacheApplyDispatcher (semantic threshold overrides, agent tool TTL policy updates, and both cache types’ invalidate flows), including safer error propagation (ApplyFailedError w/ proposalId) and metrics like actualAffected/duration.

Introduces CacheReadonlyService plus new MCP HTTP endpoints and @betterdb/mcp tools for cache listing/health, threshold recommendations, tool effectiveness, similarity distributions, and recent changes, and adds a web “Cache Proposals” page with unread badge/polling, pending cards (approve/reject/edit), and history/detail views.

Reviewed by Cursor Bugbot for commit 1f6442e. Bugbot is set up for automated code reviews on this repo. Configure here.

@jamby77 jamby77 requested a review from KIvanow April 27, 2026 11:07
Comment thread apps/api/src/cache-proposals/cache-readonly.service.ts Outdated
@jamby77 jamby77 force-pushed the feature/cache-mcp-readonly-tools branch 4 times, most recently from b999cd6 to a098313 Compare April 27, 2026 12:01
@jamby77 jamby77 force-pushed the feature/cache-mcp-readonly-tools branch 2 times, most recently from 54794d1 to 85cd223 Compare April 27, 2026 13:25
Comment thread apps/api/src/cache-proposals/cache-readonly.service.ts
jamby77 added 8 commits April 27, 2026 16:45
CacheReadonlyService exposes:
- listCaches: enumerate __betterdb:caches with hit rate + live status
- cacheHealth: discriminated union by cache_type; semantic returns
  category breakdown + uncertain_hit_rate, agent returns per-tool
  breakdown
- thresholdRecommendation: replicates SemanticCache.thresholdEffectiveness
  by reading the rolling similarity window directly
- toolEffectiveness: replicates AgentCache.toolEffectiveness from the
  __stats hash
- similarityDistribution: 20 fixed buckets of width 0.1 across the
  0..2 cosine distance range, with optional category and window-hours
  filters
- recentChanges: thin wrapper over listCacheProposals filtered by
  cache_name (any status, newest first)

Six matching MCP tools registered, plus six GET endpoints on the
existing McpController. Per-type dispatch raises InvalidCacheTypeError
(HTTP 400 / code INVALID_CACHE_TYPE) when a semantic-only or agent-only
tool is called against the wrong cache type.

The library methods (thresholdEffectiveness, toolEffectiveness) require
SemanticCache.initialize(), which would side-effect-create the FT
index, so we read the underlying Valkey state directly instead of
spawning a transient cache instance per request.
…types

CacheReadonlyService now imports REGISTRY_KEY and heartbeatKeyFor()
from @betterdb/shared (added in the parent commit on this branch's
base) instead of redefining the literals locally. Ensures the API
reader and the cache-package writers reference the same constants.

The 12 exported types describing the read-only response shapes
(CacheHealth, CacheListEntry, ThresholdRecommendation, etc.) move
to a sibling cache-readonly.types.ts file. The service file
re-exports them so existing import paths continue to work, but the
types now live separately and the service file shrinks back to
behavior-only.
…ervice

The local readInt() in CacheReadonlyService duplicated a pattern
already used inside AgentCache.stats() and was a minor utility worth
sharing. Extracted to apps/api/src/common/utils/valkey-fields.ts as
readHashInt(record, field) and the service now imports it.

Drive-by cleanups while in the file:
- Drop two `const health: ... = { ... }; return health;` patterns in
  favor of returning the literal directly.
- Collapse the two-step recordedAt + category filter in
  similarityDistribution into a single boolean expression.
Picks up the constants added on the parent branch and replaces the
remaining 'semantic_cache' / 'agent_cache' string literals in
cache-readonly.service.ts and the discriminator types in
cache-readonly.types.ts (via typeof SEMANTIC_CACHE / typeof AGENT_CACHE).

The unknown-narrowing on parsed.type after the two literal-inequality
checks doesn't propagate cleanly when the literals come via const
imports; an explicit `as CacheType` at the assignment site keeps
control-flow narrowing where it matters and avoids cluttering the
checks.
…nstants

cache-readonly.types.ts now exports two const-object enums:

  THRESHOLD_RECOMMENDATIONS = { TIGHTEN, LOOSEN, OPTIMAL, INSUFFICIENT_DATA }
  TOOL_EFFECTIVENESS_RECOMMENDATIONS = { INCREASE_TTL, OPTIMAL, DECREASE_TTL_OR_DISABLE }

with the existing union types (ThresholdRecommendationKind,
ToolEffectivenessRecommendation) derived from them via
typeof[keyof typeof].

The four threshold-recommendation reasoning templates also move out
of the service, into a THRESHOLD_REASONINGS map of small functions
that take only the relevant context. The "% of foo" formatting is
now expressed once via a local formatPct helper.

CacheReadonlyService imports the consts and the reasoning map and
references them by name, so adding or renaming a recommendation no
longer requires touching the inline strings in two places.
The helper takes a generic Record<string, string>; nothing about it
is Valkey-specific. The first caller happens to feed it HGETALL
output, but a future caller could feed it process.env or any other
string-valued record without surprise.

  apps/api/src/common/utils/valkey-fields.ts → record-fields.ts
  readHashInt(raw, field)                    → readIntField(record, field)
readBaseStats(client, key) constructs ${key}:__stats. listCaches was
passing marker.name; for caches whose discovery-marker name happens
to equal their prefix this is fine, but the protocol allows the two
to differ (e.g. an alias-style registration). Stats would then read
from the wrong key and report zeros even for an active cache.

Adds a regression test where ALIAS != PREFIX and asserts stats are
sourced from PREFIX.
…ranch

bugbot LOW: nearMisses is filtered to s.score <= threshold + 0.03 so
avgNearMissDelta is constrained to (0, 0.03] by construction. The
extra '< 0.03' guard never excluded anything useful but did suppress
the loosen recommendation in the boundary case where every near miss
sits at exactly threshold + 0.03 (delta avg == 0.03), falling through
to 'optimal' despite a high near-miss rate.
@jamby77 jamby77 force-pushed the feature/cache-mcp-readonly-tools branch from 85cd223 to af032f6 Compare April 27, 2026 13:46
jamby77 added 10 commits April 27, 2026 16:46
- Add approve/reject/editAndApprove/expireProposals on CacheProposalService
  sharing logic between HTTP controller (actor_source='ui') and MCP tools
  (actor_source='mcp')
- Add CacheApplyService that runs Valkey work and transitions
  approved -> applied|failed with idempotency on re-entry
- Add CacheApplyDispatcher with 4 handlers:
  - semantic threshold_adjust: HSET {cache_name}:__config (gated on
    threshold_adjust capability advertised in marker)
  - agent tool_ttl_adjust: HSET {cache_name}:__tool_policies
  - semantic invalidate: FT.SEARCH + DEL
  - agent invalidate: SCAN + DEL on tool/key_prefix/session pattern
- Add CacheExpirationCron (5 min cadence, setInterval pattern)
  marking past-due pending proposals as expired with system audit
- Add /cache-proposals HTTP controller with 6 endpoints
- Add 5 MCP approval endpoints in mcp.controller.ts
- Extract shared mapCacheProposalErrorToHttp helper used by both
  controllers and add typed errors for proposal lifecycle
- Tests: 18 new specs covering approve/reject/edit/expire flows and
  dispatcher behaviour
- HIGH: parseFtSearchKeys was iterating with i+=2, missing every other
  key when FT.SEARCH was called with RETURN 0 (which behaves like
  NOCONTENT and returns just [count, key1, key2, ...]). Step by 1.
- LOW: extract optionalString, optionalFiniteNumber, formatApprovalResult
  to controller-helpers.ts, share between cache-proposal.controller and
  mcp.controller
- Add regression test exercising RETURN 0 response shape
bugbot HIGH: applyAgentInvalidate's key_prefix branch built a SCAN
pattern as `<filter_value>*` without prepending `<cache.name>:`,
unlike the tool/session branches. This let a key_prefix invalidation
match keys belonging to other caches or unrelated application data on
the same Valkey instance. Scope the pattern to the cache namespace and
add a regression test.
…lob cache name

bugbot MEDIUM: applySemanticThresholdAdjust, applyAgentToolTtlAdjust,
applySemanticInvalidate were passing cache.name as the first arg to
ApplyFailedError, which the constructor labels as proposalId — that
wrong value was landing in applied_result.details. Plus, the constructor
spread '{ proposalId, ...details }' allowed a stale proposalId in
details to overwrite the explicit one. Pass proposalId through to all
dispatcher methods, attach cacheName separately in details, and put the
explicit proposalId last in the spread so it always wins.

bugbot LOW: tool and session SCAN patterns interpolated cache.name
without escaping while key_prefix did. Apply escapeGlob uniformly to
cache.name across all three branches.
Add /cache-proposals route with Pending and History tabs. Pending cards
render four (cache_type, proposal_type) variants with approve, reject,
and edit-and-approve flows. Edit hidden on invalidate cards. History
table filters by status/cache_name and opens a detail panel with full
reasoning, payload, and audit trail. Sidebar shows an unread badge for
new pending proposals.
Use a module-level subscription store with useSyncExternalStore so the
sidebar badge clears when CacheProposals.markAllRead() runs. Wraps
markAllRead in useCallback to avoid firing the page-level effect every
render. Drops the unused cacheProposalQueryKeys export.
- proposalSource now reads proposed_by only (was falling through to
  reviewed_by, mislabelling UI-proposed/MCP-reviewed entries as 'mcp').
- Track unread by last-seen timestamp instead of id, so when the
  marker proposal is approved/rejected/expired it doesn't inflate the
  unread count to all remaining (already-seen) pending proposals.
…sion

Math.max guards setLastSeenAt so a markAllRead call where the newest
pending proposal has a smaller timestamp (e.g. the previous newest was
approved by another user) cannot move the marker backward and resurrect
already-seen proposals as unread.
## Summary

- Add the **Cache Proposals** tab to Monitor (route `/cache-proposals`)
with Pending and History sub-views.
- **Pending view**: cards render the 4 `(cache_type, proposal_type)`
variants — semantic threshold adjust, agent tool TTL adjust, semantic
invalidate, agent invalidate. Approve is optimistic ("Applying…"),
Reject opens an inline reason input, Edit swaps the proposed value for
an inline numeric input that posts via `/edit-and-approve`. Edit is
hidden on invalidate cards. `estimated_affected > 10000` triggers a warn
visual.
- **History view**: table with status + cache_name filters, source
column, clickable rows opening a detail panel with reasoning, payload,
apply result, and full audit trail.
- **Unread indicator**: sidebar badge on the Cache Proposals nav item,
cleared when the user opens the Pending tab. Persisted in localStorage.
- New helpers: `lib/formatters.ts` (`formatTtlSeconds`, `formatTimeAgo`,
`formatExpiresIn`).
- Component tests (Vitest + RTL) cover all 4 card variants, edit hidden
on invalidate, warn visual at >10000, optimistic Applying state, reject
flow with/without reason, edit-and-approve, and direct approve.

Stacked on #137 — merge after the apply-dispatcher PR lands.

Closes the UI portion of issue-179753285 (Days 7–9). Spec:
`docs/plans/specs/spec-cache-proposals-ui.md`.

## Test plan

- [ ] `pnpm --filter @betterdb/web test` — all web tests pass (175
tests, 27 files).
- [ ] `pnpm --filter @betterdb/web build` — typecheck + Vite build
clean.
- [ ] Manual: visit `/cache-proposals`, verify all 4 card variants
render correctly, approve/reject/edit flows POST to the right endpoint,
history filters work, detail panel opens with audit trail, unread badge
clears on tab visit.
- [ ] Verify no hard-coded hex colours — all status colours use
`var(--chart-*)` / shadcn theme variables.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Medium risk because it introduces new approve/reject/edit-and-approve
mutation flows that trigger operational changes via new API endpoints,
plus polling/unread state persisted in localStorage.
> 
> **Overview**
> Introduces a new `/cache-proposals` page with **Pending** and
**History** views for reviewing agent-submitted cache proposals.
> 
> Pending proposals render as actionable cards (approve, reject with
optional reason, and edit+approve for supported proposal types) with
basic validation and “high impact” highlighting; history adds filtering
and a row-click detail sheet that shows payload, apply result, and audit
events.
> 
> Adds a `cacheProposalsApi` client plus React Query hooks (including
pending polling and query invalidation) and a sidebar **unread badge**
backed by `localStorage`, along with new time/TTL formatting utilities
and targeted component/unit tests.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1bef37b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
## Summary

Implements the cache proposal approval and apply pipeline for issue
179753285 (cache intelligence v1, advisory mode):

- **CacheProposalService**: new `approve`, `reject`, `editAndApprove`,
`expireProposals`, `getProposalWithAudit` methods, sharing logic between
  UI (HTTP) and MCP callers
- **CacheApplyService**: idempotent transition `approved → applied |
failed`,
  writes `applied`/`failed` audit
- **CacheApplyDispatcher**: 4 handlers
- semantic + threshold_adjust: `HSET {cache_name}:__config
threshold[:category] <value>`
    (gated on `threshold_adjust` capability in discovery marker)
- agent + tool_ttl_adjust: `HSET {cache_name}:__tool_policies <tool>
<json>`
  - semantic + invalidate: `FT.SEARCH` + `DEL`
- agent + invalidate: `SCAN` + `DEL` on tool/key_prefix/session patterns
- **CacheExpirationCron**: 5-min `setInterval`, drives
  `expireCacheProposalsBefore`, writes `actor_source='system'` audit
- **HTTP controller** under `/cache-proposals`: 6 endpoints (pending,
history,
  get, approve, reject, edit-and-approve)
- **MCP controller**: 5 new endpoints for the approval flow
  (`actor_source='mcp'`)
- Extracts shared `mapCacheProposalErrorToHttp` helper

Stacked on PR #136 (`feature/cache-mcp-readonly-tools`).

## Test plan
- [x] Unit tests pass: 87 cache-related specs (18 new, 69 pre-existing)
- [x] `tsc --noEmit` clean
- [x] `eslint` clean
- [ ] Manual e2e: propose → approve → verify Valkey HSET (left for the
  integration-tests spec)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Adds new proposal approval/edit/apply flows that can mutate Valkey
state (HSET/DEL/SCAN/FT.SEARCH) and introduces background expiration, so
mistakes could cause unintended cache invalidations or config changes.
> 
> **Overview**
> Implements an end-to-end **cache proposal lifecycle** beyond creation:
proposals can now be listed, fetched with audit, **approved/rejected**,
and (for some types) **edited then approved**, with consistent audit
logging and error-to-HTTP mapping.
> 
> Introduces an idempotent apply pipeline: `CacheApplyService`
transitions `approved → applied|failed` and records `applied_result`,
while `CacheApplyDispatcher` executes the concrete Valkey mutations for
`semantic_cache` threshold updates, `agent_cache` tool TTL policy
updates, and both cache types’ invalidations (including safeguards like
cache-type mismatch, capability checks, and key-pattern scoping).
> 
> Adds a periodic `CacheExpirationCron` to expire overdue pending
proposals, wires the new controller/module providers, extends MCP with
endpoints for listing/approving/rejecting/editing proposals, and adds
unit tests covering dispatcher behavior and approval/edit/expiration
semantics.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
af5cf8b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
@jamby77 jamby77 merged commit bbedf2d into feature/cache-proposal-data-model Apr 29, 2026
2 checks passed
@jamby77 jamby77 deleted the feature/cache-mcp-readonly-tools branch April 29, 2026 06:24
@github-actions github-actions Bot locked and limited conversation to collaborators Apr 29, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant