DEVCON 2026    |    2-5 November 2026 – QEII Centre – London, UK    |    Register now! 

Blogs

Building a Liferay Environment by Talking to It: Dummy Factory Meets the DXP MCP Server

How I exposed Dummy Factory's workflow engine as MCP tools without writing a single line of MCP protocol code, and the handful of things that did not go the way I expected.

Yasuyuki Takeo
Yasuyuki Takeo
10 perces olvasmány

Reproducing a customer issue rarely fails because of the bug.

 

It fails because of the environment.

 

When I was helping the Liferay Support team, the ticket would say something like "the page breaks when a site has a few hundred web contents across several sites, with categories, and users in organizations." The fix might take an hour. Building the environment to see the problem took the rest of the day.

 

That is why Dummy Factory exists. And with Liferay DXP 2026.Q3, there is a new way to drive it: you just ask.

 

## Liferay DXP Is Now an MCP Server

 

If you have not seen it yet, David Nebinger already wrote a great introduction in [Liferay DXP Is an MCP Server. Now What?](https://liferay.dev/b/liferay-dxp-is-an-mcp-server-now-what). I will not repeat all of it here, but the short version is this.

 

Liferay ships a **Model Context Protocol (MCP) Server**. It has been around as a beta feature since 2025.Q4, and in **2026.Q3 it was promoted to a release feature**. The [2026.Q3 feature flag changes](https://learn.liferay.com/w/dxp/self-hosted-installation-and-upgrades/upgrading-liferay/deprecations-and-breaking-changes-reference/2026-deprecations-and-breaking-changes/2026-q3-default-setting-and-feature-flag-changes) list `feature.flag.LPD-63311.type` moving from `beta` to `release`.

 

A release flag is still off by default, so per the [official documentation](https://learn.liferay.com/w/dxp/ai/using-liferay-as-an-mcp-server) there are two switches:

 

1. Enable the **LPD-63311** feature flag.

2. Go to **Control Panel → Instance Settings → Platform → MCP Server** and check **Enabled**.

 

After that, `/o/mcp` answers MCP requests over Streamable HTTP, using Basic Auth or an OAuth 2.0 bearer token, and the client acts with that user's permissions. If the flag or the configuration is off, you get a 404.

 

Two details are worth noticing. The setting is **per virtual instance** and disabled by default, so each instance you want to talk to has to be switched on separately. And OAuth 2.0 discovery through `/o/.well-known/oauth-protected-resource/mcp` needs a second release flag, **LPD-63415**. Basic Auth works without it, which is all I needed for a local container.

 

The documentation also has ready-made client configurations for GitHub Copilot, Cursor, Claude Desktop and Gemini CLI, plus instructions for the official MCP Inspector, which is the first thing I would reach for when a tool does not show up.

 

Dummy Factory 2.0 targets **DXP 2026.Q3.6**, so this was the moment to try it.

 

## Wait, What Is a Dummy Factory Workflow?

 

Dummy Factory was developed as a support tool for generating dummy data. It started with one form per entity: create 50 users, create 10 sites, create 200 web contents.

 

That works fine for one entity type. It does not work well for a realistic environment.

 

A realistic environment is a chain. You create a site, then pages in that site, then web contents in that site, then categories, then message board threads, then replies to those threads. Every step needs an id from a previous step. Doing it with forms means copying ids between browser tabs.

 

So 2.0 added **workflows**. A workflow is one JSON document that runs several operations in order, and ids flow from step to step:

 

```json

{

"schemaVersion": "1.0",

"workflowId": "sample-site-and-page",

"input": {},

"steps": [

{

"id": "createSite",

"operation": "site.create",

"idempotencyKey": "sample-site-1",

"onError": { "policy": "FAIL_FAST" },

"params": [

{ "name": "count", "value": 1 },

{ "name": "baseName", "value": "sample-site" }

]

},

{

"id": "createPage",

"operation": "layout.create",

"idempotencyKey": "sample-page-1",

"onError": { "policy": "FAIL_FAST" },

"params": [

{ "name": "count", "value": 1 },

{ "name": "baseName", "value": "welcome" },

{ "name": "groupId", "from": "steps.createSite.items[0].groupId" },

{ "name": "type", "value": "portlet" }

]

}

]

}

```

 

The important part is the `from` reference. `steps.createSite.items[0].groupId` means "use the group id of the site that the previous step just created."

 

There are 14 operations today, covering companies, organizations, roles, sites, users, pages, web content, documents, blogs, vocabularies, categories, and message board categories, threads and replies.

 

The workflow is exposed as a small JAX-RS application under `/o/ldf-workflow`:

 

| Method and path | Purpose |

| ------------------------------ | --------------------------------- |

| `GET /functions` | Operations and their parameters |

| `GET /schema` | JSON Schema of a workflow request |

| `POST /plan` | Validate without running |

| `POST /execute` | Validate, then run |

| `POST /operations/{operation}` | Run a single operation |

| `GET /openapi.json` | OpenAPI 3.1 document |

 

You can paste a workflow into the **Workflow JSON** tab in the UI, or `curl` it from a script.

 

## Why Bother With MCP?

 

Here is the thing about that JSON.

 

It is precise, it is repeatable, and nobody enjoys writing it.

 

What a support engineer actually has in their head is a sentence: "I need a site with ten web contents and a couple of users." Translating that sentence into step ids, operation names and reference paths is exactly the kind of mechanical work a language model is good at.

 

So the motivation was simple. **I wanted to describe an environment in plain English and have it built.**

 

The workflow engine already did the hard part. MCP was the missing front door.

 

## I Did Not Write an MCP Server

 

Liferay's MCP server does not let you _register_ tools. It _discovers_ them. Any JAX-RS application that publishes an OpenAPI document in the way Liferay's own headless APIs do becomes a tool set, and every operation in it becomes a tool:

 

- `operationId` becomes the tool name.

- `summary` and `description` become the tool description.

- Parameters and the request body become the tool's `inputSchema`.

 

So the whole integration is an OpenAPI document. On the resource class, it is a few component properties and one method:

 

```java

@Component(

property = {

"openapi.resource=true",

"openapi.resource.path=/ldf-workflow",

"osgi.jaxrs.application.select=(osgi.jaxrs.name=ldf-workflow)",

"osgi.jaxrs.resource=true"

},

service = Object.class

)

public class WorkflowResource {

 

@GET

@Path("openapi.json")

public Response getOpenAPI(

@Context HttpServletRequest httpServletRequest,

@QueryParam("type") String type, @Context UriInfo uriInfo) {

...

}

 

}

```

 

Liferay finds the service through `openapi.resource=true` and calls `getOpenAPI` by reflection. The document itself is generated at request time from the same operation descriptors the engine uses, so there is nothing hand-written to drift out of sync.

 

That is a powerful combination: the protocol, transport, auth and session handling are Liferay's problem, and the tool definitions are the workflow engine's own metadata.

 

### Two Kinds of Tools

 

The first version exposed four coarse tools: `getWorkflowFunctions`, `getWorkflowSchema`, `planWorkflow` and `executeWorkflow`. The OpenAPI `info.description` tells the model how to use them: learn the operations, compose a request, validate it with `planWorkflow`, then run it with `executeWorkflow`.

 

That works, but it asks a lot of the model for simple requests. "Create 5 users" should not require composing a workflow.

 

So I added 14 **per-operation tools** such as `createUsers`, `createSites` and `createWebContents`. Each one is backed by `POST /operations/{operation}`, has a strict schema (`additionalProperties: false`, typed parameters, a bounded `count`), and takes literal values only.

 

The workflow tools are for chains. The per-operation tools are for everything else. In practice, Claude picks between them on its own.

 

### A Profile, So the Model Sees Real Tools

 

There is one more layer. The default `/o/mcp` endpoint does not list every tool directly. It exposes four meta tools that let the client browse tool sets and invoke a tool by name. That keeps it manageable when there are thousands of operations, but the generic invoke tool takes an opaque, nested `body`, so the model never sees a typed schema.

 

Liferay's answer is **MCP server profiles**. A profile is served at `/o/mcp/<profile>` and pins a chosen set of tools as first-class tools.

 

Dummy Factory provisions a profile called `ldf` with all 18 tools. The provisioner is a Gradle task in the repository, not code in the bundle:

 

```bash

./gradlew startDockerContainer

./gradlew :integration-test:setupMcpProfile

# -> MCP profile 'ldf' ready at http://localhost:8080/o/mcp/ldf

 

claude mcp add --transport http ldf http://localhost:8080/o/mcp/ldf \

--header "Authorization: Basic <base64 of user:password>"

```

 

And then:

 

```

> Create 5 users with base name demo-user

tool: mcp__ldf__createUsers {"body":{"baseName":"demo-user","count":5}}

result: status SUCCEEDED, success true, count 5, demo-user1 .. demo-user5

```

 

The full walkthrough is in [docs/guides/mcp-setup.md](https://github.com/yasuflatland-lf/liferay-dummy-factory/blob/master/docs/guides/mcp-setup.md).

 

I only tested Claude Code. Because the `ldf` profile is just another MCP endpoint, the client configurations in the [official documentation](https://learn.liferay.com/w/dxp/ai/using-liferay-as-an-mcp-server) should work if you replace `/o/mcp` with `/o/mcp/ldf`. If you try one of them, I would like to hear how it goes.

 

## The Things That Did Not Go As Planned

 

The architecture was clean. Getting there was less so.

 

### `tools/list` Came Back Empty

 

The first test was a smoke test: `initialize` worked, and `tools/list` returned `{"tools":[]}`.

 

The log explained it:

 

```

Skipping MCP tool "getToolSetsPage" from tool set "mcp-server-v1.0" ... HTTP 401 for /mcp-server/v1.0/openapi.json

```

 

Liferay builds the tool list by fetching each tool set's `openapi.json` through an internal request **without credentials**. The test container forces Basic Auth on `/o/*`, so every one of those fetches got a 401, and every tool was skipped.

 

The fix was an explicit `urlsExcludes` entry per OpenAPI document in the Basic Auth verifier configuration. I deliberately did not use a `*.json` wildcard. That would have exempted far more than I intended.

 

### Then the Tool Calls Returned 401

 

With the list fixed, invoking a tool returned `Status code: 401`.

 

When the MCP server runs a tool, it forwards the request internally, and the user travels as a request attribute rather than an `Authorization` header. With `forceBasicAuth` on, the Basic Auth verifier is the only one that runs, so it challenged the forward.

 

The solution was to split the verifier into two instances: the default one keeps forcing Basic Auth everywhere except `/o/ldf-workflow/*`, and a second instance covers `/o/ldf-workflow/*` with `forceBasicAuth=false`. A wrong password still gets a 401.

 

To be clear, this is a quirk of my test container's forced Basic Auth, not something you will hit on a default installation.

 

### Which Exposed a Gap in Access Control

 

Dummy Factory is a testing tool. It belongs on local and disposable environments, never in production, so what follows was never a real-world exposure.

 

It was still worth fixing.

 

Once the forced challenge was gone, I tried the obvious next thing: call `/execute` with no credentials at all. It ran as Guest, and it created data. That was not new; it was true on a stock DXP as well. MCP just made me look.

 

While I was there, I also found that a step could pass `userId` or `companyId` and act as somebody else.

 

On a test container, neither of those is going to hurt anyone. But an MCP client is not a person clicking through a form. It composes requests on its own, and I want a tool that runs unattended like that to stay inside the permissions of the user it signed in as. So I tightened things up:

 

- Every endpoint that writes now requires a signed-in user, and returns a 401 with a proper `Basic realm` otherwise.

- The workflow API requires a company administrator, and `company.create` requires an omniadmin.

- Steps always run as the signed-in user. The per-operation tools reject `userId`, `companyId` and any other unknown key with a 400.

- Every Creator rejects ids that belong to another virtual instance.

 

The lesson I took away: **an MCP client is just another API client, and it will find every gap your UI was quietly hiding.**

 

### An LLM Can Type `100000` As Easily As `10`

 

A form has a person in front of it. A tool call does not.

 

So every entry point now shares one check, `BatchSpec.validateCount`, with a cap of 1000 per request. Over the cap is rejected, never silently truncated. For web content, the cap applies to `count × groupIds`, because the count is per site.

 

Writing the tests for that turned up a loophole. A number like `1000.9` was truncated to 1000, and `4294968296` wrapped around to 1000, both slipping under the cap. Parsing now uses `BigDecimal.intValueExact()`, so those are rejected too.

 

### Tool Definitions Are Cached

 

I changed a tool description, hot-redeployed the bundle, and the new text was in `openapi.json` right away.

 

The MCP tools kept the old text for as long as I was willing to watch.

 

The MCP server caches each OpenAPI document per company, and each profile keeps its own snapshot of the schemas. I did not try to fight that. It is documented instead: after a redeploy that changes the OpenAPI document, restart the container, then re-run `setupMcpProfile`.

 

### The Image Was Not the Source

 

I designed the profile provisioning by reading the `master` branch of `liferay-portal`. The 2026.Q3.6 image did not match it. On that image, a server profile has `name`, `description` and a required `tools` text field, and the richer profile tool APIs I had been reading about simply return 404 because they arrived upstream later.

 

It also silently accepts a tool line naming a tool that does not exist.

 

So provisioning is a single idempotent `PUT` by external reference code, followed by a `tools/list` check to make sure the profile really exposes what it was supposed to.

 

If you are building against a quarterly release, read the source at that release, not at `master`.

 

### `executeWorkflow` Returns 200 When a Step Fails

 

The MCP server marks a tool result as an error only for HTTP status 300 or above. `/execute` returns 200 with a per-step status in the body, which the UI depends on.

 

Rather than break the UI, the tool description says so explicitly. The per-operation tools return 422 on failure, so those failures do show up as errors to the model.

 

## A Few Caveats

 

Dummy Factory is a development and testing tool. **Do not install it on a production system**, with or without MCP.

 

The MCP setup in this project is aimed at one scenario: Claude Code on your machine, talking to a local DXP container with Basic Auth. Remote access and OAuth 2.0 flows are out of scope for now. Liferay's MCP server supports OAuth 2.0 bearer tokens, so that is a reasonable next step, but I have not tested it with Dummy Factory.

 

And the usual caution applies. An MCP client acts with the permissions of the user it signs in as. Use an account on a disposable environment.

 

## Try It, and Tell Me What Breaks

 

The repository is at [github.com/yasuflatland-lf/liferay-dummy-factory](https://github.com/yasuflatland-lf/liferay-dummy-factory), and the 2026.Q3.6 build is on the [releases page](https://github.com/yasuflatland-lf/liferay-dummy-factory/releases/tag/2026.q3.6). Drop the JAR into `${LIFERAY_HOME}/deploy`, enable the MCP server, and follow the setup guide.

 

I would really like to hear how it holds up in your environment:

 

- **Found a bug?** [Open an issue](https://github.com/yasuflatland-lf/liferay-dummy-factory/issues/new?labels=bug) with the request and the response you got.

- **Missing an entity type or a tool?** [Request an enhancement](https://github.com/yasuflatland-lf/liferay-dummy-factory/issues/new?labels=enhancement) and tell me what environment you were trying to build.

 

The tool list is driven by real use. If "create a site with three levels of categories and a few hundred documents" is a sentence you keep needing, that is exactly the kind of thing I want to know about.

 

## Final Thoughts

 

The interesting part of this project was how little of it was about MCP.

 

Liferay already handles the protocol. The workflow engine already knew how to build a chain of entities. The integration was an OpenAPI document and a profile. Almost all the real work was elsewhere: authentication edge cases, a security gap, input limits, and caching behaviour that only shows up when a machine, not a person, is on the other end.

 

A model will do anything your API allows it to do. It does not hesitate at a missing permission check, and it does not stop at a number that looks too large. If the door is open, it walks through.

 

The flip side is what I did not expect to learn. The same AI is very good at finding those open doors. Ask it to probe your API the way an unattended agent would, and it turns up the gaps a person clicking through the UI never hits. Many of the issues in this post surfaced that way.

 

That is the takeaway I would offer anyone exposing their own Liferay module to MCP. Assume the model will try everything it can, and let the AI help you find out what "everything" means before you ship.

 

 

Hozzászólások az oldalhoz

Related Assets...

Nincsenek találatok

More Blog Entries...