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:

FieldSourceDescription
resourceAuto (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_serversAuth module oidConfigDerived from the OIDC discovery URL by stripping /.well-known/openid-configuration
scopes_supportedConfig override or auth module scopeIf scopes_supported is set in the plugin config, it is used. Otherwise the scopes are extracted from the auth module's scope field.
jwks_uriAuth module jwtVerifierExtracted from the JWKS URL in the auth module's JWT verifier settings
bearer_methods_supportedConfigDefaults 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)​

FieldValueDescription
enforce_oauthtrueEnables built-in OAuth 2.0 token validation on all MCP requests
auth_module_refauth_mod_keycloak-mcpReferences 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 nameValue
AuthorizationBearer <your-access-token>
tip

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

  1. 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