Skip to content

feat(mcp): split the library and automation surfaces read from write - #705

Open
cpellan561 wants to merge 2 commits into
mainfrom
feat/split-read-write
Open

feat(mcp): split the library and automation surfaces read from write#705
cpellan561 wants to merge 2 commits into
mainfrom
feat/split-read-write

Conversation

@cpellan561

@cpellan561 cpellan561 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

organize switched behaviour on which arguments arrived: no short_ids read the
workspace vocabulary, short_ids alone inspected artifacts, and
add/remove/set/collection/state wrote, including state:'deleted', a permanent
delete. A tool containing that has to declare destructiveHint, so browsing a tag
vocabulary prompted the user exactly as hard as destroying an artifact. automate
had the same shape, with list sitting alongside create/run_now/record.

tool annotation does
browse_library readOnly the reads
organize write tags + collections, honestly non-destructive
shelve destructive retire / restore / delete, the one hard verb
list_automations readOnly the read
automate write create / run_now / record / create_context

Annotation-honouring clients auto-approve read-only tools, so the common path stops
interrupting. That is the reason to do this. MCP directory review happens to require
the same split, which is how it surfaced.

Behaviour is unchanged for everything already reachable

Each pair is one handler with two or three schemas over it. The handler still
routes on which arguments arrived, so every guard, cascade and response shape is the
code that was already there. Worth knowing before you open the diff: most of it is
reindentation from extracting that handler, not new logic.

The one deliberate behaviour change: automate now refuses action:"list"
rather than merely not advertising it. Easet it allows, so
the split is enforced server-side. A split that exists only in a description is not a
split: a caller with a cached schema woule read through the
tool that declares itself a write. This is the line to argue with if you disagree,
since anything passing that string breaksing the read.

Two things deliberately preserved

  • **shelve keeps both directions on oneoriginal comment
    argues that splitting retire from restore "is how you end up with a surface that can
    retire something and no obvious way bac response still
    hands back its own undo, now naming shelve.
  • **list_automations keeps the owner gation branch, so
    listing automations always required owner. Splitting a surface is not the moment to
    quietly widen a permission, even thoughmpting.

The design rule in mcp.ts needed amend

"A new capability is a parameter on an exl" still holds, and
now carries one stated carve-out: a parameter may not take a tool across the read/write
line, because annotations are per-tool anthout that, the next
reader follows a rule the code no longer fully obeys. That header also claimed TEN tools
while serving twelve, before this change.

Budget

SURFACE_BUDGET 11000 to 11450 (tool deseasured 3571 + 7636
= 11207. That is +532 characters for three more tools, roughly 130 tokens against a
surface already costing ~2.6k, and the paplicated
workspace / short_ids text without moving.

The rationale sits at SURFACE_BUDGET, the number the file itself says is the one that
matters, rather than being duplicated acrd as what it is: the
first raise that buys something, fewer approval prompts and annotations that are true,
rather than paying for a new capability.

Verification

pnpm run ci clean, pnpm typecheck clesing. Both failures
are the 5000ms coverage-load flake; all 191 tests in those two files pass in isolation.

wanted("organize") now gates all three library tools and wanted("automate") both
automation tools, so a caller naming either still gets a coherent set.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Preview

https://derive-pr-705.derive-to.workers.dev

Deployed from ccbd72f as derive-pr-705.

It shares production's database — sign in with your real account, and treat anything you change here as changed for real. It has no routes, no cron, no queue consumer and no OG renderer, so it cannot serve derive.to, run scheduled work, or write images onto real artifacts.

Unlike production it serves artifact HTML on its own origin (that is what makes frame-side changes visible here). Storage is still sandboxed away, but untrusted HTML and the sign-in form share a hostname — treat this URL as you would any link: don't type a password into it because a page asked you to.

cpellan561 and others added 2 commits August 12, 2026 21:34
`organize` switched behaviour on which arguments arrived: no short_ids read the
workspace vocabulary, short_ids alone inspected artifacts, and add/remove/set/
collection/state wrote — including `state:'deleted'`, a permanent delete. A tool
containing that has to declare destructiveHint, so browsing a tag vocabulary
prompted the user exactly as hard as destroying an artifact. `automate` had the
same shape with `list` sitting alongside create/run_now/record.

Now:
  browse_library    readOnly       the reads
  organize          write          tags + collections, honestly non-destructive
  shelve            destructive    retire / restore / delete, the one hard verb
  list_automations  readOnly       the read
  automate          write          create / run_now / record / create_context

Annotation-honouring clients auto-approve read-only tools, so the common path
stops interrupting. That is the reason to do this; MCP directory review happens
to require the same split, which is how it surfaced.

BEHAVIOUR IS UNCHANGED for everything that was already reachable. Each pair is
one handler with two or three schemas over it — the handler still routes on which
arguments arrived, so every guard, cascade and response shape is the code that
was already there. Worth knowing while reading the diff: most of it is
reindentation from extracting that handler, not new logic.

The one deliberate behaviour change: `automate` now REFUSES `action:"list"`
rather than merely not advertising it. Each tool passes the action set it allows,
so the split is enforced server-side. A split that exists only in a description
is not a split — a caller with a cached schema would otherwise still reach the
read through the tool that declares itself a write.

Two things deliberately preserved:
- `shelve` keeps BOTH directions on one `state` parameter. Retire and restore are
  the same decision read two ways, and the response still hands back its own undo
  — now naming `shelve` rather than `organize`.
- `list_automations` keeps the owner gate. It sits above the action branch, so
  listing automations always required owner, and splitting a surface is not the
  moment to quietly widen a permission.

mcp.ts's design rule needed amending rather than leaving stale: "a new capability
is a parameter, not a new tool" still holds, with one carve-out now stated there
— a parameter may not carry a tool across the read/write line, because
annotations are per-tool and clients gate on them. The header also said TEN tools
while serving twelve, before this change.

Surface budget 11000 -> 11450 (tool descriptions 3200 -> 3650). Measured 3571 +
7636 = 11207: +532 characters, roughly 130 tokens against a surface already
costing ~2.6k, and the param budget absorbed the duplicated workspace/short_ids
text without moving. The rationale is at SURFACE_BUDGET, the number the file says
is the one that matters. This is the first raise that buys something — fewer
approval prompts and annotations that are true — rather than paying for a new
capability.

`wanted("organize")` now gates all three library tools and `wanted("automate")`
both automation tools, so a caller naming either still gets a coherent set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s are

Two description changes, both aimed at the same class of reader: something
scanning this surface cold, with no access to the code, deciding whether it
behaves.

`stage target:'api'` mints a bearer and said it was "a REAL bearer for REST".
The token is spent against endpoints the CALLER composes, so that left the one
question a caller holding a credential most needs answered — which REST? — to
inference. It now names the Derive REST API and points at its reference. Read
uncharitably, an undocumented credential-minting tool with a freeform target is
indistinguishable from a catch-all request tool.

The server instructions said core skills were "a resource". Telling a model to
fetch procedure before it acts is, in outline, the shape of a prompt-injection
vector, and the distinction that makes ours legitimate is that the resources are
this server's own. That was implied by the URI scheme and by nothing else, so
the sentence now says it: "CORE SKILLS are this server's own resources". The
same sentence got shorter by dropping the redundant second way to say it.

Both fit the existing budgets, no ceiling raised: tool descriptions 3604/3650,
instructions 2345/2400, total surface 11240/11450.
@cpellan561
cpellan561 force-pushed the feat/split-read-write branch from e7170b0 to b23d221 Compare August 13, 2026 01:35
Sign up for free to 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