Skip to content
📌 Enable SQLite data replication to S3 with Litestream sync

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 same app:access grant 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-Email and X-Openrun-Perms headers plus X-Openrun-Scopes (the granted scopes) and X-Openrun-Client-Id (the OAuth client holding the token, or apikey).

The app itself implements no authentication. Any MCP server that speaks Streamable HTTP without its own auth works unchanged.

The MCP client discovers the protected-resource metadata, logs in through the OpenRun authorization server to get a token bound to the app, and calls the app MCP endpoint, which strips the token and adds the user headers before proxying to the app The MCP client discovers the protected-resource metadata, logs in through the OpenRun authorization server to get a token bound to the app, and calls the app MCP endpoint, which strips the token and adds the user headers before proxying to the app

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 valueEffect
--mcpThe whole app is the MCP endpoint
--mcp=/mcpThe upstream serves MCP at /mcp; the app root is rewritten to it when proxying
--mcp='{...}'A JSON document with the fields listed below
--mcp=@fileReads the JSON document from a local file (the CLI expands it; the API only accepts the document itself)
--mcp=actionsOpenRun serves the app’s actions as MCP tools, see action apps below
--mcp=disableTurns 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=True action 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_confirm mode, overridable per app with action.destructive_confirm and per API key with openrun apikey create --confirm skip, see MCP tools.
  • The tool list is private to the caller with a freshness hint of action.mcp_list_ttl (default 3m).

--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

fieldmeaning
sourceWhat 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
pathThe 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_pathThe path the upstream serves MCP at when it differs from path. Only with path /; the app root is rewritten to it
scopesThe app’s own scope names, offered at consent and advertised to clients. Omit for unscoped tokens
default_scopeAdvertised in the 401 challenge and granted when the client asks for nothing. Must be in scopes
toolsTool name to required scope. A tools/call for a listed tool needs that scope on the token; unlisted tools need none
allowed_originsBrowser 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, then security.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 with callback_url set for SSO uses that. The resolved value is logged at startup. See Callback Url.
  • Tokens are bound to it: setting api.external_url later 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, default auth.<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 *.localhost name (a plain http issuer is served to loopback callers only). admin and builtin logins 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/orders

The 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 domain

An 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 anonymous user needs app:access on the app. The default grant (users *) includes it; a server with narrower grants needs one for * or anonymous on the app.
  • The tools scope 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_required set, token-less calls are refused and only API keys work.
  • allowed_origins and 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 403 with a WWW-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-Method and Mcp-Name headers 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 a Host: localhost header 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: set api.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 as http://localhost:25222/orders.
    • With the default https issuer, the client is sent to https://auth.localhost:25223 for login and needs to trust that certificate.
  • 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 the http event of the request). Notifications are not recorded. The console’s Audit Logs page filters on the mcp type.
    • Operation: the JSON-RPC method (tools/call, tools/list, initialize). Target: the tool name.
    • Detail: the request, the endpoint (endpoint=app, management for /_openrun/mcp, apps for /_openrun/app_mcp), the app, and the client (client=<oauth client id or apikey> cred=<credential id>, client=none for a call without a token).
    • Status: Failed for 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.