Skip to content

fix(authoring): stop silently rewriting authored structure and affordances - #315

Merged
DavidBabinec merged 1 commit into
mainfrom
fix/mcp-authoring-friction
Jul 30, 2026
Merged

fix(authoring): stop silently rewriting authored structure and affordances#315
DavidBabinec merged 1 commit into
mainfrom
fix/mcp-authoring-friction

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

Found while building a complete site (13 documents, 21 routes, a post type, six reusable tables, a page-scoped runtime) entirely through the MCP authoring surface. Every issue here shares a failure mode: the call reported success and the stored result was quietly different from what was authored, so the symptom only appeared much later — a route that 404s, a script that never initialises, a filter that matches nothing.

What was wrong

A post type created outside the Content UI was unroutable. buildPostTypeDefaultFields() is called client-side by the collection dialog, so a table created through the data API, an MCP connector or an import arrived with only its custom fields. slugForTable() returns '' for a table with no slug field, so every entry published with an empty slug and no public route. Diagnosing this from the outside is brutal: the table lists fine, entries save fine, statuses read published, and the routes 404.

HTML import kept custom attributes on five module types only. HTML_ATTRIBUTE_MODULES allowlists container/text/link/button/image, so <div data-x> survived and <form data-x> did not — and a form is precisely what a progressive-enhancement script binds to. The script exited at its first querySelector with nothing logged anywhere.

<button type="reset"> came back as type="button">. base.button had no buttonType prop and hardcoded the attribute in its renderer, so a native reset control could not be expressed at all.

<option value=""> came back as value="<label>". The import used attr(el,'value') || textContent, so an explicitly empty value fell through to the label; the renderer then dropped value="" even once stored. The conventional neutral option became a real filter value named after itself, and filtering silently matched zero rows.

Posting the exported .zip to /import blamed the schema. That endpoint takes a JSON bundle; the archive belongs to /import/archive. The error — body does not conform to SiteBundleSchema — reads as a malformed bundle, not a wrong door.

What changed

  • createDataTable seeds post-type built-ins server-side, so the routability invariant holds for every caller. Caller-supplied fields still win on id collision, and ordinary data tables are untouched.
  • base.form gains htmlAttributes (schema, control, render) and joins the import allowlist, with its generated wiring excluded so nothing duplicates.
  • base.button gains a buttonType prop (button | reset); both the <button> and <input type=reset> import rules map it, and the renderer emits it.
  • Option import decides on attribute presence, and the renderer always emits value.
  • /import detects the ZIP magic bytes and names /import/archive.

Verification

  • bun test — 6450 pass, 0 fail. tsc -b and eslint clean.
  • 18 new tests across three files covering each defect and its inverse (an ordinary button stays type="button"; a data table stays non-routable; a JSON bundle is not mistaken for an archive; generated form wiring is not duplicated).
  • Live run against a clean instance: a post type created purely through the data API now yields a routable entry slug, and the misdirected ZIP gets actionable guidance.

Known gaps, not addressed here

Two bridge-side problems were reproduced but are not in this PR, because both look like state races in the editor sync layer rather than contained defects, and guessing at them risked more than it fixed:

  • site_write_code_asset persisted its runtime config while site.files stayed empty, so the script never published. The tool's own readback confirmed the file was in the store, and a hand-written site-document PUT persisted it correctly — pointing at a stale-shell overwrite between the store and the save, in the same family as the known stale-bridge finding.
  • site_insert_html returned node ids for three documents whose subtrees never persisted (a page, a template, and a second page), reported as success each time. Re-inserting into the same document worked.

Both are worth their own investigation with the sync layer in view.

🤖 Generated with Claude Code

@DavidBabinec
DavidBabinec changed the base branch from main to feat/stateless-mcp-upgrades July 30, 2026 18:57
@DavidBabinec

Copy link
Copy Markdown
Contributor Author

Stacked on feat/stateless-mcp-upgrades (PR #313), since that is the build these defects were found on. Retarget to main once #313 merges.

While retesting #313's own fixes during this build: MCP-009/MCP-024 (outlet semantics), MCP-010 (Pages no longer offered as a writable Content collection), MCP-016 (post-type discovery purity) and MCP-023 (font declarations in token-inclusive style reads) all confirmed working against a live site. MCP-027 did not reproduce — no node move failed during the build, so it needs a deliberate repro rather than an incidental one.

…ances

Building a full site through the MCP surface surfaced five places where an
authoring call reported success and the result was quietly different from
what was written. Each one failed silently, so the symptom always appeared
somewhere else — a route that 404s, a script that never initialises, a
filter that matches nothing.

- A `postType` table created anywhere other than the Content UI arrived with
  no `title`/`slug` fields, because the UI seeds them client-side.
  `slugForTable` returns an empty slug for such a table, so every entry
  published without a public route. `createDataTable` now seeds the built-ins
  itself, so the invariant holds for the data API, MCP connectors and imports
  alike; caller-supplied fields still win on id collision.

- HTML import preserved safe custom attributes on five module types only, so
  `<form data-x>` lost its hooks while `<div data-x>` kept them. A form is
  exactly what a progressive-enhancement script binds to, so the script
  exited before initialising with nothing logged. `base.form` now carries
  `htmlAttributes` and renders them alongside its generated wiring.

- `<button type="reset">` became `type="button"`: `base.button` had no
  `buttonType` prop and hardcoded the attribute. A native reset control could
  not be authored at all. Adds the prop, maps it on import, renders it.

- `<option value="">` became `value="<label>"` on import, and the renderer
  dropped an empty value even when stored. The conventional "no filter"
  option therefore became a real filter value named after its own label.
  Presence now decides on import, and `value` is always emitted.

- Posting the exported .zip to `/import` (which takes a JSON bundle) answered
  "body does not conform to SiteBundleSchema", sending callers to hunt a bug
  in their bundle rather than at the wrong door. The handler now recognises
  the ZIP magic bytes and names `/import/archive`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@DavidBabinec
DavidBabinec force-pushed the fix/mcp-authoring-friction branch from 49342ca to 57efd15 Compare July 30, 2026 19:03
@DavidBabinec
DavidBabinec changed the base branch from feat/stateless-mcp-upgrades to main July 30, 2026 19:03
@DavidBabinec
DavidBabinec merged commit 17899bd into main Jul 30, 2026
9 checks passed
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