Developers · Configuration

There is no config:/modules/h1-mcp. This module is configured in three places: environment variables (the LLM provider and its key), light-module YAML (the app, the endpoints, the tool registrations, the decorations) and constants in code for the conversation and loop limits. The tools it exposes are configured by whichever module owns them.

Environment

Read by LlmClientFactory the first time a chat request needs a client, then cached for the life of the JVM.

Variable Values Default Notes
HABITO_AI_PROVIDER openai · claude openai Falls back to the habito.ai.provider system property before defaulting. Compared case-insensitively; anything that is not claude means OpenAI
OPENAI_API_KEY — — Required when the provider resolves to OpenAI. Missing ⇒ IllegalStateException ⇒ the endpoint answers 503
ANTHROPIC_API_KEY — — Required when the provider is claude. Same failure path
HABITO_AI_MODEL a model id gpt-4o · claude-sonnet-4-6 Passed through verbatim to the provider

The chosen provider and model are logged once, at client creation: AI provider: Claude (model: …).

The AI Chat

The endpoint

Route Body Answer
POST /.rest/v1/ai/chat { "message": "…", "sessionId": "…"?, "context": { "currentApp": "…", "currentPath": "…" }? } { "reply": "…", "sessionId": "…" }
GET /.rest/v1/ai/chat — text/html: the standalone chat UI, which the ai-chat-app embeds and the sidebar loads in an iframe

message is required (400 without it). A missing sessionId gets a fresh UUID, returned in the answer so the client can keep the thread. 503 means the provider is not configured; 500 carries the exception message.

The MCP protocol surface

Two endpoints implement the same protocol against different registries:

Admin Public
Base path /.rest/v1/mcp/admin /.rest/v1/mcp
Registry McpToolRegistry McpPublicToolRegistry
Folder h1McpTools/ of every module h1McpPublicTools/ of every module
Routes POST / (JSON-RPC) · GET /sse · POST /message?sessionId= the same three
Method Behaviour
initialize protocolVersion: 2024-11-05, capabilities: { tools: {} }, serverInfo: { name: "h1-mcp", version: "<module version> (built <timestamp>)" }. The version comes from h1-mcp-build.properties, filtered by Maven; unfiltered it falls back to 1.0.0
notifications/initialized Accepted, no response (202 on the message route)
ping Empty result
tools/list { tools: [ {name, description, inputSchema} ] } — only tools whose isAvailable() is true
tools/call { content: [ { type: "text", text: "<the tool's JSON>" } ], isError: false }
anything else JSON-RPC error -32601

Error codes: -32600 (no method), -32601 (unknown method), -32602 (missing params or name in tools/call), -32603 (internal error). A tool failure is not a JSON-RPC error: it answers 200 with isError: true and Error: <message> as the text, so the model sees it.

The five tools

Registered by this module in h1McpTools/. Their contract with the model is getDescription() plus getInputSchema(); what follows is the action surface.

Tool Class Scope
website WebsiteTool Pages, areas and nested components in website
asset AssetTool DAM assets and folders
content_type ContentTypeTool Content type discovery, plus publishing and deleting in any workspace
marketing_tag MarketingTagTool Analytics/marketing scripts (DX Core marketing-tags)
h1mcp_keywords H1McpKeywordsTool The h1mcpKeywords* writing guide, on pages and on content items

website

Action Parameters Notes
list_pages path? Direct child pages, in real JCR order
get_page path The page's own properties plus its area/component tree. Pointed at a component or an area it returns that instead, with a type field saying which
create_page parentPath, nodeName, template, title?, properties?, translations?, areas? areas takes the same shape get_page returns, so a whole page can be recreated in one call. The parent must be the root or a page; the node name follows the rename rules
update_page uuid|path, properties?, translations? PATCH on an existing page's own fields. Refuses anything that is not an mgnl:page
add_component pagePath, areaName, templateId, properties?, areas?, translations? Creates the whole nested subtree in one call. Availability is validated against the parent template's area
update_component componentPath, properties?, translations? PATCH. Refuses anything that is not an mgnl:component
delete_component componentPath, confirm Two-step
move_component componentPath, position Reorders within its own area; first|last|up|down|index. Returns moved, order and the area's resulting component list
list_templates path | templateId The template's own editable fields plus, per area, the templateIds allowed there. Shallow on purpose — drill down by calling it again with the component's templateId
publish · unpublish uuid|path, recursive?
delete uuid|path, confirm Two-step; unpublishes first
rename · move uuid|path + newName / destParentPath, confirm Two-step: the path changes
reorder uuid|path, position A page among its siblings. No confirmation: the path does not change
remove_property uuid|path, property Refuses jcr:/mgnl: and a field the node's template dialog marks required

Its field contract, all read from the template's dialog at runtime:

  • A component's own fields go in properties; nested components go in areas. An object with a templateId inside properties is rejected — that is the one place where FormDrivenNodeIO is constructed with rejectNestedComponents = true.
  • A multi-entry field (jcrMultiField) takes an array of objects and a write is a full replace; list_templates marks it "multiple": true and get_page reads it back as an array rather than as the raw links0, links1 children.
  • A multi-select scalar (checkBoxGroup, twinColSelect) takes an array of plain values, also a full replace.
  • A field whose list_templates entry carries "valueFormat": "path" and a referenceSource is a reference: the value must be the target node's full path, and referenceSource names the workspace and root path where the candidates live.
  • A component's node name (0, 1, 2) is an id, not a position. Order is the JCR child order; get_page reports both name and a 0-based order.

asset

list, get, upload, update, create_folder, delete_folder, move, rename, reorder, publish, unpublish, delete, remove_property.

