Operator setup
The four settings the process needs, and the realm side of it.
Two halves. Four settings on the service, and a small amount of configuration in the realm. The realm half lives outside this repository and is not captured by any code change, which is exactly why it is written down here.
Turning it on
MCP_ENABLED defaults to false. While it is false, the /mcp route is not
registered at all, no new client identifier is needed, and the route table is
unchanged.
Source: services/runtime/src/runtime/config.py:513-522 and
services/runtime/src/runtime/main.py:249-261.
The four required settings
| Setting | What it is |
|---|---|
KEYCLOAK_ISSUER | The realm that issues tokens, and the one whose keys are used to verify them |
MCP_PUBLIC_BASE_URL | The externally reachable origin this deployment's /mcp is dialled at, for example https://mcp.example.com |
KEYCLOAK_MCP_RESOURCE_AUDIENCE | The audience every token must carry |
KEYCLOAK_MCP_REQUIRED_SCOPE | The scope every token must have been granted |
Source: services/runtime/src/runtime/config.py:522,
:535, :548 and :559.
Missing configuration stops the process
Setting MCP_ENABLED=true with any of those four empty makes the service refuse
to start, with an error naming all four. It does not start in a reduced state
and it does not serve /mcp with the checks disabled.
Source: services/runtime/src/runtime/config.py:561-580.
That is deliberate. A /mcp route with no issuer to verify tokens against, or
no public URL to put in its own metadata document, answers every request the
same wrong way. Refusing to start is the safer failure.
MCP_PUBLIC_BASE_URL in particular has to be configured rather than derived
from the request's Host header. The metadata document asserts its own address,
and a caller-supplied value is the wrong source for that.
Source: services/runtime/src/runtime/config.py:553-559.
The realm side
This is Keycloak configuration. Nothing in this repository creates or checks it.
-
A public client using PKCE. MCP clients are public clients: they hold no secret. Dynamic Client Registration must be permitted for clients to register themselves, because an MCP client has no pre-registered identifier.
-
A client scope for MCP, set to OPTIONAL. Its name is the value you put in
KEYCLOAK_MCP_REQUIRED_SCOPE. Carry an audience mapper on it, stamping the value you put inKEYCLOAK_MCP_RESOURCE_AUDIENCE. -
Nothing else. No allowlist of client identifiers, because there is nothing stable to list.
Why the scope is optional and not default
A default client scope is added to every token the realm issues, without the client asking. If the scope carrying the audience were default, every token from the realm would carry the audience, and checking it would prove nothing about who asked: every application in the realm would pass.
An optional scope is granted only when the client's authorization request names it. That turns the token into evidence that this client deliberately asked to talk to this resource, rather than evidence that the realm exists.
Source: services/runtime/src/runtime/config.py:536-548 and
services/runtime/src/runtime/mcp/verifier.py:22-30.
Check which scope carries your audience mapper
The comments in
services/runtime/src/runtime/config.py:524-535 describe a deployment where the
audience arrives from a default scope, with the optional scope carrying only
the scope requirement. If your realm is set up that way, the audience proves
only that the token came from this realm, and the optional scope is the only
part doing any binding at all. Putting the audience mapper on the optional scope
is the stronger arrangement, and it is what this page recommends above.
The weakness, stated plainly
Registration from the trusted host is anonymous. Anyone who can reach the registration endpoint can create a client, and that client can request the scope.
So this arrangement:
- Does stop silent inheritance. An application that never asks for the scope never gets it, no matter what else the realm grants it.
- Does stop unrelated applications in the same realm from reaching
/mcpwith tokens they were issued for their own purposes. - Does not stop someone who deliberately self-registers a client and asks for the scope. Nothing here prevents that.
This is defence in depth. It is not a hard boundary, and it must not be described as preventing unauthorised clients. What still stands behind it is everything on the per-call path: the person must sign in with a real account, that account must be a member of the workspace, it must hold the role the tool requires, and its MCP access in that workspace must not have been revoked.
Source: services/runtime/src/runtime/mcp/tools.py:132-166 and
services/runtime/src/runtime/auth/mcp_access.py:67-91.
Verifying the setup
With the service running and MCP enabled, fetch the discovery document. It is public, so no credential is needed:
GET {MCP_PUBLIC_BASE_URL}/.well-known/oauth-protected-resource/mcpCheck three things in the response: resource matches the URL clients will
dial, authorization_servers names your realm, and scopes_supported holds the
scope you configured.
Source: services/runtime/src/runtime/mcp/well_known.py:43-56.
Revoking one member's access
Per workspace, and through the API. See Workspace access.