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 itemProvider is currentItemProvider is 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 — the h1seo* that h1-seo-metadata adds to products — visible and writable. The check is against ComplexPropertyDefinition, so it covers a switchable with currentItemProvider too.

The create and update pipeline

create runs, in this order:

  1. normalizeRelations — a link/reference field with no converter gets its bare UUID prefixed to jcr:<uuid>. A value that is already prefixed, or that is not a UUID, is left alone.
  2. resolvePathReferences — a field with JcrNodeToPathConverter accepts 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.
  3. applyDeclaredDefaults — every form field with a defaultValue that the payload omits is injected, so a defaulted field satisfies required without the caller sending it. Create only.
  4. validateInput(data, true) — the single validation pass (below).
  5. assertNoForeignFields — refuses fields another tool owns, before the repository is touched.
  6. validateTranslations — locales and translatable fields.
  7. Switchable objects are lifted out of the payload (the bind→mapper path does not persist their subtree).
  8. doCreate → uuidOf → placeInTaxonomyFolder → the switchable subtrees are written → applyTranslations.
  9. 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 ''", listing the valid ones
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 EditorPropertyDefinition carries no provenance. So the operative question is not "is it decorated?" but "does another tool claim it?", answered by McpToolRegistry.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 — nodesOfType walks the tree instead of querying on purpose: a SELECT with no ORDER BY returns Jackrabbit's undefined order, which then disagrees with what Magnolia renders and with the indices reorder uses. listByFolder does 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+2010 and U+2011 (hyphen and non-breaking hyphen) to ASCII -. A U+2011 was 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.