Skip to content

feat(mcp): add create_frame and update_frame tools - #73

Merged
dcmcand merged 10 commits into
mainfrom
feat/mcp-create-update-frame-tools
Aug 21, 2026
Merged

feat(mcp): add create_frame and update_frame tools#73
dcmcand merged 10 commits into
mainfrom
feat/mcp-create-update-frame-tools

Conversation

@dcmcand

@dcmcand dcmcand commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Closes #51.

What this does

The MCP endpoint was read-only. create_frame and update_frame now expose writes to any MCP-capable client, gated by the same RBAC the Connect API uses.

The issue's central requirement was that writes reuse the existing validation and permission checks rather than reimplement them. PublishFrame's body moved into PublishDoc plus an internal publish, so both front doors execute one copy of the logic: creating needs the publisher or admin role, writing to an existing Frame needs edit permission on it. A PublishIntent decides create-vs-update inside the service, next to the checks it interacts with, rather than in the MCP adapter. The Connect RPC passes PublishUpsert and keeps its existing behavior.

Tool input is typed after frames.Doc, so a client is guided by the schema instead of authoring YAML blind. The adapter deliberately does not validate - frames.Validate remains the single definition of a valid Frame.

delete_frame is intentionally out of scope; it is #67.

The part most worth reviewing

update_frame merges onto the Frame's current document: anything omitted keeps its value, an explicitly empty list clears. The merge base is SourceDoc - the Frame's own stored document - and never the composed form.

This matters more than it sounds. List slots are a union across the whole ancestry and prose slots are an override, so feeding composed output back into a write copies the parents' content into the child. It validates, it composes identically afterwards, and it leaves no signal - but the child has silently stopped tracking its parents' future revisions. Two review rounds landed on this from different angles:

  • the first found that a replace-style update dropped visibility/scope/maintainer, so a Frame declared private read as internal;
  • the second found that even with merge, no MCP read returned a Frame's own content, so "add a rule to X" - the most likely instruction this tool will ever receive - still forced a model to send the parents' content back.

get_frame therefore takes source=true. The default stays composed, which is what a consumer loading context wants; the source read is what an editor needs, and both tool descriptions say which is which.

Also in here

A 512KB per-version content cap (frames.MaxContentBytes), enforced in the shared publish path so both front doors are covered. The design doc had promised this cap for some time; it was implemented nowhere. It matters more with an LLM authoring content that lands verbatim in a single-writer SQLite database.

Reviewers please note: this is a new rejection for existing Connect/CLI/web callers. If any deployed Frame is already over 512KB, its next publish fails. I have not checked whether the CLI and web editor surface that error usefully or as a generic validation failure after the user has composed a long document.

Review history

Two rounds of independent review. Both confirmed by mutation that there is no RBAC bypass and no behavior change for existing Connect callers, and independently re-derived that a stale per-session claims closure cannot execute as another user (the SDK pins the session to TokenInfo.UserID, and role is re-read from the database on every call).

Three defects fixed along the way: the metadata loss and the flattening described above, plus an extraction that re-marshalled the document and would have rewritten every Connect publish's stored bytes - stripping author comments and changing digests for unchanged content.

One test-quality fix worth calling out: a comment claimed a round-trip test enforced slot parity. It did not - it compared against a hand-written literal, so a new slot was zero on both sides and passed. That is precisely how the metadata loss slipped through. There are now two reflective guards: one that fails when frames.Doc or SlotTable grows a field the tool input lacks, and one that fails when an input field exists but is not wired into applyTo. Both were mutation-verified.

Testing

go test ./... -race green, golangci-lint run ./... clean. The integration tests wire the real frames.Service with nothing stubbed on the permission path, and the denial cases assert that nothing was persisted, not merely that an error came back. The size-cap boundary is pinned exactly, so the comparison cannot drift between > and >=.

Follow-ups filed

Test evidence

Beyond go test ./... -race and golangci-lint, the write tools were exercised over the real MCP endpoint with real bearer tokens, against make dev-auth (not dev mode - dev mode skips token validation entirely and so proves nothing about the authenticated path).

Both users obtained tokens through the genuine authorization-code + PKCE flow. Tokens carry the MCP resource audience via the mapper added here; without it every call is rejected with 401.

Two users, opposite permissions. alice is a publisher, bob holds only the org-level read grant:

[alice = publisher] create and update
  PASS  alice can create a frame          Published team-charter@1.0.0.
  PASS  alice can update her frame        Published team-charter@1.1.0.

[bob = viewer] read
  PASS  bob can list frames               - team-charter@1.1.0: How the team works
  PASS  bob can read the frame            sees the updated content

[bob = viewer] must NOT modify
  PASS  bob CANNOT update the frame       permission denied: publisher or admin role required
  PASS  bob CANNOT create a frame         permission denied: publisher or admin role required

