Skip to content

PRD: Expose Plasmic's invokeRefAction interaction type in the MCP (gap #81) #310

@field123

Description

@field123

Problem Statement

A designer or agent using the Plasmic MCP wants to wire an event on a slot child (typically an <input> onChange or <button> onClick) to invoke a refAction exposed by an ancestor code-component. For example, in the EP catalog-search package, the EPSearchBox provider exposes setValue and clear ref-actions, and the designer wants the slot's input/button to call them via Plasmic's interaction system. This is the canonical "headless component plus designer-controlled chrome" pattern.

In Plasmic Studio's UI, this is one well-known choice: the user adds an interaction, picks "Run element action" from the action dropdown, picks the target instance, picks the action method, fills in arg expressions. Internally Studio writes an Interaction of type invokeRefAction whose codegen produces ({tplRef, action, args}) => $refs?.[tplRef]?.[action]?.(...(args ?? [])). This is a first-class Plasmic concept defined in platform/wab/src/wab/shared/core/states.ts:invokeRefAction.

The MCP's interaction.add tool today only exposes three actionName values: navigation, updateVariable, customFunction. invokeRefAction is not in the enum. Agents and designers driving Plasmic via the MCP cannot wire ref-action invocations the canonical way. The customFunction workaround ((() => { $refs[name].setValue(event.target.value); })()) saves cleanly but does not execute at runtime — $refs is not in scope at the customFunction's call site for HTML element event handlers (verified live by typing into a wired input: the DOM value updates but the parent's setValue is never called and the InstantSearch query never refines).

In the headless-component flow PRD #308 just landed, the MCP successfully drove every other step end-to-end:

  • node.add populated the EPSearchBox slot with a Plasmic <input> and <button> (TplTags, which escape Plasmic's TPL_COMPONENT_PROPS appearance filter).
  • node.update-attrs set static attrs and the dynamic value binding to $ctx.searchFieldData.value.
  • node.update-styles applied border, padding, font, background, color, radius — every appearance property the designer set in Plasmic Studio's panel reached the live DOM, validating the architectural answer to designer-controlled headless code components.
  • The two final wiring steps — input onChange to invoke setValue, button onClick to invoke clear — could not be completed via MCP. The designer must drop into Studio UI and click through the "Run element action" dropdown for each one.

This breaks the headless-component design pattern at the final 5% of the flow, forcing a Studio-UI handoff for every interactive code component the MCP creates. It also means PRD #308's slot-composition design only fully ships value when used via the MCP plus Studio UI, not via the MCP alone — undermining the design's "agent can drive a complete UI build" promise.

Solution

The MCP gains the ability to wire invokeRefAction interactions on a TplTag's event handler, mirroring Studio UI's "Run element action" workflow as closely as possible and reusing the existing Plasmic-shared code paths for codegen, model construction, and validation rather than re-implementing them.

Concretely, the agent/designer can add an interaction shaped like this and have it work end-to-end without Studio UI:

  • interaction.add with actionName: "invokeRefAction" (or alias runAction/runElementAction).
  • Args:
    • tplRef resolved to a TplComponent instance via the same node-resolution helpers node.add/update-styles/etc. already use (UUID, name, path, or numeric index). The resolver rejects targets that are not TplComponent instances of code components with refActions registered.
    • action is the ref-action method name; the resolver looks up the target component's refActions map (via the same dev-host registry the MCP loads at project.set) and rejects unknown action names with a clear error listing the valid options.
    • args accept either an ordered array of arg-expression strings OR an object keyed by the action's argType name. Each arg becomes a FunctionArg inside a CollectionExpr, with the same argType resolution Studio uses (propTypeToWabTypetypeFactory.func).
  • The resulting Interaction model is byte-for-byte identical to what Studio UI would emit for the same wiring — same actionName, same NameArg structure for tplRef/action/args, same TplRef and CollectionExpr and FunctionArg model classes.
  • Codegen for invokeRefAction is already shared (platform/wab/src/wab/shared/core/states.ts); the MCP does not write a new template. The runtime $refs?.[tplRef]?.[action]?.(...(args ?? [])) is what Plasmic emits regardless of whether the interaction was authored in Studio UI or via the MCP.

After the fix, the EP catalog-search wiring example becomes a clean MCP path:

interaction.add { event: "onChange", actionName: "invokeRefAction",
  args: { tplRef: "OnXiNxQuXIlX", action: "setValue", args: ["event.target.value"] } }
interaction.add { event: "onClick", actionName: "invokeRefAction",
  args: { tplRef: "OnXiNxQuXIlX", action: "clear", args: [] } }

No customFunction shim, no $refs confusion, no Studio-UI handoff.

User Stories

  1. As an agent driving Plasmic via the MCP, I want to wire an <input> onChange to invoke a parent code-component's setValue ref-action via a single tool call, so that I can complete a headless-component build without forcing the user into Studio UI for the final step.
  2. As an agent, I want to wire a <button> onClick to invoke a parent code-component's clear ref-action via the same invokeRefAction action type, with no special-case code path for argument-less actions versus actions that take arguments.
  3. As an agent, I want to reference the target TplComponent by the same identifier shapes I use everywhere else in the MCP (UUID, name, path, index), so that I do not have to learn a new resolution dialect for ref-actions.
  4. As an agent, when I supply a tplRef that resolves to a non-TplComponent element (e.g. a <div>) or a code-component with no refActions registered, I want the MCP to reject the call with a clear error listing valid candidates, so that I can correct the call before saving and avoid corrupt bundles.
  5. As an agent, when I supply an action name that the target component does not expose, I want the MCP to reject the call with a clear error listing the available action names on that target, so that I can pick the right one.
  6. As an agent, when I supply too few or too many args for a refAction, I want the MCP to reject the call with a clear error showing the action's expected argTypes and the count it received, so that I do not commit a partially-wired interaction.
  7. As an agent, when I supply an args value whose type does not match the corresponding argType (e.g. passing a number where a string is expected), I want the MCP to reject the call before save with a type-mismatch error mirroring Studio's coercion rules.
  8. As an agent, I want to supply args either as an ordered array (matching the action's argTypes order, like Studio UI's positional bindings) OR as an object keyed by argType name, so that I can choose whichever shape makes my prompt more readable.
  9. As an agent, after wiring the interaction, I want interaction.list to return a structured representation of the invokeRefAction interaction (event, actionName, target instance ref, action name, arg expressions), so that I can verify what I wrote and round-trip-edit it.
  10. As an agent, when I want to remove a previously-wired ref-action interaction, interaction.remove should work the same way it does for navigation/updateVariable/customFunction interactions, so that I do not need a special workflow for ref-action interactions.
  11. As an agent, when I want to update an existing invokeRefAction interaction (e.g. change the action method or arg expressions), interaction.update should accept the same args shape as interaction.add, so that I can iterate without removing-then-adding.
  12. As a designer using Plasmic Studio, I want interactions wired via the MCP to be visually indistinguishable from interactions I wire myself in the UI — same display name "Run element action", same parameters, same target picker — so that handoff between agent-driven and human-driven editing is seamless.
  13. As a designer, I want existing Studio-UI-wired invokeRefAction interactions to be readable by the MCP's interaction.list after switching to MCP-driven editing, so that mixed workflows do not force a re-wiring step.
  14. As a developer maintaining the MCP, I want the implementation to reuse Plasmic's shared invokeRefAction codegen template (platform/wab/src/wab/shared/core/states.ts) and model classes (TplRef, NameArg, CollectionExpr, FunctionArg) rather than duplicating the codegen, so that future Plasmic upstream changes flow through the MCP without divergence.
  15. As a developer, I want the MCP's refAction resolution logic to use the same dev-host registry / code-component metadata path that Studio's getTplRefActions reads from, so that there is one source of truth for what refActions exist on a given TplComponent instance.
  16. As a developer, I want a regression test for the model-shape parity between MCP-authored invokeRefAction interactions and Studio-authored ones, so that I can guarantee mixed workflows do not produce subtly-different bundles.
  17. As a downstream consumer (e.g. EP catalog-search), I want the existing PRD PRD: Make EPSearchBox styleable by lifting its DOM out of the code-component #308 EPSearchBox example app's setValue / clear wiring to be completable via MCP without Studio UI involvement, so that the published agent recipes for headless components are end-to-end MCP-driven.

Implementation Decisions

  • The MCP's interaction.add tool gains invokeRefAction as a new actionName value. The new value is added to the existing ACTION_ALIASES map, the SUPPORTED_ACTIONS set, and resolveActionName validation in the same file/structure today's three actions live in. User-friendly aliases (runAction, runElementAction) are accepted via the same alias map.
  • buildActionArgs gains an invokeRefAction case that constructs the same NameArg array Studio's UI builder produces, using the existing shared model classes (TplRef, NameArg, CustomCode, CollectionExpr, FunctionArg) — no new model classes, no new codegen template, no shadow validation logic.
  • tplRef resolution uses the existing node-resolution helper (resolveNode) that the rest of the MCP uses, with an additional type guard: the resolved node must be a TplComponent of a registered code component whose meta has at least one entry in refActions. Error messages list the valid candidate uuids/names within the same parent component scope.
  • action validation mirrors Studio's getTplRefActions logic: look up the resolved TplComponent's component.codeComponentMeta.refActions (the same source Studio's studioCtx.getCodeComponentMeta returns). Server-side, this means walking the dev-host registry that's already loaded at project.set time. Unknown action names are rejected with a list of valid keys.
  • args shape: accept either Array<string> (ordered, position-matched against argTypes) or Record<string, string> (keyed by argType name). Internally normalise to an ordered array indexed by argTypes order, then build one FunctionArg per argType. Missing args for required argTypes are rejected; extra args are rejected; type-mismatch detection delegates to the same propTypeToWabType machinery Studio uses (or equivalent shared utility).
  • interaction.list reads back invokeRefAction by adding a case to the existing arg-extraction switch that walks an interaction's args array. Returns { tplRef: <name-or-uuid>, action: <string>, args: <Array<expression-string>> }. The shape is symmetric with the input format so round-trip edits work.
  • interaction.update automatically supports invokeRefAction once add does, since update reuses the same args-building path.
  • No changes to interaction.remove — it operates on interaction index/uuid and is already action-type-agnostic.
  • The MCP imports the invokeRefAction action descriptor from the shared wab module if available, rather than redefining its codegen template. If the shared module's exports do not currently make this clean, a small refactor in platform/wab/src/wab/shared/core/states.ts to export the metadata for external consumers is part of this PRD's scope.
  • Tool description update: interaction.add's description gains a invokeRefAction example. The description also adds a one-line note about customFunction's closure scope (gap build: trigger fresh terraform plans after service discovery permissions #78 already filed; can be addressed in the same change set if convenient).
  • Backwards compatibility: existing customFunction interactions that emulate invokeRefAction via $refs continue to be valid. We do NOT auto-migrate them; we just add the cleaner native path. A follow-up PRD could detect the $refs[...].action(...) pattern in customFunction bodies and offer migration, but that is out of scope here.

Testing Decisions

  • The behavioural test suite for interaction.add lives at packages/plasmic-mcp/src/__tests__/interaction.test.ts. New invokeRefAction test cases are added here, alongside the existing navigation / updateVariable / customFunction cases.
  • A good test for this work asserts the observable model shape that Plasmic's codegen will see — i.e. the Interaction object's actionName, args array contents (NameArg name, expr type, expr fields). It does NOT assert the runtime DOM behaviour (that requires a full integration harness; see Out of scope). It does NOT assert internal MCP intermediate state.
  • Specific assertions:
    • interaction.add with invokeRefAction, valid tplRef (UUID), valid action, ordered-array args produces an Interaction whose args contains: a NameArg tplRef wrapping a TplRef whose tpl is the resolved TplComponent; a NameArg action with a CustomCode whose code is JSON.stringify(action); a NameArg args with a CollectionExpr whose entries are FunctionArgs in argType order.
    • Same with args as an object — produces the identical Interaction. Asserts the two input shapes converge.
    • Unknown tplRef raises with a clear error mentioning node-not-found and listing nearest-matches by name.
    • tplRef resolves to a TplTag (not a TplComponent) — rejects with "must be a TplComponent with refActions" error.
    • tplRef resolves to a TplComponent but the code-component meta has no refActions — rejects with "this component does not expose refActions" listing the friendly displayName.
    • Unknown action — rejects with "action not found; available: ".
    • Wrong arg count — rejects with "expected N args (); received M".
    • Wrong arg type — rejects with "arg expected type ; received ".
    • Round-trip: interaction.add then interaction.list returns an entry with the same tplRef/action/args shape that was input.
    • Cross-authoring parity: an Interaction object built via the MCP's invokeRefAction path is structurally equal to the Interaction object Studio's interactions-meta.ts:invokeRefAction would build for the same inputs (sharedmodel comparison via existing cloneTreeChecker or equivalent equality helper).
  • Prior art: the existing interaction.test.ts uses mockApiClient, mockEnsureBaseVariantSetting, mockWithRecording patterns. The new tests follow the same patterns with no new test infra introduced.

Out of Scope

  • Wiring an invokeEventHandler (the cousin action type for invoking a Plasmic-defined event prop on a child component, not a refAction). That action is structurally similar but distinct — separate PRD if needed.
  • A full integration test against a running Plasmic Studio + Next dev server that asserts the wired interaction actually fires setValue at runtime when typed into. Browser-level verification happens manually post-merge via Playwright MCP, mirroring the verification flow for PR feat(ep-commerce): headless styling contract + EPSearchBox provider refactor (#305, #308) #309.
  • Auto-migrating existing customFunction interactions that emulate invokeRefAction via $refs[name].action(...) to the canonical type. Worth a follow-up once the canonical path is widely used.
  • Adding the invokeEventHandler / dataSourceOp / customFunctionOp / login / logout / updateVariant / invalidateDataQuery action types that Studio's interactions-meta.ts also defines but the MCP does not yet expose. Each is a separate gap.
  • Updating gap build: trigger fresh terraform plans after service discovery permissions #78 (interaction.add customFunction tool description omits $refs). Once invokeRefAction is the canonical path, the customFunction $refs confusion is much less load-bearing — the description fix can be handled in a doc-only PR.
  • A new MCP tool dedicated to ref-action wiring (e.g. interaction.add-ref-action). The existing interaction.add with a new actionName value is the right surface; no new tool needed.

Further Notes

Three decisions taken without explicit confirmation. Reviewers can override before implementation:

  • args shape: support both array and object. Studio's UI internally uses positional argTypes order, so the MCP's normalised internal shape is an array. Accepting an object input is a developer-experience addition; if it complicates the implementation we can drop it and keep array-only.
  • Hard-error on validation failures, not warn-and-save. A saved invalid invokeRefAction would corrupt the bundle (target component IID points to nothing meaningful). Hard-error matches the rest of the MCP's write-action validation pattern (per gap chore: add Getting Started guide for Plasmic development #25).
  • Reuse Studio's shared model construction directly, not a re-implementation. Specifically: import invokeRefAction parameters/codegen template from the wab shared module if the module exports them; if not, treat that small refactor as part of this PRD. Avoid drift by construction.

Open questions worth a comment before implementation:

  1. Does the dev-host registry the MCP loads at project.set carry refActions in the form Studio's getTplRefActions expects? Need to verify before validation can be cheap. If not, the MCP needs an extra registry-side enrichment pass.
  2. Does the tplRef TplRef model class require a name on the target TplComponent, or does it accept anonymous TplComponents? Studio UI's auto-naming flow may matter here (Plasmic refs use tpl.name mangled via toVarName; anonymous TplComponents have no name). If anonymous, the MCP may need to either auto-name the target or reject the call with "target instance must be named first".
  3. Should interaction.update accept partial args (e.g. only change the action while keeping tplRef and args)? Studio's UI lets users update one parameter at a time; the MCP could mirror this by treating any unspecified arg as "leave as-is".

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions