Skip to main content

# Tutorial: OAuth-protected MCP server with geocoding tools

In this tutorial, you will build a complete, production-ready MCP server that exposes geocoding tools from the French national geocoding API (Géoplateforme IGN). The server will be protected with OAuth 2.0 using the built-in `enforceOAuth` option of the MCP plugin and will expose the standard `.well-known/oauth-protected-resource` discovery document via the dedicated `McpProtectedResourceMetadata` plugin.

## What you will build​

- 2 tool functions using HTTP backends that call the IGN geocoding API (no API key required)
  - `geocode_address` — convert a French address into GPS coordinates
  - `reverse_geocode` — find the nearest address from GPS coordinates
- 1 MCP server route exposing these tools via the MCP HTTP transport with built-in OAuth 2.0 protection
- OAuth Protected Resource metadata automatically derived from your OIDC auth module and served at `/.well-known/oauth-protected-resource`

## Prerequisites​

- A running Otoroshi instance with the LLM Extension installed (install guide)
- An OIDC provider (this tutorial uses Keycloak, but any OIDC provider works)

## Architecture overview​

                                    ┌──────────────────────────────────┐
                                    │         Otoroshi Route           │
                                    │   mcp-geocoding.oto.tools        │
       MCP Client ──── request ────►│                                  │
                                    │  1. McpProtectedResourceMetadata │
                                    │     (.well-known/oauth-pr...     │
                                    │     auto-generated from OIDC)    │
                                    │                                  │
                                    │  2. McpRespEndpoint              │
                                    │     enforce_oauth: true          │
                                    │     (validates Bearer token +    │
                                    │      MCP HTTP transport)         │
                                    │         │                        │
                                    └─────────┼────────────────────────┘
                                              │
                               ┌──────────────┼──────────────┐
                               │              │              │
                          geocode_address  reverse_geocode   │
                               │              │              │
                               └──────┬───────┘              │
                                      │                      │
                                      ▼                      │
                         ┌────────────────────────┐          │
                         │  IGN Geocoding API     │          │
                         │  data.geopf.fr         │          │
                         │  (free, no API key)    │          │
                         └────────────────────────┘          │
                                                             │
                                                  ┌──────────┴──────────┐
                                                  │   Keycloak          │
                                                  │   (OIDC provider)   │
                                                  └─────────────────────┘

## Step 1: Create the OIDC auth module​

First, configure an OAuth2/OIDC authentication module in Otoroshi that points to your Keycloak realm. Go to Settings > Authentication modules > Create new module and select OAuth2 / OIDC provider.

    {
      "type": "oauth2",
      "id": "auth_mod_keycloak-mcp",
      "name": "Keycloak MCP",
      "desc": "Keycloak OIDC module for MCP OAuth",
      "clientId": "${vault://local/keycloak-client-id}",
      "clientSecret": "${vault://local/keycloak-client-secret}",
      "authorizeUrl": "https://your-keycloak.example.com/realms/mcp/protocol/openid-connect/auth",
      "tokenUrl": "https://your-keycloak.example.com/realms/mcp/protocol/openid-connect/token",
      "userInfoUrl": "https://your-keycloak.example.com/realms/mcp/protocol/openid-connect/userinfo",
      "introspectionUrl": "https://your-keycloak.example.com/realms/mcp/protocol/openid-connect/token/introspect",
      "loginUrl": "https://your-keycloak.example.com/realms/mcp/protocol/openid-connect/auth",
      "logoutUrl": "https://your-keycloak.example.com/realms/mcp/protocol/openid-connect/logout",
      "callbackUrl": "http://your-otoroshi.oto.tools:8080/privateapps/generic/callback",
      "scope": "openid email profile",
      "readProfileFromToken": false,
      "jwtVerifier": {
        "type": "JWKSAlgoSettings",
        "url": "https://your-keycloak.example.com/realms/mcp/protocol/openid-connect/certs",
        "timeout": 2000,
        "headers": {},
        "ttl": 3600000,
        "kty": "RSA"
      },
      "nameField": "email",
      "emailField": "email",
      "oidConfig": "https://your-keycloak.example.com/realms/mcp/.well-known/openid-configuration",
      "kind": "security.otoroshi.io/AuthModule"
    }

tip

You can use the Otoroshi Vault to store your `clientId` and `clientSecret` securely using `${vault://local/keycloak-client-id}` and `${vault://local/keycloak-client-secret}`.

## Step 2: Create the tool functions​

### geocode_address​

This tool converts a French address into GPS coordinates using the IGN `/search` endpoint. Create a new tool function entity from the admin API or from the Otoroshi UI.

    {
      "id": "tool-function_geocode-address",
      "name": "geocode_address",
      "description": "Geocode a French address: returns GPS coordinates (latitude, longitude), full label, city, postcode, and confidence score. Use this to find the location of any address in France.",
      "strict": true,
      "parameters": {
        "address": {
          "type": "string",
          "description": "The French address to geocode (e.g. '73 Avenue de Paris, Saint-Mandé')"
        },
        "limit": {
          "type": "string",
          "description": "Maximum number of results to return (default: 5, max: 50)",
          "enum": ["1", "3", "5", "10"]
        }
      },
      "required": ["address"],
      "backend": {
        "kind": "Http",
        "options": {
          "url": "https://data.geopf.fr/geocodage/search?q=${address}&limit=${limit}",
          "method": "GET",
          "headers": {
            "Accept": "application/json"
          },
          "timeout": 10000
        }
      }
    }

### reverse_geocode​

This tool finds the nearest address from GPS coordinates using the IGN `/reverse` endpoint.

    {
      "id": "tool-function_reverse-geocode",
      "name": "reverse_geocode",
      "description": "Reverse geocode GPS coordinates: returns the nearest French address, city, postcode, and label from latitude and longitude.",
      "strict": true,
      "parameters": {
        "lat": {
          "type": "string",
          "description": "Latitude (e.g. '48.8566')"
        },
        "lon": {
          "type": "string",
          "description": "Longitude (e.g. '2.3522')"
        }
      },
      "required": ["lat", "lon"],
      "backend": {
        "kind": "Http",
        "options": {
          "url": "https://data.geopf.fr/geocodage/reverse?lat=${lat}&lon=${lon}&limit=5",
          "method": "GET",
          "headers": {
            "Accept": "application/json"
          },
          "timeout": 10000
        }
      }
    }

info

The IGN Géoplateforme geocoding API is free and requires no API key. It supports up to 50 requests per second per IP address. Responses are in GeoJSON format.

### Test the tools manually​

You can verify that the IGN API works by calling it directly:

    # Geocode an address
    curl "https://data.geopf.fr/geocodage/search?q=10+rue+de+Rivoli+Paris&limit=1"

    # Reverse geocode coordinates (Eiffel Tower)
    curl "https://data.geopf.fr/geocodage/reverse?lat=48.8584&lon=2.2945&limit=1"

## Step 3: Create the MCP server route​

Create a new route in Otoroshi that exposes the tool functions as an MCP HTTP server, protected by OAuth 2.0.

This route uses two plugins:

1. McpProtectedResourceMetadata — serves the RFC 9728 Protected Resource Metadata document at `/.well-known/oauth-protected-resource`, automatically derived from your OIDC auth module (authorization server URL, JWKS URI, scopes)
2. McpRespEndpoint — handles MCP protocol requests with built-in OAuth enforcement: validates the Bearer token using the same auth module and returns a `401` with `WWW-Authenticate` header pointing to the PRM document if the token is missing or invalid

    {
      "_loc": {
        "tenant": "default",
        "teams": ["default"]
      },
      "id": "route_mcp-geocoding-server",
      "name": "MCP Geocoding Server (OAuth-protected)",
      "description": "An OAuth-protected MCP server exposing IGN geocoding tools",
      "tags": ["mcp", "geocoding", "oauth"],
      "metadata": {},
      "enabled": true,
      "debug_flow": false,
      "export_reporting": false,
      "capture": false,
      "groups": ["default"],
      "bound_listeners": [],
      "frontend": {
        "domains": ["mcp-geocoding.oto.tools"],
        "strip_path": true,
        "exact": false,
        "headers": {},
        "query": {},
        "methods": []
      },
      "backend": {
        "targets": [
          {
            "id": "target_1",
            "hostname": "request.otoroshi.io",
            "port": 443,
            "tls": true,
            "weight": 1,
            "predicate": { "type": "AlwaysMatch" },
            "protocol": "HTTP/1.1",
            "ip_address": null,
            "tls_config": {
              "certs": [],
              "trusted_certs": [],
              "enabled": false,
              "loose": false,
              "trust_all": false
            }
          }
        ],
        "root": "/",
        "rewrite": false,
        "load_balancing": { "type": "RoundRobin" }
      },
      "backend_ref": null,
      "plugins": [
        {
          "enabled": true,
          "debug": false,
          "plugin": "cp:otoroshi.next.plugins.OverrideHost",
          "include": [],
          "exclude": [],
          "config": {},
          "bound_listeners": [],
          "plugin_index": {
            "transform_request": 0
          }
        },
        {
          "enabled": true,
          "debug": false,
          "plugin": "cp:otoroshi_plugins.com.cloud.apim.otoroshi.extensions.aigateway.plugins.McpProtectedResourceMetadata",
          "include": ["/.well-known/oauth-protected-resource"],
          "exclude": [],
          "config": {
            "auth_module_ref": "auth_mod_keycloak-mcp",
            "scopes_supported": ["openid", "mcp:tools", "mcp:resources"],
            "bearer_methods_supported": ["header", "query"]
          },
          "bound_listeners": [],
          "plugin_index": {}
        },
        {
          "enabled": true,
          "debug": false,
          "plugin": "cp:otoroshi_plugins.com.cloud.apim.otoroshi.extensions.aigateway.plugins.McpRespEndpoint",
          "include": ["/mcp"],
          "exclude": ["/.well-known/oauth-protected-resource"],
          "config": {
            "name": "mcp-geocoding",
            "version": "1.0.0",
            "enforce_oauth": true,
            "auth_module_ref": "auth_mod_keycloak-mcp",
            "refs": [
              "tool-function_geocode-address",
              "tool-function_reverse-geocode"
            ],
            "mcp_refs": []
          },
          "bound_listeners": [],
          "plugin_index": {}
        }
      ],
      "kind": "proxy.otoroshi.io/Route"
    }

### Key configuration details​

#### McpProtectedResourceMetadata plugin​

This plugin serves the OAuth Protected Resource Metadata document at `/.well-known/oauth-protected-resource`. Instead of hardcoding the metadata as a static JSON string, it automatically derives the document from your OIDC auth module:

Field | Source | Description
--- | --- | ---
`resource` | Auto (request URL) | The resource identifier. Defaults to the current protocol and host (e.g. `http://mcp-geocoding.oto.tools:8080`). Can be overridden in config.
`authorization_servers` | Auth module `oidConfig` | Derived from the OIDC discovery URL by stripping `/.well-known/openid-configuration`
`scopes_supported` | Config override or auth module `scope` | If `scopes_supported` is set in the plugin config, it is used. Otherwise the scopes are extracted from the auth module's `scope` field.
`jwks_uri` | Auth module `jwtVerifier` | Extracted from the JWKS URL in the auth module's JWT verifier settings
`bearer_methods_supported` | Config | Defaults to `["header", "query"]`

The resulting document looks like:

    {
      "resource": "http://mcp-geocoding.oto.tools:8080",
      "bearer_methods_supported": ["header", "query"],
      "authorization_servers": ["https://your-keycloak.example.com/realms/mcp"],
      "scopes_supported": ["openid", "mcp:tools", "mcp:resources"],
      "jwks_uri": "https://your-keycloak.example.com/realms/mcp/protocol/openid-connect/certs"
    }

This document tells MCP clients how to authenticate — which authorization server to use, which scopes to request, and where to find the public keys for token verification.

tip

If you change your OIDC provider or its configuration, the PRM document updates automatically — no need to manually edit a static JSON body.

#### McpRespEndpoint plugin (with OAuth enforcement)​

Field | Value | Description
--- | --- | ---
`enforce_oauth` | `true` | Enables built-in OAuth 2.0 token validation on all MCP requests
`auth_module_ref` | `auth_mod_keycloak-mcp` | References the OIDC auth module created in step 1. The plugin uses its JWKS settings to verify Bearer tokens.
`exclude` | `["/.well-known/oauth-protected-resource"]` | The discovery endpoint is handled by the McpProtectedResourceMetadata plugin

When a request arrives without a valid token, the plugin returns:

    HTTP/1.1 401 Unauthorized
    WWW-Authenticate: Bearer realm="mcp",
      resource_metadata="http://mcp-geocoding.oto.tools:8080/.well-known/oauth-protected-resource"
    Content-Type: application/json

    {"error":"unauthorized"}

The `resource_metadata` URL in the `WWW-Authenticate` header is automatically constructed from the request's protocol and host, pointing to the path served by the McpProtectedResourceMetadata plugin. This follows the RFC 9728 standard, enabling MCP clients to automatically discover the authorization server and obtain a token.

