diff --git a/.ralph/AGENTS.md b/.ralph/AGENTS.md index 7c2beab0a..eaa366f0e 100644 --- a/.ralph/AGENTS.md +++ b/.ralph/AGENTS.md @@ -21,7 +21,7 @@ cd packages/plasmic-mcp && npm run typecheck # TypeScript type checking (ts - Monorepo: platform/ (apps), packages/ (SDK), plasmicpkgs/ (code components) - MCP server source: `packages/plasmic-mcp/src/` - Plasmic registry package: `packages/plasmic-mcp-registry/` (tests: `cd packages/plasmic-mcp-registry && npx vitest run`) -- STRAP architecture: 8 domain tools (project, inspect, component, node, variant, design, data, interaction) consolidating 103 actions +- STRAP architecture: 8 domain tools (project, inspect, component, node, variant, design, data, interaction) consolidating 104 actions - Embedded WAB editing engine from `platform/wab/src/wab/` - Claude Code skills in `.claude/commands/` (6 slash commands) - Use explicit `git add ` — never `git add -A` or `git add .` diff --git a/.ralph/IMPLEMENTATION_PLAN.md b/.ralph/IMPLEMENTATION_PLAN.md index 83e7cedde..d173e2060 100644 --- a/.ralph/IMPLEMENTATION_PLAN.md +++ b/.ralph/IMPLEMENTATION_PLAN.md @@ -1,3 +1,60 @@ # Implementation Plan -No active plan. +_Last updated: 2026-03-04_ + +## Priority 1 — Remaining Work + +All P1 items completed. + +## Completed Items + +### P1.4 — Fix `project.list` HTTP 500 (query param encoding) ✓ +- **Completed:** 2026-03-04 +- **What:** The MCP client sent `?query=all` (bare string) but the server's `parseQueryParams` (util.ts:189) runs `JSON.parse()` on every query param value, expecting JSON-encoded strings. The official Plasmic browser client (`client/api.ts:67`) runs `JSON.stringify(v)` before appending values. `JSON.parse("all")` throws SyntaxError → HTTP 500. +- **Fix:** Changed `api-client.ts:202` from hardcoded `?query=all` to `` ?query=${encodeURIComponent(JSON.stringify("all"))} `` which produces `?query=%22all%22` — matching the official client behavior. +- **Files modified:** + - `packages/plasmic-mcp/src/api-client.ts` — JSON-encode the query param value + - `packages/plasmic-mcp/src/__tests__/api-client.test.ts` — updated assertion to match new encoding +- **Test count:** 1578 unit tests pass (no regressions) +- **Note:** The P1.2 entry below incorrectly claimed this was "ALREADY FIXED". Unit tests passed because they mock fetch and never hit the real server's `parseQueryParams`. This was only a real bug when hitting the actual Plasmic server. + +### P1.3 — `packages/plasmic-mcp/FEATURE_REFERENCE.md` (spec: MCP-FEATURE-REFERENCE.md) ✓ +- **Completed:** 2026-03-04 +- **What:** Self-contained developer reference doc covering all 8 domain tools, 104 actions, architecture overview (STRAP pattern), and known feature gaps +- **Files created/modified:** + - `packages/plasmic-mcp/FEATURE_REFERENCE.md` — new file, full reference document + - `packages/plasmic-mcp/README.md` — updated action counts (103→104, node 15→16 actions, added `update-props`) + - `packages/plasmic-mcp/src/index.ts` — updated action count in header comment (99→104) + - `.ralph/AGENTS.md` — updated action count (103→104) +- **Test count:** 1727 unit tests pass (no regressions) + +### P1.2 — Fix `project.list` HTTP 500 (JSON encoding bug) — SUPERSEDED by P1.4 +- **Status:** Was incorrectly marked "ALREADY FIXED". See P1.4 for the actual fix. + +### P1.1 — `node.update-props` action (spec: NODE-UPDATE-PROPS.md) ✓ +- **Completed:** 2026-03-04 +- **What:** New `update-props` action on the `node` tool for setting/updating prop values on TplComponent instances +- **Files modified:** + - `packages/plasmic-mcp/src/wab-externals.d.ts` — added `getTplComponentArg`, `setTplComponentArg` declarations + - `packages/plasmic-mcp/src/__mocks__/wab-tpl-mgr.ts` — added mock implementations + - `packages/plasmic-mcp/src/edit-tools.ts` — new `updateProps()` export + `UpdatePropsResult` interface + - `packages/plasmic-mcp/src/server.ts` — added `update-props` to action enum, `props` Zod param, switch case + - `packages/plasmic-mcp/src/__tests__/node.test.ts` — 13 test cases covering all acceptance criteria +- **Capabilities:** scalar props, dynamic expressions ($expr / {{expr}}), boolean/number literals, slot content (PlasmicElement), prop deletion (null), variant targeting, fail-fast validation, merge semantics + +--- + +## Known Limitations (non-blocking) + +| Limitation | Location | Notes | +|-----------|----------|-------| +| Mixin-inherited styles not resolved in inspect output | `tree-reader.ts:14` | MVP limitation — inspect shows only direct VariantSetting styles, not resolved mixin styles | +| Rich text marks cannot combine with dynamic text | `edit-tools.ts:1743` | Use `update-text` with `dynamic:true` instead of `update-rich-text` for dynamic content | +| No interactive/OAuth auth | `auth.ts:6` | Pre-configured credentials only (env vars or `.plasmic.auth` file) | +| `component.create-page/create/clone` don't support dryRun | `server.ts` | Server-side API operations that cannot be previewed | + +## Notes + +- **Branch context:** `fix/dynamic-value-feature-gap` +- **Action count:** 104 actions across 8 tools +- **Scope:** This plan is scoped to `packages/plasmic-mcp/` only. EP commerce gaps are tracked separately. diff --git a/.ralph/specs/MCP-FEATURE-REFERENCE.md b/.ralph/specs/MCP-FEATURE-REFERENCE.md new file mode 100644 index 000000000..4c32173f4 --- /dev/null +++ b/.ralph/specs/MCP-FEATURE-REFERENCE.md @@ -0,0 +1,252 @@ +# Plasmic MCP Server — Developer Feature Reference + +## Jobs to Be Done + +- As a developer working on the MCP server, I want a single reference document that explains every MCP tool and action with enough context that I can understand the feature without prior Plasmic knowledge +- As a developer integrating with the MCP, I want concise, self-contained descriptions of each capability so I can build effective tool calls + +## Acceptance Criteria + +- [ ] New file at `packages/plasmic-mcp/FEATURE_REFERENCE.md` +- [ ] Covers all 8 domain tools and all 104 actions (103 existing + 1 new `update-props`) +- [ ] Each tool section includes: a concept explanation (what the underlying feature is and why it exists), list of actions with self-contained descriptions, key parameters +- [ ] Self-contained — a developer with zero Plasmic experience can understand every feature from the doc alone +- [ ] Includes a "Feature Gap" section noting known Studio features not yet in MCP +- [ ] Includes architecture overview (STRAP pattern, 8-tool consolidation) + +## Document Structure + +### 1. Architecture Overview + +**What is this?** The Plasmic MCP server exposes a visual web builder's editing engine as programmatic tools. Instead of clicking in a GUI, you call tool actions to build pages, style elements, wire data, and manage design systems. + +- **STRAP architecture**: 8 domain tools consolidating 104 actions. Each tool groups related actions under a single endpoint with an `action` discriminator field. +- **Transport**: JSON-RPC over MCP protocol (Model Context Protocol) — a standard for AI tool use. +- **Source**: `packages/plasmic-mcp/src/server.ts` (tool definitions + routing), `packages/plasmic-mcp/src/edit-tools.ts` (mutation logic) +- **Editing engine**: Embeds the WAB engine from `platform/wab/src/wab/` — the same code Plasmic Studio uses. Mutations go through the same code paths as the GUI. + +**Core concepts you need to know:** +- **Project**: A container for pages and components. Must be loaded (`project.set`) before any editing. +- **Component**: A reusable UI building block. Pages are components with a URL route. +- **Element tree**: Every component has a tree of elements — like a DOM tree. Elements are either HTML tags (`TplTag`) or instances of other components (`TplComponent`). +- **Variant**: An alternative version of a component's styles/content. Used for responsive breakpoints (mobile/desktop), interaction states (hover/focus), or feature toggles (dark mode). +- **VariantSetting**: The styles, text, visibility, and prop overrides that apply when a specific variant is active. +- **Design token**: A named value (color, spacing, font) that can be referenced throughout the project for consistency. + +### 2. Tool Reference — project + +**Concept**: Every editing session starts by loading a project. The project tool manages the session lifecycle — loading, saving, batching multiple edits into a single save, and undoing mistakes. + +| Action | What it does | +|--------|-------------| +| `set` | Loads a project into memory by its ID. **Required before calling any other tool.** Downloads the project data from the Plasmic server and initializes the editing engine. | +| `list` | Returns all projects accessible to the authenticated user, with their IDs and names. Use this to find a project ID before calling `set`. | +| `get-meta` | Returns project metadata: name, number of pages, number of components, and structural overview. Useful for orientation. | +| `save` | Force-saves all pending changes to the Plasmic server. Changes are auto-saved periodically, but this guarantees immediate persistence. | +| `refresh` | Discards all in-memory changes and reloads the project from the server. Use when someone else has made changes in Studio and you need the latest version. | +| `begin-batch` | Starts a batch editing session. All subsequent edits are accumulated in memory without triggering individual saves. Reduces server round-trips when making many changes. | +| `end-batch` | Commits all accumulated edits from a batch session as a single revision. The project is saved once with all changes applied atomically. | +| `undo` | Reverts the most recent edit operation. Works like Ctrl+Z in Studio. | + +### 3. Tool Reference — inspect + +**Concept**: The inspect tool lets you read the current state of any component without changing it. You can view the full element tree (every div, text block, and component instance with their styles), or zoom in on a single element. This is how you understand what's already built before making edits. + +| Action | What it does | +|--------|-------------| +| `tree` | Returns the full element tree for a component, including each element's tag/type, styles, text content, and layout properties. This is the most detailed view — like viewing the DOM inspector in browser DevTools. | +| `summary` | Returns a compact outline of the element tree: just the type, tag, name, uuid, and child count for each node. Much smaller than `tree` — good for orientation before drilling into specific elements. | +| `node` | Returns full details for a single element identified by name, uuid, or path. Includes all styles, attributes, text, variant overrides, and slot contents. | +| `subtree` | Returns the tree from a specific element downward (element + all its descendants). Useful when a component is large and you only care about one section. | +| `export` | Writes the full tree JSON to a temporary file and returns the file path. For trees too large to return inline. | +| `style-properties` | Lists all valid CSS property names in camelCase format (e.g., `backgroundColor`, `borderRadius`). Use this to discover what style properties are available. | +| `preview-url` | Returns the preview URL (rendered page) and Studio URL (editing interface) for a component or page. | +| `page-meta` | Reads a page's SEO metadata: title, description, canonical URL, and Open Graph image. | + +Key parameters: `componentUuid` (which component to inspect), `nodeRef` (specific element by name/uuid/path), `maxDepth` (limit tree depth), `format` (`concise` for ~70% token reduction) + +### 4. Tool Reference — component + +**Concept**: Components are the building blocks of a Plasmic project. A **page** is a component with a URL route (e.g., `/checkout`). A **component** is a reusable piece of UI (e.g., a button, card, or form). This tool manages their lifecycle and their **prop/state schemas** — the interface contract that defines what data a component accepts (props) and what data it tracks internally (state). + +| Action | What it does | +|--------|-------------| +| `list` | Lists all pages and components in the project with their names, UUIDs, and types. | +| `create-page` | Creates a new page with a URL path and a body defined as a PlasmicElement tree (a JSON structure describing the element hierarchy). | +| `create` | Creates a new reusable component (not a page — no URL route). | +| `clone` | Duplicates an existing page or component, creating an independent copy. | +| `rename` | Changes a page's or component's name. For pages, optionally updates the URL path too. | +| `delete` | Removes a page or component. Use `force: true` to delete even if other components reference it. | +| `extract` | Takes a subtree of elements inside a component and extracts it into a new standalone component. The original elements are replaced with an instance of the new component. This is how you refactor repeated patterns into reusable pieces. | +| `convert-to-page` | Converts a component into a page by assigning it a URL route. | +| `convert-to-component` | Converts a page into a component by removing its URL route. | +| `update-page-meta` | Sets a page's SEO metadata: ``, `<meta description>`, canonical URL, Open Graph image. | +| `list-props` | Lists all prop definitions on a component's schema — the named inputs that instances of this component accept (e.g., `label: string`, `onClick: function`). | +| `add-prop` | Adds a new prop to a component's schema. This defines the interface — instances can then receive values for this prop. | +| `update-prop` | Modifies a prop definition's type, default value, or description. | +| `remove-prop` | Removes a prop from the component schema. | +| `list-states` | Lists all state variables on a component. States are internal reactive values (e.g., `isOpen: boolean`, `count: number`) that the component tracks and can change at runtime. | +| `add-state` | Adds a new state variable with a type (text, number, boolean, array), access level (private, readonly, writable), and initial value. | +| `update-state` | Modifies a state variable's definition. | +| `remove-state` | Removes a state variable from the component. | + +### 5. Tool Reference — node + +**Concept**: Every component has a tree of **nodes** (elements). A node is either an HTML tag (`<div>`, `<button>`, `<img>` — called a **TplTag**) or an instance of another component (called a **TplComponent**). The node tool is the core editing tool — it's how you build and modify the actual UI: adding elements, styling them, setting text, wiring data, and controlling visibility. + +| Action | What it does | +|--------|-------------| +| `add` | Inserts a new element into the tree. Can be an HTML tag (`type: "div"`), a component instance (`type: "component", component: "Button"`), or a text block. Supports setting initial props, styles, and slot content. | +| `remove` | Deletes an element and all its children from the tree. | +| `move` | Moves an element from its current parent to a different parent element, optionally at a specific position. | +| `clone` | Creates a copy of an element (and its children) within the same component. | +| `reorder` | Changes the order of children under a parent element. Pass an ordered array of child references. | +| `update-styles` | Sets CSS styles on an element using camelCase property names (e.g., `{ backgroundColor: "#ff0000", padding: "16px" }`). Styles can be set per-variant so an element looks different on mobile vs desktop, or on hover vs default. Supports design token references (e.g., `var(--token-xyz)`). | +| `update-text` | Sets the text content of a text element. Can be a plain string or a dynamic expression (e.g., `$ctx.params.title`) that evaluates at runtime. | +| `update-rich-text` | Sets text with inline formatting marks — bold, italic, underline, strikethrough, links, and inline code. Each mark specifies a character range and a format type. | +| `update-attrs` | Sets HTML attributes on an HTML tag element (TplTag only). Attributes like `id`, `class`, `aria-label`, `data-testid`, `href`, `target`, etc. Does **not** work on component instances — use `update-props` for those. | +| `update-props` | **NEW** — Sets or updates prop values on a **component instance** (TplComponent). This is the component equivalent of `update-attrs`. Supports literal values (`"hello"`, `42`, `true`), dynamic expression bindings (`$ctx.params.orderId`, `{{$queries.cart.data.id}}`), slot content (PlasmicElement trees for render props), and prop deletion (`null`). Can target specific variants. | +| `set-visibility` | Controls whether an element is visible. Options: `true` (visible), `false` (hidden but takes space), `"displayNone"` (hidden and removed from layout). Can be set per-variant — e.g., hide on mobile, show on desktop. | +| `set-image` | Sets the source of an image element. Can reference an uploaded asset by name/UUID, or use a raw URL. | +| `apply-mixin` | Applies a **mixin** (a saved bundle of styles) to an element. Mixins are like CSS classes — define styles once, apply to many elements. When the mixin changes, all elements using it update. | +| `detach-mixin` | Removes a mixin from an element, converting the mixin's styles into inline styles on the element. | +| `add-animation` | Applies a CSS `@keyframes` animation to an element. Configure duration, delay, timing function, iteration count, direction, and fill mode. | +| `remove-animation` | Removes an animation from an element. | + +Key parameters: `componentUuid`, `nodeRef` (element by name/uuid/path/index), `parentRef`, `position` (`"first"`, `"last"`, or index), `variant`, `styles`, `attrs`, `props`, `text`, `marks` + +### 6. Tool Reference — variant + +**Concept**: A **variant** is an alternative version of how a component looks or behaves. Think of variants as conditional layers of overrides. There are several kinds: + +- **Style variants** — triggered by CSS pseudo-classes like `:hover`, `:focus`, `:active`. They override styles when the user interacts with an element. +- **Component variant groups** — named categories like "Size" (small/medium/large) or "Theme" (light/dark) that instances can select. +- **Global variant groups** — project-wide toggles like dark mode, locale, or feature flags that affect all components. +- **Screen variants** — responsive breakpoints (e.g., "Mobile: max-width 768px") that activate based on viewport size. + +When you set styles or visibility with a `variant` parameter, those overrides only apply when that variant is active. + +| Action | What it does | +|--------|-------------| +| `list` | Lists all variants defined on a component, grouped by type (base, style, group, screen). | +| `create-style` | Creates a style variant triggered by a CSS pseudo-class. For example, creating a `:hover` variant lets you define styles that only apply on mouse hover. Can be scoped to a specific element. | +| `create-group` | Creates a named variant group with a type: `single` (only one active at a time, like a radio button), `multi` (multiple can be active, like checkboxes), or `toggle` (on/off). Initial variants can be provided. | +| `list-global-groups` | Lists all global variant groups in the project (e.g., "Dark Mode", "Locale"). | +| `create-global-group` | Creates a new global variant group that applies across all components. | +| `add-global` | Adds a new variant option to an existing global group (e.g., adding "French" to a "Locale" group). | +| `remove-global-group` | Deletes an entire global variant group and all its variants. | +| `rename-global` | Renames a global variant. | +| `create-screen` | Creates a responsive breakpoint variant. Specify `minWidth` and/or `maxWidth` in pixels. Styles set under this variant only apply at that viewport range. | +| `update-screen` | Changes the min/max width of an existing screen variant. | +| `rename` | Renames a variant (component-level or global). | +| `remove` | Deletes a single variant. | + +### 7. Tool Reference — design + +**Concept**: The design tool manages your project's **design system** — the shared visual language that keeps your UI consistent. It covers five areas: + +**Tokens** — Named values that represent your design decisions. Instead of hardcoding `#3B82F6` everywhere, you create a token called "Primary Blue" and reference it. Change the token once, every usage updates. Token types: Color, Spacing, FontFamily, FontSize, Opacity, LineHeight. + +| Action | What it does | +|--------|-------------| +| `list-tokens` | Lists all design tokens, optionally filtered by type (e.g., only Color tokens). | +| `create-token` | Creates a new token with a name, type, and value (e.g., name: "Primary", type: "Color", value: "#3B82F6"). | +| `update-token` | Changes a token's name or value. All elements referencing the token automatically reflect the change. | +| `remove-token` | Deletes a token. Elements referencing it fall back to the raw value. | +| `duplicate-token` | Creates a copy of a token (useful for creating variations like "Primary Light" from "Primary"). | + +**Mixins** — Reusable bundles of CSS styles, like a saved preset. Define a mixin with padding, background, border-radius, etc., then apply it to multiple elements. Update the mixin, all elements update. Similar to CSS utility classes. + +| Action | What it does | +|--------|-------------| +| `list-mixins` | Lists all style mixins in the project. | +| `create-mixin` | Creates a new mixin with a name and CSS styles (camelCase properties). | +| `update-mixin` | Modifies a mixin's name or styles. | +| `remove-mixin` | Deletes a mixin. Elements using it retain the styles as inline styles. | + +**Animations** — CSS `@keyframes` definitions. Each animation is a sequence of keyframe stops at percentage points (0%, 50%, 100%) with CSS styles at each stop. Animations are defined here and applied to elements via `node.add-animation`. + +| Action | What it does | +|--------|-------------| +| `list-animations` | Lists all animation sequences in the project. | +| `create-animation` | Creates a new animation with a name and keyframe stops (e.g., `[{ percentage: 0, styles: { opacity: "0" } }, { percentage: 100, styles: { opacity: "1" } }]`). | +| `update-animation` | Modifies an animation's name or keyframes. | +| `remove-animation` | Deletes an animation. Elements referencing it lose the animation. | + +**Themes** — Typography presets. A theme defines default font styles for the entire project and optional per-tag overrides (e.g., `<h1>` gets 36px bold, `<p>` gets 16px regular). Only one theme is active at a time. + +| Action | What it does | +|--------|-------------| +| `list-themes` | Lists all themes with their default styles and per-tag overrides. | +| `create-theme` | Creates a new theme with `defaultStyles` (base typography) and optional `themeStyles` (per-tag overrides like `{ selector: "h1", styles: { fontSize: "36px" } }`). | +| `update-theme` | Modifies a theme's default styles or tag overrides. | +| `remove-theme` | Deletes a theme. | +| `set-active-theme` | Sets which theme is currently active. The active theme's typography applies as the project-wide default. | + +**Assets** — Images and icons uploaded to the project. Once uploaded, assets can be referenced by name in `node.set-image` instead of using raw URLs. Assets are optimized and served from Plasmic's CDN. + +| Action | What it does | +|--------|-------------| +| `list-assets` | Lists all uploaded images and icons with their names, UUIDs, and types. | +| `upload-asset` | Uploads an image from a URL or inline data URI. Specify type (`picture` or `icon`) and optional dimensions. | +| `rename-asset` | Changes an asset's name. | +| `remove-asset` | Deletes an asset from the project. | + +### 8. Tool Reference — data + +**Concept**: The data tool manages how components connect to runtime data. This includes conditional rendering (show/hide based on data), looping (repeat elements for each item in a list), data fetching (queries), site-wide constants (data tokens), and A/B testing (splits). + +| Action | What it does | +|--------|-------------| +| `set-data-cond` | Sets a JavaScript expression that controls whether an element renders. If the expression evaluates to falsy at runtime, the element and all its children are removed from the DOM. Example: `$ctx.user.isAdmin` to show admin-only UI. Pass `null` to remove the condition. | +| `set-data-rep` | Makes an element repeat for each item in a collection. You provide a JS expression for the collection (e.g., `$queries.products.data`), a variable name for each item (e.g., `currentProduct`), and an optional index variable. The element and its children are duplicated for each item at runtime. Pass `null` collection to remove repetition. | +| `list-queries` | Lists all data queries defined on a component. Queries fetch data that the component can reference in expressions. | +| `add-query` | Creates a new data query. Types: `dataQuery` (client-side fetch) or `serverQuery` (server-side, SSR-compatible). | +| `update-query` | Modifies a query's parameters or configuration. | +| `remove-query` | Deletes a query from the component. | +| `list-data-tokens` | Lists site-level data tokens — named JSON constants accessible everywhere via `$ctx.tokenName`. | +| `create-data-token` | Creates a site-wide JSON constant. Useful for configuration values, API endpoints, or shared data that multiple components need. | +| `update-data-token` | Changes a data token's value. | +| `remove-data-token` | Deletes a data token. | +| `list-splits` | Lists A/B tests and audience segments. Splits let you show different content to different users for experimentation or targeting. | +| `create-split` | Creates an experiment (random A/B split) or segment (condition-based targeting). Define slices with names, probabilities, or conditions. | +| `update-split` | Modifies a split's slices, probabilities, conditions, or status (new/running/stopped). | +| `remove-split` | Deletes a split. | +| `get-code-meta` | Returns metadata for registered code components — components defined in code (React) and registered with Plasmic for use in the visual builder. Shows their props, default values, and descriptions. | +| `list-functions` | Lists available functions that can be referenced in expressions and interactions. | + +### 9. Tool Reference — interaction + +**Concept**: Interactions are event handlers attached to elements — they define what happens when a user clicks, hovers, submits, or interacts with the page. Each interaction has a trigger event (e.g., `onClick`), an action to perform, and optional arguments. + +| Action | What it does | +|--------|-------------| +| `list` | Lists all interactions on an element, showing their events, actions, arguments, and conditions. | +| `add` | Attaches a new event handler to an element. Specify the event (`onClick`, `onChange`, `onSubmit`, etc.) and one of three action types: **navigation** (go to a page or URL), **updateVariable** (change a state variable's value), or **customFunction** (run arbitrary JavaScript). Arguments vary by action type. | +| `update` | Modifies an existing interaction's action, arguments, or execution condition. | +| `remove` | Removes one or all interactions from an element. | + +Supported action types: +- `navigation` — Navigate to a page (by component UUID) or external URL. Args: `destination`, `url` +- `updateVariable` — Mutate a component state variable. Args: `variable` (state ref), `operation` (set/toggle/increment/etc.), `value` +- `customFunction` — Execute arbitrary JavaScript. Args: `code` (JS expression) + +### 10. Known Feature Gaps + +Studio features not yet exposed in MCP (as of 2026-03-04): + +| Gap | What it is | Impact | +|-----|-----------|--------| +| Arena/Frame management | The design canvas workspace in Studio where you arrange and preview multiple component frames | Low — only relevant for GUI workflows, not programmatic building | + +### Known Issues + +No known issues. The `project.list` HTTP 500 bug (query param encoding) was fixed — `api-client.ts` now JSON-encodes the query value to match what the server's `parseQueryParams` expects. + +All major previously-reported gaps (visibility, interactions, state, rich text, props, mixins, tokens, animations, themes, assets, data queries, variants, splits) have been resolved. + +## Out of Scope + +- MCP transport/protocol specification (covered by the MCP spec itself) +- Tutorial or getting-started guide (separate concern) +- Code component registration guide (existing Plasmic docs) diff --git a/.ralph/specs/NODE-UPDATE-PROPS.md b/.ralph/specs/NODE-UPDATE-PROPS.md new file mode 100644 index 000000000..f180c5491 --- /dev/null +++ b/.ralph/specs/NODE-UPDATE-PROPS.md @@ -0,0 +1,94 @@ +# node.update-props — Component Instance Prop Updates & Dynamic Bindings + +## Jobs to Be Done + +- As an MCP user (AI agent or developer), I want to update prop values on an already-placed code component instance so that I can wire data-driven integrations without manual Studio intervention +- As an MCP user, I want to bind a component prop to a dynamic expression (`$ctx.params.orderId`, `$queries.cart.data.id`, `$state.formData`) so that components react to runtime data +- As an MCP user, I want to set different prop values per variant so that component behaviour adapts to responsive breakpoints or component states +- As an MCP user, I want to remove a previously-set prop value so that the component falls back to its default + +## Acceptance Criteria + +- [ ] New `update-props` action added to the `node` tool in `server.ts` +- [ ] Accepts `componentUuid`, `nodeRef` (must resolve to a TplComponent), `props` object, optional `variant` +- [ ] Scalar prop values (string, number, boolean) are converted to `CustomCode` literal expressions via `createAttrExpr` (reuse existing helper) +- [ ] Dynamic bindings via `$expression` or `{{expression}}` syntax are converted to `CustomCode` with the runtime expression code (reuse `createAttrExpr`) +- [ ] Slot props: when a prop value is a PlasmicElement object (or array), convert to `RenderExpr` with `plasmicElementToTpl` and set as `Arg` +- [ ] Prop deletion: setting a prop value to `undefined` or explicit JSON `null` removes the `Arg` from the target `VariantSetting` +- [ ] Variant-specific: when `variant` parameter is provided, target that variant's `VariantSetting`; otherwise target base variant +- [ ] Strict validation: prop name must exist in `tpl.component.params`; return clear error message like `Prop "xyz" does not exist on component "CompName". Available props: a, b, c` +- [ ] Node type validation: `nodeRef` must resolve to `TplComponent`; return clear error like `Node "ref" is a TplTag, not a TplComponent. Use update-attrs for HTML elements.` +- [ ] Reuses `setTplComponentArg` from `TplMgr.ts` for the core mutation (mirrors Studio behaviour) +- [ ] Existing props not mentioned in the `props` object are left unchanged (merge semantics) +- [ ] Returns summary of props set/updated/deleted in the response + +## Happy Path + +1. User has a project loaded via `project.set` +2. User places a code component via `node.add` with `type: "component"` and optional initial `props` +3. Later, user calls `node.update-props` with: + ```json + { + "action": "update-props", + "componentUuid": "<page-uuid>", + "nodeRef": "CloverPayButton", + "props": { + "orderId": "{{$ctx.params.orderId}}", + "amount": "{{$queries.cart.data.total}}", + "currency": "USD", + "testMode": true + } + } + ``` +4. MCP resolves nodeRef to the TplComponent instance +5. For each prop in the `props` object: + - Finds the matching `Param` on `tpl.component.params` by `variable.name` + - Converts the value to the appropriate expression type (`CustomCode` for scalars/expressions, `RenderExpr` for slots) + - Calls `setTplComponentArg(tpl, vs, param.variable, expr)` to create or update the `Arg` +6. Returns success with summary: `Updated props on "CloverPayButton": orderId (dynamic), amount (dynamic), currency (literal), testMode (literal)` + +## Edge Cases + +| Scenario | Expected Behaviour | +|----------|-------------------| +| Prop name doesn't exist on component | Error: `Prop "xyz" does not exist on component "CompName". Available props: a, b, c` | +| nodeRef resolves to TplTag | Error: `Node "ref" is a TplTag, not a TplComponent. Use update-attrs for HTML elements.` | +| nodeRef doesn't exist | Error: `Node "ref" not found in component "CompName"` (existing `resolveNode` behaviour) | +| Empty props object `{}` | No-op, return success with "No props updated" | +| Prop value is `null` or `undefined` | Remove the Arg from the VariantSetting (prop deletion) | +| Prop value is a PlasmicElement object | Detect object with `type` key, convert to RenderExpr for slot params | +| Prop value is a PlasmicElement but param is not a slot | Error: `Prop "xyz" is not a slot param. Pass a scalar value or expression instead.` | +| Scalar value for a slot param | Error: `Prop "xyz" is a slot param. Pass a PlasmicElement object or array instead.` | +| Variant doesn't exist | Error from existing variant resolution (consistent with other actions) | +| Multiple props, one invalid | Fail-fast on the first invalid prop before making any mutations (atomic) | +| Prop already has a value | Overwrite with new value (update semantics via `setTplComponentArg`) | + +## Implementation Notes + +### Key Source Locations + +- **Add action handler**: `packages/plasmic-mcp/src/server.ts` — add `update-props` case to node tool switch +- **Core function**: `packages/plasmic-mcp/src/edit-tools.ts` — new `updateProps()` export +- **Expression conversion**: `packages/plasmic-mcp/src/edit-tools.ts:173` — reuse `createAttrExpr()` +- **WAB mutation**: `platform/wab/src/wab/shared/TplMgr.ts:300` — `setTplComponentArg()` +- **Type guards**: `packages/plasmic-mcp/src/wab-externals.d.ts` — `isKnownTplComponent`, `isSlot` +- **Schema**: `packages/plasmic-mcp/src/server.ts` — add `props` parameter to node tool Zod schema + +### Reuse Strategy (Aligns with Studio) + +- `createAttrExpr()` already handles `$expr`, `{{expr}}`, literals → `CustomCode` +- `plasmicElementToTpl()` already converts PlasmicElement → TplNode tree (used by `node.add`) +- `setTplComponentArg()` is the exact same function Studio's prop panel calls +- `resolveNode()`, `requireSingleNode()`, variant resolution — all existing + +### Slot Detection + +To distinguish slot params from scalar params: +```typescript +import { isSlot } from "platform/wab/src/wab/shared/SlotUtils"; +// or check: isRenderableType(param.type) +``` + +## Out of Scope + +- Nothing — this is the full feature spec including scalar props, dynamic bindings, variant-specific props, prop deletion, and slot updates diff --git a/packages/plasmic-mcp/FEATURE_REFERENCE.md b/packages/plasmic-mcp/FEATURE_REFERENCE.md new file mode 100644 index 000000000..dadb42e97 --- /dev/null +++ b/packages/plasmic-mcp/FEATURE_REFERENCE.md @@ -0,0 +1,250 @@ +# Plasmic MCP Server — Developer Feature Reference + +## Architecture Overview + +**What is this?** The Plasmic MCP server exposes a visual web builder's editing engine as programmatic tools. Instead of clicking in a GUI, you call tool actions to build pages, style elements, wire data, and manage design systems. + +- **STRAP architecture**: 8 domain tools consolidating 104 actions. Each tool groups related actions under a single endpoint with an `action` discriminator field. +- **Transport**: JSON-RPC over MCP protocol (Model Context Protocol) — a standard for AI tool use. +- **Source**: `packages/plasmic-mcp/src/server.ts` (tool definitions + routing), `packages/plasmic-mcp/src/edit-tools.ts` (mutation logic) +- **Editing engine**: Embeds the WAB engine from `platform/wab/src/wab/` — the same code Plasmic Studio uses. Mutations go through the same code paths as the GUI. + +**Core concepts you need to know:** +- **Project**: A container for pages and components. Must be loaded (`project.set`) before any editing. +- **Component**: A reusable UI building block. Pages are components with a URL route. +- **Element tree**: Every component has a tree of elements — like a DOM tree. Elements are either HTML tags (`TplTag`) or instances of other components (`TplComponent`). +- **Variant**: An alternative version of a component's styles/content. Used for responsive breakpoints (mobile/desktop), interaction states (hover/focus), or feature toggles (dark mode). +- **VariantSetting**: The styles, text, visibility, and prop overrides that apply when a specific variant is active. +- **Design token**: A named value (color, spacing, font) that can be referenced throughout the project for consistency. + +## Tool Reference — project + +**Concept**: Every editing session starts by loading a project. The project tool manages the session lifecycle — loading, saving, batching multiple edits into a single save, and undoing mistakes. + +| Action | What it does | +|--------|-------------| +| `set` | Loads a project into memory by its ID. **Required before calling any other tool.** Downloads the project data from the Plasmic server and initializes the editing engine. | +| `list` | Returns all projects accessible to the authenticated user, with their IDs and names. Use this to find a project ID before calling `set`. | +| `get-meta` | Returns project metadata: name, number of pages, number of components, and structural overview. Useful for orientation. | +| `save` | Force-saves all pending changes to the Plasmic server. Changes are auto-saved periodically, but this guarantees immediate persistence. | +| `refresh` | Discards all in-memory changes and reloads the project from the server. Use when someone else has made changes in Studio and you need the latest version. | +| `begin-batch` | Starts a batch editing session. All subsequent edits are accumulated in memory without triggering individual saves. Reduces server round-trips when making many changes. | +| `end-batch` | Commits all accumulated edits from a batch session as a single revision. The project is saved once with all changes applied atomically. | +| `undo` | Reverts the most recent edit operation. Works like Ctrl+Z in Studio. | + +## Tool Reference — inspect + +**Concept**: The inspect tool lets you read the current state of any component without changing it. You can view the full element tree (every div, text block, and component instance with their styles), or zoom in on a single element. This is how you understand what's already built before making edits. + +| Action | What it does | +|--------|-------------| +| `tree` | Returns the full element tree for a component, including each element's tag/type, styles, text content, and layout properties. This is the most detailed view — like viewing the DOM inspector in browser DevTools. | +| `summary` | Returns a compact outline of the element tree: just the type, tag, name, uuid, and child count for each node. Much smaller than `tree` — good for orientation before drilling into specific elements. | +| `node` | Returns full details for a single element identified by name, uuid, or path. Includes all styles, attributes, text, variant overrides, and slot contents. | +| `subtree` | Returns the tree from a specific element downward (element + all its descendants). Useful when a component is large and you only care about one section. | +| `export` | Writes the full tree JSON to a temporary file and returns the file path. For trees too large to return inline. | +| `style-properties` | Lists all valid CSS property names in camelCase format (e.g., `backgroundColor`, `borderRadius`). Use this to discover what style properties are available. | +| `preview-url` | Returns the preview URL (rendered page) and Studio URL (editing interface) for a component or page. | +| `page-meta` | Reads a page's SEO metadata: title, description, canonical URL, and Open Graph image. | + +Key parameters: `componentUuid` (which component to inspect), `nodeRef` (specific element by name/uuid/path), `maxDepth` (limit tree depth), `format` (`concise` for ~70% token reduction) + +## Tool Reference — component + +**Concept**: Components are the building blocks of a Plasmic project. A **page** is a component with a URL route (e.g., `/checkout`). A **component** is a reusable piece of UI (e.g., a button, card, or form). This tool manages their lifecycle and their **prop/state schemas** — the interface contract that defines what data a component accepts (props) and what data it tracks internally (state). + +| Action | What it does | +|--------|-------------| +| `list` | Lists all pages and components in the project with their names, UUIDs, and types. | +| `create-page` | Creates a new page with a URL path and a body defined as a PlasmicElement tree (a JSON structure describing the element hierarchy). | +| `create` | Creates a new reusable component (not a page — no URL route). | +| `clone` | Duplicates an existing page or component, creating an independent copy. | +| `rename` | Changes a page's or component's name. For pages, optionally updates the URL path too. | +| `delete` | Removes a page or component. Use `force: true` to delete even if other components reference it. | +| `extract` | Takes a subtree of elements inside a component and extracts it into a new standalone component. The original elements are replaced with an instance of the new component. This is how you refactor repeated patterns into reusable pieces. | +| `convert-to-page` | Converts a component into a page by assigning it a URL route. | +| `convert-to-component` | Converts a page into a component by removing its URL route. | +| `update-page-meta` | Sets a page's SEO metadata: `<title>`, `<meta description>`, canonical URL, Open Graph image. | +| `list-props` | Lists all prop definitions on a component's schema — the named inputs that instances of this component accept (e.g., `label: string`, `onClick: function`). | +| `add-prop` | Adds a new prop to a component's schema. This defines the interface — instances can then receive values for this prop. | +| `update-prop` | Modifies a prop definition's type, default value, or description. | +| `remove-prop` | Removes a prop from the component schema. | +| `list-states` | Lists all state variables on a component. States are internal reactive values (e.g., `isOpen: boolean`, `count: number`) that the component tracks and can change at runtime. | +| `add-state` | Adds a new state variable with a type (text, number, boolean, array), access level (private, readonly, writable), and initial value. | +| `update-state` | Modifies a state variable's definition. | +| `remove-state` | Removes a state variable from the component. | + +## Tool Reference — node + +**Concept**: Every component has a tree of **nodes** (elements). A node is either an HTML tag (`<div>`, `<button>`, `<img>` — called a **TplTag**) or an instance of another component (called a **TplComponent**). The node tool is the core editing tool — it's how you build and modify the actual UI: adding elements, styling them, setting text, wiring data, and controlling visibility. + +| Action | What it does | +|--------|-------------| +| `add` | Inserts a new element into the tree. Can be an HTML tag (`type: "div"`), a component instance (`type: "component", component: "Button"`), or a text block. Supports setting initial props, styles, and slot content. | +| `remove` | Deletes an element and all its children from the tree. | +| `move` | Moves an element from its current parent to a different parent element, optionally at a specific position. | +| `clone` | Creates a copy of an element (and its children) within the same component. | +| `reorder` | Changes the order of children under a parent element. Pass an ordered array of child references. | +| `update-styles` | Sets CSS styles on an element using camelCase property names (e.g., `{ backgroundColor: "#ff0000", padding: "16px" }`). Styles can be set per-variant so an element looks different on mobile vs desktop, or on hover vs default. Supports design token references (e.g., `var(--token-xyz)`). | +| `update-text` | Sets the text content of a text element. Can be a plain string or a dynamic expression (e.g., `$ctx.params.title`) that evaluates at runtime. | +| `update-rich-text` | Sets text with inline formatting marks — bold, italic, underline, strikethrough, links, and inline code. Each mark specifies a character range and a format type. | +| `update-attrs` | Sets HTML attributes on an HTML tag element (TplTag only). Attributes like `id`, `class`, `aria-label`, `data-testid`, `href`, `target`, etc. Does **not** work on component instances — use `update-props` for those. | +| `update-props` | Sets or updates prop values on a **component instance** (TplComponent). This is the component equivalent of `update-attrs`. Supports literal values (`"hello"`, `42`, `true`), dynamic expression bindings (`$ctx.params.orderId`, `{{$queries.cart.data.id}}`), slot content (PlasmicElement trees for render props), and prop deletion (`null`). Can target specific variants. | +| `set-visibility` | Controls whether an element is visible. Options: `true` (visible), `false` (hidden but takes space), `"displayNone"` (hidden and removed from layout). Can be set per-variant — e.g., hide on mobile, show on desktop. | +| `set-image` | Sets the source of an image element. Can reference an uploaded asset by name/UUID, or use a raw URL. | +| `apply-mixin` | Applies a **mixin** (a saved bundle of styles) to an element. Mixins are like CSS classes — define styles once, apply to many elements. When the mixin changes, all elements using it update. | +| `detach-mixin` | Removes a mixin from an element, converting the mixin's styles into inline styles on the element. | +| `add-animation` | Applies a CSS `@keyframes` animation to an element. Configure duration, delay, timing function, iteration count, direction, and fill mode. | +| `remove-animation` | Removes an animation from an element. | + +Key parameters: `componentUuid`, `nodeRef` (element by name/uuid/path/index), `parentRef`, `position` (`"first"`, `"last"`, or index), `variant`, `styles`, `attrs`, `props`, `text`, `marks` + +## Tool Reference — variant + +**Concept**: A **variant** is an alternative version of how a component looks or behaves. Think of variants as conditional layers of overrides. There are several kinds: + +- **Style variants** — triggered by CSS pseudo-classes like `:hover`, `:focus`, `:active`. They override styles when the user interacts with an element. +- **Component variant groups** — named categories like "Size" (small/medium/large) or "Theme" (light/dark) that instances can select. +- **Global variant groups** — project-wide toggles like dark mode, locale, or feature flags that affect all components. +- **Screen variants** — responsive breakpoints (e.g., "Mobile: max-width 768px") that activate based on viewport size. + +When you set styles or visibility with a `variant` parameter, those overrides only apply when that variant is active. + +| Action | What it does | +|--------|-------------| +| `list` | Lists all variants defined on a component, grouped by type (base, style, group, screen). | +| `create-style` | Creates a style variant triggered by a CSS pseudo-class. For example, creating a `:hover` variant lets you define styles that only apply on mouse hover. Can be scoped to a specific element. | +| `create-group` | Creates a named variant group with a type: `single` (only one active at a time, like a radio button), `multi` (multiple can be active, like checkboxes), or `toggle` (on/off). Initial variants can be provided. | +| `list-global-groups` | Lists all global variant groups in the project (e.g., "Dark Mode", "Locale"). | +| `create-global-group` | Creates a new global variant group that applies across all components. | +| `add-global` | Adds a new variant option to an existing global group (e.g., adding "French" to a "Locale" group). | +| `remove-global-group` | Deletes an entire global variant group and all its variants. | +| `rename-global` | Renames a global variant. | +| `create-screen` | Creates a responsive breakpoint variant. Specify `minWidth` and/or `maxWidth` in pixels. Styles set under this variant only apply at that viewport range. | +| `update-screen` | Changes the min/max width of an existing screen variant. | +| `rename` | Renames a variant (component-level or global). | +| `remove` | Deletes a single variant. | + +## Tool Reference — design + +**Concept**: The design tool manages your project's **design system** — the shared visual language that keeps your UI consistent. It covers five areas: + +### Tokens + +Named values that represent your design decisions. Instead of hardcoding `#3B82F6` everywhere, you create a token called "Primary Blue" and reference it. Change the token once, every usage updates. Token types: Color, Spacing, FontFamily, FontSize, Opacity, LineHeight. + +| Action | What it does | +|--------|-------------| +| `list-tokens` | Lists all design tokens, optionally filtered by type (e.g., only Color tokens). | +| `create-token` | Creates a new token with a name, type, and value (e.g., name: "Primary", type: "Color", value: "#3B82F6"). | +| `update-token` | Changes a token's name or value. All elements referencing the token automatically reflect the change. | +| `remove-token` | Deletes a token. Elements referencing it fall back to the raw value. | +| `duplicate-token` | Creates a copy of a token (useful for creating variations like "Primary Light" from "Primary"). | + +### Mixins + +Reusable bundles of CSS styles, like a saved preset. Define a mixin with padding, background, border-radius, etc., then apply it to multiple elements. Update the mixin, all elements update. Similar to CSS utility classes. + +| Action | What it does | +|--------|-------------| +| `list-mixins` | Lists all style mixins in the project. | +| `create-mixin` | Creates a new mixin with a name and CSS styles (camelCase properties). | +| `update-mixin` | Modifies a mixin's name or styles. | +| `remove-mixin` | Deletes a mixin. Elements using it retain the styles as inline styles. | + +### Animations + +CSS `@keyframes` definitions. Each animation is a sequence of keyframe stops at percentage points (0%, 50%, 100%) with CSS styles at each stop. Animations are defined here and applied to elements via `node.add-animation`. + +| Action | What it does | +|--------|-------------| +| `list-animations` | Lists all animation sequences in the project. | +| `create-animation` | Creates a new animation with a name and keyframe stops (e.g., `[{ percentage: 0, styles: { opacity: "0" } }, { percentage: 100, styles: { opacity: "1" } }]`). | +| `update-animation` | Modifies an animation's name or keyframes. | +| `remove-animation` | Deletes an animation. Elements referencing it lose the animation. | + +### Themes + +Typography presets. A theme defines default font styles for the entire project and optional per-tag overrides (e.g., `<h1>` gets 36px bold, `<p>` gets 16px regular). Only one theme is active at a time. + +| Action | What it does | +|--------|-------------| +| `list-themes` | Lists all themes with their default styles and per-tag overrides. | +| `create-theme` | Creates a new theme with `defaultStyles` (base typography) and optional `themeStyles` (per-tag overrides like `{ selector: "h1", styles: { fontSize: "36px" } }`). | +| `update-theme` | Modifies a theme's default styles or tag overrides. | +| `remove-theme` | Deletes a theme. | +| `set-active-theme` | Sets which theme is currently active. The active theme's typography applies as the project-wide default. | + +### Assets + +Images and icons uploaded to the project. Once uploaded, assets can be referenced by name in `node.set-image` instead of using raw URLs. Assets are optimized and served from Plasmic's CDN. + +| Action | What it does | +|--------|-------------| +| `list-assets` | Lists all uploaded images and icons with their names, UUIDs, and types. | +| `upload-asset` | Uploads an image from a URL or inline data URI. Specify type (`picture` or `icon`) and optional dimensions. | +| `rename-asset` | Changes an asset's name. | +| `remove-asset` | Deletes an asset from the project. | + +## Tool Reference — data + +**Concept**: The data tool manages how components connect to runtime data. This includes conditional rendering (show/hide based on data), looping (repeat elements for each item in a list), data fetching (queries), site-wide constants (data tokens), and A/B testing (splits). + +| Action | What it does | +|--------|-------------| +| `set-data-cond` | Sets a JavaScript expression that controls whether an element renders. If the expression evaluates to falsy at runtime, the element and all its children are removed from the DOM. Example: `$ctx.user.isAdmin` to show admin-only UI. Pass `null` to remove the condition. | +| `set-data-rep` | Makes an element repeat for each item in a collection. You provide a JS expression for the collection (e.g., `$queries.products.data`), a variable name for each item (e.g., `currentProduct`), and an optional index variable. The element and its children are duplicated for each item at runtime. Pass `null` collection to remove repetition. | +| `list-queries` | Lists all data queries defined on a component. Queries fetch data that the component can reference in expressions. | +| `add-query` | Creates a new data query. Types: `dataQuery` (client-side fetch) or `serverQuery` (server-side, SSR-compatible). | +| `update-query` | Modifies a query's parameters or configuration. | +| `remove-query` | Deletes a query from the component. | +| `list-data-tokens` | Lists site-level data tokens — named JSON constants accessible everywhere via `$ctx.tokenName`. | +| `create-data-token` | Creates a site-wide JSON constant. Useful for configuration values, API endpoints, or shared data that multiple components need. | +| `update-data-token` | Changes a data token's value. | +| `remove-data-token` | Deletes a data token. | +| `list-splits` | Lists A/B tests and audience segments. Splits let you show different content to different users for experimentation or targeting. | +| `create-split` | Creates an experiment (random A/B split) or segment (condition-based targeting). Define slices with names, probabilities, or conditions. | +| `update-split` | Modifies a split's slices, probabilities, conditions, or status (new/running/stopped). | +| `remove-split` | Deletes a split. | +| `get-code-meta` | Returns metadata for registered code components — components defined in code (React) and registered with Plasmic for use in the visual builder. Shows their props, default values, and descriptions. | +| `list-functions` | Lists available functions that can be referenced in expressions and interactions. | + +## Tool Reference — interaction + +**Concept**: Interactions are event handlers attached to elements — they define what happens when a user clicks, hovers, submits, or interacts with the page. Each interaction has a trigger event (e.g., `onClick`), an action to perform, and optional arguments. + +| Action | What it does | +|--------|-------------| +| `list` | Lists all interactions on an element, showing their events, actions, arguments, and conditions. | +| `add` | Attaches a new event handler to an element. Specify the event (`onClick`, `onChange`, `onSubmit`, etc.) and one of three action types: **navigation** (go to a page or URL), **updateVariable** (change a state variable's value), or **customFunction** (run arbitrary JavaScript). Arguments vary by action type. | +| `update` | Modifies an existing interaction's action, arguments, or execution condition. | +| `remove` | Removes one or all interactions from an element. | + +Supported action types: +- `navigation` — Navigate to a page (by component UUID) or external URL. Args: `destination`, `url` +- `updateVariable` — Mutate a component state variable. Args: `variable` (state ref), `operation` (set/toggle/increment/etc.), `value` +- `customFunction` — Execute arbitrary JavaScript. Args: `code` (JS expression) + +## Action Summary + +| Domain | Actions | Count | +|--------|---------|-------| +| `project` | set, list, get-meta, save, refresh, begin-batch, end-batch, undo | 8 | +| `inspect` | tree, summary, node, subtree, export, style-properties, preview-url, page-meta | 8 | +| `component` | list, create-page, create, clone, rename, delete, extract, convert-to-page, convert-to-component, update-page-meta, list-props, add-prop, update-prop, remove-prop, list-states, add-state, update-state, remove-state | 18 | +| `node` | add, remove, move, clone, reorder, update-styles, update-text, update-rich-text, update-attrs, update-props, set-visibility, set-image, apply-mixin, detach-mixin, add-animation, remove-animation | 16 | +| `variant` | list, create-style, create-group, list-global-groups, create-global-group, add-global, remove-global-group, rename-global, create-screen, update-screen, rename, remove | 12 | +| `design` | list-tokens, create-token, update-token, remove-token, duplicate-token, list-mixins, create-mixin, update-mixin, remove-mixin, list-animations, create-animation, update-animation, remove-animation, list-themes, create-theme, update-theme, remove-theme, set-active-theme, list-assets, upload-asset, rename-asset, remove-asset | 22 | +| `data` | set-data-cond, set-data-rep, list-queries, add-query, update-query, remove-query, list-data-tokens, create-data-token, update-data-token, remove-data-token, list-splits, create-split, update-split, remove-split, get-code-meta, list-functions | 16 | +| `interaction` | list, add, update, remove | 4 | +| **Total** | | **104** | + +## Known Feature Gaps + +Studio features not yet exposed in MCP: + +| Gap | What it is | Impact | +|-----|-----------|--------| +| Arena/Frame management | The design canvas workspace in Studio where you arrange and preview multiple component frames | Low — only relevant for GUI workflows, not programmatic building | + +All major previously-reported gaps (visibility, interactions, state, rich text, props, mixins, tokens, animations, themes, assets, data queries, variants, splits) have been resolved. diff --git a/packages/plasmic-mcp/README.md b/packages/plasmic-mcp/README.md index 54463ace2..66c923670 100644 --- a/packages/plasmic-mcp/README.md +++ b/packages/plasmic-mcp/README.md @@ -66,14 +66,14 @@ Add to `claude_desktop_config.json`: ## Tool Reference -The server exposes 8 domain tools following the STRAP pattern (Structured Tool Resource Action Pattern), consolidating 103 actions into a manageable surface: +The server exposes 8 domain tools following the STRAP pattern (Structured Tool Resource Action Pattern), consolidating 104 actions into a manageable surface: | Domain | # | Actions | Purpose | |--------|---|---------|---------| | `project` | 8 | set, list, get-meta, save, refresh, begin-batch, end-batch, undo | Session lifecycle | | `inspect` | 8 | tree, summary, node, subtree, export, style-properties, preview-url, page-meta | Read-only queries | | `component` | 18 | list, create-page, create, clone, rename, delete, extract, convert-to-page, convert-to-component, update-page-meta, list-props, add-prop, update-prop, remove-prop, list-states, add-state, update-state, remove-state | Component/page lifecycle | -| `node` | 15 | add, remove, move, clone, reorder, update-styles, update-text, update-rich-text, update-attrs, set-visibility, set-image, apply-mixin, detach-mixin, add-animation, remove-animation | Element mutations | +| `node` | 16 | add, remove, move, clone, reorder, update-styles, update-text, update-rich-text, update-attrs, update-props, set-visibility, set-image, apply-mixin, detach-mixin, add-animation, remove-animation | Element mutations | | `variant` | 12 | list, create-style, create-group, list-global-groups, create-global-group, add-global, remove-global-group, rename-global, create-screen, update-screen, rename, remove | Variant management | | `design` | 22 | list-tokens, create-token, update-token, remove-token, duplicate-token, list-mixins, create-mixin, update-mixin, remove-mixin, list-animations, create-animation, update-animation, remove-animation, list-themes, create-theme, update-theme, remove-theme, set-active-theme, list-assets, upload-asset, rename-asset, remove-asset | Design system | | `data` | 16 | set-data-cond, set-data-rep, list-queries, add-query, update-query, remove-query, list-data-tokens, create-data-token, update-data-token, remove-data-token, list-splits, create-split, update-split, remove-split, get-code-meta, list-functions | Data bindings | @@ -436,7 +436,7 @@ Then style the variant: ## Architecture -- **STRAP pattern** — 8 domain tools consolidate 103 actions, keeping the MCP tool surface manageable for LLMs +- **STRAP pattern** — 8 domain tools consolidate 104 actions, keeping the MCP tool surface manageable for LLMs - **Embedded WAB engine** — editing operations run against Plasmic's own `platform/wab/src/wab/` classes (no separate API calls for edits) - **Vitest workspace** — unit tests (mocked WAB) + integration tests (real WAB classes) - **Stdio transport** — JSON-RPC over stdin/stdout; all logging goes to stderr diff --git a/packages/plasmic-mcp/src/__mocks__/wab-tpl-mgr.ts b/packages/plasmic-mcp/src/__mocks__/wab-tpl-mgr.ts index a08df9543..8f4617bec 100644 --- a/packages/plasmic-mcp/src/__mocks__/wab-tpl-mgr.ts +++ b/packages/plasmic-mcp/src/__mocks__/wab-tpl-mgr.ts @@ -86,6 +86,21 @@ export const mockAddAnimation = vi.fn(( iterationCount, direction, fillMode, playState, }; }); +export const mockGetTplComponentArg = vi.fn((tpl: any, vs: any, argVar: any) => { + return (vs.args ?? []).find((a: any) => a.param?.variable === argVar); +}); +export const mockSetTplComponentArg = vi.fn((tpl: any, vs: any, argVar: any, expr: any) => { + if (!vs.args) vs.args = []; + const existing = vs.args.find((a: any) => a.param?.variable === argVar); + if (existing) { + existing.expr = expr; + } else { + const param = (tpl.component?.params ?? []).find((p: any) => p.variable === argVar); + vs.args.push({ param: param ?? { variable: argVar }, expr }); + } +}); +export const getTplComponentArg = (...args: any[]) => mockGetTplComponentArg(...args); +export const setTplComponentArg = (...args: any[]) => mockSetTplComponentArg(...args); export const mockReorderChildren = vi.fn(); export const mockConvertComponentToPage = vi.fn(); export const mockConvertPageToComponent = vi.fn(); diff --git a/packages/plasmic-mcp/src/__tests__/api-client.test.ts b/packages/plasmic-mcp/src/__tests__/api-client.test.ts index b00783d9c..c0e954d1a 100644 --- a/packages/plasmic-mcp/src/__tests__/api-client.test.ts +++ b/packages/plasmic-mcp/src/__tests__/api-client.test.ts @@ -39,7 +39,7 @@ describe("PlasmicApiClient", () => { }); describe("listProjects", () => { - it("makes GET request to /api/v1/projects?query=all", async () => { + it("makes GET request to /api/v1/projects with JSON-encoded query param", async () => { const mockResponse = { projects: [{ id: "proj1", name: "Test Project" }], perms: [], @@ -53,7 +53,7 @@ describe("PlasmicApiClient", () => { const result = await client.listProjects(); expect(mockFetch).toHaveBeenCalledWith( - "https://studio.example.com/api/v1/projects?query=all", + `https://studio.example.com/api/v1/projects?query=${encodeURIComponent(JSON.stringify("all"))}`, expect.objectContaining({ method: "GET", headers: expect.objectContaining({ diff --git a/packages/plasmic-mcp/src/__tests__/node.test.ts b/packages/plasmic-mcp/src/__tests__/node.test.ts index 8fe3b3342..41c7dfa00 100644 --- a/packages/plasmic-mcp/src/__tests__/node.test.ts +++ b/packages/plasmic-mcp/src/__tests__/node.test.ts @@ -32,6 +32,7 @@ import { removeNodeAnimation, reorderChildren, setImage, + updateProps, } from "../edit-tools"; import { setSession, clearSession } from "../session"; import { initChangeTracker, disposeChangeTracker } from "../change-tracker"; @@ -43,6 +44,7 @@ import { mockEnsureBaseVariant, mockAddAnimation, mockReorderChildren, + mockSetTplComponentArg, } from "../__mocks__/wab-tpl-mgr"; import { mockMkTplTagX, mockMkTplInlinedText, mockMkTplComponentX, TplTagType } from "../__mocks__/wab-tpls"; import { mockEnsureVariantSetting } from "../__mocks__/wab-variants"; @@ -5974,3 +5976,322 @@ describe("setImage", () => { expect(bgValue).not.toContain('""'); }); }); + +// ======================================================================== +// updateProps — component instance prop mutations +// ======================================================================== + +describe("updateProps", () => { + let api: ReturnType<typeof mockApiClient>; + + function mockApiClient() { + return { + saveRevision: vi.fn().mockResolvedValue({}), + listProjects: vi.fn(), + getProjectBundle: vi.fn(), + updateProject: vi.fn(), + } as unknown as PlasmicApiClient & { saveRevision: ReturnType<typeof vi.fn> }; + } + + function makeSession(overrides?: Partial<Session>): Session { + return { + projectId: "proj1", + projectName: "Test", + site: { components: [] }, + bundler: { + fastBundle: mockFastBundle, + addrOf: mockAddrOf, + bundle: vi.fn().mockReturnValue({ map: {}, root: "0" }), + }, + revisionNum: 10, + modelVersion: 5, + hostlessDataVersion: 2, + projectUuid: "proj1", + ...overrides, + }; + } + + function mkParam(name: string, opts?: { isSlot?: boolean; type?: string }) { + const variable = { name, uuid: `var-${name}` }; + return { + _type: opts?.isSlot ? "SlotParam" : "PropParam", + typeTag: opts?.isSlot ? "SlotParam" : "PropParam", + uuid: `param-${name}`, + variable, + type: { name: opts?.type ?? "text" }, + tplSlot: opts?.isSlot ? { uuid: `slot-${name}` } : undefined, + required: false, + exportType: "External", + }; + } + + function mkTplComponent(name: string, params: any[]): any { + return { + _type: "TplComponent", + uuid: `tpl-${name}`, + name, + component: { name: `${name}Component`, params, uuid: `comp-inner-${name}` }, + vsettings: [{ variants: [], args: [], rs: { values: {} } }], + children: [], + }; + } + + function setupSession(component: any) { + const session = makeSession({ site: { components: [component] } } as any); + setSession(session); + initChangeTracker(session.site); + return session; + } + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + clearNodeCache(); + + api = mockApiClient(); + mockFastBundle.mockReturnValue({ map: {}, root: "0" }); + mockAddrOf.mockReturnValue({ uuid: "proj1", iid: "comp-iid-1" }); + mockWithRecording.mockReturnValue({ changes: [], newInsts: [], removedInsts: [] }); + }); + + afterEach(() => { + disposeChangeTracker(); + clearSession(); + vi.restoreAllMocks(); + }); + + it("sets static string prop via createAttrExpr", async () => { + const currencyParam = mkParam("currency"); + const tplComp = mkTplComponent("PayButton", [currencyParam]); + const comp = { uuid: "comp-1", name: "Page", tplTree: tplComp }; + setupSession(comp); + + mockEnsureBaseVariantSetting.mockImplementation((tpl: any) => { + if (!tpl.vsettings[0].args) tpl.vsettings[0].args = []; + return tpl.vsettings[0]; + }); + + const result = await updateProps(api, "comp-1", "PayButton", { currency: "USD" }); + + expect(result.updatedProps).toEqual(["currency"]); + expect(result.removedProps).toEqual([]); + expect(mockSetTplComponentArg).toHaveBeenCalledOnce(); + // Verify the expression is a CustomCode with JSON-serialized literal + const callArgs = mockSetTplComponentArg.mock.calls[0]; + expect(callArgs[2]).toBe(currencyParam.variable); // argVar + expect(callArgs[3]._type).toBe("CustomCode"); + expect(callArgs[3].code).toBe('"USD"'); + }); + + it("sets dynamic prop with $ prefix", async () => { + const orderIdParam = mkParam("orderId"); + const tplComp = mkTplComponent("PayButton", [orderIdParam]); + const comp = { uuid: "comp-1", name: "Page", tplTree: tplComp }; + setupSession(comp); + + mockEnsureBaseVariantSetting.mockImplementation((tpl: any) => { + if (!tpl.vsettings[0].args) tpl.vsettings[0].args = []; + return tpl.vsettings[0]; + }); + + const result = await updateProps(api, "comp-1", "PayButton", { orderId: "$ctx.params.orderId" }); + + expect(result.updatedProps).toEqual(["orderId"]); + const callArgs = mockSetTplComponentArg.mock.calls[0]; + expect(callArgs[3]._type).toBe("CustomCode"); + expect(callArgs[3].code).toBe("ctx.params.orderId"); // $ stripped + }); + + it("sets dynamic prop with {{expr}} syntax", async () => { + const amountParam = mkParam("amount"); + const tplComp = mkTplComponent("PayButton", [amountParam]); + const comp = { uuid: "comp-1", name: "Page", tplTree: tplComp }; + setupSession(comp); + + mockEnsureBaseVariantSetting.mockImplementation((tpl: any) => { + if (!tpl.vsettings[0].args) tpl.vsettings[0].args = []; + return tpl.vsettings[0]; + }); + + const result = await updateProps(api, "comp-1", "PayButton", { amount: "{{$queries.cart.data.total}}" }); + + expect(result.updatedProps).toEqual(["amount"]); + const callArgs = mockSetTplComponentArg.mock.calls[0]; + expect(callArgs[3].code).toBe("$queries.cart.data.total"); + }); + + it("sets boolean and number props", async () => { + const testModeParam = mkParam("testMode", { type: "boolean" }); + const countParam = mkParam("count", { type: "number" }); + const tplComp = mkTplComponent("PayButton", [testModeParam, countParam]); + const comp = { uuid: "comp-1", name: "Page", tplTree: tplComp }; + setupSession(comp); + + mockEnsureBaseVariantSetting.mockImplementation((tpl: any) => { + if (!tpl.vsettings[0].args) tpl.vsettings[0].args = []; + return tpl.vsettings[0]; + }); + + const result = await updateProps(api, "comp-1", "PayButton", { testMode: true, count: 42 }); + + expect(result.updatedProps).toEqual(["testMode", "count"]); + expect(mockSetTplComponentArg).toHaveBeenCalledTimes(2); + // boolean + expect(mockSetTplComponentArg.mock.calls[0][3].code).toBe("true"); + // number + expect(mockSetTplComponentArg.mock.calls[1][3].code).toBe("42"); + }); + + it("removes prop when value is null", async () => { + const currencyParam = mkParam("currency"); + const tplComp = mkTplComponent("PayButton", [currencyParam]); + // Pre-populate an existing arg + tplComp.vsettings[0].args = [{ param: currencyParam, expr: { _type: "CustomCode", code: '"USD"' } }]; + const comp = { uuid: "comp-1", name: "Page", tplTree: tplComp }; + setupSession(comp); + + mockEnsureBaseVariantSetting.mockImplementation((tpl: any) => { + return tpl.vsettings[0]; + }); + + const result = await updateProps(api, "comp-1", "PayButton", { currency: null }); + + expect(result.removedProps).toEqual(["currency"]); + expect(result.updatedProps).toEqual([]); + // The arg should have been spliced out + // (mockSetTplComponentArg is NOT called for removal — splice is done directly) + expect(mockSetTplComponentArg).not.toHaveBeenCalled(); + }); + + it("throws when prop name does not exist on component", async () => { + const currencyParam = mkParam("currency"); + const tplComp = mkTplComponent("PayButton", [currencyParam]); + const comp = { uuid: "comp-1", name: "Page", tplTree: tplComp }; + setupSession(comp); + + await expect( + updateProps(api, "comp-1", "PayButton", { nonExistent: "value" }) + ).rejects.toThrow('Prop "nonExistent" does not exist on component "PayButtonComponent". Available props: currency'); + }); + + it("throws when nodeRef resolves to TplTag instead of TplComponent", async () => { + const divNode = { + _type: "TplTag", + uuid: "div-1", + name: "Container", + tag: "div", + vsettings: [{ variants: [], rs: { values: {} } }], + children: [], + }; + const comp = { uuid: "comp-1", name: "Page", tplTree: divNode }; + setupSession(comp); + + await expect( + updateProps(api, "comp-1", "Container", { foo: "bar" }) + ).rejects.toThrow('Node "Container" is a TplTag, not a TplComponent. Use update-attrs for HTML elements.'); + }); + + it("handles empty props object as no-op", async () => { + const currencyParam = mkParam("currency"); + const tplComp = mkTplComponent("PayButton", [currencyParam]); + const comp = { uuid: "comp-1", name: "Page", tplTree: tplComp }; + setupSession(comp); + + const result = await updateProps(api, "comp-1", "PayButton", {}); + + expect(result.updatedProps).toEqual([]); + expect(result.removedProps).toEqual([]); + expect(mockSetTplComponentArg).not.toHaveBeenCalled(); + }); + + it("rejects scalar value for slot param", async () => { + const slotParam = mkParam("children", { isSlot: true }); + const tplComp = mkTplComponent("Card", [slotParam]); + const comp = { uuid: "comp-1", name: "Page", tplTree: tplComp }; + setupSession(comp); + + await expect( + updateProps(api, "comp-1", "Card", { children: "some string" }) + ).rejects.toThrow('Prop "children" is a slot param. Pass a PlasmicElement object or array instead.'); + }); + + it("rejects PlasmicElement for non-slot param", async () => { + const currencyParam = mkParam("currency"); + const tplComp = mkTplComponent("PayButton", [currencyParam]); + const comp = { uuid: "comp-1", name: "Page", tplTree: tplComp }; + setupSession(comp); + + await expect( + updateProps(api, "comp-1", "PayButton", { currency: { type: "text", value: "Hello" } }) + ).rejects.toThrow('Prop "currency" is not a slot param. Pass a scalar value or expression instead.'); + }); + + it("sets multiple props in a single call (merge semantics)", async () => { + const currencyParam = mkParam("currency"); + const testModeParam = mkParam("testMode", { type: "boolean" }); + const tplComp = mkTplComponent("PayButton", [currencyParam, testModeParam]); + const comp = { uuid: "comp-1", name: "Page", tplTree: tplComp }; + setupSession(comp); + + mockEnsureBaseVariantSetting.mockImplementation((tpl: any) => { + if (!tpl.vsettings[0].args) tpl.vsettings[0].args = []; + return tpl.vsettings[0]; + }); + + const result = await updateProps(api, "comp-1", "PayButton", { + currency: "EUR", + testMode: false, + }); + + expect(result.updatedProps).toEqual(["currency", "testMode"]); + expect(mockSetTplComponentArg).toHaveBeenCalledTimes(2); + }); + + it("fails fast on first invalid prop before any mutations", async () => { + const currencyParam = mkParam("currency"); + const tplComp = mkTplComponent("PayButton", [currencyParam]); + const comp = { uuid: "comp-1", name: "Page", tplTree: tplComp }; + setupSession(comp); + + await expect( + updateProps(api, "comp-1", "PayButton", { currency: "USD", badProp: "value" }) + ).rejects.toThrow('Prop "badProp" does not exist'); + + // setTplComponentArg should NOT have been called (fail-fast before mutation) + expect(mockSetTplComponentArg).not.toHaveBeenCalled(); + }); + + it("supports variant targeting", async () => { + const currencyParam = mkParam("currency"); + const tplComp = mkTplComponent("PayButton", [currencyParam]); + const mobileVariant = { uuid: "v-mobile", name: "Mobile", mediaQuery: "(max-width: 768px)" }; + const comp = { + uuid: "comp-1", name: "Page", tplTree: tplComp, + }; + + const mobileVs = { variants: [mobileVariant], args: [], rs: { values: {} } }; + mockEnsureVariantSetting.mockReturnValue(mobileVs); + + // Variant must be discoverable via site.globalVariantGroups or comp.variantGroups + const session = makeSession({ + site: { + components: [comp], + globalVariantGroups: [{ + uuid: "screen-group", + type: "global-screen", + param: { variable: { name: "Screen" } }, + variants: [mobileVariant], + }], + }, + } as any); + setSession(session); + initChangeTracker(session.site); + + const result = await updateProps(api, "comp-1", "PayButton", { currency: "GBP" }, "Mobile"); + + expect(result.updatedProps).toEqual(["currency"]); + expect(mockSetTplComponentArg).toHaveBeenCalledOnce(); + // Should have targeted the variant's VS, not the base + expect(mockSetTplComponentArg.mock.calls[0][1]).toBe(mobileVs); + }); +}); diff --git a/packages/plasmic-mcp/src/api-client.ts b/packages/plasmic-mcp/src/api-client.ts index d30533dd1..355affd7e 100644 --- a/packages/plasmic-mcp/src/api-client.ts +++ b/packages/plasmic-mcp/src/api-client.ts @@ -199,7 +199,7 @@ export class PlasmicApiClient { try { return await this.request<ListProjectsResponse>( "GET", - "/api/v1/projects?query=all" + `/api/v1/projects?query=${encodeURIComponent(JSON.stringify("all"))}` ); } catch (err: unknown) { // Add specific guidance for list-projects failures diff --git a/packages/plasmic-mcp/src/edit-tools.ts b/packages/plasmic-mcp/src/edit-tools.ts index d3ea23c46..bac27e534 100644 --- a/packages/plasmic-mcp/src/edit-tools.ts +++ b/packages/plasmic-mcp/src/edit-tools.ts @@ -85,7 +85,7 @@ import { isKnownImageAssetRef, } from "@/wab/shared/model/classes"; import { RSH } from "@/wab/shared/RuleSetHelpers"; -import { TplMgr } from "@/wab/shared/TplMgr"; +import { TplMgr, setTplComponentArg } from "@/wab/shared/TplMgr"; import { ensureVariantSetting, mkVariant } from "@/wab/shared/Variants"; import { mkTplTagX, mkTplInlinedText, mkTplComponentX, clone as cloneTpl, TplTagType } from "@/wab/shared/core/tpls"; import { flattenTpls } from "@/wab/shared/core/tpls"; @@ -2298,6 +2298,179 @@ export async function updateAttrs( }; } +// --- update-props --- + +export interface UpdatePropsResult { + save: SaveResult; + nodeName?: string; + nodeUuid: string; + updatedProps: string[]; + removedProps: string[]; +} + +/** + * Update prop values on a TplComponent instance. + * + * Supports scalar values (string, number, boolean), dynamic expressions + * ($expr or {{expr}}), slot content (PlasmicElement objects), and prop + * deletion (null/undefined values). + * + * Uses setTplComponentArg from TplMgr — the same mutation path Studio uses + * when setting props in the prop panel. This ensures full fidelity with the + * Studio data model. + * + * When `variant` is omitted, targets the base variant. + * When provided, resolves the variant and applies to that VariantSetting. + */ +export async function updateProps( + apiClient: PlasmicApiClient, + componentUuid: string, + nodeRef: string, + props: Record<string, unknown>, + variant?: string +): Promise<UpdatePropsResult> { + const component = findComponent(componentUuid); + const result = resolveNode(component, nodeRef); + const resolved = requireSingleNode(result, nodeRef); + + if (!isKnownTplComponent(resolved.node)) { + const nodeType = resolved.node?._type ?? "unknown"; + throw new Error( + `Node "${nodeRef}" is a ${nodeType}, not a TplComponent. Use update-attrs for HTML elements.` + ); + } + + const tpl = resolved.node; + const targetComponent = tpl.component; + const componentParams: any[] = targetComponent?.params ?? []; + + // Build a lookup of param name → param object for validation + const paramByName = new Map<string, any>(); + for (const param of componentParams) { + const name = param.variable?.name; + if (name) paramByName.set(name, param); + } + + // Pre-validate ALL prop names before making any mutations (fail-fast / atomic) + const updatedProps: string[] = []; + const removedProps: string[] = []; + for (const [propName, value] of Object.entries(props)) { + const param = paramByName.get(propName); + if (!param) { + const available = [...paramByName.keys()].join(", "); + throw new Error( + `Prop "${propName}" does not exist on component "${targetComponent?.name ?? componentUuid}". Available props: ${available || "(none)"}` + ); + } + + const isSlot = !!param.tplSlot || + (param.typeTag ?? param._type) === "SlotParam"; + + if (value === null || value === undefined) { + removedProps.push(propName); + } else if (typeof value === "object" && !Array.isArray(value) && value !== null && "type" in (value as any)) { + // Looks like a PlasmicElement — must be a slot param + if (!isSlot) { + throw new Error( + `Prop "${propName}" is not a slot param. Pass a scalar value or expression instead.` + ); + } + updatedProps.push(propName); + } else if (Array.isArray(value)) { + // Array of PlasmicElements — must be a slot param + if (!isSlot) { + throw new Error( + `Prop "${propName}" is not a slot param. Pass a scalar value or expression instead.` + ); + } + updatedProps.push(propName); + } else { + // Scalar or expression — must NOT be a slot param + if (isSlot) { + throw new Error( + `Prop "${propName}" is a slot param. Pass a PlasmicElement object or array instead.` + ); + } + updatedProps.push(propName); + } + } + + if (updatedProps.length === 0 && removedProps.length === 0) { + // No-op: empty props object + const noopSave: SaveResult = { revisionNum: requireSession().revisionNum, incremental: false }; + return { save: noopSave, nodeName: resolved.name, nodeUuid: resolved.uuid, updatedProps: [], removedProps: [] }; + } + + const session = requireSession(); + const tplMgr = new TplMgr({ site: session.site }); + const tracker = getChangeTracker(); + + let resolvedVariant: any = null; + const changes = tracker.withRecording(() => { + resolvedVariant = variant + ? resolveVariant(session.site, component, variant) + : null; + + const vs = resolvedVariant + ? ensureVariantSetting(tpl, [resolvedVariant]) + : tplMgr.ensureBaseVariantSetting(tpl); + + if (!vs.args) { vs.args = []; } + + for (const [propName, value] of Object.entries(props)) { + const param = paramByName.get(propName)!; + const argVar = param.variable; + + if (value === null || value === undefined) { + // Remove the Arg from this variant setting + const idx = vs.args.findIndex((a: any) => + a.param?.variable === argVar || a.param?.variable?.name === propName + ); + if (idx !== -1) { + vs.args.splice(idx, 1); + } + } else { + const isSlot = !!param.tplSlot || + (param.typeTag ?? param._type) === "SlotParam"; + + let expr: any; + if (isSlot) { + // Convert PlasmicElement(s) to RenderExpr + const baseVariant = tplMgr.ensureBaseVariant(component); + const elements = Array.isArray(value) ? value : [value]; + const tpls = elements.map((el: any) => + plasmicElementToTpl(el, session.site, baseVariant) + ); + expr = new RenderExpr({ tpl: tpls }); + } else { + // Scalar or dynamic expression — reuse createAttrExpr + expr = createAttrExpr(value); + } + + setTplComponentArg(tpl, vs, argVar, expr); + } + } + }); + + const componentIid = getComponentIid(component); + const variantLabel = resolvedVariant ? ` [variant: ${resolvedVariant.name ?? variant}]` : ""; + const allKeys = [...updatedProps, ...removedProps.map(k => `-${k}`)]; + const save = await saveOrAccumulate( + apiClient, + changes, + `update-props: [${allKeys.join(", ")}] on ${resolved.name ?? nodeRef}${variantLabel}`, + componentIid ? [componentIid] : [] + ); + + return { + save, + nodeName: resolved.name, + nodeUuid: resolved.uuid, + updatedProps, + removedProps, + }; +} + // --- add-child --- export interface AddChildResult { diff --git a/packages/plasmic-mcp/src/index.ts b/packages/plasmic-mcp/src/index.ts index c0a678b66..00c6e0001 100644 --- a/packages/plasmic-mcp/src/index.ts +++ b/packages/plasmic-mcp/src/index.ts @@ -3,7 +3,7 @@ * * Starts a stdio-based MCP server using the STRAP architecture: * 8 domain tools (project, inspect, component, node, variant, design, - * data, interaction) consolidating 99 actions total. + * data, interaction) consolidating 104 actions total. * * Usage (development): tsx packages/plasmic-mcp/src/index.ts * Usage (production): npx @elasticpath/plasmic-mcp diff --git a/packages/plasmic-mcp/src/server.ts b/packages/plasmic-mcp/src/server.ts index 183432589..c33833ff2 100644 --- a/packages/plasmic-mcp/src/server.ts +++ b/packages/plasmic-mcp/src/server.ts @@ -4,7 +4,7 @@ * Uses McpServer from @modelcontextprotocol/sdk with Zod schemas for input * validation. All tools are registered before the transport connects. * - * STRAP architecture: 103 actions consolidated into 8 domain tools. + * STRAP architecture: 104 actions consolidated into 8 domain tools. * Each domain tool uses an `action` discriminator to route to the * appropriate handler function. * @@ -12,7 +12,7 @@ * - project (8 actions): session lifecycle, persistence, batch, undo * - inspect (8 actions): read-only queries on component trees * - component (18 actions): component/page lifecycle, props, states - * - node (15 actions): element mutations (structure, style, text, attrs) + * - node (16 actions): element mutations (structure, style, text, attrs, props) * - variant (12 actions): variant management (component, global, style, screen) * - design (22 actions): site-level design system (tokens, mixins, etc.) * - data (16 actions): data flow (queries, data-tokens, splits, etc.) @@ -50,6 +50,7 @@ import { updateRichText, updateStyles, updateAttrs, + updateProps, addChild, removeChild, moveChild, @@ -2298,17 +2299,18 @@ export function createServer(): McpServer { ); // ======================================================================== - // DOMAIN 4: node (15 actions) + // DOMAIN 4: node (16 actions) // ======================================================================== server.tool( "node", "Element mutations within a component.\n" + - "Actions: add, remove, move, clone, reorder, update-styles, update-text, update-rich-text, update-attrs, set-visibility, set-image, apply-mixin, detach-mixin, add-animation, remove-animation.\n" + + "Actions: add, remove, move, clone, reorder, update-styles, update-text, update-rich-text, update-attrs, update-props, set-visibility, set-image, apply-mixin, detach-mixin, add-animation, remove-animation.\n" + "- add/remove/move/clone/reorder: Structural changes to element tree\n" + "- update-styles: Set CSS styles on an element\n" + "- update-text/update-rich-text: Set text content\n" + - "- update-attrs: Set HTML attributes\n" + + "- update-attrs: Set HTML attributes on TplTag elements\n" + + "- update-props: Set component props on TplComponent instances (scalar, dynamic, slot)\n" + "- set-visibility: Show/hide elements per variant\n" + "- set-image: Set image source (asset or URL)\n" + "- apply-mixin/detach-mixin: Apply or remove style mixins\n" + @@ -2317,7 +2319,7 @@ export function createServer(): McpServer { { action: z.enum([ "add", "remove", "move", "clone", "reorder", - "update-styles", "update-text", "update-rich-text", "update-attrs", + "update-styles", "update-text", "update-rich-text", "update-attrs", "update-props", "set-visibility", "set-image", "apply-mixin", "detach-mixin", "add-animation", "remove-animation", ]), @@ -2339,6 +2341,7 @@ export function createServer(): McpServer { href: z.string().optional(), })).optional().describe("Rich text formatting marks"), attrs: z.record(z.any()).optional().describe("HTML attributes to set"), + props: z.record(z.any()).optional().describe("Component props to set (scalar, $expr, {{expr}}, PlasmicElement for slots, null to remove)"), variant: z.string().optional().describe("Target variant by name, UUID, or selector"), dynamic: z.boolean().optional().describe("Create dynamic text expression"), fallback: z.string().optional().describe("Fallback for dynamic text"), @@ -2795,6 +2798,54 @@ export function createServer(): McpServer { }; } + case "update-props": { + const cuuid = requireParam(params.componentUuid, "componentUuid", "node.update-props"); + const nref = requireParam(params.nodeRef, "nodeRef", "node.update-props"); + const pr = requireParam(params.props, "props", "node.update-props"); + + if (params.dryRun) { + const result = await withDryRun(() => + updateProps(apiClient, cuuid, nref, pr, params.variant) + ); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + dryRun: true, + node: result.nodeName ?? result.nodeUuid, + updatedProps: result.updatedProps, + removedProps: result.removedProps, + message: "Dry run: no changes persisted", + } + ), + }, + ], + }; + } + + const result = await updateProps( + apiClient, cuuid, nref, pr, params.variant + ); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + success: true, + node: result.nodeName ?? result.nodeUuid, + updatedProps: result.updatedProps, + removedProps: result.removedProps, + revision: result.save.revisionNum, + } + ), + }, + ], + }; + } + case "set-visibility": { const cuuid = requireParam(params.componentUuid, "componentUuid", "node.set-visibility"); const nref = requireParam(params.nodeRef, "nodeRef", "node.set-visibility"); diff --git a/packages/plasmic-mcp/src/wab-externals.d.ts b/packages/plasmic-mcp/src/wab-externals.d.ts index 57fd90de2..66b4d3e7c 100644 --- a/packages/plasmic-mcp/src/wab-externals.d.ts +++ b/packages/plasmic-mcp/src/wab-externals.d.ts @@ -651,6 +651,9 @@ declare module "@/wab/shared/TplMgr" { playState?: string ): any; } + + export function getTplComponentArg(tpl: any, vs: any, argVar: any): any; + export function setTplComponentArg(tpl: any, vs: any, argVar: any, expr: any): void; } // ---------------------------------------------------------------------------