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: McpToolRegistry and McpPublicToolRegistry.
  • 2 definition types, which is what makes the registration folders work: h1McpTool → h1McpTools/, and h1McpPublicTool → h1McpPublicTools/. Magnolia watches both folders in every installed module, the same way it watches virtualUriMappings/.
  • 1 protocol class: McpTool (name, description, inputSchema).
  • 2 tool bases: BaseMcpTool (4 abstract methods) and AbstractContentTypeTool (14 standard actions, ~1 070 lines).
  • 2 YAML-configurable tools: GenericContentTypeTool and GenericDecoratedFieldsTool.
  • 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

  1. Add the dependency to your webapp POM.
  2. Build and deploy the WAR as usual.
  3. Start the instance. On first start the module creates nothing: no workspace, no bootstrap, no configuration node. H1McpApiModule.start binds the two definition types to the configuration source, and from that moment the two folders are watched.
  4. Check Tools › Modules for h1-mcp-api and confirm the version.

Note — the descriptor declares core and config. config is what supplies AbstractRegistry and 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. @Inject in a tool does nothing. Take services with Components.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() — return false to hide the tool on an instance where it makes no sense (a missing optional module, a Community edition). Both registries filter on it, in listTools and in callTool.
  • 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 — allowedActions filters by the action argument. A tool whose actions are not driven by an action key 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. nodesOfType and listByFolder return what the session can read, so an unreadable subtree looks like an empty folder. findNodePathsByName behaves the same, which means resolving an item by nodeName can 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 POST over the SSE handshake when checking the admin surface. The endpoint event that /v1/mcp/admin/sse sends back advertises /v1/mcp/message, which is the public endpoint's message path, and that one answers from McpPublicToolRegistry. An SSE client on the admin surface can therefore end up seeing only the public tool set, with no error. That is a h1-mcp matter, 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.