Skip to content

PRD: migrate plasmic-mcp mutation internals onto wab client/operations for pre-flight validation #404

Description

@field123

Status: pending spike confirmation. The mechanism this PRD assumes — that the MCP can
import and type-check against upstream's wab operations layer — is proven or disproven by
#403. A no-go there changes the Implementation Decisions section only; everything else holds.
Blocked on PR #313 landing.

Problem Statement

When an agent drives the Plasmic MCP to build or edit a project, it can silently corrupt the
project bundle. The failure is not loud at the point of damage. The MCP mutates the in-memory
model first and only validates at save time, so the sequence is: mutate, then discover, then
throw — with the model already broken. The agent sees a save failure that names neither what it
did wrong nor how to recover, and the project can be left needing manual repair in Studio before
any further write succeeds.

The damage class is dangling references. Deleting a subtree that another component still
references through an implicit state or a tpl reference leaves the reference pointing at nothing.
Inserting an element somewhere the model cannot represent it produces a tree that renders
differently from what it describes. Neither is detected until the whole site is validated on the
way to the server.

A second, quieter version of the same problem is silent wrongness. Some operations succeed, save
cleanly, and produce a model that does not render as described — children attached to a void
element, list items outside a list, an element added to a node whose content is driven by an
expression that will overwrite it. An agent cannot see this divergence, so it builds on top of it
and compounds the error.

Underneath both is a maintenance problem. The MCP hand-rolls its own version of mutation logic
that Studio already implements — tree surgery, implicit-state cleanup, slot resolution, variant
handling — and has to keep that reimplementation correct against a model it does not own. Every
divergence between our version and Studio's is a latent corruption bug, and every upstream change
to the model is one we have to notice and mirror by hand.

Solution

Stop reimplementing Studio's mutation logic and call it directly.

Upstream extracted Studio's mutation operations into a public, dependency-injected layer while
building their own Copilot tools. The tools went into their private repository; the extracted
layer did not. It is a set of named operations, each with test coverage, none of which depend on
Studio's editor context for correctness. Each one validates before it mutates and returns a
structured success-or-error result rather than throwing, and its documented contract is that a
failed check leaves the model untouched.

Adopting it inverts the failure sequence. Instead of mutate → discover → throw, the MCP does
check → refuse → explain, with the model clean. The agent gets a named reason and, where one
exists, a named remedy it can call. Damage that used to require manual repair in Studio becomes a
message the agent can act on.

This also removes a large amount of code we currently own and must keep correct, and replaces it
with code that carries upstream's test suite and is exercised by Studio itself on every use.

The migration is incremental and gated. We freeze current behaviour as a measured baseline, move
one tool at a time, and triage every newly-refused input before moving to the next. Nothing is
migrated wholesale, and no capability is dropped without an explicit, recorded decision.

Because the same operations run unchanged in both a headless process and inside Studio, doing
this correctly also positions the MCP tools to run inside the Studio experience later, without a
second implementation.

User Stories

Agents driving the MCP

  1. As an agent editing a Plasmic project, I want a destructive operation to be refused before it
    runs when it would leave a dangling reference, so that I do not corrupt the project.
  2. As an agent, I want the refusal to name the specific element holding the blocking reference,
    so that I can resolve it rather than guess.
  3. As an agent, I want the refusal to arrive from the tool I called rather than from a later save,
    so that I can attribute the failure to the right action.
  4. As an agent, I want the model to be unchanged after a refused operation, so that my next call
    starts from a known-good state.
  5. As an agent, I want a refusal that has a defined remedy to tell me the remedy, so that I can
    complete the task instead of abandoning it.
  6. As an agent, I want to be refused when I would attach children to an element that cannot render
    them, so that I do not build on top of a model that renders differently from what it describes.
  7. As an agent, I want to be refused when I would place an element that references variables into
    a context where those variables do not exist, so that I do not create an unresolvable binding.
  8. As an agent, I want to be refused when I would create a cycle by moving an element into its own
    descendant, so that I do not produce an unrenderable tree.
  9. As an agent, I want refusal messages to be stable and machine-distinguishable, so that I can
    handle categories of failure programmatically rather than by matching prose.
  10. As an agent, I want operations that previously succeeded and produced valid models to keep
    succeeding, so that migration does not silently break my existing workflows.
  11. As an agent, I want to keep expressing insertions as explicit element trees with precise
    props, styles, slots, component instances and variants, so that I do not lose the control
    that makes the MCP useful for non-trivial work.
  12. As an agent, I want cleanup that Studio performs automatically — emptied list containers,
    column redistribution — to happen on my deletions too, so that the result matches what a
    designer would have got.

