Skip to content

[WC-3105]: Mendix Pluggable MCP - #2035

Open
rahmanunver wants to merge 35 commits into
mainfrom
mendix-pluggable-mcp
Open

[WC-3105]: Mendix Pluggable MCP#2035
rahmanunver wants to merge 35 commits into
mainfrom
mendix-pluggable-mcp

Conversation

@rahmanunver

@rahmanunver rahmanunver commented Jan 20, 2026

Copy link
Copy Markdown
Contributor

Pull request type

New feature (non-breaking change which adds functionality)


Description

@mendix/pluggable-widgets-mcp — MCP Widget Generator

What this is

An MCP server that lets any AI assistant scaffold, configure, and build a
Mendix pluggable widget — following our conventions, using our tooling,
producing output that meets our quality bar.

You describe the widget. The server handles the rest: scaffolding via
@mendix/generator-widget, property configuration, code generation,
linting, builds. The output is a deployable .mpk.

The premise: the web-content team's domain knowledge should be accessible
to anyone building a widget, not just people who already know our stack.

How it works

The server registers 7 tools and 2 guideline resources over MCP. An AI
client connects, picks up the tools and resources, and can drive a full
widget development loop.

Tools:

Tool Purpose
create-widget Scaffold a new widget via @mendix/generator-widget
generate-widget-code Generate XML + TSX + SCSS from a property
definition
update-widget-properties Incrementally add, remove, or modify
properties
build-widget Run npm run build, produce an .mpk
list-widget-files List widget directory contents
read-widget-file Read a file from a widget directory
write-widget-file Write files (single or batch)

