Tutorial: assistant-enable a legacy app (no API)
Meridian HR is a complete Spring Boot + Thymeleaf HR platform — directory, leave, approvals, onboarding, and more — with server-enforced RBAC and an audit trail. It has no REST API: just server-rendered HTML and form posts, the shape of a real legacy line-of-business system. In this tutorial you add a DioscHub assistant that operates it anyway — through the same front door a browser uses, as the signed-in user, with that user’s exact permissions.
This is the tutorial for the apps you can’t rewrite. Where Northwind and Cadence expose a clean machine API, Meridian exposes nothing — so the MCP adapter reads pages and submits forms, and RBAC comes for free because every action goes through the app’s own authorization.
The repository is intrigsoft/meridian-hr:
main— the starting point. The bare HR app. This is the branch you clone.production— the finished result.git diff main..productionis exactly what this tutorial adds.tutorial/(onmain) — bulk files you copy along the way.
Prerequisites
Section titled “Prerequisites”- A running DioscHub and access to its admin portal.
- Java 21 (the app) and Node 22 (the adapter).
Step 1 — Run the bare app
Section titled “Step 1 — Run the bare app”git clone https://github.com/intrigsoft/meridian-hrcd meridian-hr./gradlew bootRun # http://localhost:8080Sign in as a sample persona (passwordless demo login). Two things matter for what follows:
- The
meridian_devicecookie keys a per-browser in-memory workspace, andSessionContextresolves the signed-in user from it on every request. That signed-in session is what the assistant will act as — through a scoped grant derived from it (step 3), never the cookie itself. - RBAC is enforced in the rendered HTML — a write control only appears when your role is allowed to act. The adapter inherits exactly this: if the button isn’t in the page, the assistant can’t press it.
Step 2 — Embed the assistant rail
Section titled “Step 2 — Embed the assistant rail”The kit loads from the hub (loader.js) — no package to install. Copy the rail fragment and wire it into the shell:
mkdir -p src/main/resources/templates/fragmentscp tutorial/step-2-embed/assistant-rail.html src/main/resources/templates/fragments/assistant-rail.html renders the <diosc-chat> element and injects the loader when the assistant is configured:
<div th:if="${diosc != null and diosc.configured}"> <diosc-chat mode="embed" th:attr="api-key=${diosc.embedKey}, backend-url=${diosc.hubUrl}, assistant-id=${diosc.assistantId}, bind-endpoint=${diosc.bindEndpoint}"></diosc-chat> <script th:inline="javascript"> var s = document.createElement('script'); s.src = /*[[${diosc.hubUrl}]]*/ '' + '/api/embed/' + /*[[${diosc.embedKey}]]*/ '' + '/loader.js'; document.head.appendChild(s); </script></div>Mount it (both edits are in tutorial/step-2-embed/layout-wiring.md): expose the config to every view from LayoutAdvice, and drop the fragment into layout.html:
// LayoutAdvice.java — inject DioscProperties, then in shell(Model):model.addAttribute("diosc", diosc);<!-- layout.html, at the end of the shell --><th:block th:replace="~{fragments/assistant-rail :: rail}"></th:block>Create an Assistant in the admin portal, generate its embed key, and configure meridian.diosc.* (in application.yml or as env):
meridian: diosc: hub-url: https://your-hub.example.com embed-key: <embed key> assistant-id: <assistant id>Verify: restart, sign in — the assistant rail appears and holds a conversation. No identity or tools yet.
Step 3 — The bind endpoint
Section titled “Step 3 — The bind endpoint”The kit is connected; the hub doesn’t know who is chatting. DioscHub never implements auth — the host tells it, server-to-server. Copy the bind controller:
mkdir -p src/main/java/com/meridian/hr/diosccp tutorial/step-3-bind/DioscBindController.java tutorial/step-3-bind/DioscProperties.java \ src/main/java/com/meridian/hr/diosc/The controller resolves the signed-in user from the meridian_device cookie, then mints a scoped grant — a short-lived signed token that says act as this user, with this much authority — and hands it to the adapter (step 4), not the hub:
// Not the raw cookie — a grant Meridian signs, scopes, and can expire/revoke.String grant = assistantGrant.mint(user.id, device, "read write", Duration.ofMinutes(480));Map<String,Object> payload = Map.of("connectionId", wsId, "identity", identity, "artifacts", "grant:" + grant);// POST to ${meridian.diosc.mcp-url}/bind with x-admin-keyWhy a grant and not the cookie? The device cookie is an unscoped, long-lived bearer — a copy of the user’s whole session; forwarding it hands the assistant full account access. The grant is strictly less dangerous: scoped (the assistant gets read write, or less), short-lived, audience-bound (it only works on the adapter’s machine-auth header — it can’t be replayed as a browser cookie), and signed by a secret only Meridian holds. The adapter and hub are blind couriers that can neither read nor forge it.
Notice what does not happen here: the app never calls the hub, and holds no hub credentials. Why becomes clear in step 4.
Step 4 — The front-door adapter (on the framework)
Section titled “Step 4 — The front-door adapter (on the framework)”This is the heart of it. The adapter is an MCP server that drives Meridian’s HTML: it GETs a page, scopes to a region by a stable anchor, sanitizes it to visible text for the model, and POSTs the same form a browser would — the write handle and hidden fields are engine-owned, never LLM-authored. Because it goes through the front door as a logged-in user, Meridian’s own RBAC gates every action.
The OAuth plumbing is not your code — it’s the framework. @dioschub/mcp-server satisfies the MCP authorization spec (2025-11-25) — accept only tokens issued for itself, never pass a client token upstream — and owns the whole bind → handle → resolve exchange. You give it your tools; it does the rest:
const server = createMcpServer<string>({ name: "meridian-hr", adminKey: process.env.MCP_BIND_SECRET, // authenticates the app's /bind call jwtSecrets: process.env.MCP_JWT_SECRET, // signs the framework's handle-JWT hub: { url: process.env.DIOSC_HUB_URL, apiKey: process.env.DIOSC_HUB_API_KEY },});registerMeridianTools(server); // your 114 toolsserver.listen(5175);Two tokens are in play — keep them separate:
- The handle-JWT (framework ↔ hub). At
/bindthe framework stashes yourartifacts(the grant) under ajti, mints a handle-JWT (audience =meridian-hr), and registers that with the hub. On each tool call the hub presents the handle; the framework verifies it and hands your grant back to the tool asctx.auth. The hub only ever holds the opaque handle — never the grant, never the cookie. - The grant (adapter → Meridian). Your tool presents
ctx.authon theX-Meridian-Grantheader; Meridian’sMachineAuthFilterverifies signature + audience + expiry, resolves the human’s sandbox, and enforces the grant’s scope on top of RBAC. The adapter holds no signing secret — it cannot read or forge the grant.
Copy the adapter and run it:
cp -r tutorial/step-4-mcp/mcp-adapter ./mcp-adaptercd mcp-adapter && npm install && npm run buildEach tool just forwards ctx.auth — the framework already did the auth:
server.tool({ name: "list_pending_approvals", handler: (_args, ctx) => { const door = new FrontDoor(config); await door.authenticate(parseArtifact(ctx.auth)); // seeds X-Meridian-Grant return text(await door.read(tool, args)); // GET the page, as the user },});Config — note the split: the hub admin key and the handle-JWT secret live on the adapter; the grant signing secret lives on the app (it mints and verifies the grant; the adapter never sees it):
DIOSC_HUB_URL=https://your-hub.example.comDIOSC_HUB_API_KEY=<diosc_ak_… admin key with auth:bind>MCP_JWT_SECRET=<strong random value> # signs the handle-JWTMCP_BIND_SECRET=<strong random value> # app→adapter bind key (sent as x-admin-key)
# the app side (application.yml / env)meridian.diosc.mcp-url=http://localhost:5175meridian.diosc.bind-secret=<same as MCP_BIND_SECRET>meridian.diosc.grant-secret=<strong random value> # signs the grant — APP ONLYThe tool catalog is data, not code (src/config.ts): 114 tools across 11 HR domains, each a declarative row pointing the generic engine at one Meridian route. Adding a domain is adding entries.
Verify: npm run mcp:http, then confirm the gate — a tools/call without a valid handle returns 401:
curl -si -XPOST localhost:5175/mcp -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"whoami"}}' | head -3Step 5 — Configure the hub
Section titled “Step 5 — Configure the hub”tutorial/step-5-hub/HUB-WIRING.md has the checklist: register the adapter (http://<host>:5175/mcp, Streamable HTTP), attach it to the Assistant, grant tools per role, and approval-gate the destructive writes.
Verify the full journey: sign in as an HR-role persona and ask the assistant to find an employee and show their leave balance, then file a leave request for them. The read scrapes the directory page; the write submits the same form you would — and the activity is attributed to you. Then sign in as a persona without that permission and ask the same: the write control isn’t in the HTML the adapter fetches, so the assistant reports it can’t — no hub rule made that true, Meridian’s own RBAC did.
Least-privilege, one level deeper: the grant carries a scope (step 3). Narrow it below the user’s own permissions — say "read" — and Meridian’s MachineAuthFilter refuses every write with a 403 even for an HR user who could otherwise approve. The assistant is a distinct principal that can be handed strictly less authority than the human it acts for.
Where you are
Section titled “Where you are”git diff production -- ':!tutorial'An empty diff means you built exactly what ships. production also carries the Dockerfiles for both services.