Developers and designers using the project afterwards

  1. As a designer, I want a project an agent has edited to open in Studio without repair, so that
    I can continue working immediately.
  2. As a designer, I want agent edits to follow the same structural rules my own edits follow, so
    that the project stays consistent regardless of who edited it.
  3. As a developer, I want a project that has been agent-edited to publish and render correctly,
    so that model damage does not surface first in production.

MCP maintainers

  1. As an MCP maintainer, I want validation logic to come from upstream rather than be
    reimplemented here, so that it stays correct as the model evolves without my intervention.
  2. As an MCP maintainer, I want to delete our hand-rolled tree surgery and implicit-state
    cleanup, so that the most corruption-prone code we own stops being ours to get right.
  3. As an MCP maintainer, I want each migrated tool to inherit upstream's test coverage, so that
    regressions are caught by tests I did not have to write.
  4. As an MCP maintainer, I want a measured behavioural baseline before any internals change, so
    that I can prove what did and did not change rather than argue about it.
  5. As an MCP maintainer, I want every newly-refused input classified before the next tool is
    migrated, so that capability loss is a decision rather than an accident.
  6. As an MCP maintainer, I want a single boundary module where upstream operations are imported,
    so that upstream churn lands in one reviewable place.
  7. As an MCP maintainer, I want the tool logic to hold no reference to sessions, API clients or
    saving, so that the same logic can run in a second environment without being rewritten.
  8. As an MCP maintainer, I want migration to proceed one tool at a time behind a gate, so that a
    bad outcome costs one tool rather than the whole surface.
  9. As an MCP maintainer, I want the public MCP tool names, arguments and result shapes to be
    unchanged by this work, so that no consumer has to migrate alongside us.
  10. As an MCP maintainer, I want refusals distinguished from crashes in the tool result, so that
    a refusal is not reported as an internal error.

Future in-Studio execution

  1. As a product owner, I want the same tool implementations to be runnable inside the Studio
    experience later, so that we do not maintain two divergent versions of the same behaviour.
  2. As a product owner, I want the headless capability preserved, so that the MCP keeps working
    with no browser session open — the property upstream's own design cannot offer.
  3. As an MCP maintainer, I want the environment-specific concerns isolated to a thin adapter, so
    that adding the Studio environment is an adapter, not a refactor.

Release and operations

  1. As a release manager, I want each migrated tool to ship independently, so that a problem can
    be reverted without unwinding the whole migration.
  2. As a release manager, I want the behavioural change to be visible in release notes, so that
    newly-refused inputs are understood as intended rather than reported as regressions.
  3. As an MCP maintainer, I want an explicit record of the corruption classes this does not
    address, so that the work is not judged against a promise it never made.

Implementation Decisions

Target and scope of adoption

  • We adopt upstream's operations layer, not their Copilot tool framework. The framework's public
    surface after the upstream merge is an empty tool registry, a dispatch bridge that returns
    nothing, and type shapes. It validates tool arguments, not model integrity, and is out of scope.
  • We import the operations directly into the MCP process. We do not dispatch through Studio's
    host-frame bridge, which would require an open Studio session and forfeit headless operation.
  • The public MCP tool surface does not change. Tool names, actions, argument schemas and result
    shapes stay as they are. This is an internals migration.

Layering — the load-bearing rule

  • Three layers, each owning exactly one environment concern.
    • Operations layer (upstream): model mutation and validation. No environment.
    • Tool core (ours): the element-tree DSL, node resolution, registry enrichment, result
      shaping. Pure over an injected context. No environment.
    • Environment adapter (ours): session acquisition, change recording, fixups, persistence,
      batching. Headless today; a Studio adapter later.
  • A tool core function must not acquire a session, hold an API client, or perform a save. This is
    enforced by review from the first commit. It is what makes the later in-Studio environment an
    adapter rather than a rewrite, and it is expensive to retrofit.
  • Current tool functions violate this — they take an API client, resolve a session from module
    state, construct their own tree manager and own the save. Correcting this is part of each
    slice, not a separate cleanup.

