[WC-3105]: Mendix Pluggable MCP - #2035
Conversation
748d7fd to
86124a7
Compare
| ```json | ||
| { | ||
| "systemProperties": ["Name", "TabIndex", "Visibility"] | ||
| } | ||
| ``` |
There was a problem hiding this comment.
Maybe we can define every system property as a standalone property of particular shape, similar to other properties.
Something like:
{
type: "system",
name: "TabIndex"
}
|
|
||
| --- | ||
|
|
||
| ## Full Widget Definition Example |
There was a problem hiding this comment.
It is not clear how Property Groups are defined, are they part of this JSON, or supplied in a different was somehow?
There was a problem hiding this comment.
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.
10ecd10 to
2878cc8
Compare
…le ts, update docs
… directory errors
…y.md, clarify notifications
…d update-widget-prop tool
…eractive defaults
491c80b to
ec1c760
Compare
…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>
…y in tool description
… on pattern mismatch
…o and generator tests
ce978ff to
aa765a7
Compare
|
I'm thinking about whether Skills might be a better fit here 🤔 MCP Skills 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 Simplicity and Lifecycle
Progressive Discovery
Can we used both by Agents + Humans
Thoughts on Developer Experience Some other questions
|
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.
Pull request type
New feature (non-breaking change which adds functionality)
Description
@mendix/pluggable-widgets-mcp— MCP Widget GeneratorWhat 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:
create-widget@mendix/generator-widgetgenerate-widget-codeupdate-widget-propertiesbuild-widgetnpm run build, produce an.mpklist-widget-filesread-widget-filewrite-widget-fileResources (injected into the LLM's context on demand):
mendix://guidelines/property-types— every widget property type withJSON schema and usage notes
mendix://guidelines/widget-patterns— reusable TSX/SCSS patterns forcommon 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:
create-widgetto scaffold the initial projectgenerate-widget-codewith the right property definitionswrite-widget-fileto implement the component logicbuild-widgetto compile and produce the.mpkOther details
client
generations/by default; configurable viaMCP_ALLOWED_OUTPUT_PATHSpaths sandboxed
conversation — this is per MCP spec, not a bug
What should be covered while testing?