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). 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 coordinates
    • reverse_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"
}
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 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 via mcp_path), and
  • the Protected Resource Metadata document (RFC 9728) on /.well-known/oauth-protected-resource (customizable via well_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"
}
Manual wiring

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:

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_supportedAuth module scopeExtracted from the auth module's scope field (here openid, mcp:tools, mcp:resources).
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.

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​

  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 5.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 of the virtual server
  • 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 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 scope field β€” edit it to change which scopes the PRM document advertises
  • Custom resource identifier: the resource defaults to the request's protocol and host; to override it (e.g. behind a reverse proxy or load balancer), wire the McpProtectedResourceMetadata plugin manually instead of the preset and set its resource field (it also accepts a scopes_supported override there)