Modules to build or modify

  • Operation context provider (new, deep). Builds the injected context the operations
    require from an environment. Two implementations behind one interface: headless, and later
    Studio. Encapsulates all knowledge of where the model comes from; the tool core sees only the
    context. Testable against a synthetic site with no environment at all.
  • Upstream operations boundary (new, thin). The single module through which upstream
    operations are imported. Gives the build carve-out one target and gives upstream churn one
    place to land. Nothing else in the MCP imports the operations directly.
  • Refusal mapper (new, deep). Translates upstream's structured operation errors and
    cannot-add reason variants into MCP-facing results: a stable machine-readable category, a human
    message, the referencing element resolved to a node reference the agent can address, and where
    one exists, the named remedy operation. This is the triage process expressed as code, and it is
    the module that determines whether refusals are actionable or merely correct. Pure — no
    environment, no model mutation.
  • Transaction adapter (extracted from existing code). Owns change recording, post-change
    fixups, batch and micro-batch accumulation, and persistence. Largely exists already as a single
    internal function; this work promotes it to the explicit environment-adapter boundary.
  • Node domain tool core (modified). The node tools are re-expressed as pure functions over
    the injected context, delegating mutation to upstream operations and refusal shaping to the
    refusal mapper. Retains our element-tree DSL, node resolution and registry enrichment — these
    have no upstream equivalent, since the operations take already-constructed model nodes.

Sequencing

  • Slice 0: freeze the behavioural baseline. No production code changes.
  • Slice 1: node removal. Smallest slice that exercises the whole pipeline — boundary module,
    context provider, refusal mapper, transaction adapter, eval parity, refusal triage — against
    the corruption risk that motivates the work. The upstream deletion operation requires no editor
    context, which keeps incidental complexity out of the first slice.
  • Slice 2: node addition. The larger prize and the larger surface: it brings in the element-tree
    builder, slot resolution and registry enrichment simultaneously, and it is where the majority
    of refusal triage will occur. Sequenced second deliberately, with the pattern already proven.
  • Further slices are scoped after slice 2 and are not committed to by this PRD. Roughly a third
    of our mutation surface has an upstream counterpart; the remainder — mixins, themes, splits,
    assets, data tokens, queries, rich text, global and screen variants, page metadata — has none
    and stays ours indefinitely.

Refusal handling

  • Refusals are not overridden. There is no bypass flag. Every refusal upstream can produce is
    either structurally impossible in the model, invalid markup that browsers will restructure, or
    silently lossy at render — and a bypass would reintroduce precisely the failure mode this work
    exists to remove.
  • Where upstream exports the remedy for a refusal, the MCP surfaces it as a named, callable next
    step. Where it does not, the refusal stands and the reason is returned.
  • Every newly-refused input is classified before the next slice begins. A refusal with neither an
    accepted rationale nor an exported remedy is a signal to stop and reconsider the slice.

Types and build

  • Types resolve against upstream's real operation signatures rather than hand-written ambient
    stubs. Extending the existing stub file would add maintained code to a project whose purpose is
    removing it, and would leave everything untyped — runtime safety with no compile-time safety.
  • The bundler's client-code exclusion gets a narrow allowlist for the operations directory, minus
    the modules that depend on editor context, browser APIs or modal dialogs. This is a carve-out,
    not a relocation of the boundary.
  • Both mechanisms are proven or disproven by spike(plasmic-mcp): prove the build + type path for importing wab client/operations #403 before any slice begins.

Testing Decisions

What makes a good test here

A good test asserts observable behaviour at a module's interface: given this model state and this
input, the result is success with these effects, or refusal with this category and this named
element. It does not assert which upstream function was called, in what order, or with what
arguments. Tests that pin our call sequence into upstream would defeat the purpose of the
migration — they would break on upstream refactors that change nothing we care about, and would
have to be rewritten every merge.

Refusal tests assert the category and the presence of an actionable referent, not the exact
prose. Message wording belongs to upstream and will change; the category and the referenced
element are our contract with the agent.

Every test that asserts a refusal must also assert that the model is unchanged. The refusal is
half the guarantee; the untouched model is the other half, and it is the half that was broken
before.

Modules to be tested

  • Refusal mapper — the highest-value target and fully pure. Every upstream refusal variant maps
    to a stable category; those with remedies surface the remedy; those carrying a referencing
    element resolve it to an addressable node reference; unrecognised variants degrade to a safe
    generic refusal rather than crashing. Exhaustive unit coverage.
  • Operation context provider — builds a valid context from a synthetic site; the headless
    implementation omits the optional editor-dependent callbacks and operations still behave
    correctly without them.
  • Node domain tool core, per slice — behavioural coverage of each migrated tool: success paths
    produce the expected model change; each refusal path returns the right category and leaves the
    model untouched; the cleanup upstream performs and we previously did not is observable.
  • Transaction adapter — recording, fixups and batch accumulation behave as before. This is
    primarily a no-regression boundary; existing coverage largely applies.
  • Upstream operations boundary — not unit tested directly. Its correctness is that it compiles
    and that the bundle contains no editor-side imports, which is a build assertion.

