From b2a6d7293f41d7f8b106bd00889efb9f8fdd7f8f Mon Sep 17 00:00:00 2001 From: Aditya Parikh Date: Mon, 31 Aug 2026 09:30:46 -0400 Subject: [PATCH 1/3] feat(compose): provision Keycloak with the realm the http profile needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The http profile authenticates against Keycloak, but nothing started one. A developer had to run the container by hand and then script a realm, a client and an audience mapper before the server would boot — and the mapper is easy to miss, because skipping it produces a token that is issued normally and then refused with 401. compose.yaml now defines a keycloak service that imports keycloak/solr-mcp-realm.json, so the realm, both clients and the mapper exist before the server asks for a token. The import covers what the Quick Start created by hand: solr-mcp-service (confidential, service accounts) for machine-to-machine callers, solr-mcp-client (public) for MCP Inspector, and testuser. The credentials in it are development credentials, committed on purpose; a real deployment provisions its own. The healthcheck is load-bearing rather than decoration. The server resolves the issuer while building its JWT decoder and fails to boot if the realm is not yet answering, which is the ordering constraint keycloak.md warns about; declaring a healthcheck makes Spring Boot's compose support wait for the container instead. Keycloak's image ships neither curl nor wget, so the probe goes through bash's /dev/tcp against the management port. Verified by running the service from this compose file: the container reaches healthy, the realm resolves, and both grants return tokens carrying aud http://localhost:8080/mcp — client_credentials for the service client and the password grant for the imported test user. Signed-off-by: Aditya Parikh Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011nUD34DFfoJeyQRTquPy7a --- compose.yaml | 34 +++++++++++++++++++ docs/security/keycloak.md | 26 +++++++++++++-- keycloak/solr-mcp-realm.json | 64 ++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 keycloak/solr-mcp-realm.json diff --git a/compose.yaml b/compose.yaml index 75c59201..5d7bea45 100644 --- a/compose.yaml +++ b/compose.yaml @@ -57,6 +57,40 @@ services: # Prevent Spring Boot auto-configuration from trying to manage this service org.springframework.boot.ignore: "true" + # ============================================================================= + # Keycloak - the identity provider the http profile authenticates against + # ============================================================================= + # Started only by the http profile, which sets spring.docker.compose.enabled=true. The realm is + # imported from keycloak/solr-mcp-realm.json, so the clients and — critically — the audience + # protocol mapper exist before the server ever asks for a token. + # + # The mapper is what makes tokens acceptable here. Keycloak does not honour the RFC 8707 + # `resource=` parameter, so without it a token is issued normally and then rejected by + # validateAudienceClaim(true) with a 401. See docs/security/keycloak.md. + # + # The healthcheck is not decoration: the server resolves the issuer at startup and fails to boot + # if the realm is not yet answering, so Spring Boot must wait for this container to be healthy + # before the application context starts. Keycloak's image ships neither curl nor wget, hence + # bash's /dev/tcp. + keycloak: + image: quay.io/keycloak/keycloak:26.0 + ports: + - "8180:8080" + networks: [ search ] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + command: [ "start-dev", "--import-realm" ] + volumes: + - ./keycloak:/opt/keycloak/data/import:ro + healthcheck: + test: [ "CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'" ] + interval: 5s + timeout: 5s + retries: 30 + start_period: 10s + volumes: data: diff --git a/docs/security/keycloak.md b/docs/security/keycloak.md index 3befbf2c..d421adae 100644 --- a/docs/security/keycloak.md +++ b/docs/security/keycloak.md @@ -77,11 +77,33 @@ The Quick Start below is ordered to satisfy both. You do **not** need to start Solr yourself. `application-http.properties` sets `spring.docker.compose.enabled=true`, so `./gradlew bootRun` starts the Solr, -ZooKeeper and LGTM containers from `compose.yaml` before the application -context comes up. +ZooKeeper, LGTM and Keycloak containers from `compose.yaml` before the +application context comes up. ## Quick Start +> **The `http` profile now brings its own Keycloak.** `compose.yaml` defines a `keycloak` service +> that imports `keycloak/solr-mcp-realm.json` on startup, so the realm, both clients and the +> audience mapper exist before the server asks for a token — and because the service declares a +> healthcheck, Spring Boot waits for it rather than failing on an unresolvable issuer. Running +> `PROFILES=http ./gradlew bootRun` is enough: +> +> ```bash +> export PROFILES=http +> export OAUTH2_ISSUER_URI=http://localhost:8180/realms/solr-mcp +> ./gradlew bootRun +> ``` +> +> The imported realm provides `solr-mcp-service` (confidential, service accounts, secret +> `dev-only-not-a-secret`) for machine-to-machine callers, `solr-mcp-client` (public) for MCP +> Inspector, and `testuser` / `testpassword`. These are development credentials committed on +> purpose; a real deployment provisions its own. +> +> The manual walkthrough below remains the reference for what that import contains, and for setting +> the same thing up against an existing Keycloak. It binds its own container to the same port, +> 8180, so use one or the other: if you follow it, start the server with +> `SPRING_DOCKER_COMPOSE_ENABLED=false` so Boot does not bring up a second Keycloak. + This block is runnable end to end — copy the whole thing. It waits for Keycloak, creates the realm, client, audience mapper and test user, verifies the realm resolves, and only then starts the server. diff --git a/keycloak/solr-mcp-realm.json b/keycloak/solr-mcp-realm.json new file mode 100644 index 00000000..3fb0cd4b --- /dev/null +++ b/keycloak/solr-mcp-realm.json @@ -0,0 +1,64 @@ +{ + "realm": "solr-mcp", + "enabled": true, + "displayName": "Solr MCP (development)", + "clients": [ + { + "clientId": "solr-mcp-service", + "name": "Machine-to-machine caller", + "description": "Confidential client for services that call the MCP server on their own behalf.", + "enabled": true, + "publicClient": false, + "serviceAccountsEnabled": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "secret": "dev-only-not-a-secret", + "protocolMappers": [ + { + "name": "mcp-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.custom.audience": "http://localhost:8080/mcp", + "access.token.claim": "true", + "id.token.claim": "false", + "introspection.token.claim": "true" + } + } + ] + }, + { + "clientId": "solr-mcp-client", + "name": "MCP Inspector and browser clients", + "enabled": true, + "publicClient": true, + "directAccessGrantsEnabled": true, + "redirectUris": [ "http://localhost:6274/*", "http://localhost:*" ], + "webOrigins": [ "http://localhost:6274" ], + "protocolMappers": [ + { + "name": "mcp-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { + "included.custom.audience": "http://localhost:8080/mcp", + "access.token.claim": "true", + "id.token.claim": "false", + "introspection.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "username": "testuser", + "email": "test@example.com", + "firstName": "Test", + "lastName": "User", + "enabled": true, + "emailVerified": true, + "credentials": [ { "type": "password", "value": "testpassword", "temporary": false } ] + } + ] +} From 365ae7e6a29e59ee305bd8f8746106584650fcc4 Mon Sep 17 00:00:00 2001 From: Aditya Parikh Date: Sat, 12 Sep 2026 23:18:45 -0400 Subject: [PATCH 2/3] feat(compose): gate Keycloak behind the http compose profile The service comment said it was started only by the http profile, but it had no profiles key, so a plain docker compose up -d for STDIO users pulled Keycloak, bound 8180 and waited on its healthcheck. The service now sits in the http compose profile, which application-http.properties activates via spring.docker.compose.profiles.active. The Quick Start no longer says bootRun alone is enough while also requiring OAUTH2_ISSUER_URI, and it tells compose users to reuse solr-mcp-service instead of creating a client by hand. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CiUHyyXLTo9ATdgg8eRFZJ Signed-off-by: Aditya Parikh --- compose.yaml | 4 +++- docs/security/keycloak.md | 9 ++++++--- src/main/resources/application-http.properties | 2 ++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/compose.yaml b/compose.yaml index 5d7bea45..cf4a4430 100644 --- a/compose.yaml +++ b/compose.yaml @@ -60,7 +60,8 @@ services: # ============================================================================= # Keycloak - the identity provider the http profile authenticates against # ============================================================================= - # Started only by the http profile, which sets spring.docker.compose.enabled=true. The realm is + # Gated behind the `http` compose profile so a plain `docker compose up -d` (STDIO users) does not + # start it; the http Spring profile activates it via spring.docker.compose.profiles.active. The realm is # imported from keycloak/solr-mcp-realm.json, so the clients and — critically — the audience # protocol mapper exist before the server ever asks for a token. # @@ -73,6 +74,7 @@ services: # before the application context starts. Keycloak's image ships neither curl nor wget, hence # bash's /dev/tcp. keycloak: + profiles: [ http ] image: quay.io/keycloak/keycloak:26.0 ports: - "8180:8080" diff --git a/docs/security/keycloak.md b/docs/security/keycloak.md index d421adae..36e76a82 100644 --- a/docs/security/keycloak.md +++ b/docs/security/keycloak.md @@ -85,8 +85,10 @@ application context comes up. > **The `http` profile now brings its own Keycloak.** `compose.yaml` defines a `keycloak` service > that imports `keycloak/solr-mcp-realm.json` on startup, so the realm, both clients and the > audience mapper exist before the server asks for a token — and because the service declares a -> healthcheck, Spring Boot waits for it rather than failing on an unresolvable issuer. Running -> `PROFILES=http ./gradlew bootRun` is enough: +> healthcheck, Spring Boot waits for it rather than failing on an unresolvable issuer. Point the +> server at the imported realm and start it; Spring Boot's Docker Compose support brings Keycloak up +> (it sits behind the `http` compose profile, so start it by hand with +> `docker compose --profile http up -d` if you are not using `bootRun`): > > ```bash > export PROFILES=http @@ -97,7 +99,8 @@ application context comes up. > The imported realm provides `solr-mcp-service` (confidential, service accounts, secret > `dev-only-not-a-secret`) for machine-to-machine callers, `solr-mcp-client` (public) for MCP > Inspector, and `testuser` / `testpassword`. These are development credentials committed on -> purpose; a real deployment provisions its own. +> purpose; a real deployment provisions its own. With this realm, skip the client-creation steps +> below and use `solr-mcp-service` / `dev-only-not-a-secret` wherever a confidential client is needed. > > The manual walkthrough below remains the reference for what that import contains, and for setting > the same thing up against an existing Keycloak. It binds its own container to the same port, diff --git a/src/main/resources/application-http.properties b/src/main/resources/application-http.properties index 77578a3c..d6a2cd18 100644 --- a/src/main/resources/application-http.properties +++ b/src/main/resources/application-http.properties @@ -21,6 +21,8 @@ spring.ai.mcp.server.stdio=false # Docker Compose integration: automatically start Solr and other services on bootRun. # Spring Boot detects compose.yaml and starts declared services before the application context. spring.docker.compose.enabled=true +# Keycloak sits behind the `http` compose profile so STDIO users' `docker compose up -d` skips it. +spring.docker.compose.profiles.active=http # OAuth2 Security Configuration # Configure the issuer URI for your OAuth2 authorization server # For Auth0: https:///.well-known/openid-configuration From 5dbcaf9d6169181fd4cd5f9445b11589d79f36af Mon Sep 17 00:00:00 2001 From: Aditya Parikh Date: Fri, 18 Sep 2026 10:14:26 -0400 Subject: [PATCH 3/3] refactor(compose): adopt upstream Keycloak container health check recommendation The Keycloak container image (UBI 9 Minimal) intentionally ships without curl or wget, and health endpoints are exposed on the dedicated management port 9000. Switch the healthcheck probe to Keycloak's official Containerfile HEALTHCHECK pattern (https://www.keycloak.org/observability/health#_healthcheck) using bash socket redirection. Signed-off-by: Aditya Parikh Co-authored-by: Junie --- compose.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/compose.yaml b/compose.yaml index cf4a4430..3b5b6cc8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -71,8 +71,11 @@ services: # # The healthcheck is not decoration: the server resolves the issuer at startup and fails to boot # if the realm is not yet answering, so Spring Boot must wait for this container to be healthy - # before the application context starts. Keycloak's image ships neither curl nor wget, hence - # bash's /dev/tcp. + # before the application context starts. + # Keycloak 26's minimal image (UBI 9) intentionally omits curl and wget, and health endpoints are + # exposed on the dedicated management port 9000. This probe follows Keycloak's official Containerfile + # HEALTHCHECK recommendation (https://www.keycloak.org/observability/health#_healthcheck) using + # bash's built-in /dev/tcp socket redirection. keycloak: profiles: [ http ] image: quay.io/keycloak/keycloak:26.0 @@ -87,7 +90,7 @@ services: volumes: - ./keycloak:/opt/keycloak/data/import:ro healthcheck: - test: [ "CMD-SHELL", "exec 3<>/dev/tcp/localhost/9000 && echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && cat <&3 | grep -q '\"status\": \"UP\"'" ] + test: [ "CMD-SHELL", "{ printf 'HEAD /health/ready HTTP/1.0\\r\\n\\r\\n' >&0; grep 'HTTP/1.0 200'; } 0<>/dev/tcp/localhost/9000" ] interval: 5s timeout: 5s retries: 30