Aspect Behaviour
upload Needs folderPath + fileName + the content. sourceUrl is preferred (the server downloads it); base64 is the fallback and tolerates a data-URI prefix and line breaks. With both, sourceUrl wins
sourceUrl guardrails http/https only, 10 s connect and 30 s request timeouts, redirects followed, and a 25 MB ceiling. It is a server-side fetch: close the SSRF surface at the network level if you need to
MIME type The mimeType argument → the response's Content-Type → guessed from the file extension → application/octet-stream
Node naming Non-[a-zA-Z0-9._-] becomes -, lower-cased, and a numeric suffix is added on collision (hero-1)
Storage The binary and its metadata (fileName, extension, size, jcr:mimeType) go into the asset's jcr:content resource node, which must be mgnl:resource — that is where Magnolia DAM reads them
update Metadata whitelist: title, subject, caption, description, copyright, comment, languages, downloadName. Anything else is refused. translations writes per locale; existing translations are snapshotted and restored around the write
get Pointed at a folder it returns the folder, not an asset full of nulls

content_type

Action Parameters Notes
list — Every registered content type with its workspace and node type
describe name The model's raw properties and, when the content type has an app, its formFields as the editor sees them: widget type, required, validators, converter, i18n, options
publish · unpublish workspace, uuid|path, recursive? Works in any workspace — reuse the exact workspace another tool returned
delete workspace, uuid|path, confirm Deactivates then removes. Two-step

Its description tells the model explicitly: if a dedicated tool exists for that content type, use that one; this is for discovery and for content types with no tool of their own.

marketing_tag

Standard content-type actions plus the fields of the DX Core marketingTag type — name, dashboardUrl, activeAuthor, placeToPut (headerScripts|bodyBeginScripts|bodyEndScripts), content, pageTemplate, cookies — and pageLink, a list of {path, insertInSubPages} that restricts the tag to specific pages (empty means every page).

pageLink is excluded from the automatic schema and bind, and read and written by hand as indexed mgnl:contentNode children, the same treatment a composite multi-field gets elsewhere. On update it is only touched when the payload contains it.

The class deliberately imports no info.magnolia.marketingtags.* type: everything it needs about the form comes from generic introspection, so this module still compiles without DX Core. And isAvailable() opens a session on the marketing-tags workspace — if the module is not installed, the tool hides itself instead of being listed and failing on use.

h1mcp_keywords

Properties h1mcpKeywordsPrimary, h1mcpKeywordsSecondary — multi-value strings, not i18n, one list per node shared by every language
Field type multiValueField of textField, contributed by includes/keywords-fields.yaml
Actions get, set (PATCH — only the list you send is touched; null or [] removes it), clear (needs fields)
Channels Pages by path in website (requires mgnl:page), or a content item by workspace + uuid
Normalisation on write Trimmed, blanks dropped, exact duplicates dropped, input order preserved
Read tolerance A property stored single-valued (hand-edited or legacy) is read back as a one-item list, and rewritten as multi-valued
Response Always includes "rendered": false — an explicit reminder to the model that none of this reaches the HTML
Ownership Declares both fields in ownedFields(), so another content type's tool refuses them and names this one
Guardrail On a workspace other than website, the content type must actually declare the fields in its form, or the call is refused naming the missing decoration

Note — these fields are emitted nowhere: no meta tag, no sitemap, no llms.txt, no headless endpoint. The tool is a store; the editorial judgement lives in the agent that reads it. It does not evaluate, recommend or generate keywords, and get on content with none returns empty lists without inferring anything.

REST reference

Base path /.rest. Everything is application/json unless noted.

Endpoint Method Parameters Returns
/v1/mcp/admin POST JSON-RPC body JSON-RPC result, Mcp-Session-Id header
/v1/mcp/admin/sse GET — text/event-stream; one endpoint event
/v1/mcp/admin/message POST sessionId, JSON-RPC body 202; the answer arrives over the stream. 400 without sessionId, 404 if the session is gone
/v1/mcp POST JSON-RPC body Same, against the public registry
/v1/mcp/sse GET — Same
/v1/mcp/message POST sessionId, JSON-RPC body Same
/v1/ai/chat POST message, sessionId?, context? {reply, sessionId}. 400, 503, 500 as above
/v1/ai/chat GET — text/html, the chat UI

The chat UI

Piece What it is
webresources/js/ai-chat-sidebar.js Injects the 🤖 button into .findbarAndApplauncher and a sliding panel holding an iframe on /.rest/v1/ai/chat. Waits for Vaadin with a MutationObserver and derives the context path from its own <script> URL, with window._h1AiChatUrl taking priority
webresources/js/ai-chat.js The chat itself: HabitoAI.initStandalone() for the embedded page and initOverlay() for a floating panel. Renders replies as basic Markdown, keeps the sessionId in a variable, sends currentPath and currentApp with every message, and the ↺ button drops both the id and the transcript
webresources/css/ai-chat.css · ai-chat-sidebar.css Styles for the standalone UI and for the injected panel
apps/ai-chat-app.yaml An EmbeddedPageSubApp pointing at /.rest/v1/ai/chat
decorations/admincentral/config.yaml The h1mcp launcher group, labelled [H1] AI, row two

Module components

Registered as singletons in META-INF/magnolia/h1-mcp.xml, component set main:

McpSessionManager · LlmClientFactory · AiConversationManager

The tools are deliberately not components. They are registered as definitions in h1McpTools/ and instantiated by node2bean, which is why they take their collaborators with Components.getComponent rather than @Inject. H1McpModule implements ModuleLifecycle: it logs on start, removes the obsolete filter node, and shuts the session manager down on stop.