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 inareas. An object with atemplateIdinsidepropertiesis rejected — that is the one place whereFormDrivenNodeIOis constructed withrejectNestedComponents = true. - A multi-entry field (
jcrMultiField) takes an array of objects and a write is a full replace;list_templatesmarks it"multiple": trueandget_pagereads it back as an array rather than as the rawlinks0,links1children. - A multi-select scalar (
checkBoxGroup,twinColSelect) takes an array of plain values, also a full replace. - A field whose
list_templatesentry carries"valueFormat": "path"and areferenceSourceis a reference: the value must be the target node's full path, andreferenceSourcenames 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_pagereports bothnameand a 0-basedorder.
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, andgeton 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.