Skip to content

Commit bd82d1e

Browse files
akoclaude
andcommitted
fix(mcp): reject CREATE VIEW ENTITY instead of leaving it out of sync
A view entity created over MCP showed "View Entity is out of sync with the OQL Query" in Studio Pro (CE6770). Root cause: a synced view entity needs each attribute's value to be an OqlViewValue, but PED's Attribute schema exposes no `value`, a `set` on the nested value is refused ("only primitive or reference properties"), and the sync (syncViewEntity) is reachable only through the LLM-backed `oql_generate` tool — which regenerates the query from a natural- language intent rather than writing the user's OQL verbatim (and is slow/may need Maia config). PED's deterministic write tools therefore cannot produce a synced view entity. Rather than write the OQL + entity and leave a broken, error-flagged entity in the model, reject up front. CreateViewEntitySourceDocument is the executor's first view-entity backend call (before the companion entity), so the rejection leaves nothing half-created. Gated on capabilities.yaml/view_entities (flipped to available:false with the reason); verified live that it rejects with an actionable error and creates no source doc or entity. Removed view entities from 01-mcp-domain-model-examples.mdl (now ~20 statements, all of which run clean over MCP) and from the MCP docs; the cost-doc tally is softened to "~60 calls" and no longer pinned to an exact, drifting statement count. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 730f70e commit bd82d1e

7 files changed

Lines changed: 62 additions & 132 deletions

File tree

docs-site/src/internals/mcp-backend-cost.md

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ and what it means for latency, token usage, and an LLM's context window.
1616

