Operaton MCP Client¶
This page documents the operaton-mcp-client Keycloak client and the /v1/m2m/* route group that exposes a curated set of Operaton operations to machine-to-machine (M2M) callers without tenant scoping.
Access pattern¶
This feature uses Pattern 3 โ RONL Business API M2M routes from the Operaton Access Patterns reference. Callers interact with Operaton through the RONL Business API, not directly with engine-rest. This gives the platform audit logging, a curation gate, and RONL-shaped response envelopes โ at the cost of a narrower operation surface compared to Pattern 2.
There are two distinct authentication hops in this pattern:
-
Caller โ RONL Business API โ the
operaton-mcp-clientKeycloak client obtains a JWT via OAuth 2.0 Client Credentials and presents it asAuthorization: Bearer <token>. Inm2m.routes.ts,jwtMiddlewarevalidates the token andrequireM2mClientthen admits it only when itsazpis onM2M_ALLOWED_CLIENTS(defaultoperaton-mcp-client). Every person in the realm also holds a token for theronl-business-apiaudience, so a valid token alone is not enough: any other token, a caseworker's or citizen's included, gets403 M2M_CLIENT_NOT_ALLOWEDbefore any engine call.tenantMiddlewareis intentionally absent, so nomunicipalityclaim is required or expected. -
RONL Business API โ Operaton โ the backend calls the main engine, the same
OPERATON_BASE_URLengine and basic-auth credentials the tenant-scoped routes use.OPERATON_M2M_BASE_URLis empty unless set, and it is unset on every tier, so the code below takes the: operatonServicebranch. Setting it โ withOPERATON_M2M_USERNAMEandOPERATON_M2M_PASSWORDโ points M2M at a different engine on purpose:
The M2M_ALLOWED_OPERATIONS constant in m2m.routes.ts acts as a curation gate โ any operation not listed returns 403 OPERATION_NOT_PERMITTED regardless of what Operaton supports. This is the key structural difference from Pattern 2, which exposes the full engine-rest surface with no curation.
Architecture¶
Regular RONL Business API routes (/v1/process, /v1/task, /v1/decision) enforce tenant isolation via tenantMiddleware โ every request must carry a municipality JWT claim and data is filtered to that organisation. This is correct for human caseworkers but wrong for system actors such as an MCP agent that needs a cross-organisation view of Operaton.
The M2M API solves this with a dedicated route group that applies jwtMiddleware and the requireM2mClient allow-list, and no tenant middleware:
MCP Client / Automation tool
โ
โ 1. POST /token (client_credentials)
โผ
Keycloak (acc.keycloak.open-regels.nl)
โ
โ 2. access_token (JWT, aud: ronl-business-api)
โ no municipality claim
โผ
RONL Business API (acc.api.open-regels.nl)
โ jwtMiddleware validates token
โ requireM2mClient: azp on M2M_ALLOWED_CLIENTS, else 403
โ no tenantMiddleware โ no organisation filter
โผ
Operaton (the main engine, OPERATON_BASE_URL)
Keycloak client¶
A dedicated Keycloak client operaton-mcp-client is registered in the ronl realm. It uses the Client Credentials grant โ no browser redirect or user login is involved. No municipality or organisation_type claims are present in the token by design.
| Setting | Value |
|---|---|
| Client ID | operaton-mcp-client |
| Grant type | client_credentials |
| Token endpoint | https://acc.keycloak.open-regels.nl/realms/ronl/protocol/openid-connect/token |
| Audience | ronl-business-api (set via audience mapper) |
| Municipality claim | absent โ M2M client has no tenant scope |
The token endpoint for production will be https://keycloak.open-regels.nl/realms/ronl/protocol/openid-connect/token.
M2M โ Operaton¶
packages/backend/src/routes/m2m.routes.ts registers 18 operations on 18 routes under /v1/m2m: ten process, six task and two decision operations. Every one of them passes jwtMiddleware and requireM2mClient; none passes tenantMiddleware, so the surface is deliberately cross-tenant: it lists and acts on process instances and tasks of every organisation.
The operations, their parameters, request bodies, response shapes and error codes are described in the OpenAPI document, under the Machine-to-machine tag of the API Specification, with the m2mOAuth (client credentials) security scheme. Details worth knowing before reading it:
POST /v1/m2m/process/:key/startanswers200where its/v1twin answers201, and it keeps a caller'sbusinessKeyverbatim rather than prefixing it with an organisation.- Process history is
POST /v1/m2m/process/history, its body forwarded to Operaton as the history query. There is noGETspelling:GET /v1/m2m/process/historyanswers404. A body on aGETis dropped by many clients and proxies, which would then receive the unfiltered history. - A start refuses a body that sets
municipality,originTenantId,edocsAuthororedocsAuthorName, and a task completion one that sets any of those orapplicantId. The eDOCS author variables are the backend's to stamp, from a person's token on a/v1start or completion; a machine names no employee. Both answer400 RESERVED_VARIABLEwith the refused names in areservedmember, before any engine call. A start may carryapplicantId: a machine may start a case on a citizen's behalf. - Errors are RFC 9457 problem details; branch on the
codemember.
Test script¶
scripts/test-m2m-routes.sh checks the M2M routes against a running backend. It obtains a token via Client Credentials, checks its claims, calls the read operations and the decision evaluation, drives a write lifecycle โ start, claim, complete, delete โ on instances it creates itself, and verifies that tenant isolation still holds on the standard caseworker routes.
The write lifecycle is self-cleaning by construction. It starts two instances of LIFECYCLE_KEY with a businessKey of m2m-routes-test-<pid>-a and -b, drives the first to completion through its user task, cancels the second, deletes both in a final cleanup, and checks that no instance with that prefix is left running. It never claims, completes or cancels an instance it did not start โ the M2M surface applies no tenant filter, so a stray write would land on a real case.
Prerequisites: curl and jq on $PATH; on TARGET=local without an exported CLIENT_SECRET, also python, which reads the secret from the realm file.
Usage:
# Local stack (default) โ the secret is read from config/keycloak/ronl-realm.json
bash scripts/test-m2m-routes.sh
# Acceptance โ the secret must be supplied
TARGET=acc CLIENT_SECRET=<secret> bash scripts/test-m2m-routes.sh
Overridable environment variables:
| Variable | Default | Description |
|---|---|---|
TARGET |
local |
local or acc; selects the preset URLs, decision and lifecycle key below |
BASE_URL |
http://localhost:3002 (local), https://acc.api.open-regels.nl (acc) |
RONL Business API base URL |
KEYCLOAK_URL |
http://localhost:8080 (local), https://acc.keycloak.open-regels.nl (acc) |
Keycloak base URL |
CLIENT_ID |
operaton-mcp-client |
Keycloak client ID |
CLIENT_SECRET |
from the realm file (local only) | Keycloak client secret; required for acc |
DECISION_KEY |
TreeFellingDecision (local), AwbCompletenessCheck (acc) |
DMN key for the decision tests |
DECISION_VARS |
matching preset | Request body for the evaluate test |
LIFECYCLE_KEY |
ZorgtoeslagProvisionalSubProcessE2E (local), ZorgtoeslagProvisionalSubProcess (acc) |
Process the write lifecycle starts. It must raise a user task on its own instance; a task on a called sub-process makes the block skip claim and complete |
What it checks:
- Token obtained;
azpis the client ID,audcontainsronl-business-api,municipalityis absent - The read operations return HTTP 200 (404 accepted for
form-schema,start-form,decision-documentandhistoric-variables, whose resource may not exist in the deployment); a 404 onGET /v1/m2m/decision/:keyskips both decision checks POST /v1/m2m/process/historyanswers200and applies its filter: a query by process definition key returns only instances of that key (skipped when the engine has no history yet)GET /v1/m2m/process/historyanswers404(theGETspelling is removed; against an older backend this check fails, which tells you which build you are talking to)- Write lifecycle, skipped with a note when
LIFECYCLE_KEYcannot be started:- start answers
200, keeps thebusinessKeyverbatim, labels the case'smunicipalitywith the instance's owntenantId, sets nooriginTenantId, and accepts wrapped{ value, type }variables as well as plain ones - a start that sets
municipalityanswers400with codeRESERVED_VARIABLE - a claim without a body assigns the token subject; re-claiming for the same user answers
200; claiming a held task for another user answers500and leaves the assignee unchanged; auserIdin the body overrides the token subject - a completion that sets
municipalityanswers400 RESERVED_VARIABLEand leaves the task open - complete answers
200withcompleted: true; completing the same task again answers500 - delete answers
200with the instance id; cancelling it again answers500 - no test instance is left running
- start answers
- On
localonly, known-fixture assertions: tenant-scoped processes (AwbShellProcess,AwbZorgtoeslagProcess) still yield their start forms through the untenanted surface,RipR21Processhas no start form, and its variable hints resolve GET /v1/taskwith an M2M token returns403 MISSING_TENANTโ tenant-scoped routes remain isolated
The script exits non-zero when any check fails, and lists the failures.
Curation gate¶
The M2M_ALLOWED_OPERATIONS constant at the top of m2m.routes.ts controls which operations are active. Comment out any entry to disable that operation โ no other code changes are required:
export const M2M_ALLOWED_OPERATIONS: string[] = [
// Process
'process.list',
'process.start',
// 'process.delete', // โ commented out: disabled
// Task
'task.list',
'task.complete',
// ...
];
All 18 operations are listed today. A disabled operation returns 403 OPERATION_NOT_PERMITTED. The gate runs after the client allow-list, so a caller not on M2M_ALLOWED_CLIENTS gets 403 M2M_CLIENT_NOT_ALLOWED whatever the operation; the specification carries both codes on one shared M2mForbidden response.
Operaton engine¶
The M2M routes use the main engine โ the OPERATON_BASE_URL engine the tenant-scoped routes use โ on every tier. The three OPERATON_M2M_* variables are an override for pointing M2M at a different engine on purpose; none of them is set on any tier.
| Variable | Required | Description |
|---|---|---|
OPERATON_M2M_BASE_URL |
No | Override: a different engine for /v1/m2m. Unset, the M2M routes use OPERATON_BASE_URL. |
OPERATON_M2M_USERNAME |
No | Basic auth username for the override engine. When unset, OperatonService falls back silently to OPERATON_USERNAME, so set it together with the URL |
OPERATON_M2M_PASSWORD |
No | Basic auth password for the override engine; same fallback, to OPERATON_PASSWORD |
M2M_ALLOWED_CLIENTS |
No | Comma-separated Keycloak client ids, matched against the token's azp, that may call /v1/m2m. Defaults to operaton-mcp-client. Adding a consumer means adding its client id here; nothing changes in Keycloak. |
Audit logging¶
All M2M requests are written to the audit_logs table. Because the operaton-mcp-client token carries no municipality claim, the tenant_id column is populated with the Keycloak azp claim โ i.e. operaton-mcp-client โ making M2M activity queryable and distinguishable from human caseworker activity.
Testing with curl¶
1. Obtain a token¶
TOKEN=$(curl -s -X POST \
https://acc.keycloak.open-regels.nl/realms/ronl/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=operaton-mcp-client" \
-d "client_secret=<secret-from-keycloak>" \
| jq -r .access_token)
2. Verify the token claims¶
Expected: aud contains ronl-business-api, azp is operaton-mcp-client, no municipality claim.
3. List all tasks (unfiltered across all organisations)¶
4. List all active process instances¶
5. Evaluate a decision¶
curl -s -X POST \
https://acc.api.open-regels.nl/v1/m2m/decision/AwbCompletenessCheck/evaluate \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"variables": {"productType": "TreeFellingPermit"}}' \
| jq .
6. Get process history (body forwarded to Operaton)¶
curl -s -X POST \
https://acc.api.open-regels.nl/v1/m2m/process/history \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"sorting": [{"sortBy": "startTime", "sortOrder": "desc"}]}' \
| jq .
7. Confirm tenant-scoped routes are still blocked¶
Expected: "MISSING_TENANT", the code of a 403 problem โ the token carries no municipality claim so tenantMiddleware rejects it, confirming the original caseworker routes remain fully isolated.