Resources (injected into the LLM's context on demand):

  • mendix://guidelines/property-types — every widget property type with
    JSON schema and usage notes
  • mendix://guidelines/widget-patterns — reusable TSX/SCSS patterns for
    common widget types

The LLM provides JSON property definitions → XML is generated
deterministically by our generators. The LLM never needs to reason about
our XML schema.

A typical session

Starting from a single prompt like "Build a clickable counter widget that
increments on every click"
, a capable LLM will:

  1. Call create-widget to scaffold the initial project
  2. Call generate-widget-code with the right property definitions
  3. Call write-widget-file to implement the component logic
  4. Call build-widget to compile and produce the .mpk

Other details

  • Transport: STDIO or HTTP (port 3100) — works with any MCP-compatible
    client
  • Output: Widgets land in generations/ by default; configurable via
    MCP_ALLOWED_OUTPUT_PATHS
  • Security: Path traversal blocked, extension whitelist enforced, build
    paths sandboxed
  • Progress notifications go to client UI indicators, not the
    conversation — this is per MCP spec, not a bug

What should be covered while testing?

cd packages/pluggable-widgets-mcp
pnpm install && pnpm build

Link globally for use with any MCP client:
npm link  # Use npm, not pnpm — better compatibility across clients

MCP server config (STDIO):
{
    "mcpServers": {
        "pluggable-widgets-mcp": {
            "type": "stdio",
            "command": "pluggable-widgets-mcp"
        }
    }
}

Prompt to try: "Create a widget called ProgressCircle that shows a
percentage in a circular progress bar"

For step-by-step debugging, use MCP Inspector:
npx @modelcontextprotocol/inspector node dist/index.js stdio

Explicit test flow

1. "Create a widget called ColorPicker with a color attribute property"
2. "List the files in the ColorPicker widget"
3. "Read the ColorPicker.xml file"
4. "Update the widget to add an onChange action property"
5. "Build the ColorPicker widget"

A capable LLM will drive the full loop from a single description without
needing each step broken out.

See packages/pluggable-widgets-mcp/README.md for full documentation.

@rahmanunver
rahmanunver requested a review from a team as a code owner January 20, 2026 08:27
Comment thread packages/pluggable-widgets-mcp/docs/property-types.md Outdated
Comment thread packages/pluggable-widgets-mcp/docs/property-types.md Outdated
Comment on lines +480 to +484
```json
{
"systemProperties": ["Name", "TabIndex", "Visibility"]
}
```

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe we can define every system property as a standalone property of particular shape, similar to other properties.

Something like:

{
    type: "system",
    name: "TabIndex"
}

Comment thread packages/pluggable-widgets-mcp/docs/property-types.md Outdated

---

## Full Widget Definition Example

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It is not clear how Property Groups are defined, are they part of this JSON, or supplied in a different was somehow?

Comment thread packages/pluggable-widgets-mcp/src/tools/file-operations.tools.ts Outdated
Comment thread packages/pluggable-widgets-mcp/src/api/handlers.ts Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we use this? I see currently LLM is able to read XML file directly, I didn't find where it communicates properties in json format.

Comment thread packages/pluggable-widgets-mcp/src/tools/utils/generator.ts Outdated
Comment thread packages/pluggable-widgets-mcp/src/tools/build.tools.ts Outdated
Comment thread packages/pluggable-widgets-mcp/src/tools/code-generation.tools.ts Outdated
rahmanunver and others added 5 commits February 27, 2026 11:16
…ploy support

Adds get-project-info, set-project-directory, and deploy-widget tools.
Introduces SessionState for per-session isolation and MENDIX_PROJECT_DIR
env var for project configuration. Includes findMpkFile utility and
new error codes for project/deploy failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…dd project context to startup

Extracts duplicated path-allowlist logic into shared isPathAllowed() in
sandbox.ts. Adds project directory logging on HTTP/STDIO startup and
exposes projectDir in /health endpoint for observability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds vitest config, MCP test harness (in-memory transport), temp directory
helpers, and 55 unit tests covering config validation, security guardrails,
project tools, scaffolding/build sandbox, session state, MPK finder, and
response utilities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… cleanup, build parsing

Fixes 6 issues from E2E testing to make the create → generate → build
pipeline work end-to-end:

- Pass widget name via --name flag instead of positional arg (defense-in-depth)
- Read widgetName from package.json instead of deriving from directory basename
- Clean up stale scaffold files and regenerate package.xml before code generation
- Only import executeAction for TSX patterns that actually use it (fixes TS6133)
- Parse Rollup TypeScript error format with file location correlation
- Switch generator-widget to local file: reference for development

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@rahmanunver
rahmanunver force-pushed the mendix-pluggable-mcp branch from ce978ff to aa765a7 Compare March 4, 2026 15:33
@KONRADS098

KONRADS098 commented Mar 21, 2026

Copy link
Copy Markdown

I'm thinking about whether Skills might be a better fit here 🤔

MCP
If we're returning binary .mpk files over the network, how does a client actually handle that? For example, how does Studio Pro integrate with MCP in this scenario?

Skills
What if we exposed this as a Skill instead? Install once with npx skills add https://github.com/mendix/web-widgets --skill pluggable-widgets (or alternatively provide other ways to download the skill to the user’s machine), then any agent running on the user’s device has access to the functionallity.

Perhaps the scripts themselves could be much simpler too, just CLI scripts that take arguments and output to some working directory (this can even be the Mendix project directory). Could we do everything locally this way, omitting the need for .mpk files traveling over the network?

Simplicity and Lifecycle
Skills seem to have less friction overall for both developers and users:

  • Updating: When a new version of the skill is released (e.g., additional functionality or bug fixes), you just update the skill, and that’s it. There’s no need to toggle new tools on/off or reconfigure anything like with MCP.
  • Lifecycle Management: There’s less operational overhead with Skills. You don’t need to manage an MCP server process or worry about version mismatches between client and server.

Progressive Discovery
One major benefit of Skills is the way they use progressive discovery:

  • With MCP tools, every tool (that the user wants to make use of) must be registered and packaged into the LLMs context window, potentially bloating the context.
  • Skills, on the other hand, append only the name and description to the initial context. If more details or functionalities are needed, the LLM progressively discovers the scripts, resources, and instructions it needs, keeping the context lightweight.

Can we used both by Agents + Humans
Since Skills are just CLI scripts bundled into a package, they can be programmatically invoked just as easily:

  • Most IDEs support slash commands (e.g., /create-widget) for invoking skills, users could call these same scripts directly, making them useful not just for agents but also for humans.
  • Doing this with an MCP server feels much more cumbersome, you’d need to interact with the server instead of directly executing tools (tools are not invokable, and not designed to be used by humans, they're really meant as an interface to LLMs).

Thoughts on Developer Experience
What are your thoughts on this direction? Does it feel like it'd actually improve the developer experience, or are there constraints with MCP that make it the better choice?

Some other questions

  1. Error Handling
    • How would errors (e.g., failed builds) propagate in an MCP setup? Would a skill-based approach allow agents to provide clearer, immediate feedback since everything runs locally?
  2. Diagnostics and Debugging
    • With MCP, how would debugging .mpk generation (or similar issues) work? Would a Skill with transparent, local outputs make these workflows easier to debug?
  3. Studio Pro-Specific Integration
    • Could a Skill allow .mpk files to land directly in the Studio Pro working directory, streamlining the process? Would the same thing be possible with an MCP server?

I didn't fully review the MR yet. I wanted to discuss these ideas first. The current approach with MCP still makes a lot of sense within its context, this is just a possible alternative to consider that might simplify a few aspects.

The package depended on a `file:` path outside the repo that resolved to a
local 10.24.0 fork of @mendix/generator-widget, making it uninstallable for
anyone else. That fork was needed because create-widget drove the generator by
simulating its interactive CLI: `--default --name X ...` flags that exist only
in the fork, plus scraping stdout to infer progress.

Replace the simulation with yeoman-environment's own extension point. The new
AnswerAdapter supplies answers as data: a supplied answer wins, the prompt's own
default fills a gap, and a prompt that is neither answered nor defaulted throws
MissingAnswerError, so an upstream rename fails loudly instead of silently
defaulting. It implements the full QueuedAdapter shape because environment-base
assigns the adapter directly rather than wrapping it.

Nothing in the adapter writes to stdout. Under the stdio transport that channel
carries JSON-RPC, and the generator's banner would corrupt it.

Scaffolding and dependency installation are now separate steps with separate
outcomes, so a registry stall still leaves a usable scaffold.

The 14 prompt names are pinned in a test. They are the generator's contract, and
wrong names fall through to defaults without any error.

Also switches the package off the browser/React eslint config it was borrowing
and onto a Node/TS one, bumps engines to >=22, and ships docs/ so the MCP
resources resolve in an installed copy.
2462 lines removed, no surviving behaviour changed.

tsx-generator hand-assembled React by string concatenation and emitted code
that could not compile: the container pattern called useCallback without
importing it, isCollapsible fell back to the string "false" spliced into
source, and the input pattern compared an enum to a raw string. Its job now
belongs to the client LLM, guided by docs/widget-patterns.md, which already
ships as an MCP resource and already contained the same templates written
correctly. This also removes one of three mutually disagreeing pattern
detectors.

mpk-analyzer had zero production callers and shelled out to `unzip` with an
interpolated caller path, a binary that is not present on Windows. Its tests
asserted expect(true).toBe(true) four times against fixtures in a gitignored
directory that does not exist.

protocol-logger did a synchronous appendFileSync on every request, only on the
HTTP path, and its buildOutgoingLogEntry was never called.

session.ts is superseded by the stateless transport. session-state.test.ts
asserted that JavaScript object assignment works.

code-generation.tools and property-update.tools are replaced by a single
declarative set-widget-properties. They shared a .widget-definition.json
snapshot on disk that could disagree with the XML it was supposed to describe.

clearGuidelineCache had no callers.
The server runs as a child process of Studio Pro, not as a service started by
hand from the package directory. Under that model GENERATIONS_DIR, which was
join(process.cwd(), "generations"), moved the security boundary depending on
who spawned the process — and Studio Pro's cwd is its install directory, often
read-only. It anchored both the scaffold output and the sandbox allowlist.

There is now exactly one root: state.projectDir. Scaffolding targets
{projectDir}/widget-sources/, so sources travel with the project in version
control. MCP_ALLOWED_OUTPUT_PATHS and MCP_ALLOWED_BUILD_PATHS collapse into one
optional MCP_EXTRA_ALLOWED_PATHS for development, split on path.delimiter
rather than ":" — the latter tears "C:\widgets" into ["C", "\widgets"].

MENDIX_PROJECT_DIR stops being a frozen module constant and moves into session
state, so set-project-directory can re-point on a project switch without a
restart.

Three path bugs, all of the same family:

- Containment used `resolved.startsWith(allowed + "/")`. On Windows the
  separator is not "/". Now path.sep, consistently across sandbox.ts,
  guardrails.ts and build.tools.ts, which had three different answers.
- validateFilePath rejected any path containing ".." as a substring, refusing a
  legitimate src/foo..bar.tsx. resolve() collapses ".." before comparison, so
  containment was always the real defence; the substring test only produced
  false positives.
- Extensionless filenames were matched with includes(), so "unpackaged",
  "mypackage" and "prepackage-hook" all passed as "package". Now equality.

validateProjectDir took whichever .mpr the filesystem happened to return first;
it now sorts and reports clearly when a directory holds more than one.
routes.ts dispatched on session state rather than HTTP verb, via a single
app.all("/mcp"). Replacing it with explicit verbs deletes three bugs at once:

- The GET branch minted a full McpServer that was never registered and never
  closed — one leak per request — and could only ever return 400, because a
  freshly constructed transport is never _initialized.
- GET and DELETE read req.body.method, but express.json() leaves body undefined
  on those verbs. That is why DELETE teardown never worked.
- The session map was unbounded.

The SDK supports sessionIdGenerator: undefined, so each POST now constructs a
transport and server, handles the request, and closes. SessionManager goes with
it, including a console.log that would have corrupted the JSON-RPC stream if it
were ever reached from stdio, and a toolCallCount that was set to zero, logged,
and never incremented.

http.ts bound app.listen(PORT) with no host, overriding the SDK's 127.0.0.1
default and exposing a server that spawns `npm run build` on every interface.
The permissive CORS block (origin: true with credentials and allowedHeaders "*"
for a one-route server) is gone, EADDRINUSE reports something actionable, and
logProjectConfig is awaited — it was a floating promise that would take the
process down on Node >= 15.

stdio.ts gains a stdin end/close handler. That is how a stdio MCP child learns
its parent died; without it the process orphans when Studio Pro is force-killed,
and SIGINT/SIGTERM are not reliably delivered on Windows.

index.ts cast process.argv[2] to TransportMode unchecked, so `node dist/index.js
htpp` silently ran stdio. It now validates and supports --help.

Adds a tagged, level-gated logger that always writes to stderr, replacing about
twenty ad-hoc console.error sites. Studio Pro captures child stderr and that is
the support log; stdout belongs to the protocol.
Ten tools become nine. generate-widget-code and update-widget-properties merge
into one declarative tool: callers send the complete property set the widget
should have, not a diff. The op union and the .widget-definition.json snapshot
that existed only to give the diff a base are both gone, and with them the class
of bug where the snapshot and the XML disagree.

The Mendix property-type union was written out three times with no compile-time
link between the copies, and had already drifted. It now lives once in
tools/property-schema.ts, which also exposes the nested `properties` field that
xml-generator has always handled but neither Zod schema reached. The schema
stays under tools/ because generators/ is deliberately Zod-free; validating
untrusted input is a tool-boundary concern.

validateWidgetDefinition now rejects duplicate property keys. Duplicates
produced an XML file Mendix rejects at build time and colliding entries in the
generated typings.

Deletes cleanupScaffoldFiles: 93 lines of destruction that neither the tool
description nor the success message disclosed. It unlinked every .xml in src/
that did not match, and every top-level .tsx that did not match — so a user's
src/Helper.tsx vanished while src/components/ survived — with uncaught unlink
errors, so an EPERM mid-loop aborted after a partial delete. It existed to clean
up after the generator's own renaming; with the model writing TSX under the
scaffolded name, nothing renames.

build-widget:

- Success is the child's exit code. It used to be true when the output contained
  "successfully" or "created dist/" or any .mpk token, and mpkPath was taken
  from any line mentioning .mpk, including error lines, which then fed the
  success heuristic. parseBuildOutput now returns Omit<BuildResult, "success">
  so the compiler enforces that parsing does not decide success.
- Adds a timeout. runBuild resolved only on close or error, so a hung build hung
  the MCP request forever while the heartbeat fired indefinitely.
- Drops shell: true. The argv is fixed and it handed widgetPath to a shell.
- Stops inlining file contents on failure. Errors return file:line:column; the
  model has read-widget-file.

Scaffolding defaults to {projectDir}/widget-sources/ and drops the
ERR_OUTPUT_PATH_REQUIRED workaround that only existed because of the cwd bug. An
existing directory is now verified to be this widget before it is reported as
already scaffolded. findMpkFile picks the newest .mpk by mtime rather than the
first one found, so deploy-widget cannot copy a stale build, and deploy reports
whether it replaced an existing file.

Tool descriptions say what the tool does and what it returns, nothing about what
to call next. The workflow lives once in SERVER_INSTRUCTIONS. The "RETRY LOOP,
maximum 3 attempts" prose is gone: the server cannot count attempts, and in MCP
the client owns the loop.
ToolResponse stays a type alias rather than an interface, and that is
load-bearing: the SDK's handler signature expects a type carrying an index
signature, TypeScript grants aliases an implicit one, so the alias remains
assignable while excess-property checking still catches a misspelled isError.

Two constructors, ok and fail, with no third path and no raw literals —
build.tools.ts previously bypassed all of them. Every failure now carries an
error code, and ErrorCode is pruned to codes something actually emits;
ERR_BUILD_TS, ERR_BUILD_XML, ERR_BUILD_MISSING_DEP, ERR_FILE_PATH and
ERR_FILE_WRITE were declared and never constructed.

write-widget-file returned success for a partial write ("Partial success: N
written, M failed"). It now returns isError. Its relative-path computation used
fullPath.replace(basePath + "/", ""), which replaces the first occurrence
anywhere rather than an anchored prefix; now path.relative.

New coverage:

- xml-generator golden files. Zero coverage previously on the one module the
  refactor keeps, covering every property type, nesting, escaping, propertyGroup
  and systemProperty layout, and the primitive-required quirk.
- file-operations round-trip through the in-memory client/server pair, including
  the partial-failure contract.
- both guideline resources load and cache.
- guardrails gains path.sep boundary and extensionless-equality cases.

scaffolding.tools.test.ts no longer mocks runWidgetGenerator. With
skipInstall: true a real scaffold runs in about 200ms, so the tool is tested for
real. widget-lifecycle.test.ts drops the block that tested the test harness
rather than the server.

docs/widget-patterns.md is now load-bearing — it replaced the deleted TSX
generator — so it was compiled for the first time against real Mendix typings:
scaffold a widget, generate typings from real XML, extract every template
verbatim, run tsc. Six errors in five of six templates. Four templates imported
executeAction from @mendix/widget-plugin-platform, which is a private workspace
package of the web-widgets monorepo and returns 404 from npm, so the doc's
headline rule was unbuildable in exactly the standalone widgets this server
produces. Templates now declare the five-line helper locally, keeping the
isExecuting guard that nothing type-checks. Two unused imports removed; with
jsx: "react-jsx" and noUnusedLocals the React 17 createElement idiom is now an
error rather than a habit.

README documented seven tools and omitted the project and deploy family that
SERVER_INSTRUCTIONS tells the model to call first, claimed widgets land in
generations/ inside the package, and documented no environment variables.
CHANGELOG was a single sentence.
docs/evaluation.md explains, without assuming knowledge of the codebase, what
the server does, how each claim about it is proved, and how fast it is. It is
written to be readable by someone deciding whether to use this, not only by
someone maintaining it. The timings quoted were measured by hand on 2026-07-29
against a real Mendix project; the document says so, and says which layers are
designed but not yet built.

The two skills move out of a private ~/.claude directory and into the package,
so anyone contributing gets them without local setup:

- mcp-server-test — the operating procedure for the three evaluation layers.
  Includes the rules for the open-ended layer, where a model uses the server
  knowing only what the server tells any client. Those rules are the substance
  of that layer: reading src/ first makes the result meaningless.
- pluggable-widgets-mcp — the existing working notes on the server's design,
  with its cross-reference fixed to the repository-relative path.

The private code-generation-test skill is superseded and should be removed. It
drives generate-widget-code and detectWidgetPattern, both deleted, and grades
with LLM judgment, so it cannot gate a change.
Every XML the server wrote declared

    xsi:schemaLocation="... ../../../../node_modules/mendix/custom_widget.xsd"

The file is written to <widget>/src/, so four levels up is the Mendix project
root, which has no node_modules. @mendix/generator-widget scaffolds
"../node_modules/..." for the same file, which is correct: the widget's own
install is one level up.

Only XML editors read schemaLocation, so this never broke a build — it just
meant nobody editing a generated .xml by hand got schema completion or
validation, and the reference silently pointed at nothing.

Found while blessing the first golden file for the end-to-end suite, which is
what pins it from now on.
The existing 119 tests run client and server in one process over
InMemoryTransport. That is right for a fast gate, but it cannot see spawning,
packaging, argv handling, stdout hygiene, or anything involving a real install
or a real build — which is most of what breaks the Studio Pro integration.

`npm run test:e2e` spawns dist/index.js as a child process and drives it over
the real protocol. 24 specs, about 20 seconds warm.

- pipeline: the happy path. The XML is compared byte for byte against a golden
  file and validated against Mendix's own custom_widget.xsd, and the .mpk is
  opened and its entries checked. A build reporting success while producing an
  archive Studio Pro cannot load is the failure this exists to catch.
- transport: asserts every byte the server writes to stdout is a JSON-RPC frame.
  Under stdio that channel carries the protocol, so one stray console.log
  corrupts the connection. Also covers exit-on-stdin-close, so the process does
  not orphan when Studio Pro is killed, and argv validation.
- failure-modes: a broken component reports file:line:column and does not inline
  file contents; duplicate keys are refused with nothing written; sandbox
  escapes are refused, checked twice — once from the package directory and once
  from an unrelated cwd, which is the regression test for the boundary that used
  to move with process.cwd().
- docs-templates: extracts every template from docs/widget-patterns.md verbatim,
  generates real XML through the real tool, generates real typings, and compiles
  them. That document is served to the client model and is where component code
  comes from, so a template that does not compile is a broken product.
- cold: skipped unless E2E_COLD=1. A genuine scaffold with a real npm install,
  and the source of the headline timing.

Timings are recorded per phase to docs/benchmarks/timings.jsonl, tagged with the
commit, and the runner prints the delta against the previous run.

Three specs were verified by breaking what they guard rather than trusting a
green result: a console.log added to dist/ was caught by the stdout assertion;
restoring the unpublished @mendix/widget-plugin-platform import was caught with
the offending line in widget-patterns.md; and a missing golden refuses to
self-bless, writing one and failing until it is reviewed.

Two things the harness had to work around, both recorded in comments because
they cost time to find. The warm cache scaffolds directly into its final
location rather than being copied there: npm writes absolute symlinks into
node_modules/.bin, so a copied cache points tsc at a deleted temp directory. And
the typings generator resolves package.json from process.cwd() at import time,
so it runs as a child process with cwd set to the widget.

Measured from scratch: 25.0s total, of which the server's own steps account for
6 milliseconds. Recorded in docs/evaluation.md.
The pre-commit formatter rewrote the golden XML on the way in, so the committed
golden no longer matched what the generator emits and the pipeline spec failed
on the very next run.

A golden has to be byte-identical to real output or the comparison asserts
prettier's opinion about XML rather than the server's behaviour. Caught by
re-running the suite after committing, which is the only reason it did not land
as a broken test.
A second cold run came in at 35.1s against the first at 25.0s. The whole
difference is npm install (22.4s vs 11.6s); the build moved by less than a
second and the server's own steps were six milliseconds both times.

Quoting only the faster run would have been a number nobody else could
reproduce. Showing both makes the actual claim clearer: the wall-clock belongs
to npm and the Mendix toolchain, and this server adds nothing measurable to it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants