Developers · Configuration¶
There is no config:/modules/h1-mcp-api. This module is configured in two places only: the
registration YAML of each tool (in h1McpTools/ or h1McpPublicTools/, in whichever module owns
the tool) and the code of the tool itself. Everything a tool accepts at runtime comes from a
third place it does not own: the resolved editor form of the content type, read live from
Magnolia's registries with decorations already applied.
The registration YAML¶
One file per tool, in a folder named exactly h1McpTools (admin) or h1McpPublicTools (visitors),
at the root of any module's resources. The file name is free; the tool's identity is getName().
| Key | Applies to | Notes |
|---|---|---|
class |
every tool | Required. Fully qualified name of a BaseMcpTool subclass. node2bean instantiates it, so every setX on the class is a valid key |
allowedActions |
every tool | List of action names this registration may execute. Enforced in executeChecked before execute runs. Empty or absent = no restriction |
Both registries build a definition's id with DefinitionMetadataBuilder.usingModuleAndRelativePathAsId(),
so it is derived from the module plus the file's relative path — two modules can each ship a
product.yaml without colliding.
GenericContentTypeTool¶
| Key | Required | Notes |
|---|---|---|
name |
✓ | The tool name the assistant calls. Missing ⇒ the tool logs a warning and is not offered; getName() falls back to generic-content-type-tool-sin-configurar because the registries call it before filtering |
contentType |
✓ | Content type name as registered. Not registered ⇒ warning, tool not offered |
description |
Prepended to the generated help (taxonomy + standard actions + translatable note). Default: "Gestiona el content type '<ct>'." |
|
workspace |
Overrides the workspace. Omitted, it is derived from the content type's datasource; if that cannot be derived the tool throws, naming the key to add | |
folderTemplate |
Folder taxonomy, e.g. /{categoria}/{marca?}. Every segment must be a {placeholder} or the tool throws. A {field?} is optional: empty ⇒ the path stops there. A required segment missing ⇒ root |
|
nodeNameFrom |
Ordered list of payload fields the node name is derived from. Default order: name, label, title, slug, sku |
|
excludedFields |
Field names never written and never exposed in the schema | |
includedFields |
Whitelist: only these fields are writable/exposed. Empty = all |
GenericDecoratedFieldsTool¶
| Key | Required | Notes |
|---|---|---|
name |
✓ | Missing ⇒ warning, not offered |
fields |
✓ | The decorated fields this tool writes and what ownedFields() publishes, so the content type's tool can refuse them naming this one. Empty ⇒ warning, not offered |
description |
Prepended to the generated help. Default: "Gestiona los campos <fields> de un nodo." |
|
workspaces |
Workspaces it accepts. One value ⇒ workspace becomes optional in the call and the schema types the fields from that content type's form. Several ⇒ workspace is required and validated against the list. Empty ⇒ any content type that declares the fields |
Its three actions are get, set (PATCH) and clear (needs fields). The node is addressed by
workspace + uuid, or by path. It never creates or deletes content.
BaseMcpTool — the contract¶
| Member | Kind | Purpose |
|---|---|---|
getName() |
abstract | The tool name. Must not return null: the registries call it before filtering by availability |
getDescription() |
abstract | What the model reads to decide when to use the tool. This is prompt, not documentation |
getInputSchema() |
abstract | JSON schema of the arguments |
execute(args) |
abstract | Does the work. Return value is serialised to JSON by the registry |
executeChecked(args) |
final |
Entry point the registries call: enforces allowedActions, then delegates to execute |
isAvailable() |
overridable | true by default. false hides the tool from listTools and from callTool |
ownedFields() |
overridable | Empty by default. The fields only this tool may write (ADR-024). Return a constant — the guard queries it per field per tool |
setAllowedActions(list) |
setter | Populated by node2bean from the registration YAML |
firstNonBlank(…) · nodeRef(args) · isRecursive(args) |
helpers | Shared argument reading. nodeRef requires uuid or path and throws otherwise |
AbstractContentTypeTool¶
What a subclass provides¶
| Member | Kind | Purpose |
|---|---|---|
contentTypeName() |
abstract | Content type name as registered |
workspace() |
abstract | JCR workspace |
list() · findOne(uuid) · doCreate(data) · doUpdate(uuid, data) · doDelete(uuid) |
hooks | Persistence. Unimplemented ⇒ that action answers "not supported by \<tool>" |
uuidOf(created) |
hook | UUID of what doCreate returned, so translations and folder placement can follow |
folderPathFor(args) |
overridable | The folder taxonomy. Default: the literal folder argument. marcaFolderPath(args) implements the common /{marca} case. Used by create/move/create_folder and by list, which is what makes the taxonomy fields filter |
folderExists(path) |
overridable | Whether that folder exists in the tool's workspace. Isolated so the list filter can be exercised without a running Magnolia |
excludedFields() |
overridable | Server-managed fields: excluded from the schema, never written, tolerated if sent, never required |
includedFields() |
overridable | Whitelist of form fields to expose. Empty = all |
executeCustom(action, args) |
overridable | Non-CRUD actions (test, add_to_cart, get_related) |
resolveCustomIdentifier(args) |
overridable | Resolve a business key (slug, sku) to a uuid. Return null to fall through to uuid | path | nodeName |
bind(data, Type) · bindOnto(target, data) |
protected | Payload → model. bind builds a fresh model (create); bindOnto merges onto the existing one (update/PATCH) |
The 14 standard actions¶
Every one is dispatched by execute; a name that matches none falls through to executeCustom.
| Action | Identified by | Behaviour |
|---|---|---|
list |
— | All items. Narrowed by folder, or — when folder is absent — by the tool's taxonomy (folderPathFor, e.g. marca), filtering to descendants of that path by JCR path. With neither, it returns the whole workspace. A folder that does not exist is an error naming the ones that do, not an empty list |
get |
uuid | path | nodeName |
The item, plus a translations block when the node has any |
create |
— | Full pipeline (below). Places the item in its taxonomy folder afterwards |
update |
uuid | path | nodeName |
PATCH. Refuses a payload that carries only identity/control keys, instead of being a silent no-op |
delete |
uuid | path | nodeName |
Two steps. Without confirm: true returns a preview. With it: unpublishes quietly, then deletes |
publish |
uuid | path | nodeName, or folder |
Runs the activate command. recursive includes the subtree |
unpublish |
idem | Runs deactivate |
create_folder |
folder | parentPath+folderName | taxonomy |
Slugifies the leaf only and requires the parent to exist — no phantom parent chains. The parent must itself be a folder |
move |
uuid | path → folderPathFor(args) |
Two steps. Does not create the destination: a typo is an error, not new taxonomy |
rename |
uuid | path | nodeName + newName |
Two steps. Refuses a folder, pointing at rename_folder |
rename_folder |
path | folder | taxonomy + newName |
Two steps. Verifies the target is a folder first |
delete_folder |
path | folder | taxonomy |
Two steps. Refuses anything that is not an mgnl:folder |
reorder |
uuid | path | nodeName + position |
first | last | up | down | 0-based index, among siblings |
remove_property |
uuid | path + property |
Refuses a required field of the content type, and any jcr:/mgnl: property |
create_folder, rename_folder, delete_folder and publish read folder/path directly
rather than through folderPathFor, so those signatures keep working in a tool that overrides its
taxonomy. publish deliberately does not fall back to the taxonomy: a publish carrying only
marca is ambiguous, and with recursive it would publish the whole brand.
Standard schema parameters¶
Added by buildStandardInputSchema to every content-type tool, next to the content type's own
fields:
| Parameter | Used by | Notes |
|---|---|---|
uuid |
get, update, delete, reorder, move (source) | The schema states that path or nodeName are more robust: a model reproducing a long UUID across a conversation gets it wrong |
path |
idem | JCR path |
nodeName |
create, get, update, delete, reorder | On create it is slugified. On lookup it is exact — no slugifying — resolved inside the taxonomy folder when one is given, otherwise searched across the workspace |
newName |
rename, rename_folder | |
property |
remove_property | |
position |
reorder | first | last | up | down | index |
folder |
create_folder, move (destination), list (filter), publish/unpublish (the folder itself) | In list it takes precedence over the taxonomy fields (marca, categoria…), which filter on their own |
parentPath + folderName |
create_folder | Alternative to folder |
recursive |
publish, unpublish | Default false |
confirm |
delete, delete_folder, move, rename, rename_folder | Must be true to execute |
translations |
create, update | Added only when the content type has at least one i18n: true field |
How the schema is derived from the form¶
FormSchemaBuilder walks the resolved form of the content type's detail subApp and emits one
property per field:
| Form fact | Becomes |
|---|---|
ConfiguredFieldDefinition.getType() |
integer (Long/Integer), number (Double/Float/BigDecimal), boolean, else string |
| Option list datasource | enum of the option values; labels are translated for the description |
multiField / multiValueField, a multiple model property, or a multi-select field type (checkBoxGroup, twinColSelect) |
type: array with the element's schema in items |
CompositeFieldDefinition |
type: object with its sub-fields recursed |
SwitchableFormDefinition |
type: object plus a switchable block: selectorProperty, selectorOptions and optionFields per option |
A field with JcrNodeToPathConverter on a jcrDatasource |
The description says which workspace and root path the key resolves against |
readOnly, excludedFields, or outside includedFields |
Omitted from the schema entirely |
A name field that the model does not declare as a property |
Omitted: it is the jcr:name, not content |
label (an i18n key by project convention) |
Translated into the description, with the friendly field type in parentheses |
Note — a composite whose
itemProvideriscurrentItemProvideris flattened: its sub-fields are properties of the node itself, not of a child, so they appear at the parent's level. This is what makes fields arriving through a decoration inside a composite — theh1seo*thath1-seo-metadataadds to products — visible and writable. The check is againstComplexPropertyDefinition, so it covers a switchable withcurrentItemProvidertoo.
The create and update pipeline¶
create runs, in this order:
normalizeRelations— a link/reference field with no converter gets its bare UUID prefixed tojcr:<uuid>. A value that is already prefixed, or that is not a UUID, is left alone.resolvePathReferences— a field withJcrNodeToPathConverteraccepts a key or slug and is resolved to the node's full path. An absolute path is verified to exist. Zero matches is an error; several matches is an error listing the candidates, because the same key can exist under more than one brand.applyDeclaredDefaults— every form field with adefaultValuethat the payload omits is injected, so a defaulted field satisfiesrequiredwithout the caller sending it. Create only.validateInput(data, true)— the single validation pass (below).assertNoForeignFields— refuses fields another tool owns, before the repository is touched.validateTranslations— locales and translatable fields.- Switchable objects are lifted out of the payload (the bind→mapper path does not persist their subtree).
doCreate→uuidOf→placeInTaxonomyFolder→ the switchable subtrees are written →applyTranslations.session.refresh(false)and the response is a re-read of what was persisted.
update is the same with three differences: applyDeclaredDefaults does not run (a PATCH does not
re-default), validateInput(data, false) does not enforce required, and the i18n properties are
snapshotted before the write and restored afterwards — except for the fields this call rewrote,
which the caller owns.
Validation¶
One pass, two phases, recursive into composites (FieldValidator):
| Rule | Applies to | Failure |
|---|---|---|
| Unknown key | Every payload key that is not a form field and not a tolerated control key | "Campo desconocido ' |
| Shape | Object in a scalar field, array in a non-multi field, or an object/array serialised as a string | "es escalar; no acepta un objeto/array" |
| Options | A value outside a static option list | Lists the valid values |
| Regexp | A regexpValidator on the field — applied to the value's text form, so it catches a number too |
The validator's own message, i18n key translated |
| Reference | A LinkFieldDefinition value that resolves to nothing in its target workspace |
Refuses rather than saving a dead link. Best-effort: no session ⇒ no block |
| Required | Field-level required or model-level required, on create only |
Names the field and its translated label |
Absent, blank, empty-list and empty-object all count as not present for required. The identity
field is never required: the tool derives the node name. readOnly, excluded and non-whitelisted
fields are validated if sent but never demanded.
An opaque composite — a compositeField with an implementationClass and no declared
properties, like the product variants editor — is exempt from the array check: the form says
nothing about the shape of its value, so the only declaration is the tool's own schema.
The three guards¶
The schema comes from the form; persistence goes through the module's model. When another module decorates the app, the form becomes a superset of the model and the difference has to go somewhere:
| Situation | Where it is caught | Result |
|---|---|---|
Another tool declares the field in ownedFields() |
assertNoForeignFields, in the create/update flow |
Refused, naming the owning tool and how to address it |
| Nobody owns it and the model cannot accept it | assertFieldsAreMine, from bind/bindOnto |
Refused: there is no channel for it, and sending it here would lose the value silently |
| The model accepts it with an incompatible shape | assertShapeCompatible |
Refused, naming both declarations (ADR-017) — the form says scalar, the model says Map/Collection |
The first guard lives in the flow and not in bind for a specific reason: a tool that persists
without a model — GenericContentTypeTool — never calls bind, and used to skip the check. Since
every bind/bindOnto call in the project sits inside a doCreate/doUpdate, moving it up covers
both paths without duplicating it. Model introspection that fails degrades to "do not classify"
rather than refusing everything.
Note — you cannot ask Magnolia whether a field came from a decoration: the form arrives already resolved and
EditorPropertyDefinitioncarries no provenance. So the operative question is not "is it decorated?" but "does another tool claim it?", answered byMcpToolRegistry.toolOwning. A prefix map inside this module would answer it too, and would break ADR-010.
Identity resolution¶
resolveTarget(args) tries, in order: resolveCustomIdentifier (a tool's business key) → uuid →
path → nodeName. With nodeName and a taxonomy folder it resolves inside that folder and, when
it misses, raises an error listing what the folder does contain, so the assistant can correct
itself. A bare nodeName is searched across the workspace: several matches is an ambiguity error
listing the paths.
Structural nodes are refused: mgnl:folder, mgnl:area, mgnl:page, mgnl:component. The
exception is publish/unpublish, which resolve with elementsOnly=false because publishing a
folder is legitimate — and has to happen before its children, or they have nothing to hang from
on the public instance.
Node names and folder names¶
Two rules, deliberately different:
| Helper | Case | Used for |
|---|---|---|
slugifyNodeName(s) |
lower-cased, accents stripped, non-alphanumeric → - |
Item node names. They are slugs and reach the URL. The rule itself lives in h1-helpers (StringUtils.toNodeName) so a tool and a service cannot disagree |
sanitizeFolderName(s) |
case preserved, otherwise identical | Folder names. The organisational folder is usually the brand, and its case must match the site's rootWebsite — JCR is case-sensitive, so lower-casing it silently orphans the content |
resolveNodeName(data, source) = explicit nodeName → the tool's domain source → a random UUID.
GenericContentTypeTool overrides that last step: with no identity field it fails asking for
nodeName, because a content type declared only in YAML may have no human-readable field at all,
and a node called 8f3a1b9c-… is indistinguishable in AdminCentral.
Two-step confirmations¶
Without confirm: true, the four destructive or path-changing actions return a preview and touch
nothing:
| Payload key | delete / delete_folder |
move / rename / rename_folder |
|---|---|---|
confirmationRequired |
true |
true |
action |
delete |
move, rename or rename_folder |
workspace, uuid, type |
✓ | ✓ |
path |
✓ | — |
from / to |
— | ✓ |
descendants + directChildren (first 10) |
✓ | — |
affectedDescendants |
— | ✓ |
message |
What would be deleted, and that nothing has been | What would move, and that path-based references may break while identifier-based ones survive |
Destination checks (exists, no-op, name conflict) run in the preview too, so a confirmation that looks fine is not going to fail on the second call.
reorder semantics¶
orderBefore among siblings, so reordering never leaves the folder, and the node's name does not
change. An out-of-range numeric index is clamped and reported: the answer carries
clamped: true, requestedPosition, and a message naming which end it landed on. Already in
position answers reordered: false with the current order — never a silent no-op. A parent whose
node type does not allow ordered children is an explicit error.
Helper reference¶
All six are stateless; the workspace or the content type name is a parameter.
ContentTypeIntrospector — the form, resolved¶
| Method | Returns |
|---|---|
formProperties(ct) |
The detail subApp form fields, decorations applied |
flatFormProperties(ct) · flatFormFieldNames(ct) |
The same, flattened. flatFormFieldNames returns Set<String> on purpose, so a module without Magnolia's UI dependencies can ask |
modelProperties(ct) |
Model properties — the source of required and multiple at the data level |
workspaceOf(ct) · contentTypeForWorkspace(ws) |
Each direction of the content type ↔ workspace mapping |
readOnlyFieldNames(ct) · translatableFieldNames(ct) · isTranslatable(ct) |
Field sets by flag |
listContentTypes() · describeContentType(name) |
Discovery, used by the content_type tool. describe includes model, sub-models, form fields and the node-name policy |
expandFlatteningComposites(fields) · isFlatteningComposite(f) |
The currentItemProvider flattening |
isGroupingField(f) · subFields(f) · switchableSubFields(f) · multiElement(f) |
The single "how do I go one level down" — a switchable is not a composite, so recursions written against CompositeFieldDefinition alone used to skip everything under it |
switchableInfo(f) |
Selector property, options, and the fields of each option |
pathReferenceSource(f) |
Workspace + root path of a JcrNodeToPathConverter reference |
describeStaticOptions(f) · friendlyFieldType(name) · fieldHint(type) |
Option lists; TextFieldDefinition → textField; a hint per widget type |
translateDefinitionText(s) |
Resolves an i18n key to text. Best-effort: something with spaces or without dots is treated as literal text, and a key missing from every bundle is returned unchanged |
assertScalarShape(f, value, ctx[, modelMulti]) |
The single "this field is scalar" message |
validateTranslations(t, fields, locales) |
Locales configured, fields existing and translatable, recursive |
validateScalarWhitelist(fields, allowed, ctx) |
Same messages for a tool with no introspectable form (DAM metadata) |
WorkspaceContentOps — JCR mechanics¶
| Group | Methods |
|---|---|
| Resolution | resolveNode, resolveByUuidOrPath, findNodePathsByName (SQL2, by name, optionally by node type), nodeExists, isFolder, notFoundInFolder |
| Listing | nodesOfType (JCR child order, recursive), listByFolder (SQL2, optionally grouped), childNodes, groupBySubfolder, nodeToMap |
| Folders | normalizeFolderPath, ensureFolder (with or without requiring the parent), moveToFolder (3 overloads: create-missing, and two-step confirm), renameNode (idem), validateNodeName |
| Ordering | reorderSibling, clampMessage |
| Publishing | activateNode, deactivateQuietly, deleteWithUnpublish, deletePreview, deleteFolder |
| Writing | writeContentValue (scalar → property, Map → child node, null → removes both; does not handle lists), writeTypedScalar (the single source of write typing) |
| Reading | getString, getBoolean, getLong (static), removeProperty |
| References | assertReferenceResolvable — implements the ReferenceCheck injected into the validator, so the introspector stays free of JCR |
Warning —
nodesOfTypewalks the tree instead of querying on purpose: aSELECTwith noORDER BYreturns Jackrabbit's undefined order, which then disagrees with what Magnolia renders and with the indicesreorderuses.listByFolderdoes use SQL2 — do not swap one for the other where order is part of the contract.
FormDrivenNodeIO — read and write, driven by a form¶
The symmetric pair. The form arrives as List<EditorPropertyDefinition>, so the engine does not
know whether it came from a page template's dialog or a content type's detail form — same type, same
rules.
| Method | Behaviour |
|---|---|
readProperties(node, fields) |
Content properties; a multi-value property reads back as a list of strings, a multi-entry field as an array rather than the raw indexed children (links0, links1, …) |
readNodeFlat(node, fields) |
uuid, path, nodeName plus the properties at the same level — the shape a content-type DTO has |
writeProperties(node, values, fields) |
PATCH. Scalar → typed property (type from the field's getType()); List in a multi-entry field → indexed children under a container node, replaced wholesale; List in a multi-select → multi-value property (empty list removes it); Map → composite child node, recursive; null → removes both property and child |
compositeSubs · compositeItemFields · readMultiField · readMultiValueStrings |
The pieces above |
Because the JCR type comes from the field definition, this write cannot diverge from the editor: both read the same declaration. A hand-written mapper can (ADR-017). A stray object for a multi-entry field is accepted as a one-item list, rather than creating an entry with no index.
I18nOps — translations¶
| Method | Behaviour |
|---|---|
applyTranslationsNested(ws, ref, translations) |
Writes a tree that mirrors the content: scalar → field_<locale> (bare property for the default locale), object → descends into the composite child, list → aligns by order with the existing children of a repeatable composite, or writes a multi-value property. Rejects an unconfigured locale before writing anything. After saving it re-reads every property it wrote and throws if one did not persist |
applyTranslations(ws, ref, t, fields[, default]) |
The flat, whitelist-filtered variant |
readTranslations(ws, ref) |
The inverse, for verification: one tree per non-default locale. A repeatable composite reads back index-keyed ({"0":{…}}) — and the validator accepts that shape, so what get emits can be sent straight back |
snapshotI18n / restoreI18n |
Preserve _<locale> properties across a destructive write. restoreI18n skips the fields this call rewrote, never overwrites what is present, and never resurrects a node that was deleted on purpose |
availableLocaleCodes() · defaultLocaleCode() |
From I18nContentSupport; empty when i18n is not configured |
Warning — the writer normalises
U+2010andU+2011(hyphen and non-breaking hyphen) to ASCII-. AU+2011was silently breaking the persistence of rich-text i18n properties in the store. En dash and em dash are not touched: they are different typography, and if they ever fail the post-save check reports them instead of losing them.
FormSchemaBuilder and FieldValidator¶
Pure, no JCR, unit-testable. FormSchemaBuilder owns buildInputSchema,
buildStandardInputSchema, standardActionsHelp, translationsSchema, isRequiredField,
validateInput and normalizeRelations. FieldValidator owns the two-phase pass, configured
through a Spec (enforceRequired, extraAllowedKeys, excludedNames, includedNames,
modelRequiredNames, modelMultipleNames, referenceCheck, identityField) — which is why the
same validator serves content-type tools, that have a model, and WebsiteTool, which has only
dialogs.
The registries¶
McpToolRegistry |
McpPublicToolRegistry |
|
|---|---|---|
| Definition type | h1McpTool |
h1McpPublicTool |
| Folder watched | h1McpTools/ |
h1McpPublicTools/ |
| Base class | BaseMcpTool |
BaseMcpTool |
listTools() |
Available tools as McpTool |
idem |
callTool(name, args) |
Runs executeChecked and serialises the result to JSON. Unknown or unavailable ⇒ "Tool not found" |
idem |
toolOwning(field, excludeToolName) |
The tool claiming that field, or null. Best-effort — it only improves an error message, it never decides whether something is written |
— |
Both extend AbstractRegistry, so the folder observation is Magnolia's own — the same mechanism as
virtualUriMappings/. The plural folder name is the convention: a definition type called
h1McpTool is looked for in h1McpTools/.
Module components¶
Registered as singletons in META-INF/magnolia/h1-mcp-api.xml, component set main:
McpToolRegistry · McpPublicToolRegistry
H1McpApiModule implements ModuleLifecycle; on start it binds each registry to the
configuration source with its definition type, provider builder, metadata builder and reference id
resolver. That binding is what makes the two folders live. There is no version handler and no
configuration bean.