1717
> The numbers below trace the worked example
1818
> [`mdl-examples/doctype-tests/01-mcp-domain-model-examples.mdl`](https://github.com/mendixlabs/mxcli/blob/main/mdl-examples/doctype-tests/01-mcp-domain-model-examples.mdl)
19-
> — a 25-statement domain-model script that stays entirely inside the MCP
19+
> — a ~20-statement domain-model script that stays entirely inside the MCP
2020
> authoring surface. To see the surface for your connected version, run
2121
> `mxcli mcp capabilities -p app.mpr --mcp`.
2222
@@ -29,17 +29,19 @@ The choreography — not the statement count — drives the cost.
2929
|---|---|---|
3030
| `create module` | `ped_create_module` | 1 |
3131
| `create enumeration` | `ped_create_document` + `ped_check_errors` | 2 |
32-
| `create entity` (incl. `extends`, non-persistent) | `ped_update_document` (add) + `ped_check_errors` | 2 |
33-
| `create association` | `ped_update_document` (add) + `ped_check_errors` | 2 |
34-
| `create view entity` | create source-doc + set `/oql` + entity add + check | 4 |
32+
| `create entity` (incl. `extends`) | `ped_update_document` (add) + `ped_check_errors` | 2 |
33+
| … plus each `NOT NULL` / `UNIQUE` rule | `ped_read_document` (locate entity) + `ped_update_document` (add rules) | +2 |
34+
| `create association` | `ped_read_document` (resolve parent/child entity refs) + `ped_update_document` (add) + `ped_check_errors` | 4 |
3535
| `alter entity` (add / rename / set documentation) |`ped_read_document` (locate + read live attrs) + `ped_update_document` + `ped_check_errors` | 4 |
3636
| `show` / `describe` of a **just-modified** module | `ped_read_document` (entities + associations) + an enrichment read for attribute types | 2 |
3737
| `show enumerations` (and other reads of unchanged docs) | served from the local `.mpr` | 0 |
38-
| one-time `ped_get_schema` per element type (entity, attribute, association, view source) | fetched once per session, then cached | 4 total |
38+
| one-time `ped_get_schema` per element type (entity, attribute, association, validation rule) | fetched once per session, then cached | ~4 total |
3939
| `initialize` handshake | once per connection | 1 |
4040

41-
For the 25-statement example that works out to **≈ 62 tool calls** on the happy
42-
path: ~47 writes-plus-validation, ~10 read-backs, 4 schema fetches, 1 handshake.
41+
For a ~20-statement domain-model script like the example, that's **on the order of
42+
60 tool calls** — overwhelmingly per-write validation and read-backs, not the
43+
statement count. (Run it with `--mcp-trace` to see the exact tally for your
44+
script; the precise number shifts as the example evolves.)
4345

4446
> **See it yourself.** Run any script with `--mcp-trace` (or `--mcp-verbose`) to
4547
> print the actual PED calls each MDL command makes — the runtime view of this
@@ -48,7 +50,7 @@ path: ~47 writes-plus-validation, ~10 read-backs, 4 schema fetches, 1 handshake.
4850
### Three things inflate the count
4951

5052
1. **Validation runs after every write.** `ped_check_errors` fires once per write
51-
statement, so **~17 of the 62 calls are validation alone**. This keeps the live
53+
statement, so **a large share of the calls are validation alone**. This keeps the live
5254
model provably consistent at each step, but it is not free.
5355
2. **`ALTER` is read-modify-write.** Because PED cannot change an attribute's type
5456
in place, mxcli first *reads* the live entity (to tell a rename from a drop+add,
@@ -73,9 +75,9 @@ statements. The practical levers:
7375

7476
- **Batch writes into one script run**, not many small invocations — the
7577
`initialize` handshake and schema fetches are paid once per connection.
76-
- **Drop trailing `SHOW`/`DESCRIBE` verification** from production scripts. In the
77-
example, the eight read-back lines account for ~10 of the 62 calls; removing them
78-
cuts roughly a sixth of the traffic.
78+
- **Drop trailing `SHOW`/`DESCRIBE` verification** from production scripts. Each
79+
read-back of a just-written module costs ~2 calls, so the verification block is an
80+
easy chunk to cut when you don't need it.
7981
- **The file backend has zero round trips.** If Studio Pro doesn't need to be open
8082
— e.g. CI, bulk generation, or scripted migrations — the file backend applies the
8183
same script far faster. Reserve `--mcp` for when live, validated, no-reload edits
@@ -90,7 +92,7 @@ hinges on a single question: *who makes the tool calls?*
9092

9193
When an agent runs `mxcli exec script.mdl -p app.mpr --mcp` (via the shell, the
9294
REPL, or an editor command), the agent emits **one** action — the MDL script — and
93-
receives **one** summarized result. The ~62 PED tool calls, and their often
95+
receives **one** summarized result. The ~60 PED tool calls, and their often
9496
verbose, schema-laden JSON responses, happen *inside the mxcli process*. They never
9597
enter the model's context window, and they don't each cost a model turn.
9698

@@ -104,12 +106,12 @@ That is a few thousand tokens, regardless of how many PED round trips it took.
104106
### Agent → Studio Pro MCP directly (expensive)
105107

106108
An agent connected straight to Studio Pro's MCP server has no such amortization. To
107-
build the same domain model it must issue each of the ~62 calls **as its own tool
109+
build the same domain model it must issue each of the ~60 calls **as its own tool
108110
call**:
109111

110112
- every request *and* every response (schemas, validation output, read-backs) is
111113
written into the context window, and
112-
- each call is a separate model turn — so the model "thinks" ~62 times instead of
114+
- each call is a separate model turn — so the model "thinks" ~60 times instead of
113115
once, multiplying both latency and per-turn token cost.
114116

115117
The same work that is one declarative script for the mxcli path becomes dozens of
@@ -120,14 +122,14 @@ imperative, stateful tool calls whose intermediate results accumulate in context
120122
```
121123
calls in LLM context model turns tokens in context
122124
Agent → mxcli (--mcp) 0 1 low (script + summary)
123-
Agent → Studio Pro MCP ~62 ~62 high (every request + result)
125+
Agent → Studio Pro MCP ~60 ~60 high (every request + result)
124126
```
125127

126128
mxcli's MCP backend is, in effect, a **compression layer over the PED tool surface**:
127129
the LLM works in compact, reviewable MDL while mxcli absorbs the chatty,
128130
validation-heavy, read-modify-write round trips on the model's behalf. For bulk
129131
authoring this is the difference between one cheap turn and dozens of expensive
130-
ones — and it is why, even though a script "costs" ~62 tool calls, running it
132+
ones — and it is why, even though a script "costs" ~60 tool calls, running it
131133
*through mxcli* keeps that cost out of the model's context.
132134

133135
## See also

docs-site/src/tools/mcp-connect.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ mxcli exec mdl-examples/doctype-tests/01-mcp-domain-model-examples.mdl \
8282
--mcp-dial host.docker.internal:7784
8383
```
8484

85-
The new entities, enumerations, associations, and view entities appear in Studio
86-
Pro as the script runs — no save or reload required.
85+
The new entities, enumerations, and associations appear in Studio Pro as the
86+
script runs — no save or reload required.
8787

8888
The same `--mcp` / `--mcp-dial` flags work for a single statement (`-c "…"`) and in
8989
the interactive REPL (`mxcli -p app.mpr --mcp …` with no subcommand) — in every
@@ -151,12 +151,12 @@ mxcli exec changes.mdl -p app.mpr --mcp http://localhost/mcp --mcp-dial 127.0.0.
151151

152152
## What works over MCP
153153

154-
The MCP backend authors a subset of MDL — entities (incl. `extends` and
155-
`ALTER`), associations, enumerations, constants, microflows, pages (`CREATE` +
156-
`ALTER`), workflows, and view entities. Some document types (nanoflows, Java
157-
actions, business-event services), security, navigation, and `MOVE` are **not**
158-
authorable over MCP today. Reads of any document type always work (they come from
159-
the local `.mpr`).
154+
The MCP backend authors a subset of MDL — entities (incl. `extends`, `ALTER`, and
155+
NOT NULL / UNIQUE), associations, enumerations, constants, microflows, pages
156+
(`CREATE` + `ALTER`), and workflows. Some document types (nanoflows, Java actions,
157+
business-event services, **view entities**), security, navigation, and `MOVE` are
158+
**not** authorable over MCP today. Reads of any document type always work (they
159+
come from the local `.mpr`).
160160

161161
`mxcli mcp capabilities -p app.mpr --mcp … --mcp-dial …` reports exactly what your
162162
connected version supports. See also

mdl-examples/doctype-tests/01-mcp-domain-model-examples.mdl

Lines changed: 5 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@
1818
-- - create persistent entity, with NOT NULL / UNIQUE validation rules (+ error messages)
1919
-- - create entity ... extends ... (generalization)
2020
-- - create association ... (reference, referenceset) WITHIN a single module
21-
-- - create view entity ... as <OQL>
2221
-- - alter entity ... add / rename attribute, set documentation
2322
--
2423
-- OUT of scope — NOT used here, with reasons (capabilities.yaml):
24+
-- - view entities (OQL) PED syncs view-entity attributes to the OQL only via
25+
-- the LLM-backed oql_generate tool; its deterministic
26+
-- tools leave the entity out of sync (CE6770)
2527
-- - default values the entity slice doesn't author attribute defaults yet
2628
-- (PED's attribute object carries name/type only)
2729
-- - non-persistent entities PED's entity constructor exposes no persistable flag
@@ -206,51 +208,8 @@ create persistent entity McpDm.Truck extends McpDm.Vehicle (
206208
);
207209
/
208210

209-
-- @version: 10.18+
210211
-- ############################################################################
211-
-- PART 5: VIEW ENTITIES (OQL) — requires Mendix 10.18+
212-
-- ############################################################################
213-
214-
/**
215-
* Active products only.
216-
*/
217-
@position(1100, 100)
218-
create view entity McpDm.ActiveProducts (
219-
Name: string(200),
220-
Code: string(50),
221-
Price: decimal,
222-
Stock: integer
223-
) as
224-
select
225-
p.Name as Name,
226-
p.Code as Code,
227-
p.Price as Price,
228-
p.Stock as Stock
229-
from McpDm.Product as p
230-
where p.IsActive = true;
231-
/
232-
233-
/**
234-
* Order totals per customer, joined via the association.
235-
*/
236-
@position(1100, 300)
237-
create view entity McpDm.CustomerOrderTotals (
238-
CustomerEmail: string(200),
239-
OrderCount: integer,
240-
TotalSpent: decimal
241-
) as
242-
select
243-
c.Email as CustomerEmail,
244-
count(o.OrderNumber) as OrderCount,
245-
sum(o.TotalAmount) as TotalSpent
246-
from McpDm.SalesOrder as o
247-
inner join o/McpDm.SalesOrder_Customer/McpDm.Customer as c
248-
GROUP by c.Email;
249-
/
250-
251-
-- @version: any
252-
-- ############################################################################
253-
-- PART 6: ALTER ENTITY (add / rename attribute, documentation)
212+
-- PART 5: ALTER ENTITY (add / rename attribute, documentation)
254213
-- ############################################################################
255214
--
256215
-- Only the ALTER actions the MCP backend supports are used here: add attribute
@@ -274,12 +233,11 @@ alter entity McpDm.Customer
274233
set comment 'Customer master, MCP-authored.';
275234

276235
-- ############################################################################
277-
-- PART 7: VERIFY (reads always work over MCP — they delegate to the local .mpr)
236+
-- PART 6: VERIFY (reads always work over MCP — they delegate to the local .mpr)
278237
-- ############################################################################
279238

280239
show entities in McpDm;
281240
show enumerations in McpDm;
282241
describe entity McpDm.Customer;
283242
describe entity McpDm.SalesOrder;
284243
describe entity McpDm.Truck;
285-
describe entity McpDm.CustomerOrderTotals;

mdl/backend/mcp/capabilities.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const (
2424
capNanoflowCreate = "nanoflow_create"
2525
capJavaActionCreate = "javaaction_create"
2626
capBusinessEventCreate = "businessevent_create"
27+
capViewEntityCreate = "view_entities"
2728
)
2829

2930
// Capability is one authorable/blocked feature for a given server version.

mdl/backend/mcp/capabilities.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ features:
5151
available: true
5252
note: "CREATE / CREATE OR REPLACE / DROP / ALTER (full, any nesting depth)"
5353
- key: view_entities
54-
feature: "View entities"
55-
available: true
56-
note: "CREATE"
54+
feature: "View entities — CREATE"
55+
available: false
56+
note: "PED syncs a view entity's attributes to its OQL only via the LLM-backed oql_generate tool; its deterministic write tools leave attributes as StoredValue, out of sync with the query (CE6770). mxcli writes user OQL verbatim and does not invoke the generator, so it does not author view entities over MCP"
5757
- key: documents_into_folders
5858
feature: "Documents into folders"
5959
available: true

mdl/backend/mcp/viewentity.go

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,33 +6,25 @@ import (
66
"github.com/mendixlabs/mxcli/model"
77
)
88

9-
const viewEntitySourceDocType = "DomainModels$ViewEntitySourceDocument"
10-
11-
// CreateViewEntitySourceDocument creates the OQL source document that backs a
12-
// view entity, then sets its query. The companion view entity (created
13-
// afterwards by the executor via CreateEntity) references this document by
14-
// qualified name through its OqlViewEntitySource. By convention the source
15-
// document's name equals the view entity's name.
9+
// CreateViewEntitySourceDocument is gated on the capability model
10+
// (capabilities.yaml / view_entities) and rejects. A view entity is only valid
11+
// when its attributes are synced to the OQL's SELECT columns (each attribute's
12+
// value becomes an OqlViewValue). PED has no deterministic way to do that — its
13+
// Attribute schema exposes no `value`, a `set` on the nested value is refused,
14+
// and the sync (syncViewEntity) is reachable only through the LLM-backed
15+
// oql_generate tool, which regenerates the query rather than writing the user's
16+
// OQL verbatim. Writing the OQL with PED's plain tools leaves the entity out of
17+
// sync with its query (CE6770). So mxcli refuses up front instead of leaving a
18+
// broken entity in the model.
1619
//
17-
// Choreography (verified live): ped_create_document {name} -> ped_update_document
18-
// set /oql. No ped_check_errors here: the source document is "unlinked" until
19-
// the entity referencing it exists, so validation runs after the entity write.
20+
// This is the executor's first view-entity backend call (before the companion
21+
// entity is created), so rejecting here leaves nothing half-created.
2022
func (b *Backend) CreateViewEntitySourceDocument(moduleID model.ID, moduleName, docName, oqlQuery, documentation string) (model.ID, error) {
21-
if err := b.ensureSchema(viewEntitySourceDocType); err != nil {
22-
return "", err
23-
}
24-
if err := b.pedCreateDocument(moduleName, viewEntitySourceDocType, docName, map[string]any{"name": docName}, ""); err != nil {
25-
return "", err
26-
}
2723
qn := moduleName + "." + docName
28-
ops := []pedOpEntry{{Path: "/oql", Operation: pedOperation{Type: "set", Value: oqlQuery}}}
29-
if documentation != "" {
30-
ops = append(ops, pedOpEntry{Path: "/documentation", Operation: pedOperation{Type: "set", Value: documentation}})
31-
}
32-
if err := b.pedUpdateDoc(viewEntitySourceDocType, qn, ops...); err != nil {
33-
return "", err
24+
if !b.canAuthor(capViewEntityCreate) {
25+
return "", b.notAuthorable("view entity", qn, capViewEntityCreate)
3426
}
35-
return model.ID("mcp~vesrc~" + qn), nil
27+
return "", errCreatePathUnbuilt("view entity", qn)
3628
}
3729

3830
// DeleteViewEntitySourceDocumentByName is a no-op: the PED server exposes no

mdl/backend/mcp/viewentity_test.go

Lines changed: 12 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -53,42 +53,19 @@ func TestBuildEntityValue_ViewEntity_RequiresSourceRef(t *testing.T) {
5353
}
5454
}
5555

56-
func TestCreateViewEntitySourceDocument_Choreography(t *testing.T) {
57-
f := newFakePED(t, func(string, map[string]any) (string, bool) { return "SUCCESS", false })
58-
b := &Backend{
59-
client: f.connectClient(t),
60-
dirty: map[string]bool{},
61-
schemaFetched: map[string]bool{},
56+
// View entities are not authorable over MCP: PED can only sync a view entity's
57+
// attributes to its OQL via the LLM-backed oql_generate tool, so the deterministic
58+
// path leaves the entity out of sync (CE6770). CreateViewEntitySourceDocument is
59+
// the executor's first view-entity backend call and must reject before any PED
60+
// write, leaving nothing half-created.
61+
func TestCreateViewEntitySourceDocument_Rejected(t *testing.T) {
62+
b := &Backend{} // no client/server needed — it rejects before any PED call
63+
_, err := b.CreateViewEntitySourceDocument("m1", "MyFirstModule", "LocView", "select 1 as X", "")
64+
if err == nil {
65+
t.Fatal("expected view-entity creation to be rejected over MCP")
6266
}
63-
64-
id, err := b.CreateViewEntitySourceDocument("m1", "MyFirstModule", "LocView", "select 1 as X", "")
65-
if err != nil {
66-
t.Fatalf("CreateViewEntitySourceDocument: %v", err)
67-
}
68-
if id == "" {
69-
t.Error("expected a non-empty source document id")
70-
}
71-
72-
create, ok := f.callByName("ped_create_document")
73-
if !ok {
74-
t.Fatal("ped_create_document not called")
75-
}
76-
craw, _ := json.Marshal(create.Args["documents"])
77-
if !strings.Contains(string(craw), `"documentType":"DomainModels$ViewEntitySourceDocument"`) ||
78-
!strings.Contains(string(craw), `"documentName":"LocView"`) {
79-
t.Errorf("create-document args wrong: %s", craw)
80-
}
81-
82-
update, ok := f.callByName("ped_update_document")
83-
if !ok {
84-
t.Fatal("ped_update_document (set /oql) not called")
85-
}
86-
uraw, _ := json.Marshal(update.Args["operations"])
87-
if !strings.Contains(string(uraw), `"path":"/oql"`) || !strings.Contains(string(uraw), `"select 1 as X"`) {
88-
t.Errorf("set /oql op wrong: %s", uraw)
89-
}
90-
if update.Args["documentName"] != "MyFirstModule.LocView" {
91-
t.Errorf("oql set targeted wrong doc: %v", update.Args["documentName"])
67+
if !strings.Contains(err.Error(), "not authorable") {
68+
t.Errorf("error should explain the view entity is not authorable: %v", err)
9269
}
9370
}
9471

0 commit comments

Comments
 (0)