[integrity]
  PASS  bob's writes did not land
  PASS  update preserved 'visibility: internal'
  PASS  update preserved 'maintainer: alice'
  PASS  update preserved 'goals: Ship reliably.'

Both users see all four tools listed; permission is enforced when a tool is called, which is what the connect docs describe.

The last three assertions are the first review blocker, confirmed fixed on a live server: alice's update supplied only rules, and the spec metadata survived in the stored document. Before the merge semantics, that update erased it.

Inheritance, the second blocker. alice created a parent and an inheriting child over MCP, then updated the child:

  PASS  composed read includes inherited content
  PASS  source read excludes inherited content
  PASS  inheritance survived the update
  PASS  parent rule was NOT copied into the child
  PASS  parent prose was NOT frozen into the child
  PASS  child still composes with the parent

The stored child after the update:

name: api-team
version: 1.1.0
extends:
    - ref: dev-org/org-baseline
      version: 1.0.0
slots:
    rules:
        - Version every endpoint.
        - Document error codes.

The pin is intact and no ancestor content was absorbed, while the composed view still merges the parent in. That is the whole point of get_frame source=true: without it, a client editing a slot has no way to see the Frame's own values and must send the parents' content back as the child's.

Concurrency, version ordering, and body size were added after the first round of evidence above, and verified the same way:

create        -> Published race3@1.0.0.
version shown -> yes
A base=1.0.0  -> Published race3@1.1.0.
B base=1.0.0  -> frame changed while you were editing it: frame "race3" has moved on:
                 you based this change on 1.0.0 but the latest is 1.1.0
missing base  -> base_version is required: read the Frame first with get_frame source=true
backwards     -> invalid frame: version: must be higher than the current version 1.1.0

A's rule survived in the stored document, which is the point: before this, B's publish silently discarded it.

Worth flagging for review, because it is the kind of mistake that looks fixed: the first version of the concurrency check derived the base version from a read inside update_frame, so it always matched and could never fire. Unit tests passed because they called the service directly with a base the test chose. Live testing showed the lost update happening exactly as before. The base now comes from the caller, base_version is required on update_frame, and the integration test drives the tool the way a client does so an inert check fails.

Client-side error surfacing was checked rather than assumed. The web form marks the version field for a duplicate version, so a non-advancing version now emits a FieldViolation on the same field instead of a form-level error; the CLI names the field too. The 512KB cap surfaces through the same path as any other invalid document, carrying the byte counts.

Review round two

One blocker from @jbouder: the body cap was wired so the bearer middleware re-wrapped the uncapped handler, discarding it in every deployment that has authentication on - the inverse of what its comment claimed. Measured before and after:

dev=true   8 MiB+1 KiB -> 400   (capped)
dev=false  8 MiB+1 KiB -> 200   (not capped, before the fix)

The sharper half of that finding was why CI stayed green: the test used DevMode: true, the one branch where the wrapper survived. It now runs both auth modes and asserts a control - a small body must succeed before an oversized one is expected to fail. Without the control the non-dev case passes on a 401 with no cap present at all, which is exactly what my first attempt at the test did. The accepting stub verifier also needs a live expiry, since the middleware treats a zero Expiration as expired.

Not covered: no cluster deployment.

Closes #51.

The MCP endpoint was read-only. Writes now go through the same
RBAC-enforcing service path the Connect API uses rather than a parallel
implementation: PublishFrame's body moved into PublishDoc, and a
PublishIntent decides create versus update next to the permission checks
it interacts with. Creating requires the publisher or admin role;
writing to an existing Frame requires edit permission on it. The Connect
RPC keeps its upsert behavior.

Tool input is typed after frames.Doc so a client is guided by the schema
instead of authoring YAML blind, and validation stays with
frames.Validate so there is one definition of a valid Frame.

update_frame merges onto the Frame's current document. Anything the
caller omits keeps its value; an explicitly empty list clears. The merge
base is the Frame's own stored document, deliberately not the composed
form get_frame returns: merging onto a resolved document would copy every
parent's slots into the child and drop its extends edges, destroying the
inheritance graph with no error. SourceDoc provides that unresolved read,
and the tool description now tells clients not to feed get_frame output
back in.

Without the merge, an update also erased visibility, scope, and
maintainer, so a Frame declared private silently read as internal.

The 512KB per-version content cap the design doc promised is now real,
enforced in the shared publish path so both front doors are covered. It
matters more with an LLM authoring content that lands verbatim in a
single-writer database.

The design doc had excluded writes for blast-radius reasons; that
decision is marked superseded, including the prompt-injection threat that
is specific to exposing writes as an AI tool.

delete_frame is deliberately not included.
Follow-up to the write-tools work, from a second review pass.

