MCP Apps
Overview
An app deployed through OpenRun can be an MCP server: a container image speaking Streamable HTTP, a proxy app in front of a remote MCP endpoint, or any app that serves MCP on a path. Marking the app with --mcp makes OpenRun handle the MCP authorization flow for it:
- Authorization server: OpenRun serves the OAuth protected-resource metadata for the app and acts as the OAuth 2.1 authorization server (the same one that protects the Remote API and MCP surfaces).
- Login: users log in with the app’s configured
auth(the admin account, builtin users, an[auth.*]OAuth/OIDC provider such as GitHub or Google, or a[saml.*]provider) and approve the client on a consent page. Federated logins refresh the user’s group snapshot used by RBAC. Access requires the sameapp:accessgrant the app’s web UI requires when RBAC is on. - Tokens only: the MCP endpoint accepts only OpenRun-issued bearer tokens bound to that app. Cookies, basic auth and login redirects do not apply there. The exception is an app with
auth none, whose endpoint is open. - Headers: the token is stripped before the request reaches the app. The app receives the usual
X-Openrun-User,X-Openrun-User-Id,X-Openrun-User-EmailandX-Openrun-Permsheaders plusX-Openrun-Scopes(the granted scopes) andX-Openrun-Client-Id(the OAuth client holding the token, orapikey).
The app itself implements no authentication. Any MCP server that speaks Streamable HTTP without its own auth works unchanged.
Deploying an MCP app
Three forms of --mcp cover the common layouts. The public MCP URL is always the app URL for the first two.
# The image serves MCP at / : the whole app is the endpoint
openrun app create --spec image --param image=ghcr.io/example/orders-mcp:1.2 --param port=8080 \
--auth builtin --mcp /orders
# The image serves MCP at /mcp : the whole app is still the endpoint, OpenRun
# rewrites the app root to /mcp when proxying (note the = syntax)
openrun app create --spec image --param image=ghcr.io/example/orders-mcp:1.2 --param port=8080 \
--auth builtin --mcp=/mcp /orders
# A mixed app: a web UI at / with normal login, MCP under /mcp, with scopes
openrun app create --auth builtin \
--mcp='{"path":"/mcp","scopes":["orders:read","orders:write"],"default_scope":"orders:read","tools":{"cancel_order":"orders:write"}}' \
./src /shop--mcp value | Effect |
|---|---|
--mcp | The whole app is the MCP endpoint |
--mcp=/mcp | The upstream serves MCP at /mcp; the app root is rewritten to it when proxying |
--mcp='{...}' | A JSON document with the fields listed below |
--mcp=@file | Reads the JSON document from a local file (the CLI expands it; the API only accepts the document itself) |
--mcp=actions | OpenRun serves the app’s actions as MCP tools, see action apps below |
--mcp=disable | Turns all MCP off for the app, see action apps below |
openrun app update mcp <value> <glob> changes the setting later (- or default clears it); updates are staged and promoted like other metadata. In apply files the app() entry takes mcp=True, mcp=False, mcp="/mcp" or mcp={...}.
Action apps
Action apps need no --mcp option: an app with actions serves them as MCP tools at <app url>/mcp by default. OpenRun itself is the MCP server and there is no upstream. --mcp=actions is that endpoint as an explicit setting (the ops tools use case builds a team’s operations tools this way). Everything below about login, tokens, scopes and API keys applies to it.
- The tools carry the side-effect hints of the actions as annotations.
- A
destructive=Trueaction is confirmed by the user before it runs. Clients on MCP protocol 2026-07-28 or later with the elicitation capability are asked; by default a client which cannot confirm is refused. This is the[api.mcp] destructive_confirmmode, overridable per app withaction.destructive_confirmand per API key withopenrun apikey create --confirm skip, see MCP tools. - The tool list is private to the caller with a freshness hint of
action.mcp_list_ttl(default3m).
--mcp=disable (mcp=False in an apply file) turns all MCP off for an app: no MCP endpoint (an upstream MCP path becomes an ordinary app path with the app’s normal login), and the app’s actions are not exposed over any MCP route, including the endpoint for all apps and the management action tools.
JSON document fields
| field | meaning |
|---|---|
source | What serves MCP in the region. Omitted: the app’s container or proxied url. actions: OpenRun serves the app’s actions as tools; path then defaults to /mcp and cannot be /, container_path is not allowed |
path | The MCP region within the app, default / (whole app). With /mcp, only that subtree is the MCP endpoint and the rest of the app keeps its human login |
container_path | The path the upstream serves MCP at when it differs from path. Only with path /; the app root is rewritten to it |
scopes | The app’s own scope names, offered at consent and advertised to clients. Omit for unscoped tokens |
default_scope | Advertised in the 401 challenge and granted when the client asks for nothing. Must be in scopes |
tools | Tool name to required scope. A tools/call for a listed tool needs that scope on the token; unlisted tools need none |
allowed_origins | Browser origins (scheme://host[:port]) admitted on the endpoint. Requests with any other Origin are refused; native clients send none |
Issuer origin and login domain
An MCP app needs an OAuth issuer origin.
- Source: the server’s public origin. The lookup order is always
api.external_url, thensecurity.callback_url, then the HTTPS listener on the default domain (https://<system.default_domain>:<https.port>), so a default install (https://localhost:25223) serves MCP apps with no[api]configuration, and a server withcallback_urlset for SSO uses that. The resolved value is logged at startup. See Callback Url. - Tokens are bound to it: setting
api.external_urllater invalidates outstanding tokens, so set it up front for a server clients reach by another name. While MCP apps are deployed, a configuration update that clears or breaks the origin is refused. - Login and consent pages are served on the auth callback domain (
security.auth_callback_domain, defaultauth.<system.default_domain>), not on the issuer host, so an app’s JavaScript can never be same-origin with the consent step. The domain must resolve to the server, like the app domains. - Provider logins through an
[auth.*]or[saml.*]provider are refused when no auth callback domain resolves, or when the issuer is plain http and the auth callback domain is not a*.localhostname (a plain http issuer is served to loopback callers only).adminandbuiltinlogins do not need it.
Health checks for MCP apps
The health check for an MCP app without an explicit health path in container.config sends an MCP JSON-RPC request to the endpoint instead of a GET, since an MCP endpoint does not answer GET with 200.
On Kubernetes the pod readiness and startup probes are TCP probes on the container port (a native probe cannot send a body). OpenRun additionally sends its JSON-RPC probe to the new version through a temporary per-version Service before traffic is switched to it; a version that does not answer as an MCP server is removed and the deploy fails, as with any failed health check.
Connecting a client
Add the app URL to the client. The client discovers the metadata, opens the OpenRun login page in a browser, and receives a token bound to the app:
claude mcp add --transport http orders https://apps.example.com/ordersThe actions of every app the user may run are also available together at the aggregate /_openrun/app_mcp endpoint ([api.app_mcp], on by default), see one endpoint for all apps.
For clients that cannot run a browser flow (CI agents, server-side tools), mint an API key bound to the app and configure it as a static bearer header:
openrun apikey create --resource app:/orders # unscoped: RBAC alone governs
openrun apikey create --resource app:/orders --scopes orders:read # limited to the app's scopes
openrun apikey create --resource app:apps.example.com:/orders # app on a specific domainAn app-bound key is valid for exactly that app. Staging and preview apps are separate resources: a token for the production app does not work at the staging URL and vice versa. Use app:<stage domain>:<path> for a staging key.
Open MCP apps (auth none)
An MCP app with --auth none has no login to bind a token to, so its MCP endpoint is served without a token: the client adds the URL and connects, with no browser login. Requests run as the anonymous user, as they do for the rest of the app.
- No OAuth metadata is published for the app and the authorization page refuses it, so clients do not start a login flow.
- RBAC still applies: the
anonymoususer needsapp:accesson the app. The default grant (users*) includes it; a server with narrower grants needs one for*oranonymouson the app. - The
toolsscope map does not apply to token-less calls, there is no token to carry a scope. Any tool is callable by anyone who can reach the app. - An API key bound to the app still works and identifies its user; its scopes apply as usual. An invalid token is refused, it does not fall back to anonymous.
- With
security.auth_requiredset, token-less calls are refused and only API keys work. allowed_originsand the HTTPS (or loopback) transport requirement are unchanged.
Scopes and tool policy
Scopes are the app’s own vocabulary, not the OpenRun RBAC permissions. RBAC decides who may reach the app at all: with RBAC on, the user needs app:access on the app (checked at consent and on every call). Scopes only narrow what a specific token may do.
- At consent: the requested scopes are intersected with the app’s declared list, unknown scopes are dropped, and the user may narrow the grant.
- Missing scope: when a listed tool is called with a token lacking its scope, OpenRun answers
403with aWWW-Authenticate: Bearer error="insufficient_scope", scope="..."challenge so the client can re-consent for the missing scope. - Operation detection: the operation is read from the JSON-RPC body (requests over 1 MiB, non-JSON or malformed bodies are refused). The
Mcp-MethodandMcp-Nameheaders that current clients send must agree with the body or the request is rejected.
Transport notes
- HTTPS: the endpoint is served over HTTPS, or behind a trusted TLS-terminating proxy listed in
security.trusted_proxies. Plaintext requests get a 404, except for connections from loopback to a loopback host (localhost,127.0.0.1,::1) for local development; a remote connection with aHost: localhostheader does not qualify.- The same exception covers the discovery documents and the OAuth endpoints, so a client can complete the whole flow against
http://localhost:25222: setapi.external_url = "http://localhost:25222"(a plain http origin is accepted only on a loopback host, and only while no management API surface is enabled) and add the app ashttp://localhost:25222/orders. - With the default https issuer, the client is sent to
https://auth.localhost:25223for login and needs to trust that certificate.
- The same exception covers the discovery documents and the OAuth endpoints, so a client can complete the whole flow against
- Browser clients need their origin in
allowed_origins; CORS preflight requests from an allowed origin are passed to the app without a token so the app’s CORS handler can answer them. - Legacy sessions: MCP clients that use sessions (
Mcp-Session-Id, GET streams) pass through unchanged. On Kubernetes with several replicas such clients need a stateless-mode server; OpenRun does not pin sessions to pods. - Audit log: every MCP call is recorded as an event of type
mcp(in place of thehttpevent of the request). Notifications are not recorded. The console’s Audit Logs page filters on themcptype.- Operation: the JSON-RPC method (
tools/call,tools/list,initialize). Target: the tool name. - Detail: the request, the endpoint (
endpoint=app,managementfor/_openrun/mcp,appsfor/_openrun/app_mcp), the app, and the client (client=<oauth client id or apikey> cred=<credential id>,client=nonefor a call without a token). - Status:
Failedfor a refused request and, where OpenRun runs the tool itself (the management tools and the endpoint for all apps), for a call which returned an error, with the message in the detail.
- Operation: the JSON-RPC method (