Skip to content

Commit b92dfa3

Browse files
akoclaude
andcommitted
feat(mcp): create documents into folders (folder clause), nested + auto-created
Wire folder placement for document creates, which is what the doctype-tests scripts need most (create ... folder 'A/B' is widespread; standalone create folder is not used). PED's mechanism is the inverse of the executor's: it can't create an empty Projects$Folder (off the create whitelist) and can't re-parent an existing document (/folderPath isn't settable), but ped_create_document accepts a folderPath that auto-creates the whole path. Bridge the two: - CreateFolder records the folder as pending (no PED call); ListFolders merges these so the executor's resolveFolder builds nested paths against them. - resolveDocContainer maps a document's container (module or folder) to (moduleName, folderPath); the ped_* create paths (microflow, constant, enum, workflow) pass folderPath to ped_create_document, so the folder materializes when the document lands in it. moduleNameForContainer is now folder-aware too. - pedCreateDocument gains a folderPath param ("" = module root, omitted from the request — non-foldered creates are byte-identical to before). - Pages: CreatePage tolerates a folder container (was failing at GetModule) but creates at the module root — pg_write_page has no folderPath, so pages can't be foldered over MCP (documented; the page is still created and addressable). - DeleteFolder / MoveFolder (and the Move* family) are rejected with clear errors: PED can't delete folders or re-parent documents. Verified live against Studio Pro 11.11: create microflow ... folder 'Processing/Archive' and create constant ... folder 'Config' land in those folders (both levels auto-created), ped_list_folder confirms, ped_check_errors clean. Unit test covers resolveDocContainer (root/one-level/nested), CreateFolder→resolve, and the DROP/MOVE rejections. GetModule/findFolder are now nil-reader-safe. Build + tests + lint green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e5f2283 commit b92dfa3

14 files changed

Lines changed: 221 additions & 57 deletions

docs/01-project/MDL_FEATURE_MATRIX.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ live distinction is **MPR vs MCP**.
6969

7070
| Feature | Mendix | MDL | MPR | MCP | MCP notes |
7171
|---------|:------:|:---:|:---:|:---:|-----------|
72-
| **Folders** | Y | Y | Y | N | Not wired |
73-
| **MOVE** | Y | Y | Y | N | Not wired |
72+
| **Folders** | Y | Y | Y | P | Documents can be **created into** a folder (`create <doc> … folder 'A/B'`, nested ok — the folder auto-materializes). Empty `CREATE FOLDER`, `DROP FOLDER`, `MOVE FOLDER` rejected (PED can't create-empty / delete / re-parent). Pages land at the module root (`pg_write_page` has no folderPath). |
73+
| **MOVE** | Y | Y | Y | N | Blocked — PED can't re-parent an existing document (a document's `folderPath` is not settable). Place documents in folders at *create* time instead. |
7474

7575
### External SQL, import, catalog & analysis
7676

docs/03-development/PED_MCP_CAPABILITIES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ since a gap closing (e.g. a delete or save tool appearing) unlocks features.
7979
| **Array reads omit primitive types** | A `/entities/N/attributes` read gives attribute names (`$QualifiedName`) but not their primitive type or documentation — those need a per-*leaf* read (`/entities/N/attributes/M/type` → a `DomainModels$*AttributeType` constructor; `/…/documentation` → string). Reconstruction recovers them with a single **batched** leaf read (`enrichReconstructedEntities`) so a dirty/session module reports real types (correct DESCRIBE + a reliable ALTER diff); it falls back to placeholder `String` only if the read fails. | Recheck attribute-array read shape. |
8080
| **No in-place attribute-type change** | `set /entities/N/attributes/M/type` is rejected (`"only allowed to set primitive or reference properties directly"` — the type is a nested element), and a whole-attribute replace is rejected too (`"only allowed to update elements …"`). The only route to a new type is remove+add, which drops the attribute's `$ID`**drops the column data**. So `ALTER ENTITY … MODIFY ATTRIBUTE <type>` is rejected over MCP; do it against a local `.mpr` (Studio Pro does an in-place migration PED can't). | Watch for a type-change / migrate op. |
8181
| **Two write protocols** | Pages **must** use `pg_*`; everything else uses `ped_*`. The system prompt forbids PED for pages. | Stable, but reconfirm. |
82-
| **`ped_create_document` doc-type whitelist** | The create tool accepts only certain document types; some model documents are rejected even though they have a `$constructor` schema. Confirmed off the whitelist: **`Microflows$Nanoflow`** (`"Document type 'Microflows$Nanoflow' cannot be created. Did you mean: Microflows$Microflow?"`) — so nanoflows can't be created over MCP despite sharing the microflow body shape (microflows are accepted). CREATE/ALTER NANOFLOW are rejected with an actionable error (`mdl/backend/mcp/nanoflow.go`); DROP works via Concord; calling a nanoflow from a microflow/page is unaffected. | Re-probe the create whitelist each version. |
82+
| **`ped_create_document` doc-type whitelist** | The create tool accepts only certain document types; some model documents are rejected even though they have a `$constructor` schema. Confirmed off the whitelist: **`Microflows$Nanoflow`** (`"Did you mean: Microflows$Microflow?"`) and **`Projects$Folder`** (empty folders). So nanoflows aren't creatable over MCP (CREATE/ALTER rejected — `nanoflow.go`; DROP works via Concord), and an *empty* folder can't be created. **But `ped_create_document` accepts a `folderPath` per document, which auto-creates the whole folder path** — so a document is placed in a (possibly nested) folder at create time (`folder.go`/`resolveDocContainer`). What you can't do: re-parent an *existing* document — `/folderPath` is not a settable property (so `MOVE`/`DROP FOLDER`/`MOVE FOLDER` are rejected), and pages can't be foldered (`pg_write_page` takes no folderPath). | Re-probe the create whitelist each version. |
8383
| **No Java action document creation** | `ped_create_document` rejects `JavaActions$JavaAction` outright (`"Document type 'JavaActions$JavaAction' cannot be created."`) — a Java action is backed by a `.java` source file Studio Pro generates/manages, so the model document can't be created standalone. `CREATE/ALTER JAVA ACTION` are rejected with an actionable error (`mdl/backend/mcp/javaaction.go`); *calling* a Java action from a microflow is unaffected. | Watch for a Java-action create tool. |
8484
| **No security document ops** | PED's `ped_find/read/update/create_document` reject every security type (`Security$ModuleSecurity`, `Security$ProjectSecurity`, …) as "Unknown document type" — only `ped_get_schema` knows them as nested *elements*. Concord exposes only security *reads* (`audit_security`, `read_entity_access_rules`, `read_microflow_security`, `read_security_info`). So **security cannot be authored via MCP** (module/user roles, entity access rules, GRANT/REVOKE, demo users, project security level) — neither server has a write path. Determine support with `ped_read_document`, NOT `ped_find_document`: `find` also reports the (supported) nameless `DomainModels$DomainModel` as "Unknown", so it is not a reliable probe. | Watch for security write tools on either server. |
8585

