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). All the exposition settings live on a reusable MCP Virtual Server, which the route references with server_ref. The server is protected with OAuth 2.0 (the built-in enforce_oauth option) and exposes 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 Virtual Server bundling the tools and the OAuth settings (the reusable exposition definition)
- 1 MCP server route exposing the virtual server via the MCP Streamable 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) β
βββββββββββββββββββββββ
The OAuth settings and the tool list are defined once on an MCP Virtual Server; the route just adds the Protected MCP Streamable HTTP preset, which references it with server_ref and wires the two plugins above.
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 mcp:tools mcp:resources",
"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 Virtual Serverβ
Create an MCP Virtual Server that bundles the two tool functions and the OAuth settings. The route will reference it, so all the exposition config (and access control) lives in one reusable place.
Go to the MCP Virtual Servers page (or use the admin API resource ai-gateway.extensions.cloud-apim.com/v1/mcp-virtual-servers) and create:
{
"id": "mcp-virtual-server_geocoding",
"name": "MCP Geocoding Server",
"description": "Geocoding tools, OAuth-protected",
"enabled": true,
"tags": ["mcp", "geocoding", "oauth"],
"metadata": {},
"config": {
"name": "mcp-geocoding",
"version": "1.0.0",
"refs": [
"tool-function_geocode-address",
"tool-function_reverse-geocode"
],
"mcp_refs": [],
"enforce_oauth": true,
"auth_module_ref": "auth_mod_keycloak-mcp"
}
}
The config block is the shared exposition config. You can later add audience binding, scopeβtool RBAC, meta mode, or rate limiting & caching here β without touching the route.
Step 4: Create the MCP server routeβ
Create a route in Otoroshi and add the Protected MCP Streamable HTTP preset. From a single server_ref, it wires both halves of the protected MCP server for you:
- the MCP Streamable HTTP endpoint on
/mcp(customizable viamcp_path), and - the Protected Resource Metadata document (RFC 9728) on
/.well-known/oauth-protected-resource(customizable viawell_known_path).
OAuth enforcement, the auth module and the tools all come from the referenced virtual server β so the route configuration is just the preset and one server_ref.
{
"_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_plugins.com.cloud.apim.otoroshi.extensions.aigateway.plugins.ProtectedMcpStreamableHttpPreset",
"include": [],
"exclude": [],
"config": {
"server_ref": "mcp-virtual-server_geocoding",
"mcp_path": "/mcp",
"well_known_path": "/.well-known/oauth-protected-resource"
},
"bound_listeners": [],
"plugin_index": {}
}
],
"kind": "proxy.otoroshi.io/Route"
}
Prefer to control the plugins yourself (e.g. to add other plugins on the route)? You can add the McpRespEndpoint and McpProtectedResourceMetadata plugins manually instead of the preset β see MCP server exposition.
What the preset wires upβ
The preset expands into two plugins on the route. You don't configure them directly β everything comes from the virtual server β but here is what each does.
Protected Resource Metadata (RFC 9728)β
It 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 the OIDC auth module of the virtual server:
| 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 | Auth module scope | Extracted from the auth module's scope field (here openid, mcp:tools, mcp:resources). |
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.
OAuth enforcement on the MCP endpointβ
OAuth enforcement (enforce_oauth) and the auth module come from the virtual server. When a request arrives without a valid token, the endpoint 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 5: 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 6: 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 5.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 of the virtual server - 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 virtual server 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 scopes: the advertised scopes come from the auth module's
scopefield β edit it to change which scopes the PRM document advertises - Custom resource identifier: the
resourcedefaults to the request's protocol and host; to override it (e.g. behind a reverse proxy or load balancer), wire theMcpProtectedResourceMetadataplugin manually instead of the preset and set itsresourcefield (it also accepts ascopes_supportedoverride there)