diff --git a/.env b/.env index 618af9f..d90c578 100644 --- a/.env +++ b/.env @@ -11,6 +11,9 @@ USE_PROXY_AUTH=0 PROXY_AUTH_USER= PROXY_AUTH_PASS= PUBLIC_ADDRESS=https://localhost:8443 +ERAMBA_MCP_IMAGE_TAG=3.x +OAUTH2_INTROSPECTION_CLIENT_ID=eramba-mcp-introspect +OAUTH2_INTROSPECTION_CLIENT_SECRET=Replace-this-with-a-unique-secret DOCKER_DEPLOYMENT=1 LDAPTLS_REQCERT=never TRIGGER_RUNNER_SHARED_SECRET=c86b1a9edd5fdad7f85c95775f42246d761c5486b9059b7db23efc69410b9eaf diff --git a/.github/workflows/Docker.yml b/.github/workflows/Docker.yml index 7999b77..d2af0bb 100644 --- a/.github/workflows/Docker.yml +++ b/.github/workflows/Docker.yml @@ -5,6 +5,10 @@ on: branches: - '*' +permissions: + contents: read + packages: read + jobs: simple_install: name: Simple Installation [${{ matrix.build-type }}] @@ -19,20 +23,22 @@ jobs: steps: - name: Check out the repo uses: actions/checkout@v3 - # - # - name: Log in to GitHub Container Registry - # uses: docker/login-action@v2 - # with: - # registry: ghcr.io - # username: ${{ github.actor }} - # password: ${{ secrets.TOKEN_INCREASED_PERMISSIONS }} + - name: Check MCP simple-install contract + run: npx --yes bats@1.12.0 tests/mcp-simple-install.bats + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Run Simple Install run: | docker compose -f docker-compose.simple-install.yml down 2>&1 docker system prune --force --volumes --all - docker compose -f docker-compose.simple-install.yml up -d + ERAMBA_MCP_DEV_BEARER_TOKEN=ci-mcp-smoke-token docker compose -f docker-compose.simple-install.yml up -d - name: Wait for apache to start timeout-minutes: 5 @@ -46,6 +52,31 @@ jobs: exit 1 fi + - name: MCP protocol check + timeout-minutes: 5 + run: | + until curl --fail --silent --insecure https://localhost:8443/openapi.json >/dev/null; do + printf 'Waiting for MCP...\n' + sleep 2 + done + + curl --fail --silent --insecure https://localhost:8443/.well-known/oauth-protected-resource/mcp \ + | jq -e '.resource == "https://localhost:8443/mcp"' + + curl --fail --silent --insecure https://localhost:8443/mcp \ + -H 'Authorization: Bearer ci-mcp-smoke-token' \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"docker-ci","version":"1.0"}}}' \ + | jq -e '.result.protocolVersion == "2025-03-26"' + + curl --fail --silent --insecure https://localhost:8443/mcp \ + -H 'Authorization: Bearer ci-mcp-smoke-token' \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + --data '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ + | jq -e '.result.tools | length > 0' + - name: CMD Check run: | if docker exec -w /var/www/eramba/app/upgrade -u www-data eramba bin/cake current_config validate; then diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..ada4b46 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,18 @@ +:443 { + tls /certs/mycert.crt /certs/mycert.key + + # Keep Inspector local-only. Proxying the UI and its local bridge through the + # same public origin as Eramba/MCP makes OAuth and app rendering brittle. + @mcp path /mcp /openapi /openapi.json /.well-known/oauth-protected-resource /.well-known/oauth-protected-resource/mcp /.well-known/oauth-authorization-server /.well-known/openid-configuration + handle @mcp { + reverse_proxy {$MCP_UPSTREAM:mcp_server:3000} + } + + handle { + reverse_proxy {$ERAMBA_UPSTREAM:https://eramba:443} { + transport http { + tls_insecure_skip_verify + } + } + } +} diff --git a/README.md b/README.md index 72581bb..4989c14 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ Welcome to eramba's official Github account, for Docker installs please review our website Learning Platform ([eramba.org](https://www.eramba.org/learning/courses/12/episodes/274)) under Docker Install. The bundled files in `apache/ssl/` are a branded local development certificate intended only for local or simple demo installs. It is signed by a local development CA and will only be trusted on machines where that CA has been installed. Replace it with your own CA-issued certificate and private key for any real deployment. + +Eramba and its MCP endpoint share the `PUBLIC_ADDRESS` origin. With the default configuration the application is available at `https://localhost:8443` and MCP at `https://localhost:8443/mcp`; issuer, resource, metadata, and OpenAPI URLs are derived automatically. Do not add separate public MCP URL variables. + +Before starting a real deployment, replace `OAUTH2_INTROSPECTION_CLIENT_SECRET` in `.env` with a unique random secret. `ERAMBA_MCP_IMAGE_TAG` defaults to the supported `3.x` MCP image stream and can be pinned when required. diff --git a/docker-compose.simple-install.yml b/docker-compose.simple-install.yml index cd6c348..bb9eec3 100644 --- a/docker-compose.simple-install.yml +++ b/docker-compose.simple-install.yml @@ -26,8 +26,6 @@ services: container_name: eramba image: ghcr.io/eramba/eramba:latest restart: always - ports: - - 8443:443 volumes: - data:/var/www/eramba/app/upgrade/data - app:/var/www/eramba @@ -55,6 +53,13 @@ services: TRIGGER_RUNNER_SHARED_SECRET: ${TRIGGER_RUNNER_SHARED_SECRET} DOCKER_DEPLOYMENT: ${DOCKER_DEPLOYMENT} LDAPTLS_REQCERT: ${LDAPTLS_REQCERT} + OAUTH2_ENABLED: ${OAUTH2_ENABLED:-1} + OAUTH2_SCOPE: ${OAUTH2_SCOPE:-eramba.full_access} + OAUTH2_AUTH_CODE_TTL: ${OAUTH2_AUTH_CODE_TTL:-120} + OAUTH2_ACCESS_TOKEN_TTL: ${OAUTH2_ACCESS_TOKEN_TTL:-900} + OAUTH2_REFRESH_TOKEN_TTL: ${OAUTH2_REFRESH_TOKEN_TTL:-2592000} + OAUTH2_INTROSPECTION_CLIENT_ID: ${OAUTH2_INTROSPECTION_CLIENT_ID:?Set OAUTH2_INTROSPECTION_CLIENT_ID in .env} + OAUTH2_INTROSPECTION_CLIENT_SECRET: ${OAUTH2_INTROSPECTION_CLIENT_SECRET:?Set OAUTH2_INTROSPECTION_CLIENT_SECRET in .env} links: - mysql - redis @@ -92,6 +97,13 @@ services: TRIGGER_RUNNER_SHARED_SECRET: ${TRIGGER_RUNNER_SHARED_SECRET} DOCKER_DEPLOYMENT: ${DOCKER_DEPLOYMENT} LDAPTLS_REQCERT: ${LDAPTLS_REQCERT} + OAUTH2_ENABLED: ${OAUTH2_ENABLED:-1} + OAUTH2_SCOPE: ${OAUTH2_SCOPE:-eramba.full_access} + OAUTH2_AUTH_CODE_TTL: ${OAUTH2_AUTH_CODE_TTL:-120} + OAUTH2_ACCESS_TOKEN_TTL: ${OAUTH2_ACCESS_TOKEN_TTL:-900} + OAUTH2_REFRESH_TOKEN_TTL: ${OAUTH2_REFRESH_TOKEN_TTL:-2592000} + OAUTH2_INTROSPECTION_CLIENT_ID: ${OAUTH2_INTROSPECTION_CLIENT_ID:?Set OAUTH2_INTROSPECTION_CLIENT_ID in .env} + OAUTH2_INTROSPECTION_CLIENT_SECRET: ${OAUTH2_INTROSPECTION_CLIENT_SECRET:?Set OAUTH2_INTROSPECTION_CLIENT_SECRET in .env} links: - mysql - redis @@ -124,6 +136,51 @@ services: timeout: 5s retries: 3 start_period: 10s + mcp_server: + image: ghcr.io/eramba/eramba-mcp-server:${ERAMBA_MCP_IMAGE_TAG:-3.x} + container_name: mcp_server + restart: always + depends_on: + - eramba + environment: + MCP_TRANSPORT: http + PORT: 3000 + PUBLIC_ADDRESS: ${PUBLIC_ADDRESS} + ERAMBA_BASE_URL: https://eramba:443 + ERAMBA_OAUTH_INTROSPECTION_URL: https://eramba:443/oauth2/introspect + ERAMBA_OAUTH_INTROSPECTION_CLIENT_ID: ${OAUTH2_INTROSPECTION_CLIENT_ID:?Set OAUTH2_INTROSPECTION_CLIENT_ID in .env} + ERAMBA_OAUTH_INTROSPECTION_CLIENT_SECRET: ${OAUTH2_INTROSPECTION_CLIENT_SECRET:?Set OAUTH2_INTROSPECTION_CLIENT_SECRET in .env} + MCP_REQUIRED_SCOPE: ${OAUTH2_SCOPE:-eramba.full_access} + ERAMBA_MCP_DEV_BEARER_TOKEN: ${ERAMBA_MCP_DEV_BEARER_TOKEN:-} + ERAMBA_INSECURE_TLS: 1 + ERAMBA_REQUEST_TIMEOUT_MS: 15000 + networks: + - app_internal + - host_access + healthcheck: + test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/openapi.json').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + public_proxy: + container_name: public_proxy + image: caddy:2.8-alpine + ports: + - 8443:443 + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./apache/ssl/mycert.crt:/certs/mycert.crt:ro + - ./apache/ssl/mycert.key:/certs/mycert.key:ro + environment: + MCP_UPSTREAM: mcp_server:3000 + ERAMBA_UPSTREAM: https://eramba:443 + depends_on: + - eramba + - mcp_server + networks: + - app_internal + - host_access volumes: app: data: diff --git a/tests/mcp-simple-install.bats b/tests/mcp-simple-install.bats new file mode 100644 index 0000000..0bc49cf --- /dev/null +++ b/tests/mcp-simple-install.bats @@ -0,0 +1,42 @@ +#!/usr/bin/env bats + +setup() { + REPOSITORY_ROOT="$(cd "${BATS_TEST_DIRNAME}/.." && pwd)" + + run env -i PATH="$PATH" HOME="$HOME" docker compose \ + --project-directory "$REPOSITORY_ROOT" \ + --env-file "$REPOSITORY_ROOT/.env" \ + -f "$REPOSITORY_ROOT/docker-compose.simple-install.yml" \ + config --format json + + [ "$status" -eq 0 ] + COMPOSE_CONFIG="$output" +} + +@test "simple install keeps release app images and uses the MCP 3.x image" { + [ "$(jq -r '.services.eramba.image' <<<"$COMPOSE_CONFIG")" = "ghcr.io/eramba/eramba:latest" ] + [ "$(jq -r '.services.cron.image' <<<"$COMPOSE_CONFIG")" = "ghcr.io/eramba/eramba:latest" ] + [ "$(jq -r '.services.triggers_caddy.image' <<<"$COMPOSE_CONFIG")" = "ghcr.io/eramba/eramba-triggers:latest" ] + [ "$(jq -r '.services.mcp_server.image' <<<"$COMPOSE_CONFIG")" = "ghcr.io/eramba/eramba-mcp-server:3.x" ] +} + +@test "Caddy is the only published application edge" { + [ "$(jq -r '(.services.eramba.ports // []) | length' <<<"$COMPOSE_CONFIG")" -eq 0 ] + [ "$(jq -r '(.services.mcp_server.ports // []) | length' <<<"$COMPOSE_CONFIG")" -eq 0 ] + [ "$(jq -r '.services.public_proxy.ports | length' <<<"$COMPOSE_CONFIG")" -eq 1 ] + [ "$(jq -r '.services.public_proxy.ports[0] | "\(.published):\(.target)"' <<<"$COMPOSE_CONFIG")" = "8443:443" ] +} + +@test "MCP derives public metadata from PUBLIC_ADDRESS and introspects internally" { + [ "$(jq -r '.services.mcp_server.environment.PUBLIC_ADDRESS' <<<"$COMPOSE_CONFIG")" = "https://localhost:8443" ] + [ "$(jq -r '.services.mcp_server.environment.ERAMBA_OAUTH_INTROSPECTION_URL' <<<"$COMPOSE_CONFIG")" = "https://eramba:443/oauth2/introspect" ] + + for override in MCP_PUBLIC_URL MCP_RESOURCE ERAMBA_OAUTH_ISSUER OAUTH2_ISSUER OAUTH2_RESOURCE; do + [ "$(jq -r --arg name "$override" '.services.mcp_server.environment | has($name)' <<<"$COMPOSE_CONFIG")" = "false" ] + done +} + +@test "Caddy receives the bundled TLS certificate and key" { + [ "$(jq -r '[.services.public_proxy.volumes[].target] | index("/certs/mycert.crt") != null' <<<"$COMPOSE_CONFIG")" = "true" ] + [ "$(jq -r '[.services.public_proxy.volumes[].target] | index("/certs/mycert.key") != null' <<<"$COMPOSE_CONFIG")" = "true" ] +}