## Step 4: Test the MCP server​

### 1. Discover the OAuth metadata​

    curl http://mcp-geocoding.oto.tools:8080/.well-known/oauth-protected-resource

Expected response:

    {
      "resource": "http://mcp-geocoding.oto.tools:8080",
      "bearer_methods_supported": ["header", "query"],
      "authorization_servers": ["https://your-keycloak.example.com/realms/mcp"],
      "scopes_supported": ["openid", "mcp:tools", "mcp:resources"],
      "jwks_uri": "https://your-keycloak.example.com/realms/mcp/protocol/openid-connect/certs"
    }

### 2. Verify the 401 response without a token​

    curl -v http://mcp-geocoding.oto.tools:8080/mcp

Expected response:

    < HTTP/1.1 401 Unauthorized
    < WWW-Authenticate: Bearer realm="mcp", resource_metadata="http://mcp-geocoding.oto.tools:8080/.well-known/oauth-protected-resource"

    {"error":"unauthorized"}

### 3. Obtain an access token from Keycloak​

    # Client credentials grant
    ACCESS_TOKEN=$(curl -s -X POST \
      "https://your-keycloak.example.com/realms/mcp/protocol/openid-connect/token" \
      -d "grant_type=client_credentials" \
      -d "client_id=your-client-id" \
      -d "client_secret=your-client-secret" \
      -d "scope=openid" | jq -r '.access_token')

### 4. Initialize the MCP session​

    curl -X POST http://mcp-geocoding.oto.tools:8080/mcp \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "jsonrpc": "2.0",
        "id": 1,
        "method": "initialize",
        "params": {
          "protocolVersion": "2025-03-26",
          "capabilities": {},
          "clientInfo": {
            "name": "test-client",
            "version": "1.0.0"
          }
        }
      }'

### 5. List available tools​

    curl -X POST http://mcp-geocoding.oto.tools:8080/mcp \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "jsonrpc": "2.0",
        "id": 2,
        "method": "tools/list",
        "params": {}
      }'

Expected response (truncated):

    {
      "jsonrpc": "2.0",
      "id": 2,
      "result": {
        "tools": [
          {
            "name": "geocode_address",
            "description": "Geocode a French address: returns GPS coordinates ...",
            "inputSchema": { ... }
          },
          {
            "name": "reverse_geocode",
            "description": "Reverse geocode GPS coordinates ...",
            "inputSchema": { ... }
          }
        ]
      }
    }

### 6. Call the geocode_address tool​

    curl -X POST http://mcp-geocoding.oto.tools:8080/mcp \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "jsonrpc": "2.0",
        "id": 3,
        "method": "tools/call",
        "params": {
          "name": "geocode_address",
          "arguments": {
            "address": "10 rue de Rivoli, Paris",
            "limit": "1"
          }
        }
      }'

The tool calls the IGN API at `https://data.geopf.fr/geocodage/search?q=10+rue+de+Rivoli,+Paris&limit=1` and returns the GeoJSON result through the MCP protocol.

### 7. Call the reverse_geocode tool​

    curl -X POST http://mcp-geocoding.oto.tools:8080/mcp \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "jsonrpc": "2.0",
        "id": 4,
        "method": "tools/call",
        "params": {
          "name": "reverse_geocode",
          "arguments": {
            "lat": "48.8566",
            "lon": "2.3522"
          }
        }
      }'

## OAuth flow summary​

The complete OAuth authentication flow for an MCP client connecting to this server:

    MCP Client                           Otoroshi                          Keycloak
        │                                    │                                │
        ├── POST /mcp ───────────────────────►                                │
        │   (no token)                       │                                │
        │                          McpRespEndpoint                            │
        │                          (enforce_oauth)                            │
        │                                    │                                │
        ◄── 401 Unauthorized ────────────────┤                                │
        │   WWW-Authenticate: Bearer         │                                │
        │   resource_metadata=".../.well-    │                                │
        │   known/oauth-protected-resource"  │                                │
        │                                    │                                │
        ├── GET /.well-known/oauth-pr... ────►                                │
        │                     McpProtectedResourceMetadata                    │
        │                     (auto-generated from OIDC)                      │
        │                                    │                                │
        ◄── 200 OK ──────────────────────────┤                                │
        │   { authorization_servers: [...] } │                                │
        │                                    │                                │
        ├── POST /token ─────────────────────────────────────────────────────►│
        │   grant_type=client_credentials    │                                │
        │                                    │                                │
        ◄── 200 { access_token: "..." } ──────────────────────────────────────┤
        │                                    │                                │
        ├── POST /mcp ───────────────────────►                                │
        │   Authorization: Bearer <token>    │                                │
        │                          McpRespEndpoint                            │
        │                          (enforce_oauth)                            │
        │                                    ├── verify JWT (JWKS) ──────────►│
        │                                    ◄── OK ──────────────────────────┤
        │                                    │                                │
        ◄── 200 MCP response ────────────────┤                                │
        │                                    │                                │

## Step 5: Test with MCP Inspector​

The MCP Inspector is an interactive developer tool for testing and debugging MCP servers. You can use it to connect to your OAuth-protected MCP server with a visual UI.

### Launch the Inspector​

    npx @modelcontextprotocol/inspector

This opens the Inspector UI at http://localhost:6274.

### Connect to your MCP server​

1. In the Inspector UI, select Streamable HTTP as the transport type
2. Enter your MCP server URL: `http://mcp-geocoding.oto.tools:8080/mcp`
3. In the Headers section, add the `Authorization` header with your Bearer token:

Header name | Value
--- | ---
`Authorization` | `Bearer <your-access-token>`

tip

To quickly get a token, run the curl command from Step 4.3 and copy the resulting `access_token` value.

4. Click Connect

### Explore tools​

Once connected, the Inspector shows the server capabilities. Navigate to the Tools tab to see your two geocoding tools listed with their names, descriptions, and input schemas.

### Call a tool​

1. Click on the `geocode_address` tool
2. Fill in the arguments:
   - address: `Tour Eiffel, Paris`
   - limit: `1`
3. Click Run Tool
4. The Inspector displays the GeoJSON response from the IGN API with coordinates, label, city, and confidence score

Repeat with `reverse_geocode` using coordinates like `lat: 48.8584`, `lon: 2.2945` to verify the reverse lookup.

### CLI mode​

You can also use the Inspector in CLI mode to quickly test without the UI:

    # Obtain a token
    ACCESS_TOKEN=$(curl -s -X POST \
      "https://your-keycloak.example.com/realms/mcp/protocol/openid-connect/token" \
      -d "grant_type=client_credentials" \
      -d "client_id=your-client-id" \
      -d "client_secret=your-client-secret" \
      -d "scope=openid" | jq -r '.access_token')

    # List tools
    npx @modelcontextprotocol/inspector \
      --cli \
      --transport http \
      --header "Authorization: Bearer $ACCESS_TOKEN" \
      http://mcp-geocoding.oto.tools:8080/mcp \
      --method tools/list

    # Call geocode_address
    npx @modelcontextprotocol/inspector \
      --cli \
      --transport http \
      --header "Authorization: Bearer $ACCESS_TOKEN" \
      http://mcp-geocoding.oto.tools:8080/mcp \
      --method tools/call \
      --tool-name geocode_address \
      --tool-arg address="73 avenue de Paris, Saint-Mandé" \
      --tool-arg limit=1

    # Call reverse_geocode
    npx @modelcontextprotocol/inspector \
      --cli \
      --transport http \
      --header "Authorization: Bearer $ACCESS_TOKEN" \
      http://mcp-geocoding.oto.tools:8080/mcp \
      --method tools/call \
      --tool-name reverse_geocode \
      --tool-arg lat=48.8566 \
      --tool-arg lon=2.3522

## Going further​

- Add more tools: Create additional tool functions (e.g., cadastral parcel search using the IGN `?index=parcel` parameter) and add their IDs to the `refs` array in the McpRespEndpoint config
- Add MCP connectors: Combine your HTTP tool functions with MCP connectors by adding connector IDs to the `mcp_refs` array
- Fine-tune access control: Use the MCP plugin filters to control which tools are exposed to which clients
- Connect to an LLM provider: Attach these same tool functions to an LLM provider so the LLM can call them during chat completions
- Custom resource identifier: Set the `resource` field in the McpProtectedResourceMetadata config to override the auto-detected value (useful when behind a reverse proxy or load balancer)
- Custom scopes: Override `scopes_supported` in the McpProtectedResourceMetadata config to advertise specific scopes (e.g., `["openid", "mcp:tools", "mcp:resources"]`) instead of using all scopes from the auth module