This is an automated email from the ASF dual-hosted git repository.

epugh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-mcp.git


The following commit(s) were added to refs/heads/main by this push:
     new 02f2f601 docs(security): make the HTTP + OAuth2 setup walk end to end 
(#211)
02f2f601 is described below

commit 02f2f601d799e50bbb60aca473fbe6da936b55f5
Author: Aditya Parikh <[email protected]>
AuthorDate: Mon Oct 26 15:02:49 2026 -0400

    docs(security): make the HTTP + OAuth2 setup walk end to end (#211)
    
    * docs(security): make the HTTP + OAuth2 setup walk end to end
    
    Someone following the security guides could configure an IdP, start the
    server and still be unable to call a tool from any MCP client. Two facts
    the docs got wrong or left out:
    
    - The audience the server validates is not a fixed string. mcp-server-
      security builds it per request from the scheme, host and port the client
      used plus /mcp, so aud must contain the exact URL the client dials.
      The Auth0 guide (and the site mirror) told readers to use
      https://solr-mcp-api, which every token then fails with
      "The aud claim is not valid". The guide also claimed no audience
      validation is performed, listed a login/oauth2/code callback the
      resource server does not have, and showed a stale issuer default.
    - The server never answers an anonymous /mcp request with 401: the
      handshake is permitAll and @PreAuthorize denies inside each tool with an
      HTTP 200 "Access Denied" result. Claude Code, the MCP Inspector and
      mcp-remote start OAuth only on a 401, so the client guides' "handles the
      OAuth2 flow automatically" never happens; the client reports connected
      and every call is denied. The verified path is a bearer header.
    
    Changes:
    - http.md: how the audience is derived, a "Connecting an MCP client"
      section with the observed status matrix and the header syntax per
      client, corrected production guidance for the empty issuer default.
    - keycloak.md: audience spelling note, a "Connecting an MCP Client"
      section (raise the 300 s token lifetime, Claude Code and Inspector
      commands, what "Connected" does and does not prove).
    - auth0.md: rewritten around the resource-URI identifier, with run,
      verify and connect steps.
    - Client guides (repo and site mirror): replace the automatic-OAuth
      claims with the header configuration for Claude Code, Claude Desktop
      and JetBrains via mcp-remote, VS Code, Cursor and the Inspector.
    - Site security page, DEPLOYMENT.md and the Auth0 token script comments
      brought in line.
    
    Verified on 2026-09-16 against Keycloak 26.0, Solr 9 and Claude Code
    2.1.273: curl, the Inspector CLI and Claude Code with --header all call
    list-collections; the same token sent to 127.0.0.1 is rejected on aud;
    without a header Claude Code shows Connected and returns Access Denied.
    The Auth0 steps follow the same server-side rules but were not re-run
    against a live tenant.
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Signed-off-by: Aditya Parikh <[email protected]>
    
    * fix(scripts): send get-auth0-token.sh status lines to stderr so --quiet 
yields only the token
    
    With .env present the script printed "[INFO] Loading configuration from
    .env file" before --quiet was parsed, so TOKEN=$(... --quiet) as the docs
    show captured the log line ahead of the token. Route all status output to
    stderr; stdout now carries the token alone.
    
    Also records that the HTTP client-connection matrix was re-verified against
    an Auth0 tenant: the dashboard accepts http://localhost:8080/mcp as an API
    identifier, and the resulting client-credentials token (iss with trailing
    slash, aud = the identifier, 24 h lifetime) passes the server's checks.
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Signed-off-by: Aditya Parikh <[email protected]>
    
    * docs(security): let the MCP-client section run against the compose realm
    
    #192 made the http profile import keycloak/solr-mcp-realm.json, so a
    reader may reach this section without having run the manual Quick Start
    that defines $KC and $ADMIN_TOKEN. Define both here; the imported realm
    uses the same admin login, client and test user.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Aditya Parikh <[email protected]>
    
    ---------
    
    Signed-off-by: Aditya Parikh <[email protected]>
    Co-authored-by: Claude Fable 5.1 <[email protected]>
---
 dev-docs/DEPLOYMENT.md                             |  12 +-
 docs/clients/claude-code.md                        |  11 +-
 docs/clients/claude-desktop.md                     |  12 +-
 docs/clients/cursor.md                             |  15 +-
 docs/clients/jetbrains.md                          |  19 +-
 docs/clients/mcp-inspector.md                      |  19 +-
 docs/clients/vs-code.md                            |  19 +-
 docs/security/auth0.md                             | 348 ++++++++++++---------
 docs/security/http.md                              |  83 ++++-
 docs/security/keycloak.md                          |  69 ++++
 docs/site/content/pages/mcp/clients/claude-code.md |  11 +-
 .../content/pages/mcp/clients/claude-desktop.md    |  12 +-
 docs/site/content/pages/mcp/clients/cursor.md      |  17 +-
 docs/site/content/pages/mcp/clients/jetbrains.md   |  21 +-
 .../content/pages/mcp/clients/mcp-inspector.md     |  19 +-
 docs/site/content/pages/mcp/clients/vs-code.md     |  21 +-
 docs/site/content/pages/mcp/security.md            | 115 ++++---
 scripts/get-auth0-token.sh                         |  13 +-
 18 files changed, 596 insertions(+), 240 deletions(-)

diff --git a/dev-docs/DEPLOYMENT.md b/dev-docs/DEPLOYMENT.md
index f5148e9d..09d16b60 100644
--- a/dev-docs/DEPLOYMENT.md
+++ b/dev-docs/DEPLOYMENT.md
@@ -388,12 +388,18 @@ spec:
 - Secure by default
 
 ### HTTP Transport
-⚠️ **Warning**: HTTP mode is insecure without additional measures.
+HTTP mode is **secured by default**: `HTTP_SECURITY_ENABLED` defaults to 
`true`,
+and every tool call and every actuator endpoint except `/actuator/health`
+requires a JWT bearer token from the issuer named by `OAUTH2_ISSUER_URI`. See
+[docs/security/http.md](../docs/security/http.md) for the model and
+[Keycloak](../docs/security/keycloak.md) / [Auth0](../docs/security/auth0.md)
+for provider setup.
 
 **Production requirements:**
 1. **Use HTTPS** with TLS/SSL certificates
-2. **Implement OAuth2** authentication (see [Spring AI MCP OAuth2 
guide](https://spring.io/blog/2025/04/02/mcp-server-oauth2/))
-3. **Validate origin headers** to prevent DNS rebinding
+2. **Set `OAUTH2_ISSUER_URI`** and configure the IdP to put the server's public
+   URL in the token's `aud` claim
+3. **Keep the CORS allowlist explicit** (`MCP_CORS_ALLOWED_ORIGINS`, no 
wildcards)
 4. **Network isolation** via firewall/VPN
 5. **API Gateway** with rate limiting
 
diff --git a/docs/clients/claude-code.md b/docs/clients/claude-code.md
index 3bbd9c8a..e7bc3e38 100644
--- a/docs/clients/claude-code.md
+++ b/docs/clients/claude-code.md
@@ -106,8 +106,13 @@ claude mcp add --transport http solr-mcp 
http://localhost:8080/mcp
 }
 ```
 
-### Secured HTTP (OAuth2) ###
+### Secured HTTP (bearer token) ###
 
-Claude Code detects the OAuth2 challenge from the server and initiates the 
authorization flow automatically. The configuration is the same as unsecured 
HTTP.
+Claude Code starts an OAuth flow only when a server answers `401`/`403`. This 
server never does that on `/mcp` — the handshake is anonymous and 
`@PreAuthorize` denies inside each tool — so with the URL alone Claude Code 
shows the server as **connected** and every tool call returns `Access Denied`. 
Pass a token from your identity provider instead:
 
-See the [HTTP security model](../security/http.md) for server-side OAuth2 
setup.
+```bash
+claude mcp add --transport http solr-mcp http://localhost:8080/mcp \
+    --header "Authorization: Bearer $TOKEN"
+```
+
+The token's `aud` claim must contain the exact URL you register here 
(`http://localhost:8080/mcp`), and when it expires the connection fails rather 
than re-authenticating; re-run `claude mcp add` with a fresh token. How to 
obtain a token, and the lifetime knobs, are in the 
[Keycloak](../security/keycloak.md#connecting-an-mcp-client) and 
[Auth0](../security/auth0.md) guides; the server-side model is in [HTTP 
security](../security/http.md#connecting-an-mcp-client-to-a-secured-server).
diff --git a/docs/clients/claude-desktop.md b/docs/clients/claude-desktop.md
index bf10dc68..a7a526d2 100644
--- a/docs/clients/claude-desktop.md
+++ b/docs/clients/claude-desktop.md
@@ -91,21 +91,25 @@ The HTTP transport is secured by default: without a bearer 
token the client stil
 }
 ```
 
-### Secured HTTP (OAuth2) ###
+### Secured HTTP (bearer token) ###
 
-When OAuth2 is enabled on the server, `mcp-remote` handles the authorization 
flow automatically&mdash;it discovers the authorization server and opens a 
browser for consent.
+`mcp-remote` starts an OAuth flow only when the server answers `401`. This 
server never does that on `/mcp` (the handshake is anonymous; `@PreAuthorize` 
denies inside each tool), so with the URL alone Claude Desktop connects, lists 
the tools, and every call returns `Access Denied`. Pass a token from your 
identity provider with `--header`; `mcp-remote` expands `${TOKEN}` from the 
`env` block so the secret stays out of the argument list:
 
 ```json
 {
   "mcpServers": {
     "solr-mcp": {
       "command": "npx",
-      "args": ["mcp-remote", "http://localhost:8080/mcp";, "--allow-http"]
+      "args": [
+        "mcp-remote", "http://localhost:8080/mcp";, "--allow-http",
+        "--header", "Authorization: Bearer ${TOKEN}"
+      ],
+      "env": { "TOKEN": "<access token>" }
     }
   }
 }
 ```
 
-The `--allow-http` flag is needed for `http://` URLs (development). Omit it in 
production with HTTPS.
+The `--allow-http` flag is needed for `http://` URLs (development). Omit it in 
production with HTTPS. The token's `aud` claim must contain the exact URL given 
here, and when it expires calls fail with `401` until you paste a fresh token 
and restart Claude Desktop.
 
 See the [HTTP security model](../security/http.md) for server-side OAuth2 
setup.
diff --git a/docs/clients/cursor.md b/docs/clients/cursor.md
index ab2e6fe3..ede001ba 100644
--- a/docs/clients/cursor.md
+++ b/docs/clients/cursor.md
@@ -69,6 +69,19 @@ The HTTP transport is secured by default: without a bearer 
token the client stil
 }
 ```
 
-The configuration is the same for secured and unsecured HTTP. Cursor handles 
the MCP OAuth2 flow automatically.
+With security enabled (the default), this server does not answer an anonymous 
`/mcp` request with `401`, so Cursor will not start an OAuth flow by itself: it 
connects, lists the tools, and every call returns `Access Denied`. Pass a token 
from your identity provider in `headers`; Cursor expands `${env:…}` so the 
secret stays out of the file:
+
+```json
+{
+  "mcpServers": {
+    "solr-mcp": {
+      "url": "http://localhost:8080/mcp";,
+      "headers": { "Authorization": "Bearer ${env:SOLR_MCP_TOKEN}" }
+    }
+  }
+}
+```
+
+The token's `aud` claim must contain the exact `url` above, and it expires — 
see the [HTTP security 
model](../security/http.md#connecting-an-mcp-client-to-a-secured-server) for 
obtaining one and the lifetime knobs.
 
 See the [Cursor MCP 
documentation](https://docs.cursor.com/context/model-context-protocol) for the 
latest configuration format.
diff --git a/docs/clients/jetbrains.md b/docs/clients/jetbrains.md
index d5ace814..791433f6 100644
--- a/docs/clients/jetbrains.md
+++ b/docs/clients/jetbrains.md
@@ -72,6 +72,23 @@ The HTTP transport is secured by default: without a bearer 
token the client stil
 
 Or in IDE Settings, select the **HTTP** transport and enter 
`http://localhost:8080/mcp` as the URL. AI Assistant connects using the 
**Streamable HTTP** transport, which is what this server implements; the legacy 
SSE transport (a `/sse` URL) is not supported.
 
-The configuration is the same for secured and unsecured HTTP. JetBrains IDEs 
handle the MCP OAuth2 flow automatically.
+With security enabled (the default), this server does not answer an anonymous 
`/mcp` request with `401`, so the IDE will not start an OAuth flow by itself: 
it connects, lists the tools, and every call returns `Access Denied`. Supply a 
token from your identity provider. The portable way is to register the server 
as a STDIO command through `mcp-remote`, which forwards the header on every 
request and expands `${TOKEN}` from `env`:
+
+```json
+{
+  "mcpServers": {
+    "solr-mcp": {
+      "command": "npx",
+      "args": [
+        "mcp-remote", "http://localhost:8080/mcp";, "--allow-http",
+        "--header", "Authorization: Bearer ${TOKEN}"
+      ],
+      "env": { "TOKEN": "<access token>" }
+    }
+  }
+}
+```
+
+The token's `aud` claim must contain the exact URL above, and it expires — see 
the [HTTP security 
model](../security/http.md#connecting-an-mcp-client-to-a-secured-server) for 
obtaining one and the lifetime knobs.
 
 MCP support requires the AI Assistant plugin. See the [JetBrains MCP 
documentation](https://www.jetbrains.com/help/ai-assistant/mcp.html) for the 
latest configuration format.
diff --git a/docs/clients/mcp-inspector.md b/docs/clients/mcp-inspector.md
index 8957b689..f2ab105c 100644
--- a/docs/clients/mcp-inspector.md
+++ b/docs/clients/mcp-inspector.md
@@ -46,13 +46,20 @@ This starts the Inspector UI at `http://localhost:6274`.
 
 ***
 
-## OAuth2 ##
+## Secured HTTP (bearer token) ##
 
-When OAuth2 is enabled on the server, configure the Inspector's OAuth settings 
before connecting:
+The Inspector starts its OAuth flow only when the server answers `401`. This 
server never does that on `/mcp` — the handshake is anonymous and 
`@PreAuthorize` denies inside each tool — so with the URL alone the Inspector 
connects, lists every tool, and every call returns `Access Denied`. Pass a 
token from your identity provider with `--header` instead. It applies to the 
ad-hoc server the Inspector opens with:
 
-1. Click the **OAuth** settings in the Inspector
-2. Enter your provider's Authorization URL, Token URL, Client ID, and Redirect 
URI (`http://localhost:6274/oauth/callback`)
-3. Complete the OAuth flow
-4. The Inspector will include the Bearer token in all subsequent requests
+```bash
+# Web UI
+npx @modelcontextprotocol/inspector --server-url http://localhost:8080/mcp 
--transport http \
+    --header "Authorization: Bearer $TOKEN"
+
+# CLI: one tool call, no browser
+npx @modelcontextprotocol/inspector --cli http://localhost:8080/mcp 
--transport http \
+    --header "Authorization: Bearer $TOKEN" --method tools/call --tool-name 
list-collections
+```
+
+The token's `aud` claim must contain the exact URL given here 
(`http://localhost:8080/mcp`), and when it expires calls fail with `401` until 
you relaunch with a fresh one.
 
 See the [HTTP security model](../security/http.md) for server-side OAuth2 
setup with [Auth0](../security/auth0.md) and 
[Keycloak](../security/keycloak.md).
diff --git a/docs/clients/vs-code.md b/docs/clients/vs-code.md
index a1edd7cc..11760f13 100644
--- a/docs/clients/vs-code.md
+++ b/docs/clients/vs-code.md
@@ -79,6 +79,23 @@ The HTTP transport is secured by default: without a bearer 
token the client stil
 }
 ```
 
-The configuration is the same for secured and unsecured HTTP. VS Code handles 
the MCP OAuth2 flow automatically.
+With security enabled (the default), this server does not answer an anonymous 
`/mcp` request with `401`, so VS Code will not start an OAuth flow by itself: 
it connects, lists the tools, and every call returns `Access Denied`. Pass a 
token from your identity provider in `headers`; an `inputs` entry prompts for 
it once and keeps it out of the file:
+
+```json
+{
+  "inputs": [
+    { "type": "promptString", "id": "solr-mcp-token", "description": "Solr MCP 
access token", "password": true }
+  ],
+  "servers": {
+    "solr-mcp": {
+      "type": "http",
+      "url": "http://localhost:8080/mcp";,
+      "headers": { "Authorization": "Bearer ${input:solr-mcp-token}" }
+    }
+  }
+}
+```
+
+The token's `aud` claim must contain the exact `url` above, and it expires — 
see the [HTTP security 
model](../security/http.md#connecting-an-mcp-client-to-a-secured-server) for 
obtaining one and the lifetime knobs.
 
 See the [VS Code MCP 
documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for 
the latest configuration format.
diff --git a/docs/security/auth0.md b/docs/security/auth0.md
index 4d1027da..8d9037c8 100644
--- a/docs/security/auth0.md
+++ b/docs/security/auth0.md
@@ -17,16 +17,44 @@
 
 # Auth0 Setup Guide for Solr MCP Server
 
-This guide walks you through setting up Auth0 authentication for the Solr MCP 
server, including configuration for MCP
-Inspector.
+This guide walks you through securing the Solr MCP server's HTTP transport with
+[Auth0](https://auth0.com/): register an API and a machine-to-machine
+application, mint an access token, run the server, verify it, and connect an
+MCP client. Companion to [`keycloak.md`](./keycloak.md); the server-side model
+is in [`http.md`](./http.md).
 
 ## Table of Contents
 
 1. [Create Auth0 Application](#1-create-auth0-application)
 2. [Create Auth0 API](#2-create-auth0-api)
-3. [Configure OAuth Settings](#3-configure-oauth-settings)
-4. [Get Access Token Using Client 
Credentials](#4-get-access-token-using-client-credentials)
-5. [MCP Inspector Configuration](#5-mcp-inspector-configuration)
+3. [Authorize the Application for the 
API](#3-authorize-the-application-for-the-api)
+4. [Get an Access Token](#4-get-an-access-token)
+5. [Run the Server](#5-run-the-server)
+6. [Verify](#6-verify)
+7. [Connect an MCP Client](#7-connect-an-mcp-client)
+- [Convenience Script for Getting Access 
Tokens](#convenience-script-for-getting-access-tokens)
+- [Application Configuration Details](#application-configuration-details)
+- [Troubleshooting](#troubleshooting)
+- [Security Best Practices](#security-best-practices)
+
+## Before you start: the audience
+
+The server validates the token's `aud` claim against its **resource URI**,
+which it derives from the URL the client calls — scheme, host and port plus
+`/mcp`. For a local trial that is `http://localhost:8080/mcp`; for a deployed
+server it is the public URL, e.g. `https://mcp.example.com/mcp`. Read it from a
+running server with:
+
+```bash
+curl -s http://localhost:8080/.well-known/oauth-protected-resource | jq -r 
.resource
+# http://localhost:8080/mcp
+```
+
+Auth0 puts the API **Identifier** into `aud`, so the Identifier you choose in
+step 2 must be that URI, spelled exactly as clients will dial it. Auth0 never
+calls the URL; it only has to match. A token for any other identifier (the
+`https://solr-mcp-api` style Auth0 suggests) is rejected with
+`401 ... "The aud claim is not valid"`.
 
 ---
 
@@ -46,6 +74,11 @@ Inspector.
     - **Application Type**: Select **Machine to Machine Applications**
 4. Click **Create**
 
+A machine-to-machine application uses the Client Credentials grant: it
+obtains tokens with its client ID and secret, with no browser login. That is
+the right shape for trying the server from a terminal or from an MCP client
+that takes a bearer token.
+
 ### Step 3: Note Your Application Credentials
 
 After creation, you'll see the application settings page. Note down:
@@ -69,9 +102,9 @@ After creation, you'll see the application settings page. 
Note down:
 
 1. Enter API details:
     - **Name**: `Solr MCP API` (or your preferred name)
-    - **Identifier**: `https://solr-mcp-api` (this is your Audience)
-        - This can be any URI format, but use a URL-like identifier
-        - This value will be used as the `audience` in token requests
+    - **Identifier**: `http://localhost:8080/mcp` — the server's resource URI
+      (see [Before you start](#before-you-start-the-audience)). For a deployed
+      server use its public URL, e.g. `https://mcp.example.com/mcp`.
     - **Signing Algorithm**: Leave as **RS256** (recommended)
 2. Click **Create**
 
@@ -79,55 +112,27 @@ After creation, you'll see the application settings page. 
Note down:
 
 1. Go to the **Permissions** tab
 2. Add scopes if needed (e.g., `read:schema`, `write:documents`)
-3. For basic setup, you can skip this and add permissions later
+3. For basic setup, you can skip this and add permissions later. The shipped
+   server checks `isAuthenticated()` only; scopes are not evaluated.
 
 ---
 
-## 3. Configure OAuth Settings
-
-### Step 1: Configure Application Settings
+## 3. Authorize the Application for the API
 
 1. Go back to **Applications** > **Applications**
 2. Click on your **Solr MCP Server** application
-3. Go to the **Settings** tab
-
-### Step 2: Set Allowed Callback URLs
-
-Add the following callback URLs (comma-separated):
-
-```
-http://localhost:6274/oauth/callback,http://localhost:8080/login/oauth2/code/auth0
-```
-
-- `http://localhost:6274/oauth/callback` - For **MCP Inspector**
-- `http://localhost:8080/login/oauth2/code/auth0` - For your Solr MCP Server
-
-### Step 3: Set Allowed Logout URLs (Optional)
-
-Add if you need logout functionality:
-
-```
-http://localhost:8080/logout,http://localhost:6274
-```
-
-### Step 4: Grant API Access
-
-1. Scroll down to **APIs** section on the application settings page
-2. Or go to the **APIs** tab in your application
-3. Find your **Solr MCP API** and click the toggle to authorize it
-4. Select the permissions/scopes you want to grant (or select all)
+3. Open the **APIs** tab
+4. Find your **Solr MCP API** and click the toggle to authorize it
+5. Select the permissions/scopes you want to grant (or select all)
+6. Click **Update**
 
-### Step 5: Save Changes
-
-- Scroll to the bottom and click **Save Changes**
+No callback or logout URLs are needed: the MCP server is a resource server, it
+never redirects a browser to Auth0, and a machine-to-machine application does
+not use the authorization-code flow.
 
 ---
 
-## 4. Get Access Token Using Client Credentials
-
-### Understanding the Token Request
-
-Auth0 uses the OAuth 2.0 Client Credentials flow for machine-to-machine 
authentication.
+## 4. Get an Access Token
 
 ### Manual Token Request (using cURL)
 
@@ -138,7 +143,7 @@ curl --request POST \
   --data '{
     "client_id": "YOUR_CLIENT_ID",
     "client_secret": "YOUR_CLIENT_SECRET",
-    "audience": "https://solr-mcp-api";,
+    "audience": "http://localhost:8080/mcp";,
     "grant_type": "client_credentials"
   }'
 ```
@@ -148,7 +153,7 @@ Replace:
 - `YOUR_DOMAIN`: Your Auth0 domain (e.g., `your-tenant.auth0.com`)
 - `YOUR_CLIENT_ID`: Your application's Client ID
 - `YOUR_CLIENT_SECRET`: Your application's Client Secret
-- `audience`: Your API identifier (e.g., `https://solr-mcp-api`)
+- `audience`: Your API identifier from step 2
 
 ### Response Format
 
@@ -160,55 +165,126 @@ Replace:
 }
 ```
 
-### Using the Access Token
+### Check the audience before going further
+
+```bash
+TOKEN=$(curl -s --request POST --url https://YOUR_DOMAIN/oauth/token \
+  --header 'content-type: application/json' \
+  --data '{"client_id":"YOUR_CLIENT_ID","client_secret":"YOUR_CLIENT_SECRET",
+           
"audience":"http://localhost:8080/mcp","grant_type":"client_credentials"}' \
+  | jq -r .access_token)
+
+jwt_payload() {
+  python3 -c "import 
sys,base64,json;p=sys.argv[1].split('.')[1];p+='='*(-len(p)%4);\
+print(json.dumps(json.loads(base64.urlsafe_b64decode(p))))" "$1"
+}
+jwt_payload "$TOKEN" | jq '{iss, aud}'
+# { "iss": "https://your-tenant.auth0.com/";, "aud": 
"http://localhost:8080/mcp"; }
+```
+
+`iss` ends with a slash; `OAUTH2_ISSUER_URI` in the next step must match it
+character for character.
+
+---
+
+## 5. Run the Server
+
+The HTTP profile is secured by default; the only value you have to supply is
+the issuer. Pass the environment inline rather than `export`ing it — an
+exported `OAUTH2_ISSUER_URI` is inherited by the Gradle daemon and breaks the
+next `./gradlew build`.
+
+```bash
+PROFILES=http \
+OAUTH2_ISSUER_URI=https://your-tenant.auth0.com/ \
+./gradlew bootRun
+```
+
+Or with the fat JAR or the Docker image:
+
+```bash
+PROFILES=http OAUTH2_ISSUER_URI=https://your-tenant.auth0.com/ \
+  java -jar build/libs/solr-mcp-1.0.0-SNAPSHOT.jar
+
+docker run -p 8080:8080 --rm \
+  -e PROFILES=http \
+  -e OAUTH2_ISSUER_URI=https://your-tenant.auth0.com/ \
+  -e SOLR_URL=http://host.docker.internal:8983/solr/ \
+  solr-mcp:latest
+```
+
+**The issuer URI must end with a trailing slash `/`**, because that is how
+Auth0 writes the `iss` claim. The server resolves
+`https://your-tenant.auth0.com/.well-known/openid-configuration` **at startup**
+and exits if it cannot; a wrong domain fails immediately, not on the first
+request.
+
+The server is ready when `curl http://localhost:8080/actuator/health` answers
+`{"status":"UP"}`.
+
+---
+
+## 6. Verify
 
-Include the token in your HTTP requests:
+An unauthenticated tool call is denied inside the tool (HTTP `200`, JSON-RPC
+error); the same call with the token returns data. That contrast is the check
+that the gate is live:
 
 ```bash
-curl --request GET \
-  --url http://localhost:8080/api/some-endpoint \
-  --header 'Authorization: Bearer YOUR_ACCESS_TOKEN'
+# Without a token → "Access Denied"
+curl -s -X POST http://localhost:8080/mcp \
+  -H "Content-Type: application/json" -H "Accept: application/json, 
text/event-stream" \
+  -d 
'{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"list-collections","arguments":{}}}'
+# {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"Access 
Denied"}],"isError":true}}
+
+# With the token → the collections
+curl -s -X POST http://localhost:8080/mcp \
+  -H "Authorization: Bearer $TOKEN" \
+  -H "Content-Type: application/json" -H "Accept: application/json, 
text/event-stream" \
+  -d 
'{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"list-collections","arguments":{}}}'
+# 
{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"[\"books\",\"films\"]"}],"isError":false}}
 ```
 
-Or for MCP Inspector, configure it in the OAuth settings as described below.
+To see *why* a token is rejected, ask a protected actuator endpoint, which
+answers with a plain `401` and the reason:
+
+```bash
+curl -s -i -H "Authorization: Bearer $TOKEN" 
http://localhost:8080/actuator/metrics | grep -i www-authenticate
+# 200 and no header → token valid
+# ... error_description="... The aud claim is not valid" → wrong API 
identifier / wrong URL spelling
+# ... error_description="... Jwt expired at ..."          → request a new token
+```
 
 ---
 
-## 5. MCP Inspector Configuration
+## 7. Connect an MCP Client
 
-### Step 1: Ensure Callback URL is Added
+The server does not answer an anonymous `/mcp` request with `401` — `/mcp` is
+permitted at the HTTP layer and the denial happens inside each tool — and MCP
+clients start their OAuth flow only on a `401`. A client configured with just
+the URL therefore shows the server as connected, lists every tool, and gets
+`Access Denied` from every call. Pass the token explicitly:
 
-In Auth0 Dashboard:
+```bash
+# Claude Code
+claude mcp add --transport http solr-mcp http://localhost:8080/mcp \
+  --header "Authorization: Bearer $TOKEN"
 
-1. Go to **Applications** > **Applications**
-2. Click on your **Solr MCP Server** application
-3. In **Settings** tab, find **Allowed Callback URLs**
-4. Ensure this URL is included:
-   ```
-   http://localhost:6274/oauth/callback
-   ```
-5. Click **Save Changes**
-
-### Step 2: Configure MCP Inspector
-
-When running MCP Inspector with OAuth:
-
-1. Start your Solr MCP server with OAuth enabled
-2. Open MCP Inspector (usually at `http://localhost:6274`)
-3. When connecting to your server, use OAuth configuration:
-    - **Authorization URL**: `https://YOUR_DOMAIN/authorize`
-    - **Token URL**: `https://YOUR_DOMAIN/oauth/token`
-    - **Client ID**: Your application's Client ID
-    - **Client Secret**: Your application's Client Secret (if required)
-    - **Scope**: Scopes required by your API (e.g., `openid profile`)
-    - **Redirect URI**: `http://localhost:6274/oauth/callback`
-
-### Step 3: Test the Connection
-
-1. Click "Connect" in MCP Inspector
-2. You'll be redirected to Auth0 login page
-3. After successful authentication, you'll be redirected back to MCP Inspector
-4. The inspector should now have an access token to communicate with your 
server
+# MCP Inspector, CLI
+npx @modelcontextprotocol/inspector --cli http://localhost:8080/mcp 
--transport http \
+  --header "Authorization: Bearer $TOKEN" --method tools/call --tool-name 
list-collections
+
+# MCP Inspector, web UI
+npx @modelcontextprotocol/inspector --server-url http://localhost:8080/mcp 
--transport http \
+  --header "Authorization: Bearer $TOKEN"
+```
+
+The other clients take the same header — the table in
+[`http.md`](./http.md#connecting-an-mcp-client-to-a-secured-server) covers
+Claude Desktop and JetBrains via `mcp-remote`, VS Code, Cursor and Zed, and
+each [client guide](../clients/) shows the full snippet. Auth0
+machine-to-machine tokens last 24 hours by default (`expires_in: 86400`);
+when one expires, re-add the server with a fresh token.
 
 ---
 
@@ -226,7 +302,7 @@ arguments):
 AUTH0_DOMAIN=your-tenant.auth0.com
 AUTH0_CLIENT_ID=your-client-id-here
 AUTH0_CLIENT_SECRET=your-client-secret-here
-AUTH0_AUDIENCE=https://solr-mcp-api
+AUTH0_AUDIENCE=http://localhost:8080/mcp
 ```
 
 ### Script Usage
@@ -245,7 +321,7 @@ AUTH0_AUDIENCE=https://solr-mcp-api
   --domain your-tenant.auth0.com \
   --client-id YOUR_CLIENT_ID \
   --client-secret YOUR_CLIENT_SECRET \
-  --audience https://solr-mcp-api
+  --audience http://localhost:8080/mcp
 ```
 
 **Get just the token (for scripting):**
@@ -269,57 +345,31 @@ The script will:
 
 ---
 
-## Configuring the Solr MCP Application
-
-### Required Environment Variable
-
-Set the OAuth2 issuer URI for your Solr MCP application:
-
-```bash
-# OAuth2 Resource Server Configuration
-OAUTH2_ISSUER_URI=https://your-tenant.auth0.com/
-```
-
-**Important**: The issuer URI must end with a trailing slash `/`
-
-### Running the Application with OAuth2
-
-The application must run in `http` profile to enable OAuth2 security:
-
-```bash
-PROFILES=http ./gradlew bootRun
-```
-
-### Application Configuration Details
+## Application Configuration Details
 
-The OAuth2 configuration is in 
`src/main/resources/application-http.properties`:
+No file edits are required; the environment variables above are enough. For
+reference, the relevant entries in 
`src/main/resources/application-http.properties`:
 
 ```properties
-# OAuth2 Security Configuration
-spring.security.oauth2.resourceserver.jwt.issuer-uri=${OAUTH2_ISSUER_URI:https://your-auth0-domain.auth0.com/}
+# Security toggle — HTTP mode is secured by default.
+http.security.enabled=${HTTP_SECURITY_ENABLED:true}
+
+# OAuth2 issuer. Empty by default: the filter chain then answers 401/403 to
+# everything except /actuator/health instead of crashing on a placeholder.
+spring.security.oauth2.resourceserver.jwt.issuer-uri=${OAUTH2_ISSUER_URI:}
 ```
 
 **Key Points**:
 
 - OAuth2 is **only active** when using the `http` profile
 - The default profile is `stdio` which does **not** use OAuth2
-- The application validates JWT tokens from the configured issuer
-- No audience validation is performed - only issuer validation
-- CORS is enabled for MCP Inspector support
-- CSRF is disabled for API usage
-
-### Testing the Application
-
-Once the application is running with OAuth2:
-
-```bash
-# Get a token using the script
-TOKEN=$(./scripts/get-auth0-token.sh --quiet)
-
-# Use the token to call the API
-curl -H "Authorization: Bearer $TOKEN" \
-     http://localhost:8080/mcp/v1/your-endpoint
-```
+- The server validates the JWT signature (against the issuer's JWKS), issuer,
+  expiry **and audience** — the `aud` claim must contain the server's resource
+  URI (see [`http.md`](./http.md#2-jwt-validation))
+- `HTTP_SECURITY_ENABLED=false` turns authentication off for a local
+  experiment; never on anything reachable by other people
+- CORS allows the MCP Inspector's origin by default 
(`MCP_CORS_ALLOWED_ORIGINS`)
+- CSRF is disabled because the API is stateless bearer-token
 
 ---
 
@@ -327,21 +377,30 @@ curl -H "Authorization: Bearer $TOKEN" \
 
 ### Common Issues
 
-1. **"Invalid Callback URL"**
-    - Ensure the callback URL is exactly as configured in Auth0
-    - Check for trailing slashes or http vs https mismatches
+1. **`401` with `The aud claim is not valid`**
+    - The API Identifier is not the server's resource URI. Compare
+      `jwt_payload "$TOKEN" | jq .aud` with
+      `curl -s http://localhost:8080/.well-known/oauth-protected-resource | jq 
-r .resource`
+    - The client dials the server under a different name than the one in the
+      token (`127.0.0.1` vs `localhost`, or a public hostname). Use the same
+      spelling everywhere
 
-2. **"Unauthorized Client"**
-    - Verify Client ID and Client Secret are correct
-    - Ensure the application is authorized to access the API
+2. **`401` with `Jwt expired at ...`**
+    - Request a new token; the default lifetime is 24 hours
 
-3. **"Invalid Audience"**
-    - Check that the audience matches your API identifier exactly
-    - The audience is case-sensitive
+3. **Server exits at startup with `Unable to resolve the Configuration with 
the provided Issuer`**
+    - `OAUTH2_ISSUER_URI` is wrong or lacks the trailing slash, or the machine
+      cannot reach Auth0. Check with
+      `curl https://your-tenant.auth0.com/.well-known/openid-configuration`
+
+4. **`access_denied` / `Unauthorized` from the token endpoint**
+    - Verify Client ID and Client Secret are correct
+    - Ensure the application is authorized for the API (step 3)
 
-4. **Token Validation Fails**
-    - Ensure issuer-uri matches your Auth0 domain exactly
-    - Ensure the issuer-uri ends with a trailing slash `/`
+5. **Every tool call returns `Access Denied` from an MCP client, no browser 
opens**
+    - Expected when the client sends no token: the server does not challenge
+      `/mcp` with `401`, so the client never starts OAuth. Configure the header
+      as in [step 7](#7-connect-an-mcp-client)
 
 ---
 
@@ -361,4 +420,5 @@ curl -H "Authorization: Bearer $TOKEN" \
 - [Auth0 Documentation](https://auth0.com/docs)
 - [OAuth 2.0 Client Credentials 
Flow](https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-credentials-flow)
 - [Auth0 APIs](https://auth0.com/docs/get-started/apis)
-- [Spring Security OAuth 2.0 Resource 
Server](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html)
\ No newline at end of file
+- [Spring Security OAuth 2.0 Resource 
Server](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html)
+- [Solr MCP HTTP security model](./http.md)
diff --git a/docs/security/http.md b/docs/security/http.md
index 043964a9..cc39da79 100644
--- a/docs/security/http.md
+++ b/docs/security/http.md
@@ -69,12 +69,25 @@ The `mcpServerOAuth2()` configurer wires a Spring Security
 1. **Signature** against the JWKS fetched from 
`issuer-uri`/`.well-known/openid-configuration`.
 2. **Issuer** matches the configured `issuer-uri`.
 3. **Expiration** (`exp`) and not-before (`nbf`).
-4. **Audience** (`aud`) matches the canonical resource indicator declared by
-   `resourcePath("/mcp")` — per
+4. **Audience** (`aud`) contains the server's canonical resource URI — per
    [RFC 8707 Resource Indicators](https://www.rfc-editor.org/rfc/rfc8707.html)
    and
    [the MCP Authorization spec's Token Audience Binding 
requirement](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization).
 
+   That URI is **not a fixed string**. `McpServerOAuth2Configurer` builds it 
per
+   request from the scheme, host and port the client used plus
+   `resourcePath("/mcp")`. A client that calls `http://localhost:8080/mcp` 
needs a
+   token with `aud` containing exactly `http://localhost:8080/mcp`; the same 
token
+   sent to `http://127.0.0.1:8080/mcp` is rejected with
+   `401 ... "The aud claim is not valid"`. Behind a reverse proxy or under a
+   public hostname, the audience is the public URL. Read the value the server
+   expects from the running instance rather than guessing:
+
+   ```bash
+   curl -s http://localhost:8080/.well-known/oauth-protected-resource | jq -r 
.resource
+   # http://localhost:8080/mcp
+   ```
+
 Without audience validation, any valid JWT from the same IdP issued for any
 sibling application would be accepted (CWE-345).
 
@@ -85,9 +98,9 @@ resource URI. Per IdP:
 
 | IdP | How to populate `aud` |
 |---|---|
-| **Auth0** | Pass `audience=<MCP server URL>` on the auth request. Auth0 
reflects it into `aud` automatically. Configure the API in the Auth0 dashboard 
with the same identifier. [Auth0 
docs](https://auth0.com/docs/secure/tokens/access-tokens). |
+| **Auth0** | Create an API whose **Identifier** is the MCP server's resource 
URI (for a local trial `http://localhost:8080/mcp`), and pass `audience=<that 
identifier>` on the token request. Auth0 reflects it into `aud`. A different 
identifier such as `https://solr-mcp-api` yields tokens the server rejects. 
[Auth0 docs](https://auth0.com/docs/secure/tokens/access-tokens) · [step by 
step](./auth0.md). |
 | **Okta** | Configure the audience on the Authorization Server (`Security → 
API → Authorization Servers → Settings`). Tokens issued from that AS will carry 
the configured `aud`. |
-| **Keycloak** | Keycloak does **not** yet honor RFC 8707 `resource=` natively 
(see [Keycloak issue 
#41526](https://github.com/keycloak/keycloak/issues/41526)). Workaround: add an 
**Audience** protocol mapper on a client scope, set `Included Custom Audience` 
to the MCP server URL, and assign that client scope as a default scope on the 
MCP client. [Keycloak MCP integration 
docs](https://www.keycloak.org/securing-apps/mcp-authz-server). |
+| **Keycloak** | Keycloak does **not** yet honor RFC 8707 `resource=` natively 
(see [Keycloak issue 
#41526](https://github.com/keycloak/keycloak/issues/41526)). Workaround: add an 
**Audience** protocol mapper on a client scope, set `Included Custom Audience` 
to the MCP server URL, and assign that client scope as a default scope on the 
MCP client. [Keycloak MCP integration 
docs](https://www.keycloak.org/securing-apps/mcp-authz-server) · [step by 
step](./keycloak.md). |
 
 ### 4. CORS
 
@@ -106,14 +119,70 @@ MCP clients (Claude Desktop, Spring AI MCP client, etc.) 
speak HTTP from a
 backend or native process and don't trigger CORS preflights — the allowlist
 exists for browser-based tooling.
 
+## Connecting an MCP client to a secured server
+
+What a client observes, verified on 2026-09-16 against Keycloak 26.0 with the
+audience mapper in place and against an Auth0 tenant with the API identifier
+set to the resource URI (`curl`, the MCP Inspector CLI and Claude Code 2.1):
+
+| Request | Without a token | With a valid token |
+|---|---|---|
+| `initialize`, `tools/list` on `/mcp` | `200` — `/mcp` is `permitAll` | `200` 
|
+| `tools/call` on `/mcp` | `200` with `{"content":[{"text":"Access 
Denied"}],"isError":true}` — `@PreAuthorize` denies inside the tool | tool 
result |
+| `/actuator/metrics` (any actuator except `health`) | `401` + 
`WWW-Authenticate` | `200` |
+| Anything, with an invalid or expired token | `401` + `WWW-Authenticate` 
naming the reason (`Jwt expired at …`, `The aud claim is not valid`, `Malformed 
token`) | — |
+
+Two consequences for client setup:
+
+1. **The server never answers an anonymous `/mcp` request with `401`, so MCP
+   clients do not start an OAuth flow on their own.** Claude Code, the MCP
+   Inspector and `mcp-remote` all document that they begin OAuth only when the
+   server responds `401`/`403`. A client configured with just the URL therefore
+   shows the server as *connected*, lists every tool, and gets `Access Denied`
+   from every call — no browser opens, nothing looks broken. Give the client a
+   token explicitly. Obtain one from your IdP first
+   ([Keycloak](./keycloak.md#obtain-a-token) · 
[Auth0](./auth0.md#4-get-an-access-token)),
+   then:
+
+   | Client | Where the token goes |
+   |---|---|
+   | Claude Code | `claude mcp add --transport http solr-mcp 
http://localhost:8080/mcp --header "Authorization: Bearer $TOKEN"` |
+   | MCP Inspector, CLI | `npx @modelcontextprotocol/inspector --cli 
http://localhost:8080/mcp --transport http --header "Authorization: Bearer 
$TOKEN" --method tools/call --tool-name list-collections` |
+   | MCP Inspector, web UI | `npx @modelcontextprotocol/inspector --server-url 
http://localhost:8080/mcp --transport http --header "Authorization: Bearer 
$TOKEN"` |
+   | Claude Desktop, JetBrains (via `mcp-remote`) | `"args": ["mcp-remote", 
"http://localhost:8080/mcp";, "--header", "Authorization: Bearer ${TOKEN}"]` 
with `"env": {"TOKEN": "…"}` |
+   | VS Code | `"headers": {"Authorization": "Bearer ${input:token}"}` on the 
`http` server entry |
+   | Cursor | `"headers": {"Authorization": "Bearer ${env:TOKEN}"}` on the 
server entry |
+   | Zed | `"headers": {"Authorization": "Bearer …"}` on the remote-server 
entry |
+
+   Each per-client guide under [`docs/clients/`](../clients/) shows the full
+   snippet.
+
+2. **Tokens expire, and a static header does not refresh itself.** Keycloak's
+   default access-token lifetime is 300 s; Auth0's is 86 400 s. When the token
+   expires, tool calls fail with `401` and a client with a configured
+   `Authorization` header reports the connection as failed rather than
+   re-authenticating. For a trial, either raise the lifetime at the IdP
+   (Keycloak: [one Admin API call](./keycloak.md#connecting-an-mcp-client)) or
+   re-add the server with a fresh token. An application that calls this server
+   should hold a `client_credentials` registration and refresh the token itself
+   — [Configuring a Spring AI MCP 
Client](./keycloak.md#configuring-a-spring-ai-mcp-client)
+   shows that pattern.
+
+Interactive browser login (Claude Code `claude mcp login <name>`, the
+Inspector's OAuth flow) additionally needs a client registered at the IdP with
+the client's callback URL, and is not covered by these guides.
+
 ## Operational guidance
 
 ### Required for production
 
 1. **Set a real `OAUTH2_ISSUER_URI`** pointing at your authorization server.
-   The placeholder default fails to start.
-2. **Configure your IdP to populate `aud`** with the MCP server's URL (see
-   table above).
+   With the default (empty) value the filter chain answers `401`/`403` to
+   everything except `/actuator/health` and the anonymous `/mcp` handshake, so
+   no tool can be called; with an unreachable value the server fails to start,
+   because the JWT decoder resolves the issuer eagerly.
+2. **Configure your IdP to populate `aud`** with the URL clients use to reach
+   the server (see table above and §2).
 3. **Bind to a private network or behind an authenticated ingress**. The MCP
    transport spec recommends localhost binding for local servers and
    authentication for everything else.
diff --git a/docs/security/keycloak.md b/docs/security/keycloak.md
index e649f366..4a030201 100644
--- a/docs/security/keycloak.md
+++ b/docs/security/keycloak.md
@@ -16,6 +16,7 @@ This guide covers setting up 
[Keycloak](https://www.keycloak.org/) as an OAuth2/
 - [Spring Boot Configuration](#spring-boot-configuration)
 - [Running the Server](#running-the-server)
 - [Testing Authentication](#testing-authentication)
+- [Connecting an MCP Client](#connecting-an-mcp-client)
 - [Configuring a Spring AI MCP Client](#configuring-a-spring-ai-mcp-client)
 - [User Management Options](#user-management-options)
   - [Manual User Creation](#manual-user-creation)
@@ -311,6 +312,13 @@ curl -s 
http://localhost:8080/.well-known/oauth-protected-resource | jq -r .reso
 # http://localhost:8080/mcp
 ```
 
+That value is derived from the URL the client calls — scheme, host and port
+plus `/mcp` — not from a fixed setting. A token whose `aud` is
+`http://localhost:8080/mcp` is rejected with `The aud claim is not valid` when
+the client dials `http://127.0.0.1:8080/mcp`, and a server reached through a
+public hostname expects that hostname. Use the same spelling in the mapper and
+in every client.
+
 Keycloak does not honour the RFC 8707 `resource=` parameter yet
 ([keycloak#41526](https://github.com/keycloak/keycloak/issues/41526)), so the
 supported approach is an **Audience** protocol mapper:
@@ -495,6 +503,67 @@ curl -s -i -H "Authorization: Bearer $TOKEN" 
http://localhost:8080/actuator/metr
   | grep -i www-authenticate
 ```
 
+## Connecting an MCP Client
+
+The curl calls above prove the server; this connects a real client. One fact
+shapes the setup: **the server does not answer an anonymous `/mcp` request with
+`401`** (`/mcp` is `permitAll`, and `@PreAuthorize` denies inside each tool), 
and
+MCP clients start their OAuth flow only on a `401`. A client given just the URL
+shows the server as connected, lists every tool, and gets `Access Denied` from
+every call — no browser opens. So pass the token yourself.
+
+Keycloak's default access-token lifetime is 300 s, which is shorter than most
+trial sessions. Raise it on the realm before handing out tokens (one hour here;
+`$ADMIN_TOKEN` is from the Quick Start and itself expires after 300 s, so
+re-request it if the call returns `401`). The realm imported by `compose.yaml`
+uses the same admin login, client and test user, so the commands below work
+against it too:
+
+```bash
+KC=http://localhost:8180
+ADMIN_TOKEN=$(curl -s -X POST 
"$KC/realms/master/protocol/openid-connect/token" \
+  -d client_id=admin-cli -d username=admin -d password=admin \
+  -d grant_type=password | jq -r .access_token)
+
+curl -s -o /dev/null -w "%{http_code}\n" -X PUT "$KC/admin/realms/solr-mcp" \
+  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"accessTokenLifespan":3600}'
+# 204
+```
+
+Then obtain a token and hand it to the client:
+
+```bash
+TOKEN=$(curl -s -X POST "$KC/realms/solr-mcp/protocol/openid-connect/token" \
+  -d client_id=solr-mcp-client -d username=testuser \
+  -d password=testpassword -d grant_type=password | jq -r .access_token)
+
+# Claude Code
+claude mcp add --transport http solr-mcp http://localhost:8080/mcp \
+  --header "Authorization: Bearer $TOKEN"
+claude mcp list          # solr-mcp: http://localhost:8080/mcp (HTTP) - ✔ 
Connected
+
+# MCP Inspector, CLI — one tool call, no browser
+npx @modelcontextprotocol/inspector --cli http://localhost:8080/mcp 
--transport http \
+  --header "Authorization: Bearer $TOKEN" --method tools/call --tool-name 
list-collections
+
+# MCP Inspector, web UI — the header applies to the ad-hoc server it opens with
+npx @modelcontextprotocol/inspector --server-url http://localhost:8080/mcp 
--transport http \
+  --header "Authorization: Bearer $TOKEN"
+```
+
+Note that `claude mcp list` reports `✔ Connected` with or without the header —
+the handshake is anonymous. The test that matters is a tool call: ask Claude
+Code to run `list-collections`, and expect the collection names, not
+`Access Denied`.
+
+When the token expires, calls fail with `401` and Claude Code reports the
+connection as failed (it does not re-authenticate a server whose
+`Authorization` header you configured). Re-run `claude mcp add` with a fresh
+token. The other clients take the same header — see
+[the table in 
`http.md`](./http.md#connecting-an-mcp-client-to-a-secured-server)
+for VS Code, Cursor, Zed and `mcp-remote`.
+
 ## Configuring a Spring AI MCP Client
 
 The Quick Start's `solr-mcp-client` is a *public* client driven by the password
diff --git a/docs/site/content/pages/mcp/clients/claude-code.md 
b/docs/site/content/pages/mcp/clients/claude-code.md
index 0af65fd6..ceb84baf 100644
--- a/docs/site/content/pages/mcp/clients/claude-code.md
+++ b/docs/site/content/pages/mcp/clients/claude-code.md
@@ -127,8 +127,13 @@ claude mcp add --transport http solr-mcp 
http://localhost:8080/mcp
 }
 ```
 
-### Secured HTTP (OAuth2) ###
+### Secured HTTP (bearer token) ###
 
-Claude Code detects the OAuth2 challenge from the server and initiates the 
authorization flow automatically. The configuration is the same as unsecured 
HTTP.
+Claude Code starts an OAuth flow only when a server answers `401`/`403`. This 
server never does that on `/mcp` — the handshake is anonymous and 
`@PreAuthorize` denies inside each tool — so with the URL alone Claude Code 
shows the server as **connected** and every tool call returns `Access Denied`. 
Pass a token from your identity provider instead:
 
-See [Security](/mcp/security.html) for server-side OAuth2 setup.
+```bash
+claude mcp add --transport http solr-mcp http://localhost:8080/mcp \
+    --header "Authorization: Bearer $TOKEN"
+```
+
+The token's `aud` claim must contain the exact URL you register here 
(`http://localhost:8080/mcp`), and when it expires the connection fails rather 
than re-authenticating; re-run `claude mcp add` with a fresh token. See 
[Security](/mcp/security.html) for obtaining a token.
diff --git a/docs/site/content/pages/mcp/clients/claude-desktop.md 
b/docs/site/content/pages/mcp/clients/claude-desktop.md
index f3d9911d..432680c3 100644
--- a/docs/site/content/pages/mcp/clients/claude-desktop.md
+++ b/docs/site/content/pages/mcp/clients/claude-desktop.md
@@ -96,21 +96,25 @@ The HTTP transport is secured by default: without a bearer 
token the client stil
 }
 ```
 
-### Secured HTTP (OAuth2) ###
+### Secured HTTP (bearer token) ###
 
-When OAuth2 is enabled on the server, `mcp-remote` handles the authorization 
flow automatically&mdash;it discovers the authorization server and opens a 
browser for consent.
+`mcp-remote` starts an OAuth flow only when the server answers `401`. This 
server never does that on `/mcp` (the handshake is anonymous; `@PreAuthorize` 
denies inside each tool), so with the URL alone Claude Desktop connects, lists 
the tools, and every call returns `Access Denied`. Pass a token from your 
identity provider with `--header`; `mcp-remote` expands `${TOKEN}` from the 
`env` block so the secret stays out of the argument list:
 
 ```json
 {
   "mcpServers": {
     "solr-mcp": {
       "command": "npx",
-      "args": ["mcp-remote", "http://localhost:8080/mcp";, "--allow-http"]
+      "args": [
+        "mcp-remote", "http://localhost:8080/mcp";, "--allow-http",
+        "--header", "Authorization: Bearer ${TOKEN}"
+      ],
+      "env": { "TOKEN": "<access token>" }
     }
   }
 }
 ```
 
-The `--allow-http` flag is needed for `http://` URLs (development). Omit it in 
production with HTTPS.
+The `--allow-http` flag is needed for `http://` URLs (development). Omit it in 
production with HTTPS. The token's `aud` claim must contain the exact URL given 
here, and when it expires calls fail with `401` until you paste a fresh token 
and restart Claude Desktop.
 
 See [Security](/mcp/security.html) for server-side OAuth2 setup.
diff --git a/docs/site/content/pages/mcp/clients/cursor.md 
b/docs/site/content/pages/mcp/clients/cursor.md
index 87c92a36..cd737408 100644
--- a/docs/site/content/pages/mcp/clients/cursor.md
+++ b/docs/site/content/pages/mcp/clients/cursor.md
@@ -90,8 +90,21 @@ The HTTP transport is secured by default: without a bearer 
token the client stil
 }
 ```
 
-### Secured HTTP (OAuth2) ###
+### Secured HTTP (bearer token) ###
 
-The configuration is the same for secured and unsecured HTTP. Cursor handles 
the MCP OAuth2 flow automatically.
+With security enabled (the default), this server does not answer an anonymous 
`/mcp` request with `401`, so Cursor will not start an OAuth flow by itself: it 
connects, lists the tools, and every call returns `Access Denied`. Pass a token 
from your identity provider in `headers`; Cursor expands `${env:…}` so the 
secret stays out of the file:
+
+```json
+{
+  "mcpServers": {
+    "solr-mcp": {
+      "url": "http://localhost:8080/mcp";,
+      "headers": { "Authorization": "Bearer ${env:SOLR_MCP_TOKEN}" }
+    }
+  }
+}
+```
+
+The token's `aud` claim must contain the exact `url` above, and it expires — 
see the [Security](/mcp/security.html) for obtaining one and the lifetime knobs.
 
 See [Security](/mcp/security.html) for server-side OAuth2 setup, and the 
[Cursor MCP 
documentation](https://docs.cursor.com/context/model-context-protocol) for the 
latest configuration format.
diff --git a/docs/site/content/pages/mcp/clients/jetbrains.md 
b/docs/site/content/pages/mcp/clients/jetbrains.md
index cb707463..5f6de9d7 100644
--- a/docs/site/content/pages/mcp/clients/jetbrains.md
+++ b/docs/site/content/pages/mcp/clients/jetbrains.md
@@ -93,8 +93,25 @@ The HTTP transport is secured by default: without a bearer 
token the client stil
 
 Or in IDE Settings, select **SSE** transport and enter 
`http://localhost:8080/mcp` as the URL.
 
-### Secured HTTP (OAuth2) ###
+### Secured HTTP (bearer token) ###
 
-The configuration is the same for secured and unsecured HTTP. JetBrains IDEs 
handle the MCP OAuth2 flow automatically.
+With security enabled (the default), this server does not answer an anonymous 
`/mcp` request with `401`, so the IDE will not start an OAuth flow by itself: 
it connects, lists the tools, and every call returns `Access Denied`. Supply a 
token from your identity provider. The portable way is to register the server 
as a STDIO command through `mcp-remote`, which forwards the header on every 
request and expands `${TOKEN}` from `env`:
+
+```json
+{
+  "mcpServers": {
+    "solr-mcp": {
+      "command": "npx",
+      "args": [
+        "mcp-remote", "http://localhost:8080/mcp";, "--allow-http",
+        "--header", "Authorization: Bearer ${TOKEN}"
+      ],
+      "env": { "TOKEN": "<access token>" }
+    }
+  }
+}
+```
+
+The token's `aud` claim must contain the exact URL above, and it expires — see 
the [Security](/mcp/security.html) for obtaining one and the lifetime knobs.
 
 See [Security](/mcp/security.html) for server-side OAuth2 setup. MCP support 
requires the AI Assistant plugin — see the [JetBrains MCP 
documentation](https://www.jetbrains.com/help/idea/model-context-protocol.html) 
for the latest configuration format.
diff --git a/docs/site/content/pages/mcp/clients/mcp-inspector.md 
b/docs/site/content/pages/mcp/clients/mcp-inspector.md
index 4c264656..e6024a84 100644
--- a/docs/site/content/pages/mcp/clients/mcp-inspector.md
+++ b/docs/site/content/pages/mcp/clients/mcp-inspector.md
@@ -60,13 +60,20 @@ This starts the Inspector UI at `http://localhost:6274`.
 
 ***
 
-## OAuth2 ##
+## Secured HTTP (bearer token) ##
 
-When OAuth2 is enabled on the server, configure the Inspector's OAuth settings 
before connecting:
+The Inspector starts its OAuth flow only when the server answers `401`. This 
server never does that on `/mcp` — the handshake is anonymous and 
`@PreAuthorize` denies inside each tool — so with the URL alone the Inspector 
connects, lists every tool, and every call returns `Access Denied`. Pass a 
token from your identity provider with `--header` instead. It applies to the 
ad-hoc server the Inspector opens with:
 
-1. Click the **OAuth** settings in the Inspector
-2. Enter your provider's Authorization URL, Token URL, Client ID, and Redirect 
URI (`http://localhost:6274/oauth/callback`)
-3. Complete the OAuth flow
-4. The Inspector will include the Bearer token in all subsequent requests
+```bash
+# Web UI
+npx @modelcontextprotocol/inspector --server-url http://localhost:8080/mcp 
--transport http \
+    --header "Authorization: Bearer $TOKEN"
+
+# CLI: one tool call, no browser
+npx @modelcontextprotocol/inspector --cli http://localhost:8080/mcp 
--transport http \
+    --header "Authorization: Bearer $TOKEN" --method tools/call --tool-name 
list-collections
+```
+
+The token's `aud` claim must contain the exact URL given here 
(`http://localhost:8080/mcp`), and when it expires calls fail with `401` until 
you relaunch with a fresh one.
 
 See [Security](/mcp/security.html) for server-side OAuth2 setup with Auth0 and 
Keycloak.
diff --git a/docs/site/content/pages/mcp/clients/vs-code.md 
b/docs/site/content/pages/mcp/clients/vs-code.md
index 3769efcf..888c7503 100644
--- a/docs/site/content/pages/mcp/clients/vs-code.md
+++ b/docs/site/content/pages/mcp/clients/vs-code.md
@@ -102,8 +102,25 @@ The HTTP transport is secured by default: without a bearer 
token the client stil
 }
 ```
 
-### Secured HTTP (OAuth2) ###
+### Secured HTTP (bearer token) ###
 
-The configuration is the same for secured and unsecured HTTP. VS Code handles 
the MCP OAuth2 flow automatically.
+With security enabled (the default), this server does not answer an anonymous 
`/mcp` request with `401`, so VS Code will not start an OAuth flow by itself: 
it connects, lists the tools, and every call returns `Access Denied`. Pass a 
token from your identity provider in `headers`; an `inputs` entry prompts for 
it once and keeps it out of the file:
+
+```json
+{
+  "inputs": [
+    { "type": "promptString", "id": "solr-mcp-token", "description": "Solr MCP 
access token", "password": true }
+  ],
+  "servers": {
+    "solr-mcp": {
+      "type": "http",
+      "url": "http://localhost:8080/mcp";,
+      "headers": { "Authorization": "Bearer ${input:solr-mcp-token}" }
+    }
+  }
+}
+```
+
+The token's `aud` claim must contain the exact `url` above, and it expires — 
see the [Security](/mcp/security.html) for obtaining one and the lifetime knobs.
 
 See [Security](/mcp/security.html) for server-side OAuth2 setup, and the [VS 
Code MCP 
documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for 
the latest configuration format.
diff --git a/docs/site/content/pages/mcp/security.md 
b/docs/site/content/pages/mcp/security.md
index e9e399e7..18876f09 100644
--- a/docs/site/content/pages/mcp/security.md
+++ b/docs/site/content/pages/mcp/security.md
@@ -36,6 +36,19 @@ To turn authentication **off** for local development, set 
`HTTP_SECURITY_ENABLED
 
 ***
 
+## Before you start: the audience ##
+
+The server validates the token's `aud` claim against its **resource URI**, 
which it derives from the URL the client calls &mdash; scheme, host and port 
plus `/mcp`. For a local trial that is `http://localhost:8080/mcp`; for a 
deployed server it is the public URL. Read it from a running server:
+
+```bash
+curl -s http://localhost:8080/.well-known/oauth-protected-resource | jq -r 
.resource
+# http://localhost:8080/mcp
+```
+
+Every provider setup below has one step whose only job is to put that value 
into `aud`. Skip it and every token is rejected with `401 ... "The aud claim is 
not valid"`.
+
+***
+
 ## Auth0 ##
 
 ### 1. Create Auth0 Application ###
@@ -49,29 +62,21 @@ To turn authentication **off** for local development, set 
`HTTP_SECURITY_ENABLED
 
 1. Navigate to **Applications** > **APIs** > **Create API**
 2. Name: `Solr MCP API`
-3. Identifier (audience): `https://solr-mcp-api`
+3. Identifier (audience): `http://localhost:8080/mcp` &mdash; the server's 
resource URI, exactly as clients will dial it
 4. Signing Algorithm: **RS256**
 
-### 3. Configure Callback URLs ###
-
-In your application settings, add to **Allowed Callback URLs**:
+### 3. Authorize the Application ###
 
-    
http://localhost:6274/oauth/callback,http://localhost:3334/oauth/callback,http://localhost:8080/login/oauth2/code/auth0
-
-Each callback URL serves a different client:
-
-* `http://localhost:6274/oauth/callback` &mdash; MCP Inspector
-* `http://localhost:3334/oauth/callback` &mdash; `mcp-remote` (Claude Desktop, 
VS Code, Cursor, JetBrains in HTTP mode)
-* `http://localhost:8080/login/oauth2/code/auth0` &mdash; Direct server OAuth2 
code flow
+In your application's **APIs** tab, toggle **Solr MCP API** on. No callback 
URLs are needed: the MCP server is a resource server and a machine-to-machine 
application never opens a browser.
 
 ### 4. Run the Server ###
 
 ```bash
-export PROFILES=http
-export OAUTH2_ISSUER_URI=https://your-tenant.auth0.com/
-./gradlew bootRun
+PROFILES=http OAUTH2_ISSUER_URI=https://your-tenant.auth0.com/ ./gradlew 
bootRun
 ```
 
+The issuer must end with `/`, as Auth0 writes the `iss` claim. It is resolved 
at startup, so a wrong domain fails immediately.
+
 ### 5. Get an Access Token ###
 
 ```bash
@@ -81,7 +86,7 @@ curl --request POST \
     --data '{
       "client_id": "YOUR_CLIENT_ID",
       "client_secret": "YOUR_CLIENT_SECRET",
-      "audience": "https://solr-mcp-api";,
+      "audience": "http://localhost:8080/mcp";,
       "grant_type": "client_credentials"
     }'
 ```
@@ -93,14 +98,7 @@ Or use the convenience script:
     --domain your-tenant.auth0.com \
     --client-id YOUR_CLIENT_ID \
     --client-secret YOUR_CLIENT_SECRET \
-    --audience https://solr-mcp-api
-```
-
-### 6. Use the Token ###
-
-```bash
-curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-    http://localhost:8080/mcp
+    --audience http://localhost:8080/mcp
 ```
 
 For the full step-by-step guide, see [Auth0 Setup 
Guide](https://github.com/apache/solr-mcp/blob/main/docs/security/auth0.md).
@@ -119,7 +117,7 @@ docker run -d --name keycloak \
     quay.io/keycloak/keycloak:26.0 start-dev
 ```
 
-Access the admin console at `http://localhost:8180` (login: `admin` / `admin`).
+Access the admin console at `http://localhost:8180` (login: `admin` / 
`admin`). Wait for 
`http://localhost:8180/realms/master/.well-known/openid-configuration` to 
answer before continuing; the container is up well before its endpoints are.
 
 ### 2. Create Realm and Client ###
 
@@ -128,24 +126,34 @@ Access the admin console at `http://localhost:8180` 
(login: `admin` / `admin`).
     * Client ID: `solr-mcp-client`
     * Client type: OpenID Connect
     * Client authentication: OFF (public client)
-    * Valid redirect URIs: `http://localhost:6274/*`, 
`http://localhost:3334/*`, `http://localhost:8080/*`
-    * Web origins: `*`
+    * Authentication flow: leave **Direct access grants** enabled (the token 
request below uses it)
+    * Valid redirect URIs: `http://localhost:6274/*`
+    * Web origins: `http://localhost:6274`
 
-### 3. Create Test User ###
+### 3. Add the Audience Mapper ###
+
+Keycloak does not honour the RFC 8707 `resource=` parameter, so the audience 
must be mapped in:
+
+1. **Clients** > `solr-mcp-client` > **Client scopes** > 
`solr-mcp-client-dedicated`
+2. **Add mapper** > **By configuration** > **Audience**
+3. Name `mcp-audience`, **Included Custom Audience** 
`http://localhost:8080/mcp`, **Add to access token** ON
+4. **Save**
+
+### 4. Create Test User ###
 
 1. Navigate to **Users** > **Add user**
-2. Username: `testuser`, Email verified: ON
-3. Set password in **Credentials** tab
+2. Username: `testuser`, Email verified: ON, **First name and Last name filled 
in** (Keycloak refuses to issue a token to a user missing either)
+3. Set password in **Credentials** tab with **Temporary** OFF
 
-### 4. Run the Server ###
+### 5. Run the Server ###
 
 ```bash
-export PROFILES=http
-export OAUTH2_ISSUER_URI=http://localhost:8180/realms/solr-mcp
-./gradlew bootRun
+PROFILES=http OAUTH2_ISSUER_URI=http://localhost:8180/realms/solr-mcp 
./gradlew bootRun
 ```
 
-### 5. Get a Token ###
+The realm must exist before this: the issuer is resolved at startup.
+
+### 6. Get a Token ###
 
 ```bash
 curl -X POST 
"http://localhost:8180/realms/solr-mcp/protocol/openid-connect/token"; \
@@ -156,20 +164,37 @@ curl -X POST 
"http://localhost:8180/realms/solr-mcp/protocol/openid-connect/toke
     -d "grant_type=password"
 ```
 
-For the full guide including role-based access control and production 
deployment, see [Keycloak Setup 
Guide](https://github.com/apache/solr-mcp/blob/main/docs/security/keycloak.md).
+Tokens last 300 seconds by default. The full guide shows how to raise that for 
a trial, and covers scripted realm setup, role-based access control and 
production deployment: [Keycloak Setup 
Guide](https://github.com/apache/solr-mcp/blob/main/docs/security/keycloak.md).
 
 ***
 
-## How OAuth2 Works with MCP Clients ##
+## Verify ##
 
-When a client connects to a secured Solr MCP Server:
+An unauthenticated tool call is denied inside the tool; the same call with the 
token returns data:
 
-1. Client connects to `/mcp`
-2. Server responds with `401` + OAuth2 metadata
-3. Client discovers the authorization server from 
`/.well-known/oauth-authorization-server`
-4. Client opens a browser for login/consent
-5. Client receives an authorization code, exchanges it for an access token 
(JWT)
-6. Client attaches the Bearer token to all subsequent MCP requests
-7. Server validates the JWT with the OAuth2 provider
+```bash
+curl -s -X POST http://localhost:8080/mcp \
+    -H "Authorization: Bearer $TOKEN" \
+    -H "Content-Type: application/json" -H "Accept: application/json, 
text/event-stream" \
+    -d 
'{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"list-collections","arguments":{}}}'
+# with the header:    
{"content":[{"type":"text","text":"[\"books\",\"films\"]"}],"isError":false}
+# without the header: {"content":[{"type":"text","text":"Access 
Denied"}],"isError":true}
+```
+
+***
+
+## Connecting an MCP Client ##
+
+The server does not answer an anonymous `/mcp` request with `401` &mdash; 
`/mcp` is permitted at the HTTP layer and the denial happens inside each tool 
&mdash; and MCP clients start their OAuth flow only on a `401`. A client 
configured with just the URL therefore shows the server as connected, lists 
every tool, and gets `Access Denied` from every call; no browser opens. Pass 
the token explicitly:
+
+```bash
+# Claude Code
+claude mcp add --transport http solr-mcp http://localhost:8080/mcp \
+    --header "Authorization: Bearer $TOKEN"
+
+# MCP Inspector
+npx @modelcontextprotocol/inspector --server-url http://localhost:8080/mcp 
--transport http \
+    --header "Authorization: Bearer $TOKEN"
+```
 
-Most MCP clients handle this flow transparently&mdash;the configuration is the 
same for secured and unsecured HTTP servers. See the individual [client setup 
pages](/mcp/clients/claude-desktop.html) for details.
+VS Code and Cursor take a `headers` object on the server entry; Claude Desktop 
and JetBrains go through `mcp-remote --header`. Each [client 
page](/mcp/clients/claude-code.html) shows the snippet. The token's `aud` must 
contain the exact URL the client dials, and when it expires the client reports 
a failed connection until you configure a fresh one.
diff --git a/scripts/get-auth0-token.sh b/scripts/get-auth0-token.sh
index 771e1ef2..02d33863 100755
--- a/scripts/get-auth0-token.sh
+++ b/scripts/get-auth0-token.sh
@@ -46,7 +46,7 @@
 #   export AUTH0_DOMAIN=your-tenant.auth0.com
 #   export AUTH0_CLIENT_ID=your-client-id
 #   export AUTH0_CLIENT_SECRET=your-client-secret
-#   export AUTH0_AUDIENCE=https://solr-mcp-api
+#   export AUTH0_AUDIENCE=http://localhost:8080/mcp   # the MCP server's 
resource URI
 #   ./scripts/get-auth0-token.sh
 #
 #   # Using command line arguments
@@ -54,7 +54,7 @@
 #     --domain your-tenant.auth0.com \
 #     --client-id your-client-id \
 #     --client-secret your-client-secret \
-#     --audience https://solr-mcp-api
+#     --audience http://localhost:8080/mcp
 #
 #   # Save token to custom file
 #   ./scripts/get-auth0-token.sh -o my-token.txt
@@ -79,22 +79,23 @@ QUIET=false
 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
 
-# Function to print colored output
+# Function to print colored output. Status lines go to stderr so that
+# TOKEN=$(./scripts/get-auth0-token.sh --quiet) captures only the token.
 print_info() {
     if [ "$QUIET" = false ]; then
-        echo -e "${BLUE}[INFO]${NC} $1"
+        echo -e "${BLUE}[INFO]${NC} $1" >&2
     fi
 }
 
 print_success() {
     if [ "$QUIET" = false ]; then
-        echo -e "${GREEN}[SUCCESS]${NC} $1"
+        echo -e "${GREEN}[SUCCESS]${NC} $1" >&2
     fi
 }
 
 print_warning() {
     if [ "$QUIET" = false ]; then
-        echo -e "${YELLOW}[WARNING]${NC} $1"
+        echo -e "${YELLOW}[WARNING]${NC} $1" >&2
     fi
 }
 

Reply via email to