Baseline and parity gate

  • Before any internals change, the existing evaluation suite is run against current behaviour and
    its results frozen as the baseline. This is slice 0 and it produces no production code change.
  • The gate for each slice is parity against that baseline, plus new scenarios pinning any
    capability we are specifically concerned about losing. Divergences are triaged, not waived.
  • Byte-identical bundle comparison is explicitly rejected as a gate. Upstream's operations set
    variant settings and adopt container styles differently by design, so byte-identity would block
    on cosmetic differences while telling us nothing about behaviour.
  • Newly-refused inputs are expected and are not automatic failures. Each must be shown, against
    the baseline, to have previously produced a model that was invalid, unrenderable as described,
    or silently lossy.

Prior art

  • Unit tests for the node domain already exist and follow the pattern this work extends —
    per-domain test modules under the package's test directory, using the existing test runner.
  • The evaluation harness already provides scenario definitions, state checks, transcript checks,
    visual capture and a reporter, including a node-domain scenario set. The baseline and parity
    gate use this machinery as-is; no new harness is required.
  • Upstream ships spec coverage for nearly every operation in the layer we are adopting. We do not
    duplicate it. Our tests cover our layers — the mapping, the context, the tool cores — and treat
    the operations as trusted.

Out of Scope

  • The Copilot tool framework, its schema export and its in-Studio dispatch bridge.
  • Actually running the MCP tools inside Studio. This PRD makes it cheap by isolating environment
    concerns to an adapter; building the Studio adapter is separate work and a separate product
    decision.
  • Any tool whose domain has no upstream counterpart: mixins, themes, splits, assets and images,
    data tokens, queries, rich text, attributes, visibility, data conditions and repetition, page
    metadata, global variant groups, screen variants, page and component conversion. These stay
    ours.
  • Retiring the hand-written ambient type stubs wholesale. That is the correct end state and would
    remove a substantial body of maintained code, but the repository carries pre-existing type
    errors and it is a project in its own right.
  • Registered-function and code-component signature drift — stale argument and state-parameter
    references left when a registered signature changes underneath an existing binding. This
    originates in the dev host, outside the model, and nothing in the operations layer addresses
    it. It remains a save-time validation problem.
  • The five upstream operation modules that depend on editor context, browser APIs or modal
    dialogs, including the HTML-to-tree importer. The importer may become a second front-end onto
    the same operations later; it is additive and never a replacement for the element-tree DSL.
  • The upstream merge itself, and its deployment.

Further Notes

This will look like a regression before it looks like a fix. Inputs that previously
"succeeded" will start returning refusals. That is the intended outcome — each one was producing
a model that was invalid or that rendered differently from what it described — but it will be
reported as a regression by anyone reading only the symptom. The frozen baseline is what lets us
show, per case, that the previous success was not one. This should be stated plainly in release
notes rather than discovered.

Why the durability of this bet is better than it looks. Upstream's public and private
surfaces are separated mechanically, by a sync tool and lint rules that forbid public code from
importing private code. The operations layer sits on the public side of that boundary and is
structurally barred from acquiring private dependencies. If upstream ever did move it private, we
would inherit stubs on a merge — a loud break, not silent drift — with the last public version in
history and upstream's own spec files available to keep a fork honest. That is a considerably
better failure mode than the status quo, where the equivalent risk is a bundle corrupting in a
customer project.

A conflict-free extension point exists if we want it later. Because upstream keeps their tool
implementations private, the public tool registry arrives empty on every merge and will continue
to. If EP ever wants tools running inside Studio, that file can be filled without ever conflicting
with upstream. Worth recording in the upstream merge runbook's customization registry
independently of this work.

The element-tree DSL was never at risk. An earlier framing of this work suggested replacing
our insertion path with upstream's HTML importer. That concern does not apply: the operations
take already-constructed model nodes and have no notion of our element-tree format, so our DSL
sits above them untouched. The importer, if adopted, is a second front-end onto the same
back-end.

Module design is proposed, not confirmed. The four-module shape above — context provider,
operations boundary, refusal mapper, transaction adapter — reflects the design discussion but has
not been reviewed against implementation. The refusal mapper is the one to scrutinise: it is the
module that decides whether refusals are actionable or merely correct, and it is the most likely
to be under-specified here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions