Developers · Installation¶
What you get¶
One JAR — com.habito1:h1-mcp-api — containing 14 classes and about 5 000 lines, and nothing
else: no light module, no REST endpoint, no virtual URI, no content app, no JCR workspace, no i18n
bundle and no version handler.
- 2 singleton components:
McpToolRegistryandMcpPublicToolRegistry. - 2 definition types, which is what makes the registration folders work:
h1McpTool→h1McpTools/, andh1McpPublicTool→h1McpPublicTools/. Magnolia watches both folders in every installed module, the same way it watchesvirtualUriMappings/. - 1 protocol class:
McpTool(name,description,inputSchema). - 2 tool bases:
BaseMcpTool(4 abstract methods) andAbstractContentTypeTool(14 standard actions, ~1 070 lines). - 2 YAML-configurable tools:
GenericContentTypeToolandGenericDecoratedFieldsTool. - 6 stateless helpers:
ContentTypeIntrospector,FormSchemaBuilder,FieldValidator,WorkspaceContentOps,FormDrivenNodeIO,I18nOps.
For scale: in the H1 platform these registries hold 24 tool registrations across 9 modules, of which 3 are pure YAML with no Java.
Maven coordinates¶
In a domain module that exposes tools, the dependency is optional (ADR-010):
<!-- your-domain-module/pom.xml -->
<dependency>
<groupId>com.habito1</groupId>
<artifactId>h1-mcp-api</artifactId>
<version>1.0.0-SNAPSHOT</version>
<optional>true</optional>
</dependency>
optional means it is not transitive, so a project that adopts your module does not inherit an
MCP dependency it may not want. It does not mean optional at compile time: your tool class
imports these types and needs them to compile. At runtime, without this JAR there is no registry,
nobody reads h1McpTools/, and your tool is inert while the rest of your module works unchanged.
In the transport (h1-mcp) the same dependency is plain compile: there it is not optional at
all.
Deploy¶
- Add the dependency to your webapp POM.
- Build and deploy the WAR as usual.
- Start the instance. On first start the module creates nothing: no workspace, no bootstrap, no
configuration node.
H1McpApiModule.startbinds the two definition types to the configuration source, and from that moment the two folders are watched. - Check Tools › Modules for
h1-mcp-apiand confirm the version.
Note — the descriptor declares
coreandconfig.configis what suppliesAbstractRegistryand the folder observation; without it the module does not start.
Wiring you have to do¶
This module is an SDK. It ships the contract, the registries, the bases and the helpers — it ships no tool of its own and no way to reach one. Everything below is yours to add.
1. A transport, or nothing is reachable¶
This is the step people miss. h1-mcp-api registers and executes tools; it does not expose
them. There is no endpoint in this JAR. Install h1-mcp, which reads both
registries and publishes them:
| Surface | Path | Reads |
|---|---|---|
| Admin MCP | POST /.rest/v1/mcp/admin (JSON-RPC over HTTP) · GET /.rest/v1/mcp/admin/sse + POST /.rest/v1/mcp/admin/message |
McpToolRegistry |
| Public MCP | POST /.rest/v1/mcp · GET /.rest/v1/mcp/sse + POST /.rest/v1/mcp/message |
McpPublicToolRegistry |
| AI Chat | /.rest/v1/ai/chat |
McpToolRegistry |
Without a transport your tools are registered, valid and unreachable — and nothing logs a warning, because from this module's point of view everything is fine.
2. Register a tool¶
One file per tool, anywhere in any module's resources, in a folder named exactly h1McpTools:
# my-light-module/h1McpTools/my_tool.yaml
class: com.example.mcp.MyTool
The file name is free — the tool's identity is what getName() returns, not the file. node2bean
instantiates the class, so any setX on it is a YAML key.
Warning — the class is instantiated by node2bean, not by Guice.
@Injectin a tool does nothing. Take services withComponents.getComponent(MyService.class), which is what every tool in the platform does.
3. Write a tool from scratch¶
Extend BaseMcpTool and implement four methods:
public class MyTool extends BaseMcpTool {
@Override public String getName() { return "my_tool"; }
@Override public String getDescription() { return "What this tool does, for the model."; }
@Override public Map<String, Object> getInputSchema() { /* JSON schema */ }
@Override public Object execute(Map<String, Object> args) throws Exception { /* … */ }
}
getDescription() and getInputSchema() are the whole interface the model sees: they are prompt,
not documentation. Write the constraints, the enumerations and the "ask the user before guessing"
instructions into them — every tool in the platform does, and it is the difference between a tool an
assistant uses correctly and one it fills with invented values.
Two optional overrides:
isAvailable()— returnfalseto hide the tool on an instance where it makes no sense (a missing optional module, a Community edition). Both registries filter on it, inlistToolsand incallTool.ownedFields()— the fields this tool alone may write. See step 5.
4. Write a tool for a content type¶
Extend AbstractContentTypeTool and you inherit the fourteen standard actions, the input schema
derived from the form, the validation pipeline, the two-step confirmations and the folder taxonomy.
What is left is identity plus persistence:
public class MyThingTool extends AbstractContentTypeTool {
private final MyThingService service = Components.getComponent(MyThingService.class);
@Override protected String contentTypeName() { return "h1-my-thing"; }
@Override protected String workspace() { return "h1-my-things"; }
@Override protected String folderPathFor(Map<String, Object> args) { return marcaFolderPath(args); }
@Override public String getName() { return "my_thing"; }
@Override public String getDescription() { return "Manages my things. " + standardActionsHelp() + translatableNote(); }
@Override public Map<String, Object> getInputSchema() { return buildStandardInputSchema(List.of(), Map.of("marca", marcaProp())); }
@Override protected Object list() throws Exception { return service.findAll(); }
@Override protected Object findOne(String uuid) throws Exception { return service.findById(uuid); }
@Override protected Object doCreate(Map<String, Object> data) throws Exception {
MyThingItem item = bind(data, MyThingItem.class);
item.setNodeName(resolveNodeName(data, item.getLabel()));
return service.create(item);
}
@Override protected Object doUpdate(String uuid, Map<String, Object> data) throws Exception {
return service.update(uuid, bindOnto((MyThingItem) findOne(uuid), data));
}
@Override protected void doDelete(String uuid) throws Exception { service.delete(uuid); }
@Override protected String uuidOf(Object created) { return ((MyThingItem) created).getUuid(); }
}
Delegate to your Service. The tool is a mapping layer, not a second persistence path: your
module owns its rules and its DTO is the contract. Non-CRUD actions (add_to_cart, test,
get_related) go in executeCustom(action, args), declared in the schema through
buildStandardInputSchema(List.of("my_action"), …).
5. Own the fields you added to someone else's content type¶
A node's properties are a shared namespace, so your module may decorate another module's app and add fields. That field then appears in the other content type's form — and therefore in its tool's schema — but it is not that tool's to write: your module defines its rules.
Declare ownership and the other tool refuses it, naming yours. In Java:
private static final Set<String> OWNED = Set.of("myPrefixTitle", "myPrefixSummary");
@Override public Set<String> ownedFields() { return OWNED; }
Warning — return a constant. The guard asks every registered tool about every field of every payload, so building the set inside the method allocates per field per tool.
With no Java at all, GenericDecoratedFieldsTool does it from YAML — its fields: is both what it
writes and what it publishes as owned:
# frontend-md3/h1McpTools/md_theme_decoration_on_site.yaml
class: com.habito1.mcp.tools.generic.GenericDecoratedFieldsTool
name: md_theme_decoration_on_site
description: Associates an MD3 theme with a site. The value is the theme node's absolute path.
workspaces:
- h1-sites
fields:
- mdTheme
6. Give a content type a tool with no Java¶
When the content type exists only as light-module YAML — no Service, no Model —
GenericContentTypeTool persists straight to JCR guided by the form:
# my-light-module/h1McpTools/my_faq.yaml
class: com.habito1.mcp.tools.generic.GenericContentTypeTool
name: my_faq
description: Manages the FAQs.
contentType: h1-faq
folderTemplate: /{marca?}
nodeNameFrom: [question, title]
Note — the criterion is where persistence comes from, not how much logic there is. If the content type lives in a Java module with a Service and a Model, its tool is a Java class that delegates to the Service
7. Expose a tool to visitors¶
A second folder, a second registry, and an action whitelist per registration:
# my-light-module/h1McpPublicTools/website.yaml
class: com.habito1.mcp.tools.website.WebsiteTool
allowedActions:
- list_pages
- get_page
- list_templates
allowedActions is enforced in BaseMcpTool.executeChecked, which both registries call — so the
same class can be a full CRUD tool on the admin surface and read-only on the public one. Omit the
key and there is no restriction.
Warning —
allowedActionsfilters by theactionargument. A tool whose actions are not driven by anactionkey gets no restriction at all from it, and an unknown action name in the list simply never matches. Check the tool's schema before trusting the whitelist.
Permissions¶
This module has no workspace of its own and grants nothing. What the tools may touch is decided by whoever calls them:
| Surface | Runs as | Consequence |
|---|---|---|
Admin registry (h1McpTools/) |
The authenticated user of the calling session (MgnlContext.getJCRSession) |
The tool can read and write exactly what that user can. There is no elevation anywhere in this module. |
Public registry (h1McpPublicTools/) |
Whatever user the public transport runs as — typically anonymous | The visitor's ACLs apply. Grant anonymous read on the workspaces you expose, and restrict the actions with allowedActions — the ACL is what stops a write, the whitelist is what stops the attempt. |
publish / unpublish |
The same session, through Magnolia's default command catalog (activate / deactivate) |
The caller needs activation rights; otherwise the command fails and the tool reports it. |
Warning — a missing ACL does not surface as an error in a listing.
nodesOfTypeandlistByFolderreturn what the session can read, so an unreadable subtree looks like an empty folder.findNodePathsByNamebehaves the same, which means resolving an item bynodeNamecan answer "not found" for something that exists but is not visible to that user.
Verify the installation¶
There is nothing in this JAR to curl: the check has to go through the transport. With h1-mcp
installed, ask each surface for its tool list over plain HTTP — no SSE session needed:
# Admin surface: everything registered in h1McpTools/ across all modules.
curl -s -u "$MGNL_USER:$MGNL_PASS" -X POST \
http://localhost:8080/magnoliaAuthor/.rest/v1/mcp/admin \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# Public surface: only what is registered in h1McpPublicTools/, and only its allowedActions.
curl -s -X POST \
http://localhost:8080/magnoliaPublic/.rest/v1/mcp \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Each entry in the answer is one McpTool: the getName(), getDescription() and
getInputSchema() of a registered tool. A tool you expect and do not see is either unavailable
(isAvailable() returned false) or was never read at all — the log tells you which.
Warning — prefer this
POSTover the SSE handshake when checking the admin surface. Theendpointevent that/v1/mcp/admin/ssesends back advertises/v1/mcp/message, which is the public endpoint's message path, and that one answers fromMcpPublicToolRegistry. An SSE client on the admin surface can therefore end up seeing only the public tool set, with no error. That is ah1-mcpmatter, not this module's, but it is what makes the SSE route a misleading check.
Then read the log at startup. This module says nothing when it is happy, and one line per problem when it is not:
Hay un h1McpTool de tipo GenericContentTypeTool sin 'name' en su YAML: no se va a ofrecer.
Al h1McpTool '<name>' le falta 'contentType' en su YAML: no se va a ofrecer.
El h1McpTool '<name>' apunta al content type '<ct>', que no está registrado: no se va a ofrecer.
Al h1McpTool '<name>' le falta 'fields' en su YAML …: no se va a ofrecer.
A tool missing from the list with no such line means the YAML was never read: check that the
folder is named h1McpTools exactly, and that it sits at the root of a module's resources.
An empty tool list is a valid answer on an instance where no module registers any — it means nobody dropped a YAML, not that the registry failed.
Upgrading¶
The module declares no version handler, so there are no deltas to apply and no configuration to
migrate: an upgrade is replacing the JAR. What can break on an upgrade is source compatibility of
the bases — AbstractContentTypeTool is a class your tools extend, so a new abstract hook would
reach them at compile time, not at runtime.