mdl/backend/mcp/backend.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ type Backend struct {
8989
// (duplicate detection and create-then-reference within one run).
9090
sessionMicroflows []*microflows.Microflow
9191

92+
// sessionFolders holds folders the executor asked to create this session. PED
93+
// can't create an empty folder, so these are pending until a document is created
94+
// into one (which auto-creates the path); they are merged into ListFolders and
95+
// drive folder-path resolution for foldered document creates.
96+
sessionFolders []*types.FolderInfo
97+
9298
// sessionPages holds pages created over MCP this session, merged into
9399
// ListPages (the executor's duplicate/role checks read it).
94100
sessionPages []*pages.Page
@@ -281,6 +287,9 @@ func (b *Backend) GetModule(id model.ID) (*model.Module, error) {
281287
return m, nil
282288
}
283289
}
290+
if b.reader == nil {
291+
return nil, fmt.Errorf("module %s not found", id)
292+
}
284293
return b.reader.GetModule(id)
285294
}
286295

mdl/backend/mcp/concord.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,7 @@ func (b *Backend) StopApp() error {
172172
// moduleNameForContainer resolves a container (module) ID to its module name,
173173
// session-aware so freshly created modules resolve too.
174174
func (b *Backend) moduleNameForContainer(containerID model.ID) (string, error) {
175-
mod, err := b.GetModule(containerID)
176-
if err != nil {
177-
return "", err
178-
}
179-
return mod.Name, nil
175+
// Folder-aware: a document's container may be a folder, not the module directly.
176+
name, _, err := b.resolveDocContainer(containerID)
177+
return name, err
180178
}

mdl/backend/mcp/constant.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ const constantDocType = "Constants$Constant"
2020
// exposedToClient only — there is no documentation field, and the type enum is
2121
// limited (see pedConstantType).
2222
func (b *Backend) CreateConstant(c *model.Constant) error {
23-
mod, err := b.GetModule(c.ContainerID)
23+
moduleName, folderPath, err := b.resolveDocContainer(c.ContainerID)
2424
if err != nil {
25-
return fmt.Errorf("resolve module for constant %q: %w", c.Name, err)
25+
return fmt.Errorf("resolve container for constant %q: %w", c.Name, err)
2626
}
2727
content, err := buildConstantContent(c)
2828
if err != nil {
@@ -31,14 +31,14 @@ func (b *Backend) CreateConstant(c *model.Constant) error {
3131
if err := b.ensureSchema(constantDocType); err != nil {
3232
return err
3333
}
34-
if err := b.pedCreateDocument(mod.Name, constantDocType, c.Name, content); err != nil {
34+
if err := b.pedCreateDocument(moduleName, constantDocType, c.Name, content, folderPath); err != nil {
3535
return err
3636
}
3737
if c.ID == "" {
38-
c.ID = model.ID("mcp~const~" + mod.Name + "~" + c.Name)
38+
c.ID = model.ID("mcp~const~" + moduleName + "~" + c.Name)
3939
}
4040
b.sessionConstants = append(b.sessionConstants, c)
41-
return b.pedCheckDocument(constantDocType, mod.Name+"."+c.Name)
41+
return b.pedCheckDocument(constantDocType, moduleName+"."+c.Name)
4242
}
4343

4444
// UpdateConstant (CREATE OR MODIFY on an existing constant) sets the value and

mdl/backend/mcp/domainmodel.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -884,14 +884,20 @@ func (b *Backend) pedCheckDocument(docType, docName string) error {
884884

885885
// pedCreateDocument creates a standalone document (enumeration, microflow, …)
886886
// via ped_create_document. documentContent is the type's $constructor body.
887-
func (b *Backend) pedCreateDocument(moduleName, docType, docName string, content any) error {
887+
func (b *Backend) pedCreateDocument(moduleName, docType, docName string, content any, folderPath string) error {
888+
doc := map[string]any{
889+
"documentType": docType,
890+
"moduleName": moduleName,
891+
"documentName": docName,
892+
"documentContent": content,
893+
}
894+
// A non-empty folderPath places the document in that folder, auto-creating the
895+
// whole path (PED has no empty-folder create); "" leaves it at the module root.
896+
if folderPath != "" {
897+
doc["folderPath"] = folderPath
898+
}
888899
res, err := b.client.CallTool("ped_create_document", map[string]any{
889-
"documents": []map[string]any{{
890-
"documentType": docType,
891-
"moduleName": moduleName,
892-
"documentName": docName,
893-
"documentContent": content,
894-
}},
900+
"documents": []map[string]any{doc},
895901
})
896902
if err != nil {
897903
return err

mdl/backend/mcp/enumeration.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,21 @@ const enumerationDocType = "Enumerations$Enumeration"
1414
// registers it in the session so it is visible to ListEnumerations this run
1515
// (e.g. a subsequent CREATE ENTITY with an attribute of this enum type).
1616
func (b *Backend) CreateEnumeration(enum *model.Enumeration) error {
17-
mod, err := b.GetModule(enum.ContainerID)
17+
moduleName, folderPath, err := b.resolveDocContainer(enum.ContainerID)
1818
if err != nil {
19-
return fmt.Errorf("resolve module for enumeration %q: %w", enum.Name, err)
19+
return fmt.Errorf("resolve container for enumeration %q: %w", enum.Name, err)
2020
}
2121
if err := b.ensureSchema(enumerationDocType); err != nil {
2222
return err
2323
}
24-
if err := b.pedCreateDocument(mod.Name, enumerationDocType, enum.Name, buildEnumContent(enum)); err != nil {
24+
if err := b.pedCreateDocument(moduleName, enumerationDocType, enum.Name, buildEnumContent(enum), folderPath); err != nil {
2525
return err
2626
}
2727
if enum.ID == "" {
28-
enum.ID = model.ID("mcp~enum~" + mod.Name + "~" + enum.Name)
28+
enum.ID = model.ID("mcp~enum~" + moduleName + "~" + enum.Name)
2929
}
3030
b.sessionEnums = append(b.sessionEnums, enum)
31-
return b.pedCheckDocument(enumerationDocType, mod.Name+"."+enum.Name)
31+
return b.pedCheckDocument(enumerationDocType, moduleName+"."+enum.Name)
3232
}
3333

3434
// UpdateEnumeration (CREATE OR MODIFY on an existing enumeration) is not yet

mdl/backend/mcp/enumeration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func TestPedCreateDocument_SendsEnumConstructor(t *testing.T) {
4646
b := &Backend{client: f.connectClient(t), dirty: map[string]bool{}}
4747

4848
enum := &model.Enumeration{Name: "OrderState", Values: []model.EnumerationValue{enumVal("Open", "Open")}}
49-
if err := b.pedCreateDocument("MyFirstModule", enumerationDocType, enum.Name, buildEnumContent(enum)); err != nil {
49+
if err := b.pedCreateDocument("MyFirstModule", enumerationDocType, enum.Name, buildEnumContent(enum), ""); err != nil {
5050
t.Fatalf("pedCreateDocument: %v", err)
5151
}
5252
call, ok := f.callByName("ped_create_document")

mdl/backend/mcp/folder.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package mcp
4+
5+
import (
6+
"fmt"
7+
"strings"
8+
9+
"github.com/mendixlabs/mxcli/mdl/types"
10+
"github.com/mendixlabs/mxcli/model"
11+
)
12+
13+
// Folders over MCP work the way PED allows: a document created with a folderPath
14+
// auto-creates the whole path, but PED can neither create an *empty* folder
15+
// (Projects$Folder is off the create whitelist) nor re-parent an existing document
16+
// (a document's folderPath is not a settable property). So:
17+
//
18+
// - CreateFolder records the folder as pending; it materializes on disk when the
19+
// first document is created into it (the executor places documents into folders
20+
// it asked us to create, by setting their container to the folder's ID).
21+
// - The create paths resolve a document's container — module or folder — to a
22+
// (moduleName, folderPath) pair and pass folderPath to ped_create_document.
23+
// - DeleteFolder and MoveFolder (and the per-document Move* family) are rejected:
24+
// PED exposes no folder delete and no document re-parent.
25+
26+
// CreateFolder records a pending folder. PED can't create an empty one, but the
27+
// folder is realized when a document is created into it (see resolveDocContainer).
28+
// The executor has already assigned folder.ID, which it uses as the container of
29+
// documents placed in this folder.
30+
func (b *Backend) CreateFolder(folder *model.Folder) error {
31+
if folder.ID == "" {
32+
return fmt.Errorf("create folder %q: missing folder id", folder.Name)
33+
}
34+
b.sessionFolders = append(b.sessionFolders, &types.FolderInfo{
35+
ID: folder.ID,
36+
ContainerID: folder.ContainerID,
37+
Name: folder.Name,
38+
})
39+
return nil
40+
}
41+
42+
// DeleteFolder is rejected: PED has no folder-delete (and an empty folder doesn't
43+
// exist on disk in the first place).
44+
func (b *Backend) DeleteFolder(id model.ID) error {
45+
return fmt.Errorf("DROP FOLDER is not supported by the MCP backend — PED cannot delete folders (a folder exists only while it holds documents); reorganize in Studio Pro or against a local .mpr")
46+
}
47+
48+
// MoveFolder is rejected: PED cannot re-parent folders or documents.
49+
func (b *Backend) MoveFolder(id, newContainerID model.ID) error {
50+
return fmt.Errorf("MOVE FOLDER is not supported by the MCP backend — PED cannot re-parent folders or documents; reorganize in Studio Pro or against a local .mpr")
51+
}
52+
53+
// ListFolders returns the local reader's folders merged with folders created this
54+
// session (so the executor's resolveFolder finds a just-created folder and builds
55+
// nested paths against it instead of recreating segments).
56+
func (b *Backend) ListFolders() ([]*types.FolderInfo, error) {
57+
local, err := b.reader.ListFolders()
58+
if err != nil {
59+
return nil, err
60+
}
61+
if len(b.sessionFolders) == 0 {
62+
return local, nil
63+
}
64+
return append(append([]*types.FolderInfo{}, local...), b.sessionFolders...), nil
65+
}
66+
67+
// resolveDocContainer maps a document's container ID — a module or a folder — onto
68+
// the PED moduleName and folderPath. folderPath is "" for a module-root document;
69+
// for a foldered one it is the slash-joined folder path (e.g. "Processing/Archive").
70+
func (b *Backend) resolveDocContainer(containerID model.ID) (moduleName, folderPath string, err error) {
71+
if mod, e := b.GetModule(containerID); e == nil {
72+
return mod.Name, "", nil
73+
}
74+
// Walk up the folder chain to the owning module, collecting names.
75+
var parts []string
76+
cur := containerID
77+
for range 64 { // depth guard
78+
fi := b.findFolder(cur)
79+
if fi == nil {
80+
return "", "", fmt.Errorf("container %s is neither a module nor a known folder", containerID)
81+
}
82+
parts = append([]string{fi.Name}, parts...)
83+
if mod, e := b.GetModule(fi.ContainerID); e == nil {
84+
return mod.Name, strings.Join(parts, "/"), nil
85+
}
86+
cur = fi.ContainerID
87+
}
88+
return "", "", fmt.Errorf("folder hierarchy for %s is too deep or cyclic", containerID)
89+
}
90+
91+
// findFolder looks a folder up by ID, preferring session folders then the reader.
92+
func (b *Backend) findFolder(id model.ID) *types.FolderInfo {
93+
for _, f := range b.sessionFolders {
94+
if f.ID == id {
95+
return f
96+
}
97+
}
98+
if b.reader == nil {
99+
return nil
100+
}
101+
if all, err := b.reader.ListFolders(); err == nil {
102+
for _, f := range all {
103+
if f.ID == id {
104+
return f
105+
}
106+
}
107+
}
108+
return nil
109+
}

mdl/backend/mcp/folder_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package mcp
4+
5+
import (
6+
"testing"
7+
8+
"github.com/mendixlabs/mxcli/mdl/types"
9+
"github.com/mendixlabs/mxcli/model"
10+
)
11+
12+
func TestResolveDocContainer(t *testing.T) {
13+
mod := &model.Module{Name: "M"}
14+
mod.ID = "mod1"
15+
b := &Backend{sessionModules: []*model.Module{mod}}
16+
// Folders: M / Processing / Archive
17+
b.sessionFolders = []*types.FolderInfo{
18+
{ID: "f1", ContainerID: "mod1", Name: "Processing"},
19+
{ID: "f2", ContainerID: "f1", Name: "Archive"},
20+
}
21+
22+
cases := []struct {
23+
container model.ID
24+
wantModule, wantFP string
25+
}{
26+
{"mod1", "M", ""}, // module root
27+
{"f1", "M", "Processing"}, // one level
28+
{"f2", "M", "Processing/Archive"}, // nested
29+
}
30+
for _, c := range cases {
31+
mn, fp, err := b.resolveDocContainer(c.container)
32+
if err != nil || mn != c.wantModule || fp != c.wantFP {
33+
t.Errorf("resolve(%s) = (%q,%q,%v), want (%q,%q,nil)", c.container, mn, fp, err, c.wantModule, c.wantFP)
34+
}
35+
}
36+
// Unknown container errors rather than panicking.
37+
if _, _, err := b.resolveDocContainer("nope"); err == nil {
38+
t.Error("unknown container should error")
39+
}
40+
// CreateFolder records a pending folder that ListFolders surfaces.
41+
f := &model.Folder{ContainerID: "mod1", Name: "New"}
42+
f.ID = "f3"
43+
if err := b.CreateFolder(f); err != nil {
44+
t.Fatal(err)
45+
}
46+
_, fp, _ := b.resolveDocContainer("f3")
47+
if fp != "New" {
48+
t.Errorf("after CreateFolder, resolve(f3) folderPath = %q, want New", fp)
49+
}
50+
// DROP / MOVE folder are rejected.
51+
if b.DeleteFolder("f1") == nil || b.MoveFolder("f1", "mod1") == nil {
52+
t.Error("DeleteFolder/MoveFolder should be rejected")
53+
}
54+
}

0 commit comments

Comments
 (0)