From 31c9e903c8322d58ff4a37296a3aa4f28d8c3bca Mon Sep 17 00:00:00 2001 From: Ravi Kiran Date: Mon, 21 Sep 2026 15:49:27 +0530 Subject: [PATCH] Add MCP service to docker compose --- deploy/docker-compose/.env.defaults | 15 +++++ deploy/docker-compose/README.md | 60 +++++++++++++++---- deploy/docker-compose/docker-compose.yml | 44 +++++++++++++- .../nginx.gateway.routing.template | 49 +++++++++++++++ docs/oauth.md | 8 +++ 5 files changed, 165 insertions(+), 11 deletions(-) diff --git a/deploy/docker-compose/.env.defaults b/deploy/docker-compose/.env.defaults index c37bb9152c..8d1724e546 100644 --- a/deploy/docker-compose/.env.defaults +++ b/deploy/docker-compose/.env.defaults @@ -109,3 +109,18 @@ HOST_CLOUDSDK_CONFIG= OPS_CODE_BLOCK_MEMORY_LIMIT_IN_MB=256 OPS_SLACK_ENABLE_INTERACTIONS=true + +# --------------------------------------------------------- +# MCP server for external agents (opt-in) +# --------------------------------------------------------- + +# Set OPS_OAUTH_ENABLED=true and uncomment COMPOSE_PROFILES=mcp to serve the public URL +# plus /mcp to agents such as Claude Code with a plain "docker compose up -d". +# OPS_PUBLIC_URL must be https unless it is localhost. The client secret has no default: +# set it to a random value of at least 32 characters, e.g. the output of "openssl rand -hex 32". +# The MCP container refuses to start while it is empty. +OPS_OAUTH_ENABLED=false +OPS_OAUTH_ISSUER_URL=${OPS_PUBLIC_URL}/api +OPS_MCP_RESOURCE_URL=${OPS_PUBLIC_URL}/mcp +OPS_OAUTH_RS_CLIENT_SECRET= +# COMPOSE_PROFILES=mcp diff --git a/deploy/docker-compose/README.md b/deploy/docker-compose/README.md index 48b2fd6480..6f1e4b320e 100644 --- a/deploy/docker-compose/README.md +++ b/deploy/docker-compose/README.md @@ -1,13 +1,51 @@ - # Docker Compose Deployment This is a docker compose deployment of the OpenOps platform. - # Installation See the [getting started guide](https://docs.openops.com/getting-started/deployment/local) for local deployment in our documentation. +# MCP server for external agents + +External agents such as Claude Code or Codex connect to OpenOps through the `openops-mcp` +container, which is disabled by default. It sits behind the gateway at `${OPS_PUBLIC_URL}/mcp` +and authenticates agents with OAuth issued by the OpenOps API. + +To enable it, in `.env`: + +- `OPS_PUBLIC_URL` must be `https://...` (plain `http` is only accepted for `localhost`), so + enable TLS first. +- `OPS_OAUTH_ENABLED=true` turns on OAuth in the app. The issuer and resource URLs derive from + `OPS_PUBLIC_URL` and normally need no change. +- `OPS_OAUTH_RS_CLIENT_SECRET` is empty by default and must be set to a random value of at least + 32 characters, for example `openssl rand -hex 32`. The MCP container refuses to start without it. +- `COMPOSE_PROFILES=mcp` enables the `mcp` compose profile, so the usual `docker compose up -d` + (and the install script) also pulls and starts the MCP container. +- The MCP image version is pinned in `docker-compose.yml` and pulled from the public + `openops.azurecr.io` registry. + +Then restart the stack: + +```bash +docker compose up -d +``` + +If you prefer not to set `COMPOSE_PROFILES` in `.env`, pass the profile on the command line +instead: `docker compose --profile mcp up -d`. + +Existing installations keep their `.env` across upgrades (the install script never rewrites +it), so the `OPS_OAUTH_*`, `OPS_MCP_RESOURCE_URL` and `COMPOSE_PROFILES` lines from +`.env.defaults` have to be copied into `.env` by hand before enabling MCP. The install script +does not generate `OPS_OAUTH_RS_CLIENT_SECRET` either; always set it yourself. + +Connect an agent to `${OPS_PUBLIC_URL}/mcp`, for example: + +```bash +claude mcp add --transport http openops https:///mcp +``` + +The first tool call opens a browser to approve the connection under Settings → Connected apps. # Connections @@ -17,15 +55,15 @@ OpenOps supports Redis over TLS for deployments where Redis is not running on th same private Docker network. The application and worker accept the following environment variables (prefixed with `OPS_` in the deployment environment): -| Variable | Description | -| --- | --- | -| `OPS_REDIS_URL` | Complete ioredis connection URL. When set, it takes precedence over the host/port settings. | -| `OPS_REDIS_HOST` | Redis hostname, used when `OPS_REDIS_URL` is not set. | -| `OPS_REDIS_PORT` | Redis port, used with `OPS_REDIS_HOST`. | -| `OPS_REDIS_USE_SSL` | Set to `true` to enable TLS when using host/port settings. | -| `OPS_REDIS_USER` | Optional Redis username. | +| Variable | Description | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `OPS_REDIS_URL` | Complete ioredis connection URL. When set, it takes precedence over the host/port settings. | +| `OPS_REDIS_HOST` | Redis hostname, used when `OPS_REDIS_URL` is not set. | +| `OPS_REDIS_PORT` | Redis port, used with `OPS_REDIS_HOST`. | +| `OPS_REDIS_USE_SSL` | Set to `true` to enable TLS when using host/port settings. | +| `OPS_REDIS_USER` | Optional Redis username. | | `OPS_REDIS_PASSWORD` | Redis password or auth token. Store it in the deployment secret store rather than committing it to an environment file. | -| `OPS_REDIS_DB` | Redis database number; defaults to `0`. | +| `OPS_REDIS_DB` | Redis database number; defaults to `0`. | For an ElastiCache deployment with transit encryption required, use `OPS_REDIS_USE_SSL=true` together with `OPS_REDIS_HOST`, `OPS_REDIS_PORT`, and @@ -41,6 +79,7 @@ To use the Azure CLI block, you need to create a connection to Azure. If you use However, it is possible to share your local session with the platform for local applications. To do this, you need to set two environment variables: + - `OPS_ENABLE_HOST_SESSION=true`: enables sharing of the host session with the platform container. - `HOST_AZURE_CONFIG_DIR=/root/.azure`: defines the path to the host machine's Azure configuration folder that will be shared with the platform container @@ -50,5 +89,6 @@ To use the Google Cloud CLI block, you need to create a connection to Google Clo However, it is possible to share your local session with the platform for local applications. To do this, you need to set two environment variables: + - `OPS_ENABLE_HOST_SESSION=true`: enables sharing of the host session with the platform container. - `HOST_CLOUDSDK_CONFIG=/root/.config/gcloud`: defines the path to the host machine's Google Cloud configuration folder that will be shared with the platform container diff --git a/deploy/docker-compose/docker-compose.yml b/deploy/docker-compose/docker-compose.yml index a4f3816c3e..806bdd3bf1 100644 --- a/deploy/docker-compose/docker-compose.yml +++ b/deploy/docker-compose/docker-compose.yml @@ -26,6 +26,12 @@ services: OPS_OPENOPS_TABLES_VERSION: 0.2.22 OPS_ANALYTICS_VERSION: 0.14.8 OPS_WORKER_URL: http://openops-worker:3000 + healthcheck: + test: ['CMD-SHELL', 'wget -qO /dev/null http://127.0.0.1/api/v1/health'] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s depends_on: openops-tables: condition: service_healthy @@ -47,8 +53,44 @@ services: volumes: - ${HOST_AZURE_CONFIG_DIR:-openops_azure_cli_data}:/tmp/azure - ${HOST_CLOUDSDK_CONFIG:-openops_gcloud_cli_data}:/tmp/gcloud + healthcheck: + test: ['CMD', 'curl', '-f', 'http://127.0.0.1:3000/v1/health'] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + depends_on: + openops-app: + condition: service_healthy + openops-mcp: + image: openops.azurecr.io/openops-mcp:0.2.0 + restart: unless-stopped + environment: + OPENOPS_API_URL: http://openops-app/api + OPENOPS_MCP_PROFILE: agent + OPENOPS_MCP_ISSUER: ${OPS_OAUTH_ISSUER_URL:-} + OPENOPS_MCP_RESOURCE_URL: ${OPS_MCP_RESOURCE_URL:-} + OPENOPS_MCP_CLIENT_SECRET: ${OPS_OAUTH_RS_CLIENT_SECRET:-} + LOG_LEVEL: ${OPS_LOG_LEVEL:-info} + LOGZIO_TOKEN: ${OPS_LOGZIO_TOKEN:-} + ENVIRONMENT: ${OPS_ENVIRONMENT_NAME:-docker-compose} + healthcheck: + test: + [ + 'CMD', + 'python', + '-c', + "import urllib.request, sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:3020/.well-known/oauth-protected-resource/mcp', timeout=3).status == 200 else 1)", + ] + interval: 20s + timeout: 5s + retries: 10 + start_period: 30s depends_on: - - openops-app + openops-app: + condition: service_healthy + profiles: + - mcp openops-tables: image: openops.azurecr.io/openops-tables:0.2.22 restart: unless-stopped diff --git a/deploy/docker-compose/nginx.gateway.routing.template b/deploy/docker-compose/nginx.gateway.routing.template index e7db3a7dfc..da1774b912 100644 --- a/deploy/docker-compose/nginx.gateway.routing.template +++ b/deploy/docker-compose/nginx.gateway.routing.template @@ -82,6 +82,55 @@ location ~ ^/api/v1/webhooks/[^/]+/sync$ { send_timeout ${NGINX_WEBHOOK_SYNC_TIMEOUT}; } +# MCP server for external agents (compose profile "mcp"). proxy_pass through a variable +# with Docker's DNS so nginx starts even when the profile is off. The /mcp prefix must not +# be stripped: the MCP server derives its OAuth resource identifier from it. +location /mcp { + resolver 127.0.0.11 valid=30s; + set $openops_mcp http://openops-mcp:3020; + proxy_pass $openops_mcp; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # --- MCP streaming-specific settings --- + proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding on; + proxy_read_timeout ${NGINX_WEBHOOK_SYNC_TIMEOUT}; +} + +location /.well-known/oauth-protected-resource { + resolver 127.0.0.11 valid=30s; + set $openops_mcp http://openops-mcp:3020; + proxy_pass $openops_mcp; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +# OAuth authorization server discovery (RFC 8414 and the OpenID path) for the API issuer +# ${OPS_PUBLIC_URL}/api. Without these the catch-all serves the frontend's index.html. +# The API registers both the bare path and the "/api"-suffixed variant. +location /.well-known/oauth-authorization-server { + proxy_pass http://openops-app/api/.well-known/oauth-authorization-server; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + +location /.well-known/openid-configuration { + proxy_pass http://openops-app/api/.well-known/openid-configuration; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} + location /.well-known/acme-challenge/ { root /etc/nginx/tls/acme; } diff --git a/docs/oauth.md b/docs/oauth.md index 3979d88fcc..a0b2e22e3a 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -382,6 +382,14 @@ the advertised resource and the actual audience disagree. And `OPS_MCP_RESOURCE_ equal `OPENOPS_MCP_RESOURCE_URL` there, exactly. Sharing a host is fine, since audiences are compared with their path — `/api` and `/mcp` are distinct. +In the docker compose deployment (`deploy/docker-compose`) the two public values, issuer and +resource URL, derive from `OPS_PUBLIC_URL` in `.env.defaults`, while `OPENOPS_API_URL` is the +internal `http://openops-app/api`. The MCP server runs as the `openops-mcp` service behind the +`mcp` compose profile. The gateway nginx routes `/mcp` and `/.well-known/oauth-protected-resource` +to that service and the authorization-server discovery paths to the API; without the latter, the +catch-all would answer discovery requests with the frontend's `index.html`. Set +`OPS_OAUTH_ENABLED=true` and `COMPOSE_PROFILES=mcp` in `.env`, then `docker compose up -d`. + ## Cleanup An hourly system job (`oauth-cleanup-job.ts`) deletes expired authorization codes and pending