You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I've been trying Craft 5.12's MCP server for content model changes. I built an internal plugin that does schema editing over craft-mcp, and I've been comparing the two implementations on a real site. There isn't a huge difference when comparing the output on ordinary tasks, but some of my design decisions about safety and efficiency look to be validated in testing.
All four were over stdio (php craft mcp/serve --user=<admin>) on Craft 5.12.0.1 Pro, PHP 8.4, with SEOmatic 5.1.22 and Retour 5.0.14. We repeated item 3 over the HTTP server too.
1. A uid for something that doesn't exist is saved
Steps:configuration.create with type: fields, for an Entries field with settings.sources set to ["section:ef4c907b-df1c-4173-aba6-c33fd5f93d1c"]. No section has that uid.
Expected: an error about the source.
Actual: the field is created with that source in project config and the database. No error or warning. The response shows the settings as bullets ("sources": ["••••…"]), so you can't see what was saved.
Field layouts do the same. configuration.update on an entry type, with a CustomField element whose fieldUid matches no field, saves the element, and reading it back shows it like any other.
Suggestion: check references in settings and layouts before saving, and refuse a uid that doesn't match anything. See also item 5.
2. Field settings are hidden when read (bug)
Steps:configuration.get with type: fields for an Entries field.
Actual: every string in settings comes back as bullets, including sources, selectionCondition, defaultPlacement, viewMode and selectionLabel. An agent can't see which sources a field allows before changing it, or check its own change afterwards.
Cause: this isn't about access or auth. We saw it as an admin over stdio, and nothing in the code path depends on the user or the transport. craft\mcp\SensitiveValues gets its craft\services\Security from CapabilityContainer, which maps Craft's services by the return types of Craft::$app's getters. getSecurity() is declared in yii\base\Application with no return type, so it isn't mapped, and the container builds a new Security without the app config. Its sensitiveKeywords is empty, so isSensitive() builds the pattern /\b()\b/, which matches every key, and every string is redacted. On 5.12.0.1, (new CapabilityContainer())->get(Security::class) === Craft::$app->getSecurity() is false, and its sensitiveKeywords is [].
Suggestion: map Security::class to Craft::$app->getSecurity() in CapabilityContainer, as it already does for GeneralConfig. Separately, isSensitive() could return false when there are no keywords, rather than matching everything.
3. Over stdio, user-permissions.group.set removes permissions it was given (bug)
Steps: on a group with SEOmatic and Retour permissions, call user-permissions.group.get, then user-permissions.group.set with that full list plus one new permission (accessSiteWhenSystemIsOff), as the tool description says to.
Expected: the group gets the one new permission.
Actual: it gets the new permission and loses 22 others: all the seomatic:* and retour:* permissions, and utility:icon-picker (276 down to 255). All 22 were in the list we sent. The response just returns the new list.
Likely cause:UserPermissions::saveGroupPermissions() filters the list against getAllPermissions(). SEOmatic and Retour only register their permissions on control panel requests, so over stdio (a console request) they aren't in the list and get filtered out. Over the HTTP server at /admin/mcp/auth, which runs as a control panel request, the same call kept all 22 and added only the new one.
Suggestion: keep stored permissions the call didn't change, or refuse a call that would remove permissions it was sent, and list them.
A related risk: group.set replaces the whole list, so an agent has to send back every permission. In one of our test runs, an agent asked to send back the full list of 276 sent a shortened one instead, and the group was left with 4 permissions. Nothing stopped it. Separate calls to add and remove permissions, rather than replacing the list, would avoid that.
4. A write based on an old read overwrites a newer change
Steps:configuration.get an entry type. configuration.update its fieldLayout with field A added. Then, using the same original read, configuration.update its fieldLayout with field B added, as a second agent would.
Expected: the second update is refused, because the entry type changed after it was read.
Actual: both succeed. The second layout replaces the first, so field A disappears, with no warning.
Suggestion: an optional version or date-modified argument on configuration.update and .delete, returned by configuration.get, so an update based on an old read can be refused.
5. Handles instead of uids (suggestion)
In my plugin I made a deliberate choice: tools take and return handles, never uids, apart from a layout's own tab and element uids. A section's entry types, a field's sources, a layout's fields and a permission's subject (viewEntries:news) are all handles. A uid the tools can't turn into a handle is refused, never stored. I chose this because agents work from what a person asks for ("limit this field to Topic entries"), which is written in handles, and because a wrong handle can be checked and refused by name, while a wrong uid is easy to miss (item 1).
To see whether it makes a difference in practice, I gave agents the same five tasks against each server on the same site: build a section with fields and a selection condition, add a field to a busy layout, remove a field with and without content, and rename a handle. Same model, same prompts, five runs each. Both servers got the schema right. The difference was in how much work it took. Medians per run:
Task
Handles (my plugin)
Uids (Craft 5.12)
Build a section, entry type and fields
19 calls, 39 s
28 calls, 58 s
Add a field to a busy layout
7 calls, 38 s
8 calls, 52 s
Remove a field with no content
3 calls, 22 s
12 calls, 42 s
Remove a field with content
4 calls, 24 s
7 calls, 33 s
Rename a handle, change a URI format
4 calls, 16 s
9 calls, 23 s
All 25 runs
183 calls, 704 s
312 calls, 1,083 s
That's about 40% fewer calls and a third less time with handles. Not all of it is down to handles, since the two tool sets differ in other ways too. But much of the extra work on the Craft side was lookups: 112 configuration.get calls against 58 reads on mine, and project-config.get at paths like entryTypes.<uid>.name just to find out which uid was which.
Suggestion: accept handles wherever a uid is expected (sources, layout elements, permission names), and return them alongside uids in reads.
Happy to chat more here or on Discord and share the repo if it would be useful.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
I've been trying Craft 5.12's MCP server for content model changes. I built an internal plugin that does schema editing over
craft-mcp, and I've been comparing the two implementations on a real site. There isn't a huge difference when comparing the output on ordinary tasks, but some of my design decisions about safety and efficiency look to be validated in testing.All four were over stdio (
php craft mcp/serve --user=<admin>) on Craft 5.12.0.1 Pro, PHP 8.4, with SEOmatic 5.1.22 and Retour 5.0.14. We repeated item 3 over the HTTP server too.1. A uid for something that doesn't exist is saved
Steps:
configuration.createwithtype: fields, for an Entries field withsettings.sourcesset to["section:ef4c907b-df1c-4173-aba6-c33fd5f93d1c"]. No section has that uid.Expected: an error about the source.
Actual: the field is created with that source in project config and the database. No error or warning. The response shows the settings as bullets (
"sources": ["••••…"]), so you can't see what was saved.Field layouts do the same.
configuration.updateon an entry type, with aCustomFieldelement whosefieldUidmatches no field, saves the element, and reading it back shows it like any other.Suggestion: check references in settings and layouts before saving, and refuse a uid that doesn't match anything. See also item 5.
2. Field settings are hidden when read (bug)
Steps:
configuration.getwithtype: fieldsfor an Entries field.Actual: every string in
settingscomes back as bullets, includingsources,selectionCondition,defaultPlacement,viewModeandselectionLabel. An agent can't see which sources a field allows before changing it, or check its own change afterwards.Cause: this isn't about access or auth. We saw it as an admin over stdio, and nothing in the code path depends on the user or the transport.
craft\mcp\SensitiveValuesgets itscraft\services\SecurityfromCapabilityContainer, which maps Craft's services by the return types ofCraft::$app's getters.getSecurity()is declared inyii\base\Applicationwith no return type, so it isn't mapped, and the container builds a newSecuritywithout the app config. ItssensitiveKeywordsis empty, soisSensitive()builds the pattern/\b()\b/, which matches every key, and every string is redacted. On 5.12.0.1,(new CapabilityContainer())->get(Security::class) === Craft::$app->getSecurity()isfalse, and itssensitiveKeywordsis[].Suggestion: map
Security::classtoCraft::$app->getSecurity()inCapabilityContainer, as it already does forGeneralConfig. Separately,isSensitive()could returnfalsewhen there are no keywords, rather than matching everything.3. Over stdio,
user-permissions.group.setremoves permissions it was given (bug)Steps: on a group with SEOmatic and Retour permissions, call
user-permissions.group.get, thenuser-permissions.group.setwith that full list plus one new permission (accessSiteWhenSystemIsOff), as the tool description says to.Expected: the group gets the one new permission.
Actual: it gets the new permission and loses 22 others: all the
seomatic:*andretour:*permissions, andutility:icon-picker(276 down to 255). All 22 were in the list we sent. The response just returns the new list.Likely cause:
UserPermissions::saveGroupPermissions()filters the list againstgetAllPermissions(). SEOmatic and Retour only register their permissions on control panel requests, so over stdio (a console request) they aren't in the list and get filtered out. Over the HTTP server at/admin/mcp/auth, which runs as a control panel request, the same call kept all 22 and added only the new one.Suggestion: keep stored permissions the call didn't change, or refuse a call that would remove permissions it was sent, and list them.
A related risk:
group.setreplaces the whole list, so an agent has to send back every permission. In one of our test runs, an agent asked to send back the full list of 276 sent a shortened one instead, and the group was left with 4 permissions. Nothing stopped it. Separate calls to add and remove permissions, rather than replacing the list, would avoid that.4. A write based on an old read overwrites a newer change
Steps:
configuration.getan entry type.configuration.updateitsfieldLayoutwith field A added. Then, using the same original read,configuration.updateitsfieldLayoutwith field B added, as a second agent would.Expected: the second update is refused, because the entry type changed after it was read.
Actual: both succeed. The second layout replaces the first, so field A disappears, with no warning.
Suggestion: an optional version or date-modified argument on
configuration.updateand.delete, returned byconfiguration.get, so an update based on an old read can be refused.5. Handles instead of uids (suggestion)
In my plugin I made a deliberate choice: tools take and return handles, never uids, apart from a layout's own tab and element uids. A section's entry types, a field's sources, a layout's fields and a permission's subject (
viewEntries:news) are all handles. A uid the tools can't turn into a handle is refused, never stored. I chose this because agents work from what a person asks for ("limit this field to Topic entries"), which is written in handles, and because a wrong handle can be checked and refused by name, while a wrong uid is easy to miss (item 1).To see whether it makes a difference in practice, I gave agents the same five tasks against each server on the same site: build a section with fields and a selection condition, add a field to a busy layout, remove a field with and without content, and rename a handle. Same model, same prompts, five runs each. Both servers got the schema right. The difference was in how much work it took. Medians per run:
That's about 40% fewer calls and a third less time with handles. Not all of it is down to handles, since the two tool sets differ in other ways too. But much of the extra work on the Craft side was lookups: 112
configuration.getcalls against 58 reads on mine, andproject-config.getat paths likeentryTypes.<uid>.namejust to find out which uid was which.Suggestion: accept handles wherever a uid is expected (sources, layout elements, permission names), and return them alongside uids in reads.
Happy to chat more here or on Discord and share the repo if it would be useful.
All reactions