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:
- 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).
- 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. - The MCP endpoint accepts only OpenRun-issued bearer tokens bound to that app. Cookies, basic auth and login redirects do not apply there there. The exception is an app with
auth none, whose endpoint is open. - 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 /shopAction 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. 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=True action is confirmed by the user before it runs (for clients on MCP protocol 2026-07-28 or later with the elicitation capability), and the tool list is private to the caller with a freshness hint of action.mcp_list_ttl (default 3m).
--mcp=@file reads the JSON document from a local file (the CLI expands it; the API only accepts the document itself). openrun app update mcp <value> <glob> changes it 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={...}.
--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), the app’s actions are not exposed over any MCP route, including the endpoint for all apps and the management action tools.
The 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 |
An MCP app needs an OAuth issuer origin: api.external_url, or security.callback_url which it defaults to. When neither is set and the HTTPS listener is on, the listener on the default app domain is used: https://<system.default_domain>:<https.port>, so a default install (https://localhost:25223) serves MCP apps with no [api] configuration. The value is logged at startup. Tokens are bound to it, so setting api.external_url later invalidates outstanding tokens; 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.
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), and 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/ordersFor 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. At consent the requested scopes are intersected with the app’s declared list, unknown scopes are dropped, and the user may narrow the grant. When a listed tool is called with a token lacking its scope, OpenRun answers 403 with a WWW-Authenticate: Bearer error="insufficient_scope", scope="..." challenge so the client can re-consent for the missing scope. The operation is read from the JSON-RPC body (requests over 1 MiB, non-JSON or malformed bodies are refused); the Mcp-Method and Mcp-Name headers that current clients send must agree with the body or the request is rejected.
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.
Transport notes
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 againsthttp://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 tohttps://localhost:25223for login and needs to trust that certificate.Browser-based MCP 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 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.Every MCP call is recorded in the audit log as an event of type
mcp(in place of thehttpevent of the request): the operation is the JSON-RPC method (tools/call,tools/list,initialize), the target the tool name, and the detail has 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). Notifications are not recorded. The status isFailedfor 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. The console’s Audit Logs page filters on themcptype.
Skip to content