Skip to content

Add brain_delete_events MCP tool - #928

Merged
selfcontained merged 7 commits into
mainfrom
agt_4a1dd3fa8779/build-brain-delete-events-mcp-tool
Aug 10, 2026
Merged

Add brain_delete_events MCP tool#928
selfcontained merged 7 commits into
mainfrom
agt_4a1dd3fa8779/build-brain-delete-events-mcp-tool

Conversation

@selfcontained

Copy link
Copy Markdown
Owner

Agents could append and query Brain events but never prune them, so noisy collections grew without bound. brain_delete_events closes that gap.

Shape

Two mutually exclusive modes:

  • ids — specific events, from brain_query_events (uuid-validated, max 200)
  • filter — a required collection, optionally narrowed by kind, subject, tags, since, until

dryRun returns { deleted: 0, matched: n } by counting through the same WHERE/params the delete would use, so a preview can't drift from what a subsequent delete removes. Real deletes return { deleted, matched }. Everything is repo_root-scoped from server-side agent context, never from tool input.

Why a collection is required

This repo's job prompts all record kind: "run" in their own collection — docs-audit, tech-debt, test-enforcer, componentizer, persona-review. An unscoped brain_delete_events({ kind: "run" }) reads as pruning one job's history while actually deleting all five. Filter deletes now name a collection; the ids path is unaffected since ids are unique and already repo-scoped.

The constraint is not a dead end: the tool description and the error message both point at brain_query_events without a collection, which returns each event's collection and so enumerates what exists.

Guardrails worth calling out

  • since/until must be real ISO 8601 instants. Postgres accepts now, epoch, infinity, yesterday as timestamptz literals, so a loose string would have silently matched the entire log. Date.parse alone is insufficient too — it rolls Feb 30 to Mar 2 and accepts year 0 — hence an explicit calendar check. isIsoInstant is the single rule; the zod schema refines against it rather than restating it, and .meta({ format: "date-time" }) keeps the published schema informative.
  • Selectors are legal by construction.BrainEventDeleteSelector is a union, and one exported toEventDeleteSelector owns the ids-XOR-collection-scoped-filter rule — the MCP handler calls it to narrow its args, and deleteEvents calls it again so untyped callers hit the same gate. Guarding on presence rather than length matters: { collection, ids: [] } must be rejected, not silently widened to a collection-wide delete.
  • deleteEvent delegates to deleteEvents, so one DELETE statement remains and the existing HTTP route's id path is provably identical.

Not included

No bulk-delete HTTP route and no UI affordance — the Brains UI still deletes events one at a time. Those can follow.

Testing

pnpm run check clean · pnpm run test 3344 passed · pnpm run test:e2e 178 passed

~20 new tests, plus live verification against a dev MCP endpoint after each change: unscoped filters, Postgres special literals, impossible dates, empty ids, and ids-plus-filter all rejected as typed errors with nothing deleted; dry run and scoped delete behave. Two guards were confirmed by mutation — dropping the repo_root predicate from the ids branch, and reverting the presence-based ids check, each make their test fail.

Reviewed by the backend-security-review and architecture-review personas; all nine findings resolved.

🤖 Generated with Claude Code

selfcontainedand others added 7 commits August 10, 2026 08:30
Agents could append and query Brain events but never prune them, so noisy
collections grew without bound. brain_delete_events deletes either an explicit
set of event ids (from brain_query_events) or every event matching a filter,
and requires at least one id or filter so the log cannot be wiped by accident.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Reject non-ISO since/until. Postgres treats "now", "epoch", "infinity" and
friends as valid timestamptz literals, so a filter that reads as a narrow
bound silently matched the whole log. Validated in the zod schema and again
in the store so non-MCP callers cannot reintroduce it.
- Add dryRun, which counts matches through the same WHERE builder without
deleting, so agents can size a prune that exceeds the 200-row query cap.
- Move MAX_EVENT_IDS_PER_DELETE into the store and enforce it there, matching
MAX_LIST_ITEMS_PER_PUSH.
- Cover cross-repo isolation on the ids path, where repo_root is the only
predicate keeping one repo out of another's events.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Date.parse rolls Feb 30 over to Mar 2 and accepts year 0, so both slipped
past the ISO shape check and failed inside Postgres as a raw untyped error.
Check the calendar directly so the caller gets a validation_error instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Job collections share a kind/subject vocabulary — docs-audit, tech-debt, and
test-enforcer all record kind "run" — so brain_delete_events({kind: "run"})
reads as pruning one job's history while actually deleting every job's. Filter
deletes now require a collection; the ids path is unaffected since ids are
unique and already repo-scoped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An empty ids array reduced an id-delete to a bare filter: deleteEvents(root,
{ collection, ids: [] }) skipped the XOR guard and deleted the whole
collection. The guard now keys on presence, and one exported normalizer,
toEventDeleteSelector, owns the ids-XOR-collection-scoped-filter rule for
both the MCP handler and the store.
Also: isIsoInstant becomes the single instant rule the zod schema refines
against instead of restating it, deleteEvent delegates to deleteEvents so one
DELETE statement remains, and the collection-required error points at
brain_query_events for discovering collection names.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A .refine() is opaque to JSON Schema generation, so consolidating onto
isIsoInstant left since/until published as a bare string — dropping the hint a
client uses to format a value whose malformed case is destructive. .meta()
restores format: "date-time" as advertisement while the refine stays the only
thing that decides accept/reject.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The brain_delete_events description was long enough to widen the table, so
prettier reflowed all 60 rows for one addition. Shortened to fit the existing
column width.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@selfcontained
selfcontained merged commit c6a1317 into mainAug 10, 2026
1 check passed
@selfcontained
selfcontained deleted the agt_4a1dd3fa8779/build-brain-delete-events-mcp-tool branch August 10, 2026 16:02
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@selfcontained