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 coordinatesreverse_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"
}
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
}
}
}
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:
- 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) - McpRespEndpoint β handles MCP protocol requests with built-in OAuth enforcement: validates the Bearer token using the same auth module and returns a
401withWWW-Authenticateheader 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.
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β
- In the Inspector UI, select Streamable HTTP as the transport type
- Enter your MCP server URL:
http://mcp-geocoding.oto.tools:8080/mcp - In the Headers section, add the
Authorizationheader with your Bearer token:
| Header name | Value |
|---|---|
Authorization | Bearer <your-access-token> |
To quickly get a token, run the curl command from Step 4.3 and copy the resulting access_token value.
- 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β
- Click on the
geocode_addresstool - Fill in the arguments:
- address:
Tour Eiffel, Paris - limit:
1
- address:
- Click Run Tool
- 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=parcelparameter) and add their IDs to therefsarray in the McpRespEndpoint config - Add MCP connectors: Combine your HTTP tool functions with MCP connectors by adding connector IDs to the
mcp_refsarray - 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
resourcefield in the McpProtectedResourceMetadata config to override the auto-detected value (useful when behind a reverse proxy or load balancer) - Custom scopes: Override
scopes_supportedin the McpProtectedResourceMetadata config to advertise specific scopes (e.g.,["openid", "mcp:tools", "mcp:resources"]) instead of using all scopes from the auth module