| title | MCP Backend — execute MDL against a live Studio Pro via its MCP server | ||
|---|---|---|---|
| status | draft | ||
| date | 2026-05-29 | ||
| author | Generated with Claude Code | ||
| related |
|
Status: Draft Date: 2026-05-29 Author: Generated with Claude Code
Today mxcli writes model changes by editing the .mpr/mprcontents files
directly on disk. This forces an awkward constraint: Studio Pro must be
closed while mxcli works, because two processes editing the same files race
(see PROPOSAL_concurrent_access). It also
makes mxcli solely responsible for BSON fidelity — every $Type storage name,
default value, and pointer-inversion gotcha must be reproduced exactly, or the
project fails to open with CE0463 / TypeCacheUnknownTypeException. The whole
CLAUDE.md "BSON Storage Names vs Qualified Names" section exists to manage
this risk.
Studio Pro 11.11 now ships an MCP server (the "PED" — Progressive Element
Disclosure — server) that exposes document-level model operations over HTTP on a
local port (observed: http://localhost:7782/mcp). If mxcli could route its
writes through that server instead of editing files, then:
- Studio Pro stays open. Changes apply to the live, running model. No close/reopen cycle, no file-lock races.
- Studio Pro becomes the authoritative serializer. mxcli hands over a
document; Studio Pro writes the BSON. This eliminates the entire class of
BSON-fidelity bugs mxcli currently has to defend against, and
ped_check_errorsvalidates against the real type cache. - The MDL authoring advantage is preserved. Unlike an agent driving the MCP
server tool-call-by-tool-call (the token cost analysed in
MXCLI_STRATEGIC_POSITIONING.md), here mxcli itself is the MCP client. The agent still authors one compact MDL script; mxcli parses and plans it deterministically and only the resulting document writes cross the wire. We get MDL's compactness and Studio Pro's serializer.
The target invocation, run from inside the devcontainer with the project mounted:
mxcli -p http://localhost:7782/mcp -c "create entity Sales.Order (...)"Model (.mpr) mutations flow through the MCP server; non-model files
(Java/JavaScript/CSS) remain on the mounted filesystem and are inspected
directly as today.
mdl/backend/backend.go defines FullBackend, composed of ~23 sub-interfaces
(~226 methods). The executor never touches sdk/mpr for writes — it goes
through ctx.Backend (mdl/executor/exec_context.go:31,
ADR-0002). The concrete backend
is produced by a single BackendFactory set once:
// cmd/mxcli/main.go:205
exec.SetBackendFactory(func() backend.FullBackend { return mprbackend.New() })A new backend plugs in at exactly one dispatch site. Nothing in the grammar,
AST, visitor, or executor handlers changes — the abstraction is opaque to them.
The mock backend (mdl/backend/mock/) already proves a second, partial
implementation works: a nil Func field returns a safe default, so unsupported
operations can return a clean "not supported by MCP backend" error instead of
requiring all 226 methods up front.
The per-version tool matrix and capability gaps now live in
docs/03-development/PED_MCP_CAPABILITIES.md— the canonical, living record. Update that doc when onboarding a new Studio Pro version. The summary below is the original snapshot.
Dumped live with cmd/mcpprobe on 2026-05-29. Server identifies as
mendix-studio-pro 1.0.0, protocol 2025-06-18; initialize instructs clients
to first read the resource mendix://studio-pro/system-prompt. The server is
exposing Mendix's own "Maia" agent tools and PED contract. Full captures
(tools.json, the system-prompt resource) were taken locally with cmd/mcpprobe;
they contain Mendix-internal content and are not committed pending a decision
on whether to vendor them (see Open Questions).
16 tools. The ones the backend needs:
| Tool | Purpose | Maps to backend concept |
|---|---|---|
ped_get_schema |
Schemas for element types (mandatory before create/add). Returns a $constructor (simplified, flattened — for creation) and a $element (full, for reads/updates) |
type metadata |
ped_read_document |
Progressive read — reads to element boundary; pass JSON paths (/flows/0) to descend |
unit / element read |
ped_find_document |
Find by moduleName + documentType (mandatory before create, idempotency) |
"does X exist?" |
ped_list_folder |
Immediate contents of a module/folder | partial enumeration |
ped_create_document |
Create one or more documents. "Never create domain models." | Create* (most doctypes) |
ped_create_module |
Create a module (and its domain model) | CreateModule |
ped_update_document |
Operation-based: atomic set/add/remove ops at JSON paths | Update*, ALTER, mutator Save() |
ped_check_errors |
Validate documents (mandatory after final create/update) | post-write validation |
pg_read_page / pg_write_page |
Pages only — a separate write path; PED is forbidden for pages | page Create/Update |
Other tools (search_mendix_knowledge_base, read_skill, oql_generate,
glob/read_file/write_file over virtual "file domains") are agent helpers,
not needed by the backend — though write_file is notable: the server itself can
write Java/JS/CSS, an alternative to the mounted-filesystem approach.
- Two write protocols, not one. Pages must use
pg_*; everything else usesped_*. The system prompt is explicit: "Never use PED … for pages." The backend's page methods fork topg_write_pagewhile domain model / microflow / enum / workflow methods go throughped_*. (Convenient: pages already have the most fragile BSON in mxcli, andpg_write_pagetakes a high-level widget tree, not raw BSON.) - Domain model is update-only.
ped_create_documentrefuses domain models. The chosen vertical slice creates entities viaped_update_document(add-ops on the module's existing DomainModel), and new modules viaped_create_module. This is more aligned with mxcli's mutator pattern than with itsCreate*path. - Operation-based updates map to ALTER and mutators directly.
ped_update_document(documentType, operations:[set/add/remove @ path])is almost a wire format forPageMutator/WorkflowMutatorandALTERstatements — set property, add widget, remove activity. - Construct vs read-path schema duality. Creation uses the
$constructorshape (flattened, e.g.objects); updates/reads use real JSON paths (e.g./objectCollection/objects). The backend must hold both mappings per type (fromped_get_schema) — a real implementation gotcha to budget for. - A mandatory call choreography per write:
ped_find_document→ped_get_schema→ped_create_document/ped_update_document→ped_check_errors. The backend encodes this sequence internally; mxcli's own pre-flightcheckcan run before any of it. - Non-user modules are read-only; reserved attribute names (
Type,id, …) are rejected — both already partly enforced by mxcli's validation.
These operate at the document/element level — which is what mxcli's writer
already produces. mxcli builds the model change; instead of writing a .mxunit
it ships document/operation JSON to ped_* (or a widget tree to pg_write_page).
-p and the CONNECT statement currently accept a file path only
(ast.ConnectStmt.Path, threaded into the BackendFactory at
cmd/mxcli/main.go). The change:
- If the project argument parses as an
http(s)://URL → instantiate the MCP backend. Otherwise → the existing MPR backend. - Surface an explicit form too:
CONNECT MCP 'http://localhost:7782/mcp'alongsideCONNECT LOCAL '...', for scripts and the REPL.
The BackendFactory signature stays the same shape; the factory body inspects
the descriptor and returns the right FullBackend.
Established empirically with cmd/mcpprobe against a running Studio Pro 11.11 on
a macOS host:
- The server binds IPv6 loopback only.
lsofon the host showsstudiopro … TCP [::1]:7782 (LISTEN)— not127.0.0.1, not0.0.0.0. On the host,localhost/mcp-remoteworks because macOS resolveslocalhostto::1. From a devcontainer,host.docker.internalis an IPv4 gateway, and::1is non-routable loopback — so the container cannot reach it directly (connection refused). The earlier hang was a second Studio Pro (11.10) answering on another interface; killing it left only the[::1]listener. - The server validates the HTTP
Hostheader (DNS-rebinding guard): the/mcproute only matches whenHost: localhost(else 404). So the client must pinHost: localhostregardless of where it dials.
Working bridge: a raw TCP forwarder on the host from an IPv4 all-interfaces port to the IPv6 loopback:
# on the host
socat TCP4-LISTEN:7783,reuseaddr,fork 'TCP6:[::1]:7782'
The container then dials host.docker.internal:7783 while sending
Host: localhost. With this in place, cmd/mcpprobe completes the full
handshake and tools/list/resources/read succeed (this is how the surface
above was captured). cmd/mcpprobe bakes the Host override in via a custom
dialer (dial target ≠ Host header).
Remaining productisation questions (Phase 3, not blockers):
- Ship the forwarder so users don't hand-run
socat: a devcontainerpostStartCommand, amaketarget, or a tiny-listenmode added to the Go client (nosocatdependency). The bridge must run on the host (it reaches::1), so a container-sidemaketarget alone won't do — document the host step, or detect/instruct. - Confirm whether Studio Pro can be configured to bind
0.0.0.0/IPv4 (would remove the forwarder entirely). Needs a per-OS support matrix.
The plan was: writes via MCP; reads (SHOW/DESCRIBE, reference
validation, catalog, search, show structure) from the mounted local
.mpr/mprcontents files, reusing all existing read code and the SQLite
catalog. Rationale held: PED is document-addressed with only ped_list_folder
for enumeration, so catalog/search/structure can't be served from MCP without N
round-trips; local files give full, fast read coverage for free.
⚠ Disproven assumption (measured 2026-05-29 against test7-app). While Studio Pro is open, the on-disk files are stale by default — Studio Pro is the system of record, and most MCP edits are not persisted to disk until the user saves. Measured behaviour:
ped_create_module→ flushed to disk immediately (4 new.mxunit,.mprtouched;mxcli show modulessaw the new module).ped_update_document(add an entity) → applied to Studio Pro's in-memory model only. MCP read-back showed the entity; the user saw it in the UI marked unsaved; disk was unchanged andmxcli describe entityreturned "not found".ped_check_errorsvalidated cleanly but did not flush either.- There is no save/flush tool among the 16 exposed tools.
So a hybrid read taken right after a write returns stale data for any edit that stays in memory — which is most of them. This is not a rare edge case; it is the default during a live editing session.
This forces a real decision the original "hybrid (decided)" answer did not anticipate. Options, in order of preference given the evidence:
- Read-through-MCP for just-written documents; local files for bulk/catalog.
After a write, reads of that document/module go through
ped_read_document(consistent with memory); catalog/search/structure still use local files (accepting they reflect last-saved state). The backend tracks a "dirty set" of documents touched this session and routes their reads to MCP. - Require a save. If Mendix adds a save/flush tool (or one exists undocumented), call it after each write batch, then local reads are correct. Cleanest if available — action: ask Mendix / re-scan tool surface across versions.
- Pure-MCP reads. Always consistent, but loses the catalog/search/fast enumerate that motivated hybrid, and needs many round-trips.
Recommendation shifts toward Option 1 for the vertical slice: it keeps the catalog/search win while staying correct for the documents the session just edited. The MCP backend is therefore a composite with a dirty-set router: local MPR reader for cold/bulk reads, MCP client for writes and for reads of documents written this session.
The MPR backend currently mixes BSON-document construction (entity → BSON)
with persistence (write the .mxunit, update the SQLite _units table). An
MCP backend needs the same construction but a different persistence target.
Refactor so document construction is shared and only the bottom layer differs:
CreateEntity(...) ──> build BSON document ──> persist(unit)
(shared) │
├─ MPR: write .mxunit + _units
└─ MCP: ped_create_document(bson)
Introduce a narrow UnitStore-style seam (read unit / write unit / find unit /
schema / validate) that both backends implement; the high-level FullBackend
methods are implemented once on top of it. RawUnitBackend
(mdl/backend/infrastructure.go:20) is already close to this shape and is a
natural anchor.
- Pros: no duplication of BSON construction; the MCP backend is small (implement the seam + the hybrid read delegation); future doctypes light up on both backends at once.
- Cons: touches the MPR backend (the most load-bearing code); needs careful
regression testing to prove the refactor is behaviour-preserving (the
feedback_validation_rigorlesson applies — green tests must exercise the actual construct, not a proxy).
A new mcpbackend package implements FullBackend independently, calling
existing serializers where reachable and the local reader for the read half.
- Pros: zero changes to the MPR backend; faster to start.
- Cons: high risk of duplicating construction logic that already lives entangled in the MPR backend; the two will drift as doctypes are added.
Recommendation: Option A. The value of this feature is Studio Pro does the serialization; that only pays off if mxcli isn't separately re-deriving BSON in two places. Extracting the seam is the difference between "an MCP backend" and "a second copy of the MPR backend that happens to call HTTP." Do the extraction behind the existing MPR backend first (pure refactor, MPR-only, fully tested), then add the MCP implementation of the seam.
Prove the whole pipe on the smallest meaningful surface before committing to 226 methods:
CREATE ENTITY/ALTER ENTITY(add/modify/drop attributes),CREATE ASSOCIATION,DROP ENTITY— executed viaped_create_document/ped_update_document, validated viaped_check_errors.- Reads (
SHOW/DESCRIBE ENTITY, reference validation) served from the local mounted files (hybrid). - Everything else returns a clear
"not supported by MCP backend (use a local .mpr)"error via the mock-style default.
This exercises: URL dispatch, the Host-header transport, the seam, the create+validate round-trip, and the hybrid read split — i.e. every risky part — on one doctype.
Use cmd/mcpprobe (built in this PR — a minimal Go streamable-HTTP MCP client)
to dump the real tools/list and a sample ped_get_schema /
ped_create_document exchange from the running 11.11 server, pinning down tool
names, argument schemas, document addressing, session/auth handling. Run it on
the host until the devcontainer transport (Open Question 4) is bridged — a raw
curl cannot complete the handshake, and from the container the /mcp response
does not return (see "Transport gotcha").
Extract the unit-persistence seam under the existing MPR backend. No behaviour change. Land with full existing-test green + targeted construction tests.
New mdl/backend/mcp/ package implementing the seam over PED + delegating reads
to a local MPR reader. URL dispatch in cmd/mxcli/main.go + CONNECT MCP.
Freshness handling, error mapping (ped_check_errors → mxcli diagnostics), and
the devcontainer networking prerequisite doc.
| File | Change |
|---|---|
cmd/mcpprobe/main.go |
Done (this PR) — minimal streamable-HTTP MCP client for Phase 0 recon; seed for the real client |
mdl/backend/<seam>.go |
New narrow unit-persistence interface (read/write/find/schema/validate) |
mdl/backend/mpr/*.go |
Refactor MPR backend to implement high-level methods on top of the seam (Option A) |
mdl/backend/mcp/ (new) |
MCP client backend: PED tool calls + local-reader delegation for reads |
mdl/backend/mcp/client.go (new) |
Streamable-HTTP MCP client with Host: localhost override |
cmd/mxcli/main.go |
BackendFactory dispatch: URL → MCP backend, path → MPR backend |
mdl/ast/ast_connection.go |
Distinguish CONNECT LOCAL vs CONNECT MCP (or a transport field) |
mdl/grammar/MDLParser.g4 |
CONNECT MCP 'url' rule (regenerate with make grammar) |
mdl/visitor/ |
Bridge the new CONNECT form to AST |
mdl/executor/executor_connect.go |
Route to the right backend based on transport |
mdl/backend/mock/backend.go |
Stubs for any new seam methods ("MockBackend.X not configured") |
docs-site/src/ |
"Connecting to a live Studio Pro" user doc + devcontainer networking note |
- Requires Studio Pro 11.11+ with the MCP server enabled (the feature is gated on the running Studio Pro, not the project's Mendix version). The backend should probe the server on connect and fail with an actionable error if the MCP endpoint is absent or the tool surface is unrecognised.
- No
sdk/versions/*.yamlentry needed for MDL syntax (the slice reuses existing domain-model statements); a registry/probe for the MCP server version may be warranted in Phase 3.
Version-aware capability model (ADR-0006)
Now that coverage has grown well past the first slice, the backend's authoring
surface is bounded by what the connected PED accepts, and that boundary moves per
Studio Pro version. PED-limit knowledge is currently scattered across hardcoded
rejections (errJavaActionAuthoring, errNanoflowCreate, errBusinessEventAuthoring,
the ped_create_document whitelist checks, the "delete via Concord" fallbacks), and
an agent has no runtime way to know what is authorable against this version.
ADR-0006 decides the model; this is the build plan, in dependency order:
- Capability report (agent-facing — concern 1). ✅ Shipped.
mxcli mcp capabilities -p app.mpr --mcp …connects, then prints the server identity, the livetools/list, and a curated authorable/blocked summary (mdl/backend/mcp/capabilities.go+cmd/mxcli/mcp.go). Thelive-edit-with-studio-proskill tells the agent to run it before authoring. Implemented as a CLI subcommand, not ashow mcp capabilitiesMDL statement — the MCP backend wires existing MDL and must not extend the language. - Capability registry +
Capabilitiesstruct (concern 2 foundation). ✅ Shipped. A version-keyed table —mdl/backend/mcp/capabilities.yaml(embedded; keyed by MCPserverInfo.version, a different axis than the Mendix-version registry, so co-located with the backend rather than insdk/versions/) — holds the non-probeable facts; a blocked feature carries an optionalavailable_sinceso a lifted limit is a one-line edit.(*Backend).capabilities()merges the table for the connected version with the livetools/listprobe into aCapabilitiesvalue, and the slice-1 report is now generated entirely from it (no hardcoded lists). Still kept in step withPED_MCP_CAPABILITIES.mdby hand on onboarding. - Gate behavior on the model. ✅ Shipped. Each table feature now has a stable
key, and the create rejections consult(*Backend).canAuthor(key)with the message sourced from the table note (notAuthorable) — so the report and the gate read the same row and can't disagree. Wired for the whitelist-driven doc-type creates (CreateNanoflow/UpdateNanoflow,CreateJavaAction/UpdateJavaAction,CreateBusinessEventService/UpdateBusinessEventService); the bundled "nanoflow/java/biz" report row was split into three keyed entries.availablenow means the backend can author it (PED permits and mxcli has a create path), soavailable_sinceis set only when both hold — flipping it then lights the feature up in both the report and the gate. PED-fundamental limits (attribute type change, MOVE/re-parent, empty folder) stay inline as display-only rows — they're not whitelist-driven and won't flip. Today every gated method still rejects (baseline table); unit-tested, behavior-preserving.
PED_MCP_CAPABILITIES.md remains the human-readable narrative over the machine
table; the onboarding procedure already there is extended to update the table.
- Phase 0 probe: ✅ done —
cmd/mcpprobecapturedtools/listand themendix://studio-pro/system-promptresource (locally; see note above). Remaining: capture one fullped_get_schema→ped_update_document(add entity) →ped_check_errorsexchange as a fixture. - Seam refactor (Phase 1): existing
mdl/executor+mdl/backend/mprtests must stay green; add construction-level tests asserting the BSON produced is byte-identical before/after the refactor. - MCP slice (Phase 2): integration test against a running Studio Pro 11.11 +
test project (e.g.
test6-app, the 11.10/11.x reference fixture). Round-trip:CREATE ENTITYvia MCP →ped_check_errorsclean → entity visible in Studio Pro and in a subsequent local read. - Convergence with the BSON oracle: this backend is the natural executor for
PROPOSAL_mcp_bson_benchmark— running the same MDL through the MPR backend and the MCP backend and diffing the resulting.mxunitfiles is both a correctness test and the benchmark that proposal describes. mdl-examples/doctype-tests/: domain-model scripts run against both backends in CI where a Studio Pro instance is available.
Because the PED server is session-based (each client gets its own
Mcp-Session-Id) but backed by a single Studio Pro process with one
in-memory model, several agents can edit the model concurrently if they are
orchestrated to avoid writing the same document at the same time. The
connection multiplexes fine; the model is the shared resource. Verified
empirically: a read succeeds on one client while another session is connected.
The unit of isolation is the document, not the model. Every write tool acts
on one named document (ped_create_document, ped_update_document,
ped_check_errors). Two agents on different documents are isolated; the
hazards are all on the same document.
Orchestration rules:
- Partition by document — and domain models partition by MODULE. A module's
whole domain model is one document, so two agents both adding entities to
Salesare mutating the same/entitiesarray. Assign whole modules (not entities within a module) to agents; assign whole microflows/pages likewise. - Same-array add/remove is the sharp edge.
ped_update_documentuses positional paths (/entities/N,remove index N); the skill's own "re-read after every mutation, indices shift" rule is unenforceable across two writers (TOCTOU). If two agents must touch one document, funnel those writes through a single serialized owner. - Order cross-document dependencies first. PED resolves references by name against the live model at write time: the enum before the entity that uses it, the view-entity source doc before the entity, the callee before the caller. The orchestrator topologically sorts dependency edges and creates dependencies before dependents.
- Make writes idempotent for timeout-retry. Slow creates can return
-32000 Request timed outeven though they succeeded (Studio Pro appears to serialize work on its UI thread). Retries must check-then-act (ped_find_document/read before create) and treat "already exists" as done; blind retry produces duplicates. ped_check_errorssees everyone's in-progress state. It validates the live model as-is, including another agent's half-finished edits. Validate at a quiescent point, scoped to your own documents, tolerant of unrelated transient errors.- No per-agent commit; one human save at the end. There is no flush tool — all agents accumulate into one unsaved in-memory model and a human saves once in Studio Pro. No agent can durably commit its slice independently.
Hybrid-backend wrinkle. mxcli reads/enumerates from the local .mpr and
writes via MCP, so an agent's existence checks (findModule, ListMicroflows,
enum validation) read last-saved disk state and the dirty-set router only
tracks the current process's own writes. Multiple mxcli agents therefore cannot
see each other's unsaved work through the local reader. The orchestrator must
own the dependency graph (create dependencies first and tell dependents they
exist), or route shared-dependency lookups through MCP (ped_read_document)
rather than the local reader.
The recurring procedure for this is captured in
.claude/skills/orchestrate-mcp-agents.md.
- MCP tool surface — ✅ resolved (see "MCP tool surface"; captured locally).
Decision needed: do we vendor the
tools.jsonschemas and/or the Maia system-prompt into the repo as reference? The schemas are useful for implementation; the system prompt is Mendix-internal content. Follow-ups: the exact$constructorschema for adding an entity viaped_update_document, and the per-tooltaskSupport: forbiddenflag (does it block any of our calls, or only async "tasks"?). - Streamable-HTTP handshake — ✅ resolved.
cmd/mcpprobecompletes initialize →notifications/initialized→ call, handling JSON and SSE,Mcp-Session-Id, and theHostoverride. Server is protocol2025-06-18, single-JSON responses observed (no SSE needed so far). Decide: growmcpprobe's client core intomdl/backend/mcp/client.govs adopt a library. - Unsaved-state semantics — ✅ answered (measured). MCP edits apply to
Studio Pro's in-memory model; most (
ped_update_document) are not persisted to disk until the user saves,ped_check_errorsdoes not flush, and there is no save tool. Onlyped_create_moduleflushed immediately. → drives the "consistency hole" + dirty-set router in the Read-path section. Open follow-up: is there any flush path (a save tool in another version, an autosave timer, apg_*equivalent), and doespg_write_pageflush like module-create or stay in memory likeped_update_document? - Devcontainer networking — ✅ root-caused (IPv6-loopback bind) and bridged
with a host-side
socatIPv4→[::1]forwarder (see "Transport"). Remaining is productisation (ship the forwarder / host-bind option / per-OS matrix), not feasibility. - Error-recovery UX —
ped_check_errorsis a one-shot, post-application fix protocol. mxcli's strength is pre-flightcheck. How do we reconcile: runmxcli checklocally before shipping any write, so MCP writes are only attempted for scripts that already pass static validation? - Concurrency — ✅ characterised (see "Multi-agent orchestration"). Writes
through the single Studio Pro process sidestep the file-lock problem in
PROPOSAL_concurrent_access: the server is session-multiplexed and the model is the shared resource, so concurrent agents are safe when partitioned by document (modules for domain models) with dependency ordering and idempotent retries. Open follow-up: confirm whether the server processes sessions truly in parallel or serialises on the Studio Pro UI thread (the-32000timeouts under load suggest serialisation).