update_frame preserved a Frame's extends edges, but nothing gave a client
the Frame's own slot values. The only read available returned the composed
form, where list slots are a union across the whole ancestry and prose
slots are an override. So the obvious instruction - "add a rule to this
Frame" - forced a model to send the parents' content back as the child's
own. That validated, composed identically, and left no signal, while
detaching the child from its parents' future revisions: a later parent
edit shows up alongside the stale copy, and overridden prose never
tracks the parent again.

get_frame now takes source=true, which returns only the Frame's own
content via SourceDoc. The default stays composed, which is what a
consumer loading context wants. Both tool descriptions say which read to
use for which purpose, so the prohibition on resending composed output
now comes with an alternative.

Test hardening from the same review:

- TestApplyToWiresEveryInputField sets every input field to a sentinel and
  asserts nothing in the resulting document is left zero. The existing
  guard forced a new slot to gain an input field but not to be wired into
  applyTo, and the hand-written comparison could not catch that because an
  unwired field is zero on both sides.
- The size cap boundary is pinned exactly, so the comparison cannot drift
  between > and >=.
- The create-over-existing-name test uses a different version, so it
  exercises the create-intent check rather than version uniqueness.

Also: the optional string fields now document that an empty string clears
them, description says it is required when creating, and the
FailedPrecondition arm notes it is defensive rather than evidence that
publish checks acyclicity.

Two corrections to the design doc's security section: an admin token is
the real worst case for a compromised MCP token, since rbac.Can allows an
admin every frame before grants are consulted; and merge semantics are not
a prompt-injection mitigation, since an injected call is well-formed and
can clear parents deliberately.
@github-actions

Copy link
Copy Markdown

📄 Docs preview for feat/mcp-create-update-frame-tools:
https://feat-mcp-create-update-frame.nebari-frames.pages.dev

Keycloak does not honour the RFC 8707 `resource` parameter, so without an
audience mapper its tokens carry no audience for the MCP resource and the
/mcp endpoint rejects every one of them with 401. docs/connect/keycloak-setup.md
describes adding this mapper by hand; for the local loop it belongs in the
realm the loop imports.

The audience is the canonical resource identifier the server derives from
FRAMES_PUBLIC_URL, which dev/dev-auth.sh sets to http://localhost:5173.

Without this, the write tools added in this branch cannot be exercised
against `make dev-auth` at all - only in dev mode, which skips token
validation entirely and so proves nothing about the authenticated path.
@dcmcand
dcmcand requested a review from jbouder August 20, 2026 23:12
…st bodies

Closes #69, #71. Part of #70.

Three ways a write could go wrong that the tools made reachable.

**Concurrent updates silently lost one.** update_frame reads the current
document as its merge base, so two parallel calls both merged onto the
same base, picked different version strings so nothing collided, and both
reported success - one caller's change simply vanished. Parallel tool
calls are ordinary for an LLM client, so this was not a corner case.

PublishDocFrom takes the version the caller believes it is editing, and
rejects the publish with FailedPrecondition when the frame has moved on.
update_frame passes the version it actually read, so the guard cannot be
inert. PublishDoc keeps its signature and means "unchecked", which is
what the Connect RPC has always done.

**Publishing an older version silently unpublished newer content.**
latest_version was assigned unconditionally, so a model that misread the
current version could demote a frame: every default read, and the merge
base of the next update, then resolved to the older document. A version
below the current latest is now rejected. Republishing the current
version still reports AlreadyExists, which says more than "does not
advance".

**Request bodies were unbounded.** The SDK reads a body in full before
any tool handler, and therefore before RBAC, so a caller with no write
permission could make a single-replica deployment buffer an arbitrary
amount. Capped at 8 MiB, well above the content limit so a create at that
limit still fits with its framing.

The Connect half of the body cap lives on the branch that owns server.go.
The concurrency check added in the previous commit was inert on the path
that matters. update_frame derived the base version from its own read at
publish time, so the value always matched whatever was current and the
check could never fire. Live testing against two MCP sessions showed the
lost update happening exactly as before: both published, both reported
success, and the first caller's rule was gone from the stored document.

The window that loses a change is between the client's read and its
write, so only the client knows what it read. base_version is now a
required input on update_frame, asserted against the frame's current
latest, and the merge still happens onto the current document. A caller
that does not read first cannot write.

get_frame now reports the frame's version, in both the composed and the
source views. Without it a client had no way to obtain the value it is
now required to send.

The integration test drives this through the tool the way a client does,
rather than calling the service directly with a base the test chose - the
latter is what let the inert version pass.
The web form marks the version input for a duplicate version but showed a
generic form-level error for a version that does not advance, because the
latter was a plain InvalidArgument with no details. Both are errors about
the same field, so both now carry a FieldViolation on `version` and the
CLI names the field too.

@jbouder jbouder left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One blocker inline — the body cap is wired so it only takes effect in dev mode, the inverse of the intent. One-word fix. Everything else I found is minor and can follow up.

