From ee15e6999989216b708ff63c9e6b391feed221ce Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Tue, 7 Apr 2026 07:25:41 +0200 Subject: [PATCH 01/15] WIP --- .../routes/mcp-okta-token-exchange.yaml | 9 + .../gateway/routes/mcp-token-exchange.yaml | 9 + .../mcp-okta-token-exchange-service.yaml | 2 + .../services/mcp-token-exchange-service.yaml | 2 + ...ure-mcp-oauth2-token-exchange-with-okta.md | 349 ++++++++++++++++++ .../configure-mcp-oauth2-token-exchange.md | 239 ++++++++++++ .../mcp-oauth2/keycloak-token-exchange.md | 142 +++++++ 7 files changed, 752 insertions(+) create mode 100644 app/_data/entity_examples/gateway/routes/mcp-okta-token-exchange.yaml create mode 100644 app/_data/entity_examples/gateway/routes/mcp-token-exchange.yaml create mode 100644 app/_data/entity_examples/gateway/services/mcp-okta-token-exchange-service.yaml create mode 100644 app/_data/entity_examples/gateway/services/mcp-token-exchange-service.yaml create mode 100644 app/_how-tos/mcp/configure-mcp-oauth2-token-exchange-with-okta.md create mode 100644 app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md create mode 100644 app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange.md diff --git a/app/_data/entity_examples/gateway/routes/mcp-okta-token-exchange.yaml b/app/_data/entity_examples/gateway/routes/mcp-okta-token-exchange.yaml new file mode 100644 index 0000000000..cf5d3cff6a --- /dev/null +++ b/app/_data/entity_examples/gateway/routes/mcp-okta-token-exchange.yaml @@ -0,0 +1,9 @@ +name: mcp-okta-token-exchange +paths: + - /mcp/okta + - /.well-known/oauth-protected-resource/mcp/okta +service: + name: mcp-okta-token-exchange-service +protocols: + - http + - https diff --git a/app/_data/entity_examples/gateway/routes/mcp-token-exchange.yaml b/app/_data/entity_examples/gateway/routes/mcp-token-exchange.yaml new file mode 100644 index 0000000000..6336ed87ef --- /dev/null +++ b/app/_data/entity_examples/gateway/routes/mcp-token-exchange.yaml @@ -0,0 +1,9 @@ +name: mcp-token-exchange +paths: + - /mcp + - /.well-known/oauth-protected-resource/mcp +service: + name: mcp-token-exchange-service +protocols: + - http + - https diff --git a/app/_data/entity_examples/gateway/services/mcp-okta-token-exchange-service.yaml b/app/_data/entity_examples/gateway/services/mcp-okta-token-exchange-service.yaml new file mode 100644 index 0000000000..0fc68143a2 --- /dev/null +++ b/app/_data/entity_examples/gateway/services/mcp-okta-token-exchange-service.yaml @@ -0,0 +1,2 @@ +name: mcp-okta-token-exchange-service +url: http://host.docker.internal:3001/mcp diff --git a/app/_data/entity_examples/gateway/services/mcp-token-exchange-service.yaml b/app/_data/entity_examples/gateway/services/mcp-token-exchange-service.yaml new file mode 100644 index 0000000000..47e1921d14 --- /dev/null +++ b/app/_data/entity_examples/gateway/services/mcp-token-exchange-service.yaml @@ -0,0 +1,2 @@ +name: mcp-token-exchange-service +url: http://host.docker.internal:3001/mcp diff --git a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange-with-okta.md b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange-with-okta.md new file mode 100644 index 0000000000..f59385cac5 --- /dev/null +++ b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange-with-okta.md @@ -0,0 +1,349 @@ +--- +title: Configure token exchange with the AI MCP OAuth2 plugin and Okta +permalink: /mcp/configure-mcp-oauth2-token-exchange-with-okta/ +content_type: how_to +description: Learn how to configure token exchange with the AI MCP OAuth2 plugin using Okta +breadcrumbs: + - /mcp/ + +related_resources: + - text: "{{site.ai_gateway}}" + url: /ai-gateway/ + - text: AI MCP OAuth2 plugin + url: /plugins/ai-mcp-oauth2/ + - text: Token exchange in the AI MCP OAuth2 plugin + url: /plugins/ai-mcp-oauth2/#token-exchange + - text: AI MCP Proxy plugin + url: /plugins/ai-mcp-proxy/ + - text: Secure MCP tools with OAuth2 and Okta + url: /mcp/secure-mcp-tools-with-oauth2-and-okta/ + - text: OAuth 2.0 specification for MCP + url: https://modelcontextprotocol.io/specification/draft/basic/authorization + +plugins: + - ai-mcp-oauth2 + - ai-mcp-proxy + - cors + +entities: + - service + - route + - plugin + +products: + - gateway + - ai-gateway + +works_on: + - on-prem + - konnect + +min_version: + gateway: '3.14' + +tools: + - deck + +prereqs: + inline: + - title: Upstream MCP server + content: | + This guide uses a sample MCP server that exposes marketplace tools (users and orders). + + 1. Clone and start the server: + + ```sh + git clone https://github.com/tomek-labuk/marketplace-acl.git && \ + cd marketplace-acl && \ + npm install && \ + npm run build && \ + node dist/server.js + ``` + + 1. Verify the server is running at `http://localhost:3001/mcp`. + - title: Okta + content: | + You need an [Okta](https://login.okta.com/) admin account with a developer organization. + + This setup creates two application registrations: a **Web Application** (used by {{site.ai_gateway}} for token introspection and token exchange) and a **Native Application** (used by MCP Inspector for the authorization code flow). + + #### Add a custom scope + + 1. Go to **Security > API > Authorization Servers**. + 1. Click `default`. + 1. Go to the **Scopes** tab. + 1. Click **Add Scope**. + 1. Name: `mcp:access` + 1. Display phrase: `Access MCP tools` + 1. Check **Set as a default scope**. + 1. Click **Create**. + + #### Add an access policy + + 1. In the same `default` authorization server, go to the **Access Policies** tab. + 1. Click **Add Policy**. + 1. Name: `MCP Access` + 1. Assign to: **All clients** + 1. Click **Create Policy**. + + #### Add a rule to the policy + + 1. Inside the `MCP Access` policy, click **Add Rule**. + 1. Rule Name: `Allow MCP` + 1. Grant type: check **Client Credentials**, **Authorization Code**, and **Device Authorization**. + 1. User is: **Any user assigned the app** + 1. Scopes requested: **Any scopes** + 1. Click **Create Rule**. + + #### Export authorization server URLs + + 1. Go to **Security > API > Authorization Servers**. + 1. Click the `default` server. + 1. Copy the **Issuer** URI (for example, `https://your-org.okta.com/oauth2/default`). + 1. Export the following environment variables: + + ```sh + export DECK_OKTA_AUTH_SERVER='https://your-org.okta.com/oauth2/default' + export DECK_OKTA_INTROSPECTION_ENDPOINT='https://your-org.okta.com/oauth2/default/v1/introspect' + export DECK_OKTA_TOKEN_ENDPOINT='https://your-org.okta.com/oauth2/default/v1/token' + ``` + + #### Create the web application + + This application is used by {{site.ai_gateway}} for token introspection and token exchange. + + 1. Go to **Applications > Applications > Create App Integration**. + 1. Sign-in method: **OIDC - OpenID Connect** + 1. Application type: **Web Application** + 1. App integration name: `Kong MCP Gateway` + 1. Grant types: check **Client Credentials** and **Authorization Code**. + 1. Set Sign-in redirect URIs to `http://localhost/unused`. {{site.base_gateway}} does not use the redirect flow, but Okta requires the field. + 1. Assignments: **Skip group assignment for now** + 1. Click **Save**. + 1. Copy the **Client ID** and **Client Secret**. + 1. Go to the **Assignments** tab, click **Assign > Assign to People**, and assign your user. + 1. Export the credentials: + + ```sh + export DECK_OKTA_CLIENT_ID='your-kong-web-app-client-id' + export DECK_OKTA_CLIENT_SECRET='your-kong-web-app-client-secret' + ``` + + #### Create the native application + + This application is used by MCP Inspector for the authorization code flow. + + 1. Go to **Applications > Applications > Create App Integration**. + 1. Sign-in method: **OIDC - OpenID Connect** + 1. Application type: **Native Application** + 1. App integration name: `MCP Inspector` + 1. Grant types: check **Authorization Code**. + 1. Sign-in redirect URIs: `http://localhost:6274/oauth/callback/debug` + 1. Go to the **Assignments** tab, click **Assign > Assign to People**, and assign your user. + 1. Click **Save**. + 1. Copy the **Client ID**. No secret is needed for this public client. + + {:.info} + > The **Web Application** credentials go into the AI MCP OAuth2 Plugin config for token introspection and exchange. The **Native Application** Client ID is what you enter in MCP Inspector when connecting to the OAuth-protected MCP endpoint. + icon_url: /assets/icons/okta.svg + - title: MCP Inspector + content: | + This guide uses [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) to test the OAuth-protected MCP endpoint. + + 1. Ensure you have Node.js and npm installed. If needed, download them from https://nodejs.org. + 1. Update `npx` to the latest version: + ```sh + npm install -g npx + ``` + 1. Install the Inspector: + ```sh + npm install -g @modelcontextprotocol/inspector + ``` + icon_url: /assets/icons/mcp.svg + entities: + services: + - mcp-okta-token-exchange-service + routes: + - mcp-okta-token-exchange + +tags: + - ai + - mcp + - oauth2 + - okta + - authentication + - security + +tldr: + q: How do I configure token exchange with the AI MCP OAuth2 plugin and Okta? + a: | + Configure the AI MCP Proxy plugin in passthrough-listener mode to proxy MCP traffic + to an upstream MCP server. Add the AI MCP OAuth2 plugin with token exchange enabled + and Okta as the authorization server. The plugin validates the incoming token, + exchanges it for a new token scoped to the target audience, and forwards the + exchanged token to the upstream. + +cleanup: + inline: + - title: Clean up Konnect environment + include_content: cleanup/platform/konnect + icon_url: /assets/icons/gateway.svg + - title: Destroy the {{site.base_gateway}} container + include_content: cleanup/products/gateway + icon_url: /assets/icons/gateway.svg + +automated_tests: false +--- + +## Configure the AI MCP Proxy plugin in passthrough-listener mode + +Configure the [AI MCP Proxy plugin](/plugins/ai-mcp-proxy/) in `passthrough-listener` mode. This mode proxies incoming MCP requests directly to the upstream MCP server (the marketplace service running on port 3001) while generating observability metrics for the traffic. + +{% entity_examples %} +entities: + plugins: + - name: ai-mcp-proxy + route: mcp-okta-token-exchange + config: + mode: passthrough-listener + max_request_body_size: 1048576 +{% endentity_examples %} + +## Configure the CORS plugin + +Add the [CORS plugin](/plugins/cors/) to the Route so that MCP Inspector's browser-based OAuth callback can reach the MCP endpoint. + +{% entity_examples %} +entities: + plugins: + - name: cors + route: mcp-okta-token-exchange + config: + origins: + - http://localhost:6274 +{% endentity_examples %} + +## Configure the AI MCP OAuth2 plugin with token exchange + +Configure the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) on the same Route. The plugin validates the incoming bearer token via introspection, then exchanges it for a new token at the Okta token endpoint before forwarding the request to the upstream MCP server. + +Token exchange requires `passthrough_credentials` set to `true` so that the exchanged token is forwarded to the upstream. + +{:.info} +> This example sets `insecure_relaxed_audience_validation` to `true` because Okta does not yet include the resource URL in the `aud` claim as defined in [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707). + +{% entity_examples %} +entities: + plugins: + - name: ai-mcp-oauth2 + route: mcp-okta-token-exchange + config: + resource: http://localhost:8000/mcp/okta + metadata_endpoint: /.well-known/oauth-protected-resource/mcp/okta + authorization_servers: + - ${okta_auth_server} + introspection_endpoint: ${okta_introspection_endpoint} + client_id: ${okta_client_id} + client_secret: ${okta_client_secret} + insecure_relaxed_audience_validation: true + passthrough_credentials: true + claim_to_header: + - claim: sub + header: X-User-Id + token_exchange: + enabled: true + token_endpoint: ${okta_token_endpoint} + client_auth: client_secret_post + request: + audience: + - api://mcp-upstream +variables: + okta_auth_server: + value: $OKTA_AUTH_SERVER + okta_introspection_endpoint: + value: $OKTA_INTROSPECTION_ENDPOINT + okta_token_endpoint: + value: $OKTA_TOKEN_ENDPOINT + okta_client_id: + value: $OKTA_CLIENT_ID + okta_client_secret: + value: $OKTA_CLIENT_SECRET +{% endentity_examples %} + +Configuration breakdown: +* `resource`: The identifier for the protected MCP server. Matches the URL that MCP clients use to access it. +* `metadata_endpoint`: The path where the plugin serves OAuth Protected Resource Metadata. Must match one of the paths on the Route so MCP clients can discover the authorization server. +* `authorization_servers` and `introspection_endpoint`: Connect the plugin to Okta for token validation. +* `client_id`, `client_secret`, and `client_auth`: Credentials that {{site.base_gateway}} uses to authenticate with the introspection and token exchange endpoints. +* `passthrough_credentials`: Required for token exchange. Forwards the exchanged token to the upstream MCP server. +* `claim_to_header`: Maps the `sub` claim from the validated token to the `X-User-Id` upstream header. +* `token_exchange.enabled`: Activates token exchange after successful token validation. +* `token_exchange.token_endpoint`: The Okta token endpoint where the exchange request is sent. +* `token_exchange.client_auth: client_secret_post`: Authenticates with Okta using the client credentials in the POST body. +* `token_exchange.request.audience`: The target audience for the exchanged token. Set this to the identifier of the upstream service that will consume the token. + +## Connect with MCP Inspector + +1. Start MCP Inspector: + + ```sh + npx @modelcontextprotocol/inspector@latest --mcp-url http://localhost:8000/mcp/okta + ``` + +1. Open the MCP Inspector UI in your browser at the URL shown in the terminal output. + +1. Set **Transport Type** to **Streamable HTTP**. + +1. Set the URL to `http://localhost:8000/mcp/okta`. + +1. Click **Open Auth Settings**. + +1. Enter the **Native Application** Client ID from the Okta setup (the `MCP Inspector` app, not the `Kong MCP Gateway` app). Leave **Client Secret** empty. + + {:.info} + > Use the Client ID from the **Native Application** (`MCP Inspector`) you created in Okta. Do not use the Web Application Client ID. The Web Application credentials are used by {{site.base_gateway}} for token introspection and exchange, not by MCP clients. + +1. Click **Guided OAuth Flow**. + +1. **Metadata Discovery**: click **Continue**. + +1. **Client Registration**: click **Continue**. + +1. **Preparing Authorization**: click the authorization link. A new browser tab opens with the Okta login page. Sign in with your Okta user credentials. Copy the authorization code from the browser. + +1. **Request Authorization and acquire authorization code**: paste the authorization code and click **Continue**. + +1. **Token Request**: click **Continue**. + +1. **Authentication Complete** shows a green checkmark. + +1. Click **Connect**. MCP Inspector connects to the OAuth-protected MCP endpoint. + +## Validate + +### Verify unauthenticated requests are rejected + +Send a request without a token: + +```sh +curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/mcp/okta \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' +``` + +The response returns `401`, confirming the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) is enforcing authentication. + +### Verify MCP tools via MCP Inspector + +1. In MCP Inspector, go to the **Tools** tab and click **List Tools**. You should see the marketplace tools exposed by the upstream MCP server: + + ```text + list_users + get_user + list_orders + list_orders_for_user + search_orders + ``` + {:.no-copy-code} + +1. Select the **list_users** tool and click **Run Tool**. A successful response with marketplace user data confirms that {{site.base_gateway}} validated the original token, exchanged it at the Okta token endpoint, and forwarded the exchanged token to the upstream MCP server. diff --git a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md new file mode 100644 index 0000000000..66a839af11 --- /dev/null +++ b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md @@ -0,0 +1,239 @@ +--- +title: Configure token exchange with the AI MCP OAuth2 plugin +permalink: /mcp/configure-mcp-oauth2-token-exchange/ +content_type: how_to +description: Learn how to configure token exchange with the AI MCP OAuth2 plugin using Keycloak +breadcrumbs: + - /mcp/ + +related_resources: + - text: "{{site.ai_gateway}}" + url: /ai-gateway/ + - text: AI MCP OAuth2 plugin + url: /plugins/ai-mcp-oauth2/ + - text: Token exchange in the AI MCP OAuth2 plugin + url: /plugins/ai-mcp-oauth2/#token-exchange + - text: AI MCP Proxy plugin + url: /plugins/ai-mcp-proxy/ + - text: OAuth 2.0 specification for MCP + url: https://modelcontextprotocol.io/specification/draft/basic/authorization + +plugins: + - ai-mcp-oauth2 + - ai-mcp-proxy + +entities: + - service + - route + - plugin + +products: + - gateway + - ai-gateway + +works_on: + - on-prem + - konnect + +min_version: + gateway: '3.14' + +tools: + - deck + +prereqs: + inline: + - title: Set up Keycloak with token exchange + include_content: prereqs/auth/mcp-oauth2/keycloak-token-exchange + icon_url: /assets/icons/keycloak.svg + - title: Upstream MCP server + content: | + This guide uses a sample MCP server that exposes marketplace tools (users and orders). + + 1. Clone and start the server: + + ```sh + git clone https://github.com/tomek-labuk/marketplace-acl.git && \ + cd marketplace-acl && \ + npm install && \ + npm run build && \ + node dist/server.js + ``` + + 1. Verify the server is running at `http://localhost:3001/mcp`. + entities: + services: + - mcp-token-exchange-service + routes: + - mcp-token-exchange + +tags: + - ai + - mcp + - oauth2 + - authentication + +tldr: + q: How do I configure token exchange with the AI MCP OAuth2 plugin? + a: | + Configure the AI MCP Proxy plugin in passthrough-listener mode to proxy MCP traffic + to an upstream MCP server. Add the AI MCP OAuth2 plugin with token exchange enabled. + The plugin validates the incoming token, exchanges it for a new token, and forwards + the exchanged token to the upstream. + +cleanup: + inline: + - title: Clean up Konnect environment + include_content: cleanup/platform/konnect + icon_url: /assets/icons/gateway.svg + - title: Destroy the {{site.base_gateway}} container + include_content: cleanup/products/gateway + icon_url: /assets/icons/gateway.svg + +automated_tests: false +--- + +## Configure the AI MCP Proxy plugin in passthrough-listener mode + +Configure the [AI MCP Proxy plugin](/plugins/ai-mcp-proxy/) in `passthrough-listener` mode. This mode proxies incoming MCP requests directly to the upstream MCP server (the marketplace service running on port 3001) while generating observability metrics for the traffic. + +{% entity_examples %} +entities: + plugins: + - name: ai-mcp-proxy + route: mcp-token-exchange + config: + mode: passthrough-listener + max_request_body_size: 1048576 +{% endentity_examples %} + +## Configure the AI MCP OAuth2 plugin with token exchange + +Configure the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) on the same Route. The plugin validates the incoming bearer token via introspection, then exchanges it for a new token at the Keycloak token endpoint before forwarding the request to the upstream MCP server. + +Token exchange requires `passthrough_credentials` set to `true` so that the exchanged token is forwarded to the upstream. + +{:.info} +> This example sets `insecure_relaxed_audience_validation` to `true` because most authorization servers do not yet include the resource URL in the `aud` claim as defined in [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707). + +{% entity_examples %} +entities: + plugins: + - name: ai-mcp-oauth2 + route: mcp-token-exchange + config: + resource: http://localhost:8000/mcp + metadata_endpoint: /.well-known/oauth-protected-resource/mcp + authorization_servers: + - ${keycloak_issuer} + introspection_endpoint: ${keycloak_introspection_url} + client_id: ${mcp_gateway_client_id} + client_secret: ${mcp_gateway_client_secret} + client_auth: client_secret_post + insecure_relaxed_audience_validation: true + passthrough_credentials: true + claim_to_header: + - claim: sub + header: X-User-Id + token_exchange: + enabled: true + token_endpoint: ${keycloak_token_url} + client_auth: inherit +variables: + keycloak_issuer: + value: $KEYCLOAK_ISSUER + keycloak_introspection_url: + value: $KEYCLOAK_INTROSPECTION_URL + keycloak_token_url: + value: $KEYCLOAK_TOKEN_URL + mcp_gateway_client_id: + value: $MCP_GATEWAY_CLIENT_ID + mcp_gateway_client_secret: + value: $MCP_GATEWAY_CLIENT_SECRET +{% endentity_examples %} + +Configuration breakdown: +* `resource`: The identifier for the protected MCP server. Matches the URL that MCP clients use to access it. +* `metadata_endpoint`: The path where the plugin serves OAuth Protected Resource Metadata. Must match one of the paths on the Route so MCP clients can discover the authorization server. +* `authorization_servers` and `introspection_endpoint`: Connect the plugin to Keycloak for token validation. +* `client_id`, `client_secret`, and `client_auth`: Credentials that {{site.base_gateway}} uses to authenticate with the introspection and token exchange endpoints. +* `passthrough_credentials`: Required for token exchange. Forwards the exchanged token to the upstream MCP server. +* `claim_to_header`: Maps the `sub` claim from the validated token to the `X-User-Id` upstream header. +* `token_exchange.enabled`: Activates token exchange after successful token validation. +* `token_exchange.token_endpoint`: The Keycloak token endpoint where the exchange request is sent. +* `token_exchange.client_auth: inherit`: Reuses the `client_id` and `client_secret` configured for introspection. + +{:.info} +> The `token_exchange.request` block also supports `audience` and `scopes` fields for IdPs that honor them (for example, Okta or Azure AD). Keycloak 26 does not support custom audience targets in standard token exchange, so these fields are omitted here. + +## Validate + +### Verify unauthenticated requests are rejected + +Send a request without a token: + +```sh +curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/mcp \ + --json '{"jsonrpc":"2.0","method":"tools/list","id":1}' +``` + +The response returns `401`, confirming the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) is enforcing authentication. + +### Verify authenticated MCP requests succeed + +Obtain a token from Keycloak as the `mcp-client` and list the available MCP tools. Run both commands together because Keycloak access tokens expire in 60 seconds: + +```sh +export TOKEN=$(curl -s -X POST \ + http://localhost:8080/realms/master/protocol/openid-connect/token \ + -d "grant_type=password" \ + -d "client_id=$DECK_MCP_CLIENT_ID" \ + -d "client_secret=$DECK_MCP_CLIENT_SECRET" \ + -d "username=alex" \ + -d "password=doe" \ + -d "scope=openid" | jq -r '.access_token') && \ +curl --no-progress-meter --fail-with-body http://localhost:8000/mcp \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' +``` + +A successful response returns the tools exposed by the upstream MCP server: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "tools": [ + {"name": "list_users", "description": "List all users (id, fullName)."}, + {"name": "get_user", "description": "Get a single user by id."}, + {"name": "list_orders", "description": "List all orders."}, + {"name": "list_orders_for_user", "description": "List orders by userId."}, + {"name": "search_orders", "description": "Search orders by name (case-insensitive substring)."} + ] + } +} +``` +{:.no-copy-code} + +Call a tool to verify the full request chain, including token exchange: + +```sh +export TOKEN=$(curl -s -X POST \ + http://localhost:8080/realms/master/protocol/openid-connect/token \ + -d "grant_type=password" \ + -d "client_id=$DECK_MCP_CLIENT_ID" \ + -d "client_secret=$DECK_MCP_CLIENT_SECRET" \ + -d "username=alex" \ + -d "password=doe" \ + -d "scope=openid" | jq -r '.access_token') && \ +curl --no-progress-meter --fail-with-body http://localhost:8000/mcp \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/json, text/event-stream" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"list_users","arguments":{}},"id":2}' +``` + +A successful response with marketplace data confirms that {{site.base_gateway}} validated the original `mcp-client` token, exchanged it at the Keycloak token endpoint, and forwarded the exchanged token to the upstream MCP server. diff --git a/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange.md b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange.md new file mode 100644 index 0000000000..5c0875f9fd --- /dev/null +++ b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange.md @@ -0,0 +1,142 @@ +This tutorial requires [Keycloak](http://www.keycloak.org/) (version 26 or later) as the authorization server for MCP OAuth2 token exchange. + +#### Install and run Keycloak + +1. Run Keycloak using Docker: + + ```sh + docker run -p 127.0.0.1:8080:8080 \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ + -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ + quay.io/keycloak/keycloak start-dev + ``` + +1. Open the admin console at `http://localhost:8080/admin/master/console/`. + +#### Configure the frontend URL + +{{site.base_gateway}} runs inside Docker and validates the `iss` claim in tokens against the configured `authorization_servers`. Set the Keycloak frontend URL to `http://host.docker.internal:8080` so that the `iss` claim in issued tokens matches the URL that {{site.base_gateway}} uses to reach Keycloak. + +1. Ensure `host.docker.internal` resolves on your host machine. On macOS or Linux, add it to `/etc/hosts` if it is not already present: + + ```sh + sudo sh -c 'echo "127.0.0.1 host.docker.internal" >> /etc/hosts' + ``` + + On Windows, Docker Desktop adds this entry automatically. + +1. In the admin console, open **Realm settings**. +1. In the **General** tab, set **Frontend URL** to `http://host.docker.internal:8080`. +1. Click **Save**. + +#### Create the MCP client + +This client represents the application or agent that requests access to the MCP server. + +1. In the sidebar, open **Clients**, then click **Create client**. +1. Configure the client: + + +{% table %} +columns: + - title: Section + key: section + - title: Settings + key: settings +rows: + - section: "**General settings**" + settings: | + * Client type: **OpenID Connect** + * Client ID: `mcp-client` + - section: "**Capability config**" + settings: | + * Toggle **Client authentication** to **on** + * Make sure that **Direct access grants** is checked. + - section: "**Login settings**" + settings: "**Valid redirect URIs**: `http://localhost:8000/*`" +{% endtable %} + + +#### Create the gateway client + +This client represents {{site.base_gateway}}. It performs token introspection and token exchange. + +1. In the sidebar, open **Clients**, then click **Create client**. +1. Configure the client: + + +{% table %} +columns: + - title: Section + key: section + - title: Settings + key: settings +rows: + - section: "**General settings**" + settings: | + * Client type: **OpenID Connect** + * Client ID: `mcp-gateway` + - section: "**Capability config**" + settings: | + * Toggle **Client authentication** to **on** + * Make sure that **Standard flow** and **Standard Token Exchange** are checked. + - section: "**Login settings**" + settings: "**Valid redirect URIs**: `http://localhost:8000/*`" +{% endtable %} + + +#### Add an audience mapper to the MCP client + +Add a protocol mapper to `mcp-client` so that tokens it obtains include `mcp-gateway` in the `aud` claim. Keycloak requires the exchanging client to be present in the subject token's audience. Without this mapper, the token exchange request fails with "Client is not within the token audience". + +1. In the sidebar, open **Clients** and select `mcp-client`. +1. Open the **Client scopes** tab. +1. Click the `mcp-client-dedicated` scope. +1. Click **Configure a new mapper** and select **Audience**. +1. Configure the mapper: + + +{% table %} +columns: + - title: Field + key: field + - title: Value + key: value +rows: + - field: "**Name**" + value: "`add-mcp-gateway-audience`" + - field: "**Included Client Audience**" + value: "`mcp-gateway`" + - field: "**Add to access token**" + value: "**on**" +{% endtable %} + + +#### Create a test user + +1. In the sidebar, open **Users**, then click **Add user**. +1. Set the username to `alex`. +1. Click **Create**. +1. Open the **Credentials** tab and click **Set password**. +1. Set the password to `doe` and disable **Temporary Password**. + +#### Export environment variables + +1. In the sidebar, open **Clients** and select `mcp-client`. Open the **Credentials** tab and copy the client secret. +1. Export the following environment variables: + + ```sh + export DECK_MCP_CLIENT_ID='mcp-client' + export DECK_MCP_CLIENT_SECRET='' + ``` + +1. In the sidebar, open **Clients** and select `mcp-gateway`. Open the **Credentials** tab and copy the client secret. +1. Export the following environment variables: + + ```sh + export DECK_MCP_GATEWAY_CLIENT_ID='mcp-gateway' + export DECK_MCP_GATEWAY_CLIENT_SECRET='' + export DECK_KEYCLOAK_ISSUER='http://host.docker.internal:8080/realms/master' + export DECK_KEYCLOAK_INTROSPECTION_URL='http://host.docker.internal:8080/realms/master/protocol/openid-connect/token/introspect' + export DECK_KEYCLOAK_TOKEN_URL='http://host.docker.internal:8080/realms/master/protocol/openid-connect/token' + ``` From 7cbafb86385072079533f192d79cb1501c3377c2 Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Mon, 13 Apr 2026 13:46:04 +0200 Subject: [PATCH 02/15] Add draft how-to --- .../routes/weather-token-exchange-route.yaml | 8 + .../routes/weather-token-exchange.yaml | 9 + .../weather-token-exchange-service.yaml | 2 + .../configure-mcp-oauth2-token-exchange.md | 256 ++++++++++-------- .../keycloak-token-exchange-weather.md | 140 ++++++++++ app/_includes/prereqs/weatherapi.md | 8 + 6 files changed, 317 insertions(+), 106 deletions(-) create mode 100644 app/_data/entity_examples/gateway/routes/weather-token-exchange-route.yaml create mode 100644 app/_data/entity_examples/gateway/routes/weather-token-exchange.yaml create mode 100644 app/_data/entity_examples/gateway/services/weather-token-exchange-service.yaml create mode 100644 app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md create mode 100644 app/_includes/prereqs/weatherapi.md diff --git a/app/_data/entity_examples/gateway/routes/weather-token-exchange-route.yaml b/app/_data/entity_examples/gateway/routes/weather-token-exchange-route.yaml new file mode 100644 index 0000000000..8cdc4d0157 --- /dev/null +++ b/app/_data/entity_examples/gateway/routes/weather-token-exchange-route.yaml @@ -0,0 +1,8 @@ +name: weather-token-exchange-route +paths: + - /api/weather-exchange +service: + name: weather-token-exchange-service +protocols: + - http + - https diff --git a/app/_data/entity_examples/gateway/routes/weather-token-exchange.yaml b/app/_data/entity_examples/gateway/routes/weather-token-exchange.yaml new file mode 100644 index 0000000000..76feb092c3 --- /dev/null +++ b/app/_data/entity_examples/gateway/routes/weather-token-exchange.yaml @@ -0,0 +1,9 @@ +name: weather-token-exchange +paths: + - /weather/mcp-exchange + - /.well-known/oauth-protected-resource/weather/mcp-exchange +service: + name: weather-token-exchange-service +protocols: + - http + - https diff --git a/app/_data/entity_examples/gateway/services/weather-token-exchange-service.yaml b/app/_data/entity_examples/gateway/services/weather-token-exchange-service.yaml new file mode 100644 index 0000000000..d12e8f08bf --- /dev/null +++ b/app/_data/entity_examples/gateway/services/weather-token-exchange-service.yaml @@ -0,0 +1,2 @@ +name: weather-token-exchange-service +url: https://api.weatherapi.com/v1 diff --git a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md index 66a839af11..9f66cd16fb 100644 --- a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md +++ b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md @@ -2,7 +2,7 @@ title: Configure token exchange with the AI MCP OAuth2 plugin permalink: /mcp/configure-mcp-oauth2-token-exchange/ content_type: how_to -description: Learn how to configure token exchange with the AI MCP OAuth2 plugin using Keycloak +description: Learn how to configure token exchange with the AI MCP OAuth2 plugin using Keycloak and WeatherAPI breadcrumbs: - /mcp/ @@ -43,29 +43,18 @@ tools: prereqs: inline: - - title: Set up Keycloak with token exchange - include_content: prereqs/auth/mcp-oauth2/keycloak-token-exchange + - title: Set up isolated Keycloak token exchange + include_content: prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather icon_url: /assets/icons/keycloak.svg - - title: Upstream MCP server - content: | - This guide uses a sample MCP server that exposes marketplace tools (users and orders). - - 1. Clone and start the server: - - ```sh - git clone https://github.com/tomek-labuk/marketplace-acl.git && \ - cd marketplace-acl && \ - npm install && \ - npm run build && \ - node dist/server.js - ``` - - 1. Verify the server is running at `http://localhost:3001/mcp`. + - title: WeatherAPI + icon_url: /assets/icons/gateway.svg + include_content: prereqs/weatherapi entities: services: - - mcp-token-exchange-service + - weather-token-exchange-service routes: - - mcp-token-exchange + - weather-token-exchange-route + - weather-token-exchange tags: - ai @@ -76,10 +65,10 @@ tags: tldr: q: How do I configure token exchange with the AI MCP OAuth2 plugin? a: | - Configure the AI MCP Proxy plugin in passthrough-listener mode to proxy MCP traffic - to an upstream MCP server. Add the AI MCP OAuth2 plugin with token exchange enabled. - The plugin validates the incoming token, exchanges it for a new token, and forwards - the exchanged token to the upstream. + Configure the AI MCP Proxy plugin in conversion-only and listener modes to expose + WeatherAPI as MCP tools on dedicated routes. Add the AI MCP OAuth2 plugin with token + exchange enabled on the listener route. This setup uses separate routes, resource + metadata, and Keycloak clients so it can coexist with a JWK-based MCP configuration. cleanup: inline: @@ -93,78 +82,120 @@ cleanup: automated_tests: false --- -## Configure the AI MCP Proxy plugin in passthrough-listener mode +This guide is intentionally isolated from the JWK validation flow. It uses a dedicated WeatherAPI service, separate MCP routes, a different protected resource URL, and separate Keycloak clients and environment variables. -Configure the [AI MCP Proxy plugin](/plugins/ai-mcp-proxy/) in `passthrough-listener` mode. This mode proxies incoming MCP requests directly to the upstream MCP server (the marketplace service running on port 3001) while generating observability metrics for the traffic. +## Configure the AI MCP Proxy tools + +Configure the [AI MCP Proxy plugin](/plugins/ai-mcp-proxy/) in `conversion-only` mode on the `weather-token-exchange-route` Route. This instance converts WeatherAPI REST endpoints into MCP tool definitions. The `weather-token-exchange-tools` tag lets the listener instance discover and aggregate these tools. {% entity_examples %} entities: plugins: - name: ai-mcp-proxy - route: mcp-token-exchange + route: weather-token-exchange-route + tags: + - weather-token-exchange-tools + - token-exchange config: - mode: passthrough-listener - max_request_body_size: 1048576 + mode: conversion-only + tools: + - annotations: + title: Realtime API + description: Returns current weather data as a JSON object for a given location. + method: GET + path: current.json + query: + key: + - ${weatherapi_key} + parameters: + - name: q + in: query + description: Pass US Zipcode, UK Postcode, Canada Postalcode, IP address, Latitude/Longitude (decimal degree) or city name. + required: true + type: string +variables: + weatherapi_key: + value: $WEATHERAPI_API_KEY +{% endentity_examples %} + +## Configure the AI MCP Proxy listener + +Configure a second [AI MCP Proxy plugin](/plugins/ai-mcp-proxy/) instance in `listener` mode on the `weather-token-exchange` Route. This instance aggregates tools tagged `weather-token-exchange-tools` and serves them over the MCP protocol. + +{% entity_examples %} +entities: + plugins: + - name: ai-mcp-proxy + route: weather-token-exchange + tags: + - token-exchange + config: + mode: listener + server: + tag: weather-token-exchange-tools + timeout: 45000 + logging: + log_statistics: true + log_payloads: false + max_request_body_size: 32768 {% endentity_examples %} ## Configure the AI MCP OAuth2 plugin with token exchange -Configure the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) on the same Route. The plugin validates the incoming bearer token via introspection, then exchanges it for a new token at the Keycloak token endpoint before forwarding the request to the upstream MCP server. +Configure the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) on the same `weather-token-exchange` Route. The plugin validates the incoming bearer token via introspection, then exchanges it for a new token at the Keycloak token endpoint before forwarding the request to the upstream WeatherAPI call generated by the MCP listener. -Token exchange requires `passthrough_credentials` set to `true` so that the exchanged token is forwarded to the upstream. +Token exchange requires `passthrough_credentials` set to `true` so that the exchanged token is forwarded to the upstream request. {:.info} -> This example sets `insecure_relaxed_audience_validation` to `true` because most authorization servers do not yet include the resource URL in the `aud` claim as defined in [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707). +> This example sets `insecure_relaxed_audience_validation` to `true` because the exchanged-token flow in this guide relies on a dedicated Keycloak audience mapper for the gateway client, not on the MCP resource URL being present in the incoming token's `aud` claim. {% entity_examples %} entities: plugins: - name: ai-mcp-oauth2 - route: mcp-token-exchange + route: weather-token-exchange + tags: + - token-exchange config: - resource: http://localhost:8000/mcp - metadata_endpoint: /.well-known/oauth-protected-resource/mcp + resource: http://localhost:8000/weather/mcp-exchange + metadata_endpoint: /.well-known/oauth-protected-resource/weather/mcp-exchange authorization_servers: - ${keycloak_issuer} introspection_endpoint: ${keycloak_introspection_url} - client_id: ${mcp_gateway_client_id} - client_secret: ${mcp_gateway_client_secret} + client_id: ${weather_exchange_gateway_client_id} + client_secret: ${weather_exchange_gateway_client_secret} client_auth: client_secret_post insecure_relaxed_audience_validation: true passthrough_credentials: true - claim_to_header: - - claim: sub - header: X-User-Id token_exchange: enabled: true token_endpoint: ${keycloak_token_url} client_auth: inherit variables: keycloak_issuer: - value: $KEYCLOAK_ISSUER + value: $WEATHER_EXCHANGE_KEYCLOAK_ISSUER keycloak_introspection_url: - value: $KEYCLOAK_INTROSPECTION_URL + value: $WEATHER_EXCHANGE_KEYCLOAK_INTROSPECTION_URL keycloak_token_url: - value: $KEYCLOAK_TOKEN_URL - mcp_gateway_client_id: - value: $MCP_GATEWAY_CLIENT_ID - mcp_gateway_client_secret: - value: $MCP_GATEWAY_CLIENT_SECRET + value: $WEATHER_EXCHANGE_KEYCLOAK_TOKEN_URL + weather_exchange_gateway_client_id: + value: $WEATHER_EXCHANGE_GATEWAY_CLIENT_ID + weather_exchange_gateway_client_secret: + value: $WEATHER_EXCHANGE_GATEWAY_CLIENT_SECRET {% endentity_examples %} Configuration breakdown: -* `resource`: The identifier for the protected MCP server. Matches the URL that MCP clients use to access it. -* `metadata_endpoint`: The path where the plugin serves OAuth Protected Resource Metadata. Must match one of the paths on the Route so MCP clients can discover the authorization server. -* `authorization_servers` and `introspection_endpoint`: Connect the plugin to Keycloak for token validation. +* `resource`: The identifier for the protected MCP server. This guide uses `http://localhost:8000/weather/mcp-exchange` so it does not collide with the JWK guide's resource. +* `metadata_endpoint`: The path where the plugin serves OAuth Protected Resource Metadata. It must match one of the paths on the Route so MCP clients can discover the authorization server. +* `authorization_servers` and `introspection_endpoint`: Connect the plugin to the dedicated Keycloak realm for token validation. * `client_id`, `client_secret`, and `client_auth`: Credentials that {{site.base_gateway}} uses to authenticate with the introspection and token exchange endpoints. -* `passthrough_credentials`: Required for token exchange. Forwards the exchanged token to the upstream MCP server. -* `claim_to_header`: Maps the `sub` claim from the validated token to the `X-User-Id` upstream header. +* `passthrough_credentials`: Required for token exchange. Forwards the exchanged token to the upstream WeatherAPI request. * `token_exchange.enabled`: Activates token exchange after successful token validation. * `token_exchange.token_endpoint`: The Keycloak token endpoint where the exchange request is sent. * `token_exchange.client_auth: inherit`: Reuses the `client_id` and `client_secret` configured for introspection. {:.info} -> The `token_exchange.request` block also supports `audience` and `scopes` fields for IdPs that honor them (for example, Okta or Azure AD). Keycloak 26 does not support custom audience targets in standard token exchange, so these fields are omitted here. +> WeatherAPI ignores the forwarded bearer token. This guide isolates the Kong and Keycloak configuration so it can coexist with other MCP auth examples. If you need to inspect the exchanged token at the upstream, use an upstream that validates or echoes the `Authorization` header. ## Validate @@ -172,68 +203,81 @@ Configuration breakdown: Send a request without a token: -```sh -curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/mcp \ - --json '{"jsonrpc":"2.0","method":"tools/list","id":1}' -``` + +{% validation request-check %} +url: /weather/mcp-exchange +status_code: 401 +method: POST +headers: + - 'Content-Type: application/json' +body: + jsonrpc: "2.0" + method: tools/list + id: 1 + params: {} +{% endvalidation %} + The response returns `401`, confirming the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) is enforcing authentication. -### Verify authenticated MCP requests succeed +### Obtain a token from the dedicated Keycloak realm -Obtain a token from Keycloak as the `mcp-client` and list the available MCP tools. Run both commands together because Keycloak access tokens expire in 60 seconds: +Obtain a token from Keycloak as `weather-exchange-client`. Run the validation requests soon after issuing the token because short-lived access tokens are common in token exchange setups: ```sh -export TOKEN=$(curl -s -X POST \ - http://localhost:8080/realms/master/protocol/openid-connect/token \ +export WEATHER_EXCHANGE_TOKEN=$(curl -s -X POST \ + http://localhost:8080/realms/weather-exchange/protocol/openid-connect/token \ -d "grant_type=password" \ - -d "client_id=$DECK_MCP_CLIENT_ID" \ - -d "client_secret=$DECK_MCP_CLIENT_SECRET" \ + -d "client_id=$DECK_WEATHER_EXCHANGE_CLIENT_ID" \ + -d "client_secret=$DECK_WEATHER_EXCHANGE_CLIENT_SECRET" \ -d "username=alex" \ -d "password=doe" \ - -d "scope=openid" | jq -r '.access_token') && \ -curl --no-progress-meter --fail-with-body http://localhost:8000/mcp \ - -H "Authorization: Bearer $TOKEN" \ - -H "Accept: application/json, text/event-stream" \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' + -d "scope=openid" | jq -r '.access_token') ``` -A successful response returns the tools exposed by the upstream MCP server: - -```json -{ - "jsonrpc": "2.0", - "id": 1, - "result": { - "tools": [ - {"name": "list_users", "description": "List all users (id, fullName)."}, - {"name": "get_user", "description": "Get a single user by id."}, - {"name": "list_orders", "description": "List all orders."}, - {"name": "list_orders_for_user", "description": "List orders by userId."}, - {"name": "search_orders", "description": "Search orders by name (case-insensitive substring)."} - ] - } -} -``` -{:.no-copy-code} - -Call a tool to verify the full request chain, including token exchange: - -```sh -export TOKEN=$(curl -s -X POST \ - http://localhost:8080/realms/master/protocol/openid-connect/token \ - -d "grant_type=password" \ - -d "client_id=$DECK_MCP_CLIENT_ID" \ - -d "client_secret=$DECK_MCP_CLIENT_SECRET" \ - -d "username=alex" \ - -d "password=doe" \ - -d "scope=openid" | jq -r '.access_token') && \ -curl --no-progress-meter --fail-with-body http://localhost:8000/mcp \ - -H "Authorization: Bearer $TOKEN" \ - -H "Accept: application/json, text/event-stream" \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"list_users","arguments":{}},"id":2}' -``` +### Verify authenticated MCP requests succeed -A successful response with marketplace data confirms that {{site.base_gateway}} validated the original `mcp-client` token, exchanged it at the Keycloak token endpoint, and forwarded the exchanged token to the upstream MCP server. +List the available MCP tools: + + +{% validation request-check %} +url: /weather/mcp-exchange +status_code: 200 +method: POST +headers: + - 'Accept: application/json, text/event-stream' + - 'Content-Type: application/json' + - 'Authorization: Bearer $WEATHER_EXCHANGE_TOKEN' +body: + jsonrpc: "2.0" + method: tools/list + id: 1 + params: {} +{% endvalidation %} + + +A successful response returns the available WeatherAPI-backed MCP tools. + +Call the weather tool to verify the full request path through Kong: + + +{% validation request-check %} +url: /weather/mcp-exchange +status_code: 200 +method: POST +headers: + - 'Accept: application/json, text/event-stream' + - 'Content-Type: application/json' + - 'Authorization: Bearer $WEATHER_EXCHANGE_TOKEN' +body: + jsonrpc: "2.0" + method: tools/call + id: 2 + params: + name: realtime-api + arguments: + query_q: London +{% endvalidation %} + + +A successful response with weather data confirms that {{site.base_gateway}} validated the original token, completed token exchange with Keycloak, and served the request on the isolated WeatherAPI-backed MCP route. diff --git a/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md new file mode 100644 index 0000000000..449d0150b3 --- /dev/null +++ b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md @@ -0,0 +1,140 @@ +This tutorial requires [Keycloak](http://www.keycloak.org/) (version 26 or later) as the authorization server for MCP OAuth2 token exchange. + +This setup is intentionally separate from the JWK validation guide. It uses a dedicated realm and separate clients so it doesn't interfere with any existing MCP OAuth2 configuration. + +#### Install and run Keycloak + +1. Make sure the Docker network used by Kong Gateway exists: + + ```sh + docker network create kong-quickstart-net + ``` + +1. Run Keycloak using Docker on the same network as Kong Gateway: + + ```sh + docker run -p 127.0.0.1:8080:8080 \ + --name keycloak \ + --network kong-quickstart-net \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ + -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ + -e KC_HOSTNAME=http://localhost:8080 \ + quay.io/keycloak/keycloak start-dev + ``` + +1. Open the admin console at `http://localhost:8080/admin/master/console/`. + +#### Create the isolated realm + +1. In the top-left realm menu, click **Create realm**. +1. Set the realm name to `weather-exchange`. +1. Click **Create**. + +#### Create the MCP client + +This client represents the application or agent that requests access to the MCP server. + +1. In the `weather-exchange` realm sidebar, open **Clients**, then click **Create client**. +1. Configure the client: + + +{% table %} +columns: + - title: Section + key: section + - title: Settings + key: settings +rows: + - section: "**General settings**" + settings: | + * Client type: **OpenID Connect** + * Client ID: `weather-exchange-client` + - section: "**Capability config**" + settings: | + * Toggle **Client authentication** to **on** + * Make sure that **Direct access grants** is checked. +{% endtable %} + + +#### Create the gateway client + +This client represents {{site.base_gateway}}. It performs token introspection and token exchange. + +1. In the `weather-exchange` realm sidebar, open **Clients**, then click **Create client**. +1. Configure the client: + + +{% table %} +columns: + - title: Section + key: section + - title: Settings + key: settings +rows: + - section: "**General settings**" + settings: | + * Client type: **OpenID Connect** + * Client ID: `weather-exchange-gateway` + - section: "**Capability config**" + settings: | + * Toggle **Client authentication** to **on** + * Make sure that **Standard Token Exchange** is checked. +{% endtable %} + + +#### Add an audience mapper to the MCP client + +Add a protocol mapper to `weather-exchange-client` so that tokens it obtains include `weather-exchange-gateway` in the `aud` claim. Keycloak requires the exchanging client to be present in the subject token's audience. Without this mapper, the token exchange request fails with "Client is not within the token audience". + +1. In the sidebar, open **Clients** and select `weather-exchange-client`. +1. Open the **Client scopes** tab. +1. Click the `weather-exchange-client-dedicated` scope. +1. Click **Configure a new mapper** and select **Audience**. +1. Configure the mapper: + + +{% table %} +columns: + - title: Field + key: field + - title: Value + key: value +rows: + - field: "**Name**" + value: "`add-weather-exchange-gateway-audience`" + - field: "**Included Client Audience**" + value: "`weather-exchange-gateway`" + - field: "**Add to access token**" + value: "**on**" +{% endtable %} + + +#### Create a test user + +1. In the sidebar, open **Users**, then click **Add user**. +1. Set the username to `alex`. +1. Click **Create**. +1. Open the **Credentials** tab and click **Set password**. +1. Set the password to `doe` and disable **Temporary Password**. + +#### Export environment variables + +1. In the sidebar, open **Clients** and select `weather-exchange-client`. Open the **Credentials** tab and copy the client secret. +1. Export the following environment variables: + + ```sh + export DECK_WEATHER_EXCHANGE_CLIENT_ID='weather-exchange-client' + export DECK_WEATHER_EXCHANGE_CLIENT_SECRET='' + ``` + +1. In the sidebar, open **Clients** and select `weather-exchange-gateway`. Open the **Credentials** tab and copy the client secret. +1. Export the following environment variables: + + ```sh + export DECK_WEATHER_EXCHANGE_GATEWAY_CLIENT_ID='weather-exchange-gateway' + export DECK_WEATHER_EXCHANGE_GATEWAY_CLIENT_SECRET='' + export DECK_WEATHER_EXCHANGE_KEYCLOAK_ISSUER='http://localhost:8080/realms/weather-exchange' + export DECK_WEATHER_EXCHANGE_KEYCLOAK_INTROSPECTION_URL='http://keycloak:8080/realms/weather-exchange/protocol/openid-connect/token/introspect' + export DECK_WEATHER_EXCHANGE_KEYCLOAK_TOKEN_URL='http://keycloak:8080/realms/weather-exchange/protocol/openid-connect/token' + export KEYCLOAK_HOST='localhost' + ``` diff --git a/app/_includes/prereqs/weatherapi.md b/app/_includes/prereqs/weatherapi.md new file mode 100644 index 0000000000..b6301da326 --- /dev/null +++ b/app/_includes/prereqs/weatherapi.md @@ -0,0 +1,8 @@ +1. Go to [WeatherAPI](https://www.weatherapi.com/). +1. Sign up for a free account. +1. Navigate to [your dashboard](https://www.weatherapi.com/my/) and copy your API key. +1. Export your API key: + + ```sh + export DECK_WEATHERAPI_API_KEY='your-weatherapi-api-key' + ``` From eaf4568a440da568b74ef916cc19cba33eab93fd Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Tue, 14 Apr 2026 09:43:24 +0200 Subject: [PATCH 03/15] Add working how-to with refactored upstream mcp --- .../routes/mcp-token-exchange-isolated.yaml | 9 + .../routes/weather-token-exchange-route.yaml | 8 - .../routes/weather-token-exchange.yaml | 9 - .../mcp-token-exchange-isolated-service.yaml | 2 + .../weather-token-exchange-service.yaml | 2 - .../configure-mcp-oauth2-token-exchange.md | 352 ++++++++++++------ .../keycloak-token-exchange-weather.md | 48 +-- 7 files changed, 286 insertions(+), 144 deletions(-) create mode 100644 app/_data/entity_examples/gateway/routes/mcp-token-exchange-isolated.yaml delete mode 100644 app/_data/entity_examples/gateway/routes/weather-token-exchange-route.yaml delete mode 100644 app/_data/entity_examples/gateway/routes/weather-token-exchange.yaml create mode 100644 app/_data/entity_examples/gateway/services/mcp-token-exchange-isolated-service.yaml delete mode 100644 app/_data/entity_examples/gateway/services/weather-token-exchange-service.yaml diff --git a/app/_data/entity_examples/gateway/routes/mcp-token-exchange-isolated.yaml b/app/_data/entity_examples/gateway/routes/mcp-token-exchange-isolated.yaml new file mode 100644 index 0000000000..04d74fcf75 --- /dev/null +++ b/app/_data/entity_examples/gateway/routes/mcp-token-exchange-isolated.yaml @@ -0,0 +1,9 @@ +name: mcp-token-exchange-isolated +paths: + - /mcp-exchange + - /.well-known/oauth-protected-resource/mcp-exchange +service: + name: mcp-token-exchange-isolated-service +protocols: + - http + - https diff --git a/app/_data/entity_examples/gateway/routes/weather-token-exchange-route.yaml b/app/_data/entity_examples/gateway/routes/weather-token-exchange-route.yaml deleted file mode 100644 index 8cdc4d0157..0000000000 --- a/app/_data/entity_examples/gateway/routes/weather-token-exchange-route.yaml +++ /dev/null @@ -1,8 +0,0 @@ -name: weather-token-exchange-route -paths: - - /api/weather-exchange -service: - name: weather-token-exchange-service -protocols: - - http - - https diff --git a/app/_data/entity_examples/gateway/routes/weather-token-exchange.yaml b/app/_data/entity_examples/gateway/routes/weather-token-exchange.yaml deleted file mode 100644 index 76feb092c3..0000000000 --- a/app/_data/entity_examples/gateway/routes/weather-token-exchange.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: weather-token-exchange -paths: - - /weather/mcp-exchange - - /.well-known/oauth-protected-resource/weather/mcp-exchange -service: - name: weather-token-exchange-service -protocols: - - http - - https diff --git a/app/_data/entity_examples/gateway/services/mcp-token-exchange-isolated-service.yaml b/app/_data/entity_examples/gateway/services/mcp-token-exchange-isolated-service.yaml new file mode 100644 index 0000000000..1af59576cc --- /dev/null +++ b/app/_data/entity_examples/gateway/services/mcp-token-exchange-isolated-service.yaml @@ -0,0 +1,2 @@ +name: mcp-token-exchange-isolated-service +url: http://host.docker.internal:3002/mcp diff --git a/app/_data/entity_examples/gateway/services/weather-token-exchange-service.yaml b/app/_data/entity_examples/gateway/services/weather-token-exchange-service.yaml deleted file mode 100644 index d12e8f08bf..0000000000 --- a/app/_data/entity_examples/gateway/services/weather-token-exchange-service.yaml +++ /dev/null @@ -1,2 +0,0 @@ -name: weather-token-exchange-service -url: https://api.weatherapi.com/v1 diff --git a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md index 9f66cd16fb..884a2f6aa3 100644 --- a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md +++ b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md @@ -2,7 +2,7 @@ title: Configure token exchange with the AI MCP OAuth2 plugin permalink: /mcp/configure-mcp-oauth2-token-exchange/ content_type: how_to -description: Learn how to configure token exchange with the AI MCP OAuth2 plugin using Keycloak and WeatherAPI +description: Configure token exchange with the AI MCP OAuth2 plugin using Keycloak and an upstream MCP server breadcrumbs: - /mcp/ @@ -46,15 +46,194 @@ prereqs: - title: Set up isolated Keycloak token exchange include_content: prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather icon_url: /assets/icons/keycloak.svg - - title: WeatherAPI - icon_url: /assets/icons/gateway.svg - include_content: prereqs/weatherapi + - title: Upstream MCP server + content: | + This guide uses a small MCP debug server that rejects the original client token and only accepts the exchanged token forwarded by {{site.ai_gateway}}. This makes the token exchange observable during validation. + + 1. Create the server: + + ```sh + cat > token-exchange-mcp-server.py <<'EOF' + #!/usr/bin/env python3 + import base64 + import json + import os + from http.server import BaseHTTPRequestHandler, HTTPServer + + + HOST = os.environ.get("TOKEN_EXCHANGE_MCP_HOST", "0.0.0.0") + PORT = int(os.environ.get("TOKEN_EXCHANGE_MCP_PORT", "3002")) + EXPECTED_AZP = os.environ.get("TOKEN_EXCHANGE_EXPECTED_AZP", "token-exchange-gateway") + + USERS = [ + {"id": "a1b2c3d4", "fullName": "Alice Johnson"}, + {"id": "e5f6g7h8", "fullName": "Bob Smith"}, + ] + + + def _json_response(handler, status, payload): + body = json.dumps(payload).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + + def _decode_jwt_payload(token): + parts = token.split(".") + if len(parts) != 3: + raise ValueError("invalid JWT format") + + payload = parts[1] + payload += "=" * (-len(payload) % 4) + decoded = base64.urlsafe_b64decode(payload.encode("ascii")) + return json.loads(decoded.decode("utf-8")) + + + def _extract_claims(handler): + authorization = handler.headers.get("Authorization", "") + if not authorization.startswith("Bearer "): + raise PermissionError("missing bearer token") + + token = authorization.split(" ", 1)[1].strip() + claims = _decode_jwt_payload(token) + if claims.get("azp") != EXPECTED_AZP: + raise PermissionError( + f"unexpected azp '{claims.get('azp')}', expected '{EXPECTED_AZP}'" + ) + return claims + + + def _mcp_result(request_id, structured_content): + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps(structured_content, indent=2)}], + "structuredContent": structured_content, + }, + } + + + class Handler(BaseHTTPRequestHandler): + server_version = "token-exchange-mcp/1.0" + + def do_POST(self): + if self.path != "/mcp": + _json_response(self, 404, {"error": "not found"}) + return + + length = int(self.headers.get("Content-Length", "0")) + raw_body = self.rfile.read(length) + + try: + claims = _extract_claims(self) + request = json.loads(raw_body.decode("utf-8")) + except PermissionError as exc: + _json_response(self, 403, {"error": str(exc)}) + return + except Exception as exc: + _json_response(self, 400, {"error": str(exc)}) + return + + method = request.get("method") + request_id = request.get("id") + params = request.get("params") or {} + + if method == "tools/list": + _json_response( + self, + 200, + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "tools": [ + { + "name": "list_users", + "description": "List sample users.", + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + { + "name": "show_auth_context", + "description": "Return selected claims from the upstream bearer token.", + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + ] + }, + }, + ) + return + + if method == "tools/call": + tool_name = params.get("name") + if tool_name == "list_users": + _json_response(self, 200, _mcp_result(request_id, {"users": USERS})) + return + if tool_name == "show_auth_context": + aud = claims.get("aud") + if isinstance(aud, str): + aud = [aud] + _json_response( + self, + 200, + _mcp_result( + request_id, + { + "iss": claims.get("iss"), + "azp": claims.get("azp"), + "aud": aud or [], + "sub": claims.get("sub"), + }, + ), + ) + return + + _json_response( + self, + 200, + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"unknown tool '{tool_name}'"}, + }, + ) + return + + _json_response( + self, + 200, + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"unsupported method '{method}'"}, + }, + ) + + def log_message(self, format, *args): + return + + + if __name__ == "__main__": + httpd = HTTPServer((HOST, PORT), Handler) + print(f"Token-exchange MCP server listening at http://{HOST}:{PORT}/mcp") + httpd.serve_forever() + EOF + ``` + + 1. Start the server in a separate terminal: + + ```sh + python3 token-exchange-mcp-server.py + ``` + + 1. Verify the server is running at `http://localhost:3002/mcp`. entities: services: - - weather-token-exchange-service + - mcp-token-exchange-isolated-service routes: - - weather-token-exchange-route - - weather-token-exchange + - mcp-token-exchange-isolated tags: - ai @@ -65,86 +244,46 @@ tags: tldr: q: How do I configure token exchange with the AI MCP OAuth2 plugin? a: | - Configure the AI MCP Proxy plugin in conversion-only and listener modes to expose - WeatherAPI as MCP tools on dedicated routes. Add the AI MCP OAuth2 plugin with token - exchange enabled on the listener route. This setup uses separate routes, resource - metadata, and Keycloak clients so it can coexist with a JWK-based MCP configuration. + Configure the AI MCP Proxy plugin in passthrough-listener mode on a dedicated MCP route. + Add the AI MCP OAuth2 plugin with token exchange enabled. This setup uses separate + routes, resource metadata, and Keycloak clients so it can coexist with a JWK-based + MCP configuration while still validating a real token-exchange flow. cleanup: inline: - title: Clean up Konnect environment include_content: cleanup/platform/konnect icon_url: /assets/icons/gateway.svg - - title: Destroy the {{site.base_gateway}} container + - title: Destroy the {{site.ai_gateway}} container include_content: cleanup/products/gateway icon_url: /assets/icons/gateway.svg automated_tests: false --- -This guide is intentionally isolated from the JWK validation flow. It uses a dedicated WeatherAPI service, separate MCP routes, a different protected resource URL, and separate Keycloak clients and environment variables. +This guide is intentionally isolated from the JWK validation flow. It uses a dedicated upstream MCP service, a separate MCP route, a different protected resource URL, and separate Keycloak clients and environment variables. -## Configure the AI MCP Proxy tools +## Configure the AI MCP Proxy plugin in passthrough-listener mode -Configure the [AI MCP Proxy plugin](/plugins/ai-mcp-proxy/) in `conversion-only` mode on the `weather-token-exchange-route` Route. This instance converts WeatherAPI REST endpoints into MCP tool definitions. The `weather-token-exchange-tools` tag lets the listener instance discover and aggregate these tools. +Configure the [AI MCP Proxy plugin](/plugins/ai-mcp-proxy/) in `passthrough-listener` mode on the `mcp-token-exchange-isolated` Route. This mode proxies incoming MCP requests directly to the upstream MCP server while preserving the exchanged bearer token on the upstream request. {% entity_examples %} entities: plugins: - name: ai-mcp-proxy - route: weather-token-exchange-route + route: mcp-token-exchange-isolated tags: - - weather-token-exchange-tools - token-exchange config: - mode: conversion-only - tools: - - annotations: - title: Realtime API - description: Returns current weather data as a JSON object for a given location. - method: GET - path: current.json - query: - key: - - ${weatherapi_key} - parameters: - - name: q - in: query - description: Pass US Zipcode, UK Postcode, Canada Postalcode, IP address, Latitude/Longitude (decimal degree) or city name. - required: true - type: string -variables: - weatherapi_key: - value: $WEATHERAPI_API_KEY -{% endentity_examples %} - -## Configure the AI MCP Proxy listener - -Configure a second [AI MCP Proxy plugin](/plugins/ai-mcp-proxy/) instance in `listener` mode on the `weather-token-exchange` Route. This instance aggregates tools tagged `weather-token-exchange-tools` and serves them over the MCP protocol. - -{% entity_examples %} -entities: - plugins: - - name: ai-mcp-proxy - route: weather-token-exchange - tags: - - token-exchange - config: - mode: listener - server: - tag: weather-token-exchange-tools - timeout: 45000 - logging: - log_statistics: true - log_payloads: false - max_request_body_size: 32768 + mode: passthrough-listener + max_request_body_size: 1048576 {% endentity_examples %} ## Configure the AI MCP OAuth2 plugin with token exchange -Configure the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) on the same `weather-token-exchange` Route. The plugin validates the incoming bearer token via introspection, then exchanges it for a new token at the Keycloak token endpoint before forwarding the request to the upstream WeatherAPI call generated by the MCP listener. +Configure the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) on the same `mcp-token-exchange-isolated` Route. The plugin validates the incoming bearer token via introspection, then exchanges it for a new token at the Keycloak token endpoint before forwarding the request to the upstream MCP server. -Token exchange requires `passthrough_credentials` set to `true` so that the exchanged token is forwarded to the upstream request. +Token exchange requires `passthrough_credentials` set to `true` so that the exchanged token is forwarded to the upstream. {:.info} > This example sets `insecure_relaxed_audience_validation` to `true` because the exchanged-token flow in this guide relies on a dedicated Keycloak audience mapper for the gateway client, not on the MCP resource URL being present in the incoming token's `aud` claim. @@ -153,51 +292,43 @@ Token exchange requires `passthrough_credentials` set to `true` so that the exch entities: plugins: - name: ai-mcp-oauth2 - route: weather-token-exchange + route: mcp-token-exchange-isolated tags: - token-exchange config: - resource: http://localhost:8000/weather/mcp-exchange - metadata_endpoint: /.well-known/oauth-protected-resource/weather/mcp-exchange + resource: http://localhost:8000/mcp-exchange + metadata_endpoint: /.well-known/oauth-protected-resource/mcp-exchange authorization_servers: - ${keycloak_issuer} introspection_endpoint: ${keycloak_introspection_url} - client_id: ${weather_exchange_gateway_client_id} - client_secret: ${weather_exchange_gateway_client_secret} + client_id: ${token_exchange_gateway_client_id} + client_secret: ${token_exchange_gateway_client_secret} client_auth: client_secret_post insecure_relaxed_audience_validation: true passthrough_credentials: true + claim_to_header: + - claim: sub + header: X-User-Id token_exchange: enabled: true token_endpoint: ${keycloak_token_url} client_auth: inherit variables: keycloak_issuer: - value: $WEATHER_EXCHANGE_KEYCLOAK_ISSUER + value: $TOKEN_EXCHANGE_KEYCLOAK_ISSUER keycloak_introspection_url: - value: $WEATHER_EXCHANGE_KEYCLOAK_INTROSPECTION_URL + value: $TOKEN_EXCHANGE_KEYCLOAK_INTROSPECTION_URL keycloak_token_url: - value: $WEATHER_EXCHANGE_KEYCLOAK_TOKEN_URL - weather_exchange_gateway_client_id: - value: $WEATHER_EXCHANGE_GATEWAY_CLIENT_ID - weather_exchange_gateway_client_secret: - value: $WEATHER_EXCHANGE_GATEWAY_CLIENT_SECRET + value: $TOKEN_EXCHANGE_KEYCLOAK_TOKEN_URL + token_exchange_gateway_client_id: + value: $TOKEN_EXCHANGE_GATEWAY_CLIENT_ID + token_exchange_gateway_client_secret: + value: $TOKEN_EXCHANGE_GATEWAY_CLIENT_SECRET {% endentity_examples %} -Configuration breakdown: -* `resource`: The identifier for the protected MCP server. This guide uses `http://localhost:8000/weather/mcp-exchange` so it does not collide with the JWK guide's resource. -* `metadata_endpoint`: The path where the plugin serves OAuth Protected Resource Metadata. It must match one of the paths on the Route so MCP clients can discover the authorization server. -* `authorization_servers` and `introspection_endpoint`: Connect the plugin to the dedicated Keycloak realm for token validation. -* `client_id`, `client_secret`, and `client_auth`: Credentials that {{site.base_gateway}} uses to authenticate with the introspection and token exchange endpoints. -* `passthrough_credentials`: Required for token exchange. Forwards the exchanged token to the upstream WeatherAPI request. -* `token_exchange.enabled`: Activates token exchange after successful token validation. -* `token_exchange.token_endpoint`: The Keycloak token endpoint where the exchange request is sent. -* `token_exchange.client_auth: inherit`: Reuses the `client_id` and `client_secret` configured for introspection. +With this configuration, {{site.ai_gateway}} first validates the incoming bearer token against the dedicated Keycloak realm using introspection, then exchanges that token at the Keycloak token endpoint before proxying the MCP request upstream. `passthrough_credentials: true` ensures the upstream server receives the exchanged token instead of the original client token. The dedicated `resource` and `metadata_endpoint` keep this flow isolated from the JWK-based setup while still exposing OAuth Protected Resource Metadata for MCP clients. -{:.info} -> WeatherAPI ignores the forwarded bearer token. This guide isolates the Kong and Keycloak configuration so it can coexist with other MCP auth examples. If you need to inspect the exchanged token at the upstream, use an upstream that validates or echoes the `Authorization` header. - -## Validate +## Validate the flow ### Verify unauthenticated requests are rejected @@ -205,7 +336,7 @@ Send a request without a token: {% validation request-check %} -url: /weather/mcp-exchange +url: /mcp-exchange status_code: 401 method: POST headers: @@ -220,34 +351,36 @@ body: The response returns `401`, confirming the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) is enforcing authentication. -### Obtain a token from the dedicated Keycloak realm +### Obtain a token from Keycloak -Obtain a token from Keycloak as `weather-exchange-client`. Run the validation requests soon after issuing the token because short-lived access tokens are common in token exchange setups: +Obtain a token from Keycloak as `token-exchange-client`, including the `add-token-exchange-gateway-audience` optional scope so that `token-exchange-gateway` is added to the audience: ```sh -export WEATHER_EXCHANGE_TOKEN=$(curl -s -X POST \ - http://localhost:8080/realms/weather-exchange/protocol/openid-connect/token \ +TOKEN_EXCHANGE_TOKEN=$(curl -s -X POST \ + http://$KEYCLOAK_HOST:8080/realms/token-exchange/protocol/openid-connect/token \ -d "grant_type=password" \ - -d "client_id=$DECK_WEATHER_EXCHANGE_CLIENT_ID" \ - -d "client_secret=$DECK_WEATHER_EXCHANGE_CLIENT_SECRET" \ + -d "client_id=$DECK_TOKEN_EXCHANGE_CLIENT_ID" \ + -d "client_secret=$DECK_TOKEN_EXCHANGE_CLIENT_SECRET" \ -d "username=alex" \ -d "password=doe" \ - -d "scope=openid" | jq -r '.access_token') + -d "scope=openid profile add-token-exchange-gateway-audience" | jq -r .access_token) && echo $TOKEN_EXCHANGE_TOKEN ``` +If you decode the token, the resulting access token will have an `aud` claim containing `token-exchange-gateway`, and an `azp` claim with `token-exchange-client`. + ### Verify authenticated MCP requests succeed -List the available MCP tools: +Send the token to {{site.ai_gateway}} and list the available MCP tools: {% validation request-check %} -url: /weather/mcp-exchange +url: /mcp-exchange status_code: 200 method: POST headers: - 'Accept: application/json, text/event-stream' - 'Content-Type: application/json' - - 'Authorization: Bearer $WEATHER_EXCHANGE_TOKEN' + - 'Authorization: Bearer $TOKEN_EXCHANGE_TOKEN' body: jsonrpc: "2.0" method: tools/list @@ -256,28 +389,39 @@ body: {% endvalidation %} -A successful response returns the available WeatherAPI-backed MCP tools. +A successful response returns the tools exposed by the upstream MCP server. + +Call the upstream directly with the original token. The request fails because the upstream only accepts tokens whose `azp` claim is `token-exchange-gateway`: + +```sh +curl -i --no-progress-meter http://localhost:3002/mcp \ + -H "Accept: application/json, text/event-stream" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN_EXCHANGE_TOKEN" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' +``` + +The response returns `403`, proving the original token is not accepted by the upstream MCP server. -Call the weather tool to verify the full request path through Kong: +Call a tool through {{site.ai_gateway}} to verify the full request chain, including token exchange: {% validation request-check %} -url: /weather/mcp-exchange +url: /mcp-exchange status_code: 200 method: POST headers: - 'Accept: application/json, text/event-stream' - 'Content-Type: application/json' - - 'Authorization: Bearer $WEATHER_EXCHANGE_TOKEN' + - 'Authorization: Bearer $TOKEN_EXCHANGE_TOKEN' body: jsonrpc: "2.0" method: tools/call id: 2 params: - name: realtime-api - arguments: - query_q: London + name: show_auth_context + arguments: {} {% endvalidation %} -A successful response with weather data confirms that {{site.base_gateway}} validated the original token, completed token exchange with Keycloak, and served the request on the isolated WeatherAPI-backed MCP route. +A successful response confirms that {{site.ai_gateway}} validated the original token, exchanged it at the Keycloak token endpoint, and forwarded the exchanged token to the upstream MCP server. The upstream accepts the request only because it receives a token whose `azp` claim is `token-exchange-gateway`. diff --git a/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md index 449d0150b3..b72a935382 100644 --- a/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md +++ b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md @@ -27,14 +27,14 @@ This setup is intentionally separate from the JWK validation guide. It uses a de #### Create the isolated realm 1. In the top-left realm menu, click **Create realm**. -1. Set the realm name to `weather-exchange`. +1. Set the realm name to `token-exchange`. 1. Click **Create**. #### Create the MCP client This client represents the application or agent that requests access to the MCP server. -1. In the `weather-exchange` realm sidebar, open **Clients**, then click **Create client**. +1. In the `token-exchange` realm sidebar, open **Clients**, then click **Create client**. 1. Configure the client: @@ -48,7 +48,7 @@ rows: - section: "**General settings**" settings: | * Client type: **OpenID Connect** - * Client ID: `weather-exchange-client` + * Client ID: `token-exchange-client` - section: "**Capability config**" settings: | * Toggle **Client authentication** to **on** @@ -60,7 +60,7 @@ rows: This client represents {{site.base_gateway}}. It performs token introspection and token exchange. -1. In the `weather-exchange` realm sidebar, open **Clients**, then click **Create client**. +1. In the `token-exchange` realm sidebar, open **Clients**, then click **Create client**. 1. Configure the client: @@ -74,7 +74,7 @@ rows: - section: "**General settings**" settings: | * Client type: **OpenID Connect** - * Client ID: `weather-exchange-gateway` + * Client ID: `token-exchange-gateway` - section: "**Capability config**" settings: | * Toggle **Client authentication** to **on** @@ -82,13 +82,14 @@ rows: {% endtable %} -#### Add an audience mapper to the MCP client +#### Create an optional audience scope for token exchange -Add a protocol mapper to `weather-exchange-client` so that tokens it obtains include `weather-exchange-gateway` in the `aud` claim. Keycloak requires the exchanging client to be present in the subject token's audience. Without this mapper, the token exchange request fails with "Client is not within the token audience". +Create an optional client scope that adds `token-exchange-gateway` to the `aud` claim. Keycloak requires the exchanging client to be present in the subject token's audience. Without this mapper, the token exchange request fails with "Client is not within the token audience". -1. In the sidebar, open **Clients** and select `weather-exchange-client`. -1. Open the **Client scopes** tab. -1. Click the `weather-exchange-client-dedicated` scope. +1. In the sidebar, open **Client scopes**, then click **Create client scope**. +1. Set the name to `add-token-exchange-gateway-audience`. +1. Click **Save**. +1. Open the **Mappers** tab. 1. Click **Configure a new mapper** and select **Audience**. 1. Configure the mapper: @@ -101,14 +102,19 @@ columns: key: value rows: - field: "**Name**" - value: "`add-weather-exchange-gateway-audience`" + value: "`add-token-exchange-gateway-audience`" - field: "**Included Client Audience**" - value: "`weather-exchange-gateway`" + value: "`token-exchange-gateway`" - field: "**Add to access token**" value: "**on**" {% endtable %} +1. In the sidebar, open **Clients** and select `token-exchange-client`. +1. Open the **Client scopes** tab. +1. Click **Add client scope**. +1. Check `add-token-exchange-gateway-audience`, click **Add**, and set it as **Optional**. + #### Create a test user 1. In the sidebar, open **Users**, then click **Add user**. @@ -119,22 +125,22 @@ rows: #### Export environment variables -1. In the sidebar, open **Clients** and select `weather-exchange-client`. Open the **Credentials** tab and copy the client secret. +1. In the sidebar, open **Clients** and select `token-exchange-client`. Open the **Credentials** tab and copy the client secret. 1. Export the following environment variables: ```sh - export DECK_WEATHER_EXCHANGE_CLIENT_ID='weather-exchange-client' - export DECK_WEATHER_EXCHANGE_CLIENT_SECRET='' + export DECK_TOKEN_EXCHANGE_CLIENT_ID='token-exchange-client' + export DECK_TOKEN_EXCHANGE_CLIENT_SECRET='' ``` -1. In the sidebar, open **Clients** and select `weather-exchange-gateway`. Open the **Credentials** tab and copy the client secret. +1. In the sidebar, open **Clients** and select `token-exchange-gateway`. Open the **Credentials** tab and copy the client secret. 1. Export the following environment variables: ```sh - export DECK_WEATHER_EXCHANGE_GATEWAY_CLIENT_ID='weather-exchange-gateway' - export DECK_WEATHER_EXCHANGE_GATEWAY_CLIENT_SECRET='' - export DECK_WEATHER_EXCHANGE_KEYCLOAK_ISSUER='http://localhost:8080/realms/weather-exchange' - export DECK_WEATHER_EXCHANGE_KEYCLOAK_INTROSPECTION_URL='http://keycloak:8080/realms/weather-exchange/protocol/openid-connect/token/introspect' - export DECK_WEATHER_EXCHANGE_KEYCLOAK_TOKEN_URL='http://keycloak:8080/realms/weather-exchange/protocol/openid-connect/token' + export DECK_TOKEN_EXCHANGE_GATEWAY_CLIENT_ID='token-exchange-gateway' + export DECK_TOKEN_EXCHANGE_GATEWAY_CLIENT_SECRET='' + export DECK_TOKEN_EXCHANGE_KEYCLOAK_ISSUER='http://localhost:8080/realms/token-exchange' + export DECK_TOKEN_EXCHANGE_KEYCLOAK_INTROSPECTION_URL='http://keycloak:8080/realms/token-exchange/protocol/openid-connect/token/introspect' + export DECK_TOKEN_EXCHANGE_KEYCLOAK_TOKEN_URL='http://keycloak:8080/realms/token-exchange/protocol/openid-connect/token' export KEYCLOAK_HOST='localhost' ``` From dd5e60c882f94d0a24e09bd3050704aa49cf95b9 Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Tue, 14 Apr 2026 10:38:54 +0200 Subject: [PATCH 04/15] drop okta token-exchange file set from branch --- .../routes/mcp-okta-token-exchange.yaml | 9 - .../mcp-okta-token-exchange-service.yaml | 2 - ...ure-mcp-oauth2-token-exchange-with-okta.md | 349 ------------------ 3 files changed, 360 deletions(-) delete mode 100644 app/_data/entity_examples/gateway/routes/mcp-okta-token-exchange.yaml delete mode 100644 app/_data/entity_examples/gateway/services/mcp-okta-token-exchange-service.yaml delete mode 100644 app/_how-tos/mcp/configure-mcp-oauth2-token-exchange-with-okta.md diff --git a/app/_data/entity_examples/gateway/routes/mcp-okta-token-exchange.yaml b/app/_data/entity_examples/gateway/routes/mcp-okta-token-exchange.yaml deleted file mode 100644 index cf5d3cff6a..0000000000 --- a/app/_data/entity_examples/gateway/routes/mcp-okta-token-exchange.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: mcp-okta-token-exchange -paths: - - /mcp/okta - - /.well-known/oauth-protected-resource/mcp/okta -service: - name: mcp-okta-token-exchange-service -protocols: - - http - - https diff --git a/app/_data/entity_examples/gateway/services/mcp-okta-token-exchange-service.yaml b/app/_data/entity_examples/gateway/services/mcp-okta-token-exchange-service.yaml deleted file mode 100644 index 0fc68143a2..0000000000 --- a/app/_data/entity_examples/gateway/services/mcp-okta-token-exchange-service.yaml +++ /dev/null @@ -1,2 +0,0 @@ -name: mcp-okta-token-exchange-service -url: http://host.docker.internal:3001/mcp diff --git a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange-with-okta.md b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange-with-okta.md deleted file mode 100644 index f59385cac5..0000000000 --- a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange-with-okta.md +++ /dev/null @@ -1,349 +0,0 @@ ---- -title: Configure token exchange with the AI MCP OAuth2 plugin and Okta -permalink: /mcp/configure-mcp-oauth2-token-exchange-with-okta/ -content_type: how_to -description: Learn how to configure token exchange with the AI MCP OAuth2 plugin using Okta -breadcrumbs: - - /mcp/ - -related_resources: - - text: "{{site.ai_gateway}}" - url: /ai-gateway/ - - text: AI MCP OAuth2 plugin - url: /plugins/ai-mcp-oauth2/ - - text: Token exchange in the AI MCP OAuth2 plugin - url: /plugins/ai-mcp-oauth2/#token-exchange - - text: AI MCP Proxy plugin - url: /plugins/ai-mcp-proxy/ - - text: Secure MCP tools with OAuth2 and Okta - url: /mcp/secure-mcp-tools-with-oauth2-and-okta/ - - text: OAuth 2.0 specification for MCP - url: https://modelcontextprotocol.io/specification/draft/basic/authorization - -plugins: - - ai-mcp-oauth2 - - ai-mcp-proxy - - cors - -entities: - - service - - route - - plugin - -products: - - gateway - - ai-gateway - -works_on: - - on-prem - - konnect - -min_version: - gateway: '3.14' - -tools: - - deck - -prereqs: - inline: - - title: Upstream MCP server - content: | - This guide uses a sample MCP server that exposes marketplace tools (users and orders). - - 1. Clone and start the server: - - ```sh - git clone https://github.com/tomek-labuk/marketplace-acl.git && \ - cd marketplace-acl && \ - npm install && \ - npm run build && \ - node dist/server.js - ``` - - 1. Verify the server is running at `http://localhost:3001/mcp`. - - title: Okta - content: | - You need an [Okta](https://login.okta.com/) admin account with a developer organization. - - This setup creates two application registrations: a **Web Application** (used by {{site.ai_gateway}} for token introspection and token exchange) and a **Native Application** (used by MCP Inspector for the authorization code flow). - - #### Add a custom scope - - 1. Go to **Security > API > Authorization Servers**. - 1. Click `default`. - 1. Go to the **Scopes** tab. - 1. Click **Add Scope**. - 1. Name: `mcp:access` - 1. Display phrase: `Access MCP tools` - 1. Check **Set as a default scope**. - 1. Click **Create**. - - #### Add an access policy - - 1. In the same `default` authorization server, go to the **Access Policies** tab. - 1. Click **Add Policy**. - 1. Name: `MCP Access` - 1. Assign to: **All clients** - 1. Click **Create Policy**. - - #### Add a rule to the policy - - 1. Inside the `MCP Access` policy, click **Add Rule**. - 1. Rule Name: `Allow MCP` - 1. Grant type: check **Client Credentials**, **Authorization Code**, and **Device Authorization**. - 1. User is: **Any user assigned the app** - 1. Scopes requested: **Any scopes** - 1. Click **Create Rule**. - - #### Export authorization server URLs - - 1. Go to **Security > API > Authorization Servers**. - 1. Click the `default` server. - 1. Copy the **Issuer** URI (for example, `https://your-org.okta.com/oauth2/default`). - 1. Export the following environment variables: - - ```sh - export DECK_OKTA_AUTH_SERVER='https://your-org.okta.com/oauth2/default' - export DECK_OKTA_INTROSPECTION_ENDPOINT='https://your-org.okta.com/oauth2/default/v1/introspect' - export DECK_OKTA_TOKEN_ENDPOINT='https://your-org.okta.com/oauth2/default/v1/token' - ``` - - #### Create the web application - - This application is used by {{site.ai_gateway}} for token introspection and token exchange. - - 1. Go to **Applications > Applications > Create App Integration**. - 1. Sign-in method: **OIDC - OpenID Connect** - 1. Application type: **Web Application** - 1. App integration name: `Kong MCP Gateway` - 1. Grant types: check **Client Credentials** and **Authorization Code**. - 1. Set Sign-in redirect URIs to `http://localhost/unused`. {{site.base_gateway}} does not use the redirect flow, but Okta requires the field. - 1. Assignments: **Skip group assignment for now** - 1. Click **Save**. - 1. Copy the **Client ID** and **Client Secret**. - 1. Go to the **Assignments** tab, click **Assign > Assign to People**, and assign your user. - 1. Export the credentials: - - ```sh - export DECK_OKTA_CLIENT_ID='your-kong-web-app-client-id' - export DECK_OKTA_CLIENT_SECRET='your-kong-web-app-client-secret' - ``` - - #### Create the native application - - This application is used by MCP Inspector for the authorization code flow. - - 1. Go to **Applications > Applications > Create App Integration**. - 1. Sign-in method: **OIDC - OpenID Connect** - 1. Application type: **Native Application** - 1. App integration name: `MCP Inspector` - 1. Grant types: check **Authorization Code**. - 1. Sign-in redirect URIs: `http://localhost:6274/oauth/callback/debug` - 1. Go to the **Assignments** tab, click **Assign > Assign to People**, and assign your user. - 1. Click **Save**. - 1. Copy the **Client ID**. No secret is needed for this public client. - - {:.info} - > The **Web Application** credentials go into the AI MCP OAuth2 Plugin config for token introspection and exchange. The **Native Application** Client ID is what you enter in MCP Inspector when connecting to the OAuth-protected MCP endpoint. - icon_url: /assets/icons/okta.svg - - title: MCP Inspector - content: | - This guide uses [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) to test the OAuth-protected MCP endpoint. - - 1. Ensure you have Node.js and npm installed. If needed, download them from https://nodejs.org. - 1. Update `npx` to the latest version: - ```sh - npm install -g npx - ``` - 1. Install the Inspector: - ```sh - npm install -g @modelcontextprotocol/inspector - ``` - icon_url: /assets/icons/mcp.svg - entities: - services: - - mcp-okta-token-exchange-service - routes: - - mcp-okta-token-exchange - -tags: - - ai - - mcp - - oauth2 - - okta - - authentication - - security - -tldr: - q: How do I configure token exchange with the AI MCP OAuth2 plugin and Okta? - a: | - Configure the AI MCP Proxy plugin in passthrough-listener mode to proxy MCP traffic - to an upstream MCP server. Add the AI MCP OAuth2 plugin with token exchange enabled - and Okta as the authorization server. The plugin validates the incoming token, - exchanges it for a new token scoped to the target audience, and forwards the - exchanged token to the upstream. - -cleanup: - inline: - - title: Clean up Konnect environment - include_content: cleanup/platform/konnect - icon_url: /assets/icons/gateway.svg - - title: Destroy the {{site.base_gateway}} container - include_content: cleanup/products/gateway - icon_url: /assets/icons/gateway.svg - -automated_tests: false ---- - -## Configure the AI MCP Proxy plugin in passthrough-listener mode - -Configure the [AI MCP Proxy plugin](/plugins/ai-mcp-proxy/) in `passthrough-listener` mode. This mode proxies incoming MCP requests directly to the upstream MCP server (the marketplace service running on port 3001) while generating observability metrics for the traffic. - -{% entity_examples %} -entities: - plugins: - - name: ai-mcp-proxy - route: mcp-okta-token-exchange - config: - mode: passthrough-listener - max_request_body_size: 1048576 -{% endentity_examples %} - -## Configure the CORS plugin - -Add the [CORS plugin](/plugins/cors/) to the Route so that MCP Inspector's browser-based OAuth callback can reach the MCP endpoint. - -{% entity_examples %} -entities: - plugins: - - name: cors - route: mcp-okta-token-exchange - config: - origins: - - http://localhost:6274 -{% endentity_examples %} - -## Configure the AI MCP OAuth2 plugin with token exchange - -Configure the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) on the same Route. The plugin validates the incoming bearer token via introspection, then exchanges it for a new token at the Okta token endpoint before forwarding the request to the upstream MCP server. - -Token exchange requires `passthrough_credentials` set to `true` so that the exchanged token is forwarded to the upstream. - -{:.info} -> This example sets `insecure_relaxed_audience_validation` to `true` because Okta does not yet include the resource URL in the `aud` claim as defined in [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707). - -{% entity_examples %} -entities: - plugins: - - name: ai-mcp-oauth2 - route: mcp-okta-token-exchange - config: - resource: http://localhost:8000/mcp/okta - metadata_endpoint: /.well-known/oauth-protected-resource/mcp/okta - authorization_servers: - - ${okta_auth_server} - introspection_endpoint: ${okta_introspection_endpoint} - client_id: ${okta_client_id} - client_secret: ${okta_client_secret} - insecure_relaxed_audience_validation: true - passthrough_credentials: true - claim_to_header: - - claim: sub - header: X-User-Id - token_exchange: - enabled: true - token_endpoint: ${okta_token_endpoint} - client_auth: client_secret_post - request: - audience: - - api://mcp-upstream -variables: - okta_auth_server: - value: $OKTA_AUTH_SERVER - okta_introspection_endpoint: - value: $OKTA_INTROSPECTION_ENDPOINT - okta_token_endpoint: - value: $OKTA_TOKEN_ENDPOINT - okta_client_id: - value: $OKTA_CLIENT_ID - okta_client_secret: - value: $OKTA_CLIENT_SECRET -{% endentity_examples %} - -Configuration breakdown: -* `resource`: The identifier for the protected MCP server. Matches the URL that MCP clients use to access it. -* `metadata_endpoint`: The path where the plugin serves OAuth Protected Resource Metadata. Must match one of the paths on the Route so MCP clients can discover the authorization server. -* `authorization_servers` and `introspection_endpoint`: Connect the plugin to Okta for token validation. -* `client_id`, `client_secret`, and `client_auth`: Credentials that {{site.base_gateway}} uses to authenticate with the introspection and token exchange endpoints. -* `passthrough_credentials`: Required for token exchange. Forwards the exchanged token to the upstream MCP server. -* `claim_to_header`: Maps the `sub` claim from the validated token to the `X-User-Id` upstream header. -* `token_exchange.enabled`: Activates token exchange after successful token validation. -* `token_exchange.token_endpoint`: The Okta token endpoint where the exchange request is sent. -* `token_exchange.client_auth: client_secret_post`: Authenticates with Okta using the client credentials in the POST body. -* `token_exchange.request.audience`: The target audience for the exchanged token. Set this to the identifier of the upstream service that will consume the token. - -## Connect with MCP Inspector - -1. Start MCP Inspector: - - ```sh - npx @modelcontextprotocol/inspector@latest --mcp-url http://localhost:8000/mcp/okta - ``` - -1. Open the MCP Inspector UI in your browser at the URL shown in the terminal output. - -1. Set **Transport Type** to **Streamable HTTP**. - -1. Set the URL to `http://localhost:8000/mcp/okta`. - -1. Click **Open Auth Settings**. - -1. Enter the **Native Application** Client ID from the Okta setup (the `MCP Inspector` app, not the `Kong MCP Gateway` app). Leave **Client Secret** empty. - - {:.info} - > Use the Client ID from the **Native Application** (`MCP Inspector`) you created in Okta. Do not use the Web Application Client ID. The Web Application credentials are used by {{site.base_gateway}} for token introspection and exchange, not by MCP clients. - -1. Click **Guided OAuth Flow**. - -1. **Metadata Discovery**: click **Continue**. - -1. **Client Registration**: click **Continue**. - -1. **Preparing Authorization**: click the authorization link. A new browser tab opens with the Okta login page. Sign in with your Okta user credentials. Copy the authorization code from the browser. - -1. **Request Authorization and acquire authorization code**: paste the authorization code and click **Continue**. - -1. **Token Request**: click **Continue**. - -1. **Authentication Complete** shows a green checkmark. - -1. Click **Connect**. MCP Inspector connects to the OAuth-protected MCP endpoint. - -## Validate - -### Verify unauthenticated requests are rejected - -Send a request without a token: - -```sh -curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/mcp/okta \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' -``` - -The response returns `401`, confirming the [AI MCP OAuth2 plugin](/plugins/ai-mcp-oauth2/) is enforcing authentication. - -### Verify MCP tools via MCP Inspector - -1. In MCP Inspector, go to the **Tools** tab and click **List Tools**. You should see the marketplace tools exposed by the upstream MCP server: - - ```text - list_users - get_user - list_orders - list_orders_for_user - search_orders - ``` - {:.no-copy-code} - -1. Select the **list_users** tool and click **Run Tool**. A successful response with marketplace user data confirms that {{site.base_gateway}} validated the original token, exchanged it at the Okta token endpoint, and forwarded the exchanged token to the upstream MCP server. From a32810bc5b18284abfc35f95507498fef98fafad Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Tue, 14 Apr 2026 10:43:54 +0200 Subject: [PATCH 05/15] appease vale --- .../auth/mcp-oauth2/keycloak-token-exchange-weather.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md index b72a935382..3a520bf0af 100644 --- a/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md +++ b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md @@ -4,13 +4,13 @@ This setup is intentionally separate from the JWK validation guide. It uses a de #### Install and run Keycloak -1. Make sure the Docker network used by Kong Gateway exists: +1. Make sure the Docker network used by {{site.ai_gateway}} exists: ```sh docker network create kong-quickstart-net ``` -1. Run Keycloak using Docker on the same network as Kong Gateway: +1. Run Keycloak using Docker on the same network as {{site.ai_gateway}}: ```sh docker run -p 127.0.0.1:8080:8080 \ From c077851ea346ec53a6cc9769655c314c9c848548 Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Tue, 14 Apr 2026 10:52:29 +0200 Subject: [PATCH 06/15] formatting --- app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md | 1 + 1 file changed, 1 insertion(+) diff --git a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md index 884a2f6aa3..2957fa6991 100644 --- a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md +++ b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md @@ -221,6 +221,7 @@ prereqs: httpd.serve_forever() EOF ``` + {:.collapsible} 1. Start the server in a separate terminal: From 5375a23b4e9d5c5a7166916038e875c1e904507d Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Tue, 14 Apr 2026 10:55:32 +0200 Subject: [PATCH 07/15] fix --- app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md index 2957fa6991..6bf6f6ed69 100644 --- a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md +++ b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md @@ -262,8 +262,6 @@ cleanup: automated_tests: false --- -This guide is intentionally isolated from the JWK validation flow. It uses a dedicated upstream MCP service, a separate MCP route, a different protected resource URL, and separate Keycloak clients and environment variables. - ## Configure the AI MCP Proxy plugin in passthrough-listener mode Configure the [AI MCP Proxy plugin](/plugins/ai-mcp-proxy/) in `passthrough-listener` mode on the `mcp-token-exchange-isolated` Route. This mode proxies incoming MCP requests directly to the upstream MCP server while preserving the exchanged bearer token on the upstream request. From 33cac3ef093d1d7fa8912da60bd2ba8460ac1682 Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Wed, 15 Apr 2026 06:10:11 +0200 Subject: [PATCH 08/15] Update --- app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md index 6bf6f6ed69..9d8e1a6cde 100644 --- a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md +++ b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md @@ -423,4 +423,9 @@ body: {% endvalidation %} -A successful response confirms that {{site.ai_gateway}} validated the original token, exchanged it at the Keycloak token endpoint, and forwarded the exchanged token to the upstream MCP server. The upstream accepts the request only because it receives a token whose `azp` claim is `token-exchange-gateway`. +A successful response confirms that {{site.ai_gateway}} validated the original token, exchanged it at the Keycloak token endpoint, and forwarded the exchanged token to the upstream MCP server. The upstream accepts the request only because it receives a token whose `azp` claim is `token-exchange-gateway`: + +```json +{"jsonrpc": "2.0", "id": 2, "result": {"content": [{"type": "text", "text": "{\n \"iss\": \"http://localhost:8080/realms/token-exchange\",\n \"azp\": \"token-exchange-gateway\",\n \"aud\": [\n \"account\"\n ],\n \"sub\": \"3f61670f-5e6b-4344-a5a0-a41fd48f3e39\"\n}"}], "structuredContent": {"iss": "http://localhost:8080/realms/token-exchange", "azp": "token-exchange-gateway", "aud": ["account"], "sub": "3f61670f-5e6b-4344-a5a0-a41fd48f3e39"}}}% +``` +{:.no-copy-code} \ No newline at end of file From e36dd923da873413ad9a2ec9f01c6f08a7337b91 Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Wed, 15 Apr 2026 08:17:49 +0200 Subject: [PATCH 09/15] remove unused mcp keycloak token-exchange prereq include --- .../mcp-oauth2/keycloak-token-exchange.md | 142 ------------------ 1 file changed, 142 deletions(-) delete mode 100644 app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange.md diff --git a/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange.md b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange.md deleted file mode 100644 index 5c0875f9fd..0000000000 --- a/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange.md +++ /dev/null @@ -1,142 +0,0 @@ -This tutorial requires [Keycloak](http://www.keycloak.org/) (version 26 or later) as the authorization server for MCP OAuth2 token exchange. - -#### Install and run Keycloak - -1. Run Keycloak using Docker: - - ```sh - docker run -p 127.0.0.1:8080:8080 \ - -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ - -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ - quay.io/keycloak/keycloak start-dev - ``` - -1. Open the admin console at `http://localhost:8080/admin/master/console/`. - -#### Configure the frontend URL - -{{site.base_gateway}} runs inside Docker and validates the `iss` claim in tokens against the configured `authorization_servers`. Set the Keycloak frontend URL to `http://host.docker.internal:8080` so that the `iss` claim in issued tokens matches the URL that {{site.base_gateway}} uses to reach Keycloak. - -1. Ensure `host.docker.internal` resolves on your host machine. On macOS or Linux, add it to `/etc/hosts` if it is not already present: - - ```sh - sudo sh -c 'echo "127.0.0.1 host.docker.internal" >> /etc/hosts' - ``` - - On Windows, Docker Desktop adds this entry automatically. - -1. In the admin console, open **Realm settings**. -1. In the **General** tab, set **Frontend URL** to `http://host.docker.internal:8080`. -1. Click **Save**. - -#### Create the MCP client - -This client represents the application or agent that requests access to the MCP server. - -1. In the sidebar, open **Clients**, then click **Create client**. -1. Configure the client: - - -{% table %} -columns: - - title: Section - key: section - - title: Settings - key: settings -rows: - - section: "**General settings**" - settings: | - * Client type: **OpenID Connect** - * Client ID: `mcp-client` - - section: "**Capability config**" - settings: | - * Toggle **Client authentication** to **on** - * Make sure that **Direct access grants** is checked. - - section: "**Login settings**" - settings: "**Valid redirect URIs**: `http://localhost:8000/*`" -{% endtable %} - - -#### Create the gateway client - -This client represents {{site.base_gateway}}. It performs token introspection and token exchange. - -1. In the sidebar, open **Clients**, then click **Create client**. -1. Configure the client: - - -{% table %} -columns: - - title: Section - key: section - - title: Settings - key: settings -rows: - - section: "**General settings**" - settings: | - * Client type: **OpenID Connect** - * Client ID: `mcp-gateway` - - section: "**Capability config**" - settings: | - * Toggle **Client authentication** to **on** - * Make sure that **Standard flow** and **Standard Token Exchange** are checked. - - section: "**Login settings**" - settings: "**Valid redirect URIs**: `http://localhost:8000/*`" -{% endtable %} - - -#### Add an audience mapper to the MCP client - -Add a protocol mapper to `mcp-client` so that tokens it obtains include `mcp-gateway` in the `aud` claim. Keycloak requires the exchanging client to be present in the subject token's audience. Without this mapper, the token exchange request fails with "Client is not within the token audience". - -1. In the sidebar, open **Clients** and select `mcp-client`. -1. Open the **Client scopes** tab. -1. Click the `mcp-client-dedicated` scope. -1. Click **Configure a new mapper** and select **Audience**. -1. Configure the mapper: - - -{% table %} -columns: - - title: Field - key: field - - title: Value - key: value -rows: - - field: "**Name**" - value: "`add-mcp-gateway-audience`" - - field: "**Included Client Audience**" - value: "`mcp-gateway`" - - field: "**Add to access token**" - value: "**on**" -{% endtable %} - - -#### Create a test user - -1. In the sidebar, open **Users**, then click **Add user**. -1. Set the username to `alex`. -1. Click **Create**. -1. Open the **Credentials** tab and click **Set password**. -1. Set the password to `doe` and disable **Temporary Password**. - -#### Export environment variables - -1. In the sidebar, open **Clients** and select `mcp-client`. Open the **Credentials** tab and copy the client secret. -1. Export the following environment variables: - - ```sh - export DECK_MCP_CLIENT_ID='mcp-client' - export DECK_MCP_CLIENT_SECRET='' - ``` - -1. In the sidebar, open **Clients** and select `mcp-gateway`. Open the **Credentials** tab and copy the client secret. -1. Export the following environment variables: - - ```sh - export DECK_MCP_GATEWAY_CLIENT_ID='mcp-gateway' - export DECK_MCP_GATEWAY_CLIENT_SECRET='' - export DECK_KEYCLOAK_ISSUER='http://host.docker.internal:8080/realms/master' - export DECK_KEYCLOAK_INTROSPECTION_URL='http://host.docker.internal:8080/realms/master/protocol/openid-connect/token/introspect' - export DECK_KEYCLOAK_TOKEN_URL='http://host.docker.internal:8080/realms/master/protocol/openid-connect/token' - ``` From 1790fb2a4e00aea1f2c755a9417f9ef37eecec56 Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Wed, 15 Apr 2026 08:24:58 +0200 Subject: [PATCH 10/15] fix --- .../prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md index 3a520bf0af..0f27254b99 100644 --- a/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md +++ b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md @@ -19,7 +19,7 @@ This setup is intentionally separate from the JWK validation guide. It uses a de -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ -e KC_HOSTNAME=http://localhost:8080 \ - quay.io/keycloak/keycloak start-dev + quay.io/keycloak/keycloak start-dev --features=token-exchange ``` 1. Open the admin console at `http://localhost:8080/admin/master/console/`. From 5b71cba18108b08afece5855151868d77dd27d8e Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Wed, 15 Apr 2026 08:30:20 +0200 Subject: [PATCH 11/15] fix --- .../auth/mcp-oauth2/keycloak-token-exchange-weather.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md index 0f27254b99..21ff8869a0 100644 --- a/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md +++ b/app/_includes/prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather.md @@ -4,13 +4,7 @@ This setup is intentionally separate from the JWK validation guide. It uses a de #### Install and run Keycloak -1. Make sure the Docker network used by {{site.ai_gateway}} exists: - - ```sh - docker network create kong-quickstart-net - ``` - -1. Run Keycloak using Docker on the same network as {{site.ai_gateway}}: +Run Keycloak using Docker on the same network as {{site.ai_gateway}}: ```sh docker run -p 127.0.0.1:8080:8080 \ From bee0782711bf840e29d495d3527b082971a22356 Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Wed, 15 Apr 2026 08:31:15 +0200 Subject: [PATCH 12/15] remove unused weatherapi prereq include --- app/_includes/prereqs/weatherapi.md | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 app/_includes/prereqs/weatherapi.md diff --git a/app/_includes/prereqs/weatherapi.md b/app/_includes/prereqs/weatherapi.md deleted file mode 100644 index b6301da326..0000000000 --- a/app/_includes/prereqs/weatherapi.md +++ /dev/null @@ -1,8 +0,0 @@ -1. Go to [WeatherAPI](https://www.weatherapi.com/). -1. Sign up for a free account. -1. Navigate to [your dashboard](https://www.weatherapi.com/my/) and copy your API key. -1. Export your API key: - - ```sh - export DECK_WEATHERAPI_API_KEY='your-weatherapi-api-key' - ``` From c11076846157b39a12fbc02b3c93c89da8c39a37 Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Wed, 15 Apr 2026 08:46:31 +0200 Subject: [PATCH 13/15] Fixes --- app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md | 7 +++++-- app/_includes/prereqs/weatherapi.md | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 app/_includes/prereqs/weatherapi.md diff --git a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md index 9d8e1a6cde..7324c29d72 100644 --- a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md +++ b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md @@ -43,6 +43,9 @@ tools: prereqs: inline: + - title: WeatherAPI + include_content: prereqs/weatherapi + icon_url: /assets/icons/gateway.svg - title: Set up isolated Keycloak token exchange include_content: prereqs/auth/mcp-oauth2/keycloak-token-exchange-weather icon_url: /assets/icons/keycloak.svg @@ -426,6 +429,6 @@ body: A successful response confirms that {{site.ai_gateway}} validated the original token, exchanged it at the Keycloak token endpoint, and forwarded the exchanged token to the upstream MCP server. The upstream accepts the request only because it receives a token whose `azp` claim is `token-exchange-gateway`: ```json -{"jsonrpc": "2.0", "id": 2, "result": {"content": [{"type": "text", "text": "{\n \"iss\": \"http://localhost:8080/realms/token-exchange\",\n \"azp\": \"token-exchange-gateway\",\n \"aud\": [\n \"account\"\n ],\n \"sub\": \"3f61670f-5e6b-4344-a5a0-a41fd48f3e39\"\n}"}], "structuredContent": {"iss": "http://localhost:8080/realms/token-exchange", "azp": "token-exchange-gateway", "aud": ["account"], "sub": "3f61670f-5e6b-4344-a5a0-a41fd48f3e39"}}}% +{"jsonrpc": "2.0", "id": 2, "result": {"content": [{"type": "text", "text": "{\n \"iss\": \"http://localhost:8080/realms/token-exchange\",\n \"azp\": \"token-exchange-gateway\",\n \"aud\": [\n \"account\"\n ],\n \"sub\": \"3f61670f-5e6b-4344-a5a0-a41fd48f3e39\"\n}"}], "structuredContent": {"iss": "http://localhost:8080/realms/token-exchange", "azp": "token-exchange-gateway", "aud": ["account"], "sub": "3f61670f-5e6b-4344-a5a0-a41fd48f3e39"}}} ``` -{:.no-copy-code} \ No newline at end of file +{:.no-copy-code} diff --git a/app/_includes/prereqs/weatherapi.md b/app/_includes/prereqs/weatherapi.md new file mode 100644 index 0000000000..b6301da326 --- /dev/null +++ b/app/_includes/prereqs/weatherapi.md @@ -0,0 +1,8 @@ +1. Go to [WeatherAPI](https://www.weatherapi.com/). +1. Sign up for a free account. +1. Navigate to [your dashboard](https://www.weatherapi.com/my/) and copy your API key. +1. Export your API key: + + ```sh + export DECK_WEATHERAPI_API_KEY='your-weatherapi-api-key' + ``` From 3e119732a9b310c68cd9546799b159f7d989cb87 Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Wed, 15 Apr 2026 09:04:48 +0200 Subject: [PATCH 14/15] remove unused mcp token-exchange base entity files --- .../gateway/routes/mcp-token-exchange.yaml | 9 --------- .../gateway/services/mcp-token-exchange-service.yaml | 2 -- 2 files changed, 11 deletions(-) delete mode 100644 app/_data/entity_examples/gateway/routes/mcp-token-exchange.yaml delete mode 100644 app/_data/entity_examples/gateway/services/mcp-token-exchange-service.yaml diff --git a/app/_data/entity_examples/gateway/routes/mcp-token-exchange.yaml b/app/_data/entity_examples/gateway/routes/mcp-token-exchange.yaml deleted file mode 100644 index 6336ed87ef..0000000000 --- a/app/_data/entity_examples/gateway/routes/mcp-token-exchange.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: mcp-token-exchange -paths: - - /mcp - - /.well-known/oauth-protected-resource/mcp -service: - name: mcp-token-exchange-service -protocols: - - http - - https diff --git a/app/_data/entity_examples/gateway/services/mcp-token-exchange-service.yaml b/app/_data/entity_examples/gateway/services/mcp-token-exchange-service.yaml deleted file mode 100644 index 47e1921d14..0000000000 --- a/app/_data/entity_examples/gateway/services/mcp-token-exchange-service.yaml +++ /dev/null @@ -1,2 +0,0 @@ -name: mcp-token-exchange-service -url: http://host.docker.internal:3001/mcp From 03426195d4193790ff70979829f1b35903fe2ed0 Mon Sep 17 00:00:00 2001 From: tomek-labuk Date: Thu, 16 Apr 2026 12:23:24 +0200 Subject: [PATCH 15/15] Fix indents --- .../configure-mcp-oauth2-token-exchange.md | 303 +++++++++--------- 1 file changed, 151 insertions(+), 152 deletions(-) diff --git a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md index 7324c29d72..c6ec090031 100644 --- a/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md +++ b/app/_how-tos/mcp/configure-mcp-oauth2-token-exchange.md @@ -53,154 +53,143 @@ prereqs: content: | This guide uses a small MCP debug server that rejects the original client token and only accepts the exchanged token forwarded by {{site.ai_gateway}}. This makes the token exchange observable during validation. - 1. Create the server: - - ```sh - cat > token-exchange-mcp-server.py <<'EOF' - #!/usr/bin/env python3 - import base64 - import json - import os - from http.server import BaseHTTPRequestHandler, HTTPServer - - - HOST = os.environ.get("TOKEN_EXCHANGE_MCP_HOST", "0.0.0.0") - PORT = int(os.environ.get("TOKEN_EXCHANGE_MCP_PORT", "3002")) - EXPECTED_AZP = os.environ.get("TOKEN_EXCHANGE_EXPECTED_AZP", "token-exchange-gateway") - - USERS = [ - {"id": "a1b2c3d4", "fullName": "Alice Johnson"}, - {"id": "e5f6g7h8", "fullName": "Bob Smith"}, - ] - - - def _json_response(handler, status, payload): - body = json.dumps(payload).encode("utf-8") - handler.send_response(status) - handler.send_header("Content-Type", "application/json") - handler.send_header("Content-Length", str(len(body))) - handler.end_headers() - handler.wfile.write(body) - - - def _decode_jwt_payload(token): - parts = token.split(".") - if len(parts) != 3: - raise ValueError("invalid JWT format") + Create the server: + + ```sh + cat > token-exchange-mcp-server.py <<'EOF' + #!/usr/bin/env python3 + import base64 + import json + import os + from http.server import BaseHTTPRequestHandler, HTTPServer + + + HOST = os.environ.get("TOKEN_EXCHANGE_MCP_HOST", "0.0.0.0") + PORT = int(os.environ.get("TOKEN_EXCHANGE_MCP_PORT", "3002")) + EXPECTED_AZP = os.environ.get("TOKEN_EXCHANGE_EXPECTED_AZP", "token-exchange-gateway") + + USERS = [ + {"id": "a1b2c3d4", "fullName": "Alice Johnson"}, + {"id": "e5f6g7h8", "fullName": "Bob Smith"}, + ] + + + def _json_response(handler, status, payload): + body = json.dumps(payload).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + + def _decode_jwt_payload(token): + parts = token.split(".") + if len(parts) != 3: + raise ValueError("invalid JWT format") + + payload = parts[1] + payload += "=" * (-len(payload) % 4) + decoded = base64.urlsafe_b64decode(payload.encode("ascii")) + return json.loads(decoded.decode("utf-8")) + + + def _extract_claims(handler): + authorization = handler.headers.get("Authorization", "") + if not authorization.startswith("Bearer "): + raise PermissionError("missing bearer token") + + token = authorization.split(" ", 1)[1].strip() + claims = _decode_jwt_payload(token) + if claims.get("azp") != EXPECTED_AZP: + raise PermissionError( + f"unexpected azp '{claims.get('azp')}', expected '{EXPECTED_AZP}'" + ) + return claims + + + def _mcp_result(request_id, structured_content): + return { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [{"type": "text", "text": json.dumps(structured_content, indent=2)}], + "structuredContent": structured_content, + }, + } + + + class Handler(BaseHTTPRequestHandler): + server_version = "token-exchange-mcp/1.0" + + def do_POST(self): + if self.path != "/mcp": + _json_response(self, 404, {"error": "not found"}) + return - payload = parts[1] - payload += "=" * (-len(payload) % 4) - decoded = base64.urlsafe_b64decode(payload.encode("ascii")) - return json.loads(decoded.decode("utf-8")) + length = int(self.headers.get("Content-Length", "0")) + raw_body = self.rfile.read(length) + try: + claims = _extract_claims(self) + request = json.loads(raw_body.decode("utf-8")) + except PermissionError as exc: + _json_response(self, 403, {"error": str(exc)}) + return + except Exception as exc: + _json_response(self, 400, {"error": str(exc)}) + return - def _extract_claims(handler): - authorization = handler.headers.get("Authorization", "") - if not authorization.startswith("Bearer "): - raise PermissionError("missing bearer token") + method = request.get("method") + request_id = request.get("id") + params = request.get("params") or {} - token = authorization.split(" ", 1)[1].strip() - claims = _decode_jwt_payload(token) - if claims.get("azp") != EXPECTED_AZP: - raise PermissionError( - f"unexpected azp '{claims.get('azp')}', expected '{EXPECTED_AZP}'" + if method == "tools/list": + _json_response( + self, + 200, + { + "jsonrpc": "2.0", + "id": request_id, + "result": { + "tools": [ + { + "name": "list_users", + "description": "List sample users.", + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + { + "name": "show_auth_context", + "description": "Return selected claims from the upstream bearer token.", + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, + }, + ] + }, + }, ) - return claims - - - def _mcp_result(request_id, structured_content): - return { - "jsonrpc": "2.0", - "id": request_id, - "result": { - "content": [{"type": "text", "text": json.dumps(structured_content, indent=2)}], - "structuredContent": structured_content, - }, - } - - - class Handler(BaseHTTPRequestHandler): - server_version = "token-exchange-mcp/1.0" - - def do_POST(self): - if self.path != "/mcp": - _json_response(self, 404, {"error": "not found"}) - return - - length = int(self.headers.get("Content-Length", "0")) - raw_body = self.rfile.read(length) + return - try: - claims = _extract_claims(self) - request = json.loads(raw_body.decode("utf-8")) - except PermissionError as exc: - _json_response(self, 403, {"error": str(exc)}) + if method == "tools/call": + tool_name = params.get("name") + if tool_name == "list_users": + _json_response(self, 200, _mcp_result(request_id, {"users": USERS})) return - except Exception as exc: - _json_response(self, 400, {"error": str(exc)}) - return - - method = request.get("method") - request_id = request.get("id") - params = request.get("params") or {} - - if method == "tools/list": + if tool_name == "show_auth_context": + aud = claims.get("aud") + if isinstance(aud, str): + aud = [aud] _json_response( self, 200, - { - "jsonrpc": "2.0", - "id": request_id, - "result": { - "tools": [ - { - "name": "list_users", - "description": "List sample users.", - "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, - }, - { - "name": "show_auth_context", - "description": "Return selected claims from the upstream bearer token.", - "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, - }, - ] + _mcp_result( + request_id, + { + "iss": claims.get("iss"), + "azp": claims.get("azp"), + "aud": aud or [], + "sub": claims.get("sub"), }, - }, - ) - return - - if method == "tools/call": - tool_name = params.get("name") - if tool_name == "list_users": - _json_response(self, 200, _mcp_result(request_id, {"users": USERS})) - return - if tool_name == "show_auth_context": - aud = claims.get("aud") - if isinstance(aud, str): - aud = [aud] - _json_response( - self, - 200, - _mcp_result( - request_id, - { - "iss": claims.get("iss"), - "azp": claims.get("azp"), - "aud": aud or [], - "sub": claims.get("sub"), - }, - ), - ) - return - - _json_response( - self, - 200, - { - "jsonrpc": "2.0", - "id": request_id, - "error": {"code": -32601, "message": f"unknown tool '{tool_name}'"}, - }, + ), ) return @@ -210,29 +199,39 @@ prereqs: { "jsonrpc": "2.0", "id": request_id, - "error": {"code": -32601, "message": f"unsupported method '{method}'"}, + "error": {"code": -32601, "message": f"unknown tool '{tool_name}'"}, }, ) - - def log_message(self, format, *args): return + _json_response( + self, + 200, + { + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"unsupported method '{method}'"}, + }, + ) + + def log_message(self, format, *args): + return + - if __name__ == "__main__": - httpd = HTTPServer((HOST, PORT), Handler) - print(f"Token-exchange MCP server listening at http://{HOST}:{PORT}/mcp") - httpd.serve_forever() - EOF - ``` - {:.collapsible} + if __name__ == "__main__": + httpd = HTTPServer((HOST, PORT), Handler) + print(f"Token-exchange MCP server listening at http://{HOST}:{PORT}/mcp") + httpd.serve_forever() + EOF + ``` - 1. Start the server in a separate terminal: + Start the server in a separate terminal: - ```sh - python3 token-exchange-mcp-server.py - ``` + ```sh + python3 token-exchange-mcp-server.py + ``` - 1. Verify the server is running at `http://localhost:3002/mcp`. + Verify the server is running at `http://localhost:3002/mcp`. entities: services: - mcp-token-exchange-isolated-service