Comment thread backend/internal/mcp/server.go
From review. The bearer middleware wrapped mcpHandler rather than the
capped handler, so the cap was discarded in every deployment that has
authentication on - the inverse of what its comment claimed.

The test only covered dev mode, which is the single branch where the
wrapper survived, so CI was green. It now runs both modes and asserts a
control: a small body must succeed before an oversized one is expected to
fail, otherwise an unrelated rejection - a 401, say - would satisfy the
assertion without the cap existing at all. Measured before the fix: with
bearer auth, 8 MiB + 1 KiB returned 200.

The accepting stub verifier needs a live expiry, because the middleware
treats a zero Expiration as expired and answers 401 - which is what made
the first attempt at this test pass vacuously.
The MCP section described a read-only adapter. It is now also a write
surface, and two of its rules are the kind that look like details until
they are broken - both were broken during this branch's development and
caught in review.

The merge base for update_frame must be the frame's own document, not the
composed form get_frame returns by default, or an edit copies every
parent's slots into the child and drops its extends edges. And the base
version it checks must come from the caller, not from a fresh server-side
read, which always matches and makes the check inert.

Also records what publish now enforces in one place, and a testing note:
asserting only that something was rejected proves nothing, because an
unrelated 401 or a parse failure satisfies it.
@dcmcand
dcmcand merged commit 3e81aa5 into main Aug 21, 2026
5 of 6 checks passed
@dcmcand
dcmcand deleted the feat/mcp-create-update-frame-tools branch August 21, 2026 18:09
jbouder added a commit that referenced this pull request Aug 24, 2026
…truncation

Rebased onto main, which brought #72, #73, #76, and #77. The rebase produced no
git conflicts but two packages that did not compile, plus a migration collision
that stopped the server booting. Fixes those, the codec data-loss bug, and the
smaller items from the same review.

Blockers:

- Port the MCP write surface to the free-form body. `write.go` declared ten
  slot-named input fields against a `frames.Doc` that no longer has them. It now
  carries `body` and `template`: `body` because that is what a Frame's content
  is, and `template` because the reflective guard in `resources_test.go` asked
  for a decision and the answer is yes — without it `create_frame` cannot make a
  template at all, and an omitted-means-keep pointer stops `update_frame`
  de-listing one by accident. The guard now walks `frames.Doc` alone, since
  `SlotTable` is gone.

- Renumber `005_frame_is_template.sql` to 006. main's `005_canonical_membership_
  email.sql` claimed the same version, and goose rejects duplicates at provider
  construction, so no migration ran at all. Adds the missing `-- +goose Down`.
  `migrate_legacy_test.go` builds a schema as of 004 and did not carry the frames
  table, so 006's ALTER failed there as "no such table"; the fixture now carries
  every table a later migration touches, and asserts a pre-006 frame comes
  forward as not-a-template.

- Match the frontmatter delimiter only at column 0. `TrimSpace(line) == "---"`
  let an indented `---` inside a YAML block scalar close the frontmatter, which
  truncated the document and dropped every field after it — including documents
  the exporter itself produced, where the error named a field the author never
  touched. Table-driven cases cover the block scalar in both positions, a `---`
  in the body, and a full round trip through the codec's own output. The
  unqualified "lossless" claims are replaced with the two normalizations that
  actually happen.

Should-fix:

- `frame-yaml.ts` claimed to mirror `legacy.go` and did not: no two-space
  continuation indent, and `.trim()` where Go trims newlines only. That reaches
  storage, since restoring a legacy version re-serializes the TypeScript render
  as canonical content — one rule with a nested list became three flat rules,
  permanently. The port is now faithful, and both sides are pinned to one shared
  fixture, `testdata/legacy-slots/`, compared whole rather than by substring.
  Substring assertions over single-line values are what let the drift through.

Also:

- Pin the legacy-vs-`body:` precedence, which was silent and untested, and stop
  `Parse` naming the unexported `frames.docYAML` in unknown-key errors that reach
  API clients unwrapped. The error now lists the recognized keys.
- Cover `is_template` at the sqlite level: four SELECT lists and four Scan calls,
  none of them exercised by the service tests, which use `store.NewMemory()`.
  Asserts both the true and false case and the columns either side, so a
  scan-order mismatch fails.
- Guard the empty version in `compose.go`'s `Inherits from:` line, matching
  `MarshalMarkdown`.
- Give the one page-level heading that cannot use `PageHeader` the classes
  `PageHeader` exists to hold steady.
- Rewrite the parts of AGENTS.md and the three design docs that described the
  deleted slot design as current. The MCP doc's §3.4 gets the struck-through
  supersede treatment #73 set the precedent for on that same file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

MCP server: add create_frame and update_frame tools (RBAC-gated)

2 participants