This guide builds an MCP endpoint that AI agents connect to: Arion speaks the Model Context Protocol on /mcp, translates each tool call into an HTTP request against your existing REST services, and hides admin tools from callers whose token lacks the right claim. Use it when you want agents to reach REST APIs through one governed front door instead of bespoke MCP servers.
Outcome
- A listener on port 8080 serving MCP over streamable HTTP at
POST /mcp. - Two tools in the
apinamespace, backed by thetools_apicluster:api.get_statuscallsGET /v1/status, andapi.delete_usercallsDELETEon a templated path. Both are listed intools/list. - JWT authentication in front of everything;
api.delete_useris visible and callable only when the token carriesrole: admin.
Prerequisites
- An Arion binary (
cargo buildin the source checkout producestarget/debug/arion). - An identity provider that issues JWTs with issuer
https://idp.example.comand audiencemcp, and publishes its keys at/.well-known/jwks.json. Substitute your own IdP throughout. - A REST service reachable at
10.0.4.11:8080— edit thetools_apiendpoint to match yours.
The architecture
- The JWT filter authenticates every request and stores the verified claims for the filters behind it.
- The MCP gateway filter owns the tool registry: it answers
initialize,tools/listandtools/callon/mcp, enforcing per-tool RBAC against those claims. - Each
tools/callis transcoded into an HTTP request and dispatched to the tool's cluster with the usual upstream machinery.
The complete config
# arion.yaml, composed from 7 features.
logging:
log_level: info
envoy_bootstrap:
static_resources:
listeners:
- name: http_8080
address:
socket_address: { address: 0.0.0.0, port_value: 8080 }
filter_chains:
- name: http_8080_chain
filters:
- name: http_gateway
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
http_filters:
- name: envoy.filters.http.jwt_authn
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
providers:
idp:
issuer: https://idp.example.com
audiences:
- mcp
from_headers:
- name: Authorization
value_prefix: "Bearer "
payload_in_metadata: jwt_payload
remote_jwks:
http_uri:
uri: https://idp.example.com/.well-known/jwks.json
cluster: idp_jwks
timeout: 5s
cache_duration: 300s
rules:
- match:
prefix: /
requires:
provider_name: idp
- name: arion.filters.http.mcp
typed_config:
"@type": type.googleapis.com/arion.extensions.filters.http.mcp.mcp_gateway.v3.McpGateway
server_info:
name: arion-mcp-gateway
version: 1.0.0
tools_mode: {}
tools:
- namespace: api
name: get_status
description: Calls GET /v1/status on cluster tools_api.
disclosure_mode: EAGER
input_schema:
inline_string: |
{"type": "object", "properties": {}}
rest_backend:
cluster: tools_api
method: GET
path: /v1/status
- namespace: api
name: delete_user
description: Calls DELETE /v1/users/{{id}} on cluster tools_api.
disclosure_mode: EAGER
input_schema:
inline_string: |
{"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}
rbac:
action: ALLOW
permissions:
- jwt_claim:
field: role
value: admin
rest_backend:
cluster: tools_api
method: DELETE
path: /v1/users/{{id}}
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
route_config:
name: http_8080_routes
virtual_hosts:
- name: backend
domains: ["*"]
routes:
- match:
prefix: /
route:
cluster: tools_api
clusters:
- name: tools_api
connect_timeout: 5s
type: STATIC
load_assignment:
cluster_name: tools_api
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: 10.0.4.11, port_value: 8080 }
- name: idp_jwks
connect_timeout: 5s
type: STRICT_DNS
load_assignment:
cluster_name: idp_jwks
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: idp.example.com, port_value: 443 }
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
sni: idp.example.com
common_tls_context:
validation_context:
trusted_ca:
filename: /etc/ssl/certs/ca-certificates.crtWalkthrough
1. JWT runs first
Filter order matters: tool RBAC reads claims that envoy.filters.http.jwt_authn has already verified, so the JWT filter sits before the MCP gateway in http_filters. The provider accepts tokens from the Authorization: Bearer header, validates them against the IdP's remote JWKS (fetched through the idp_jwks cluster), and rejects everything else. See JWT authentication.
2. The gateway filter
arion.filters.http.mcp turns the listener into an MCP server. server_info is the identity the gateway reports during initialize; keep the name unique per gateway. tools_mode: {} has agents list and call the tools directly; code mode would give them one execute tool instead. The filter answers POST /mcp itself and responds 405 to GET /mcp and 404 to any other path, so ordinary requests never travel further down the chain. The router filter and route_config still close out the connection manager as usual.
3. Tools and their REST backends
Each entry under tools declares one MCP tool: a namespace and a name, which agents call together as api.get_status; a description the agent reads; disclosure_mode: EAGER, which lists it in tools/list (without it, agents find it through tool search); an input_schema that validates call arguments; and a rest_backend naming the cluster, method and path. Arguments substitute into path templates:
rest_backend:
cluster: tools_api
method: DELETE
path: /v1/users/{{id}}
Neither tool sets an output_schema, so the upstream response body comes back to the agent as text. The details live in tools and REST backends.
4. Per-tool RBAC
api.delete_user carries an rbac block: action: ALLOW with a single jwt_claim permission on role: admin. RBAC filters both directions — unauthorized callers do not see the tool in tools/list, and a direct tools/call is denied. Multiple permissions combine with OR. See tool authorization.
5. The clusters
tools_api is a plain STATIC cluster; the gateway dispatches REST tool calls straight to it by name. idp_jwks is a STRICT_DNS cluster with upstream TLS that only the JWT filter uses, to fetch signing keys.
Run and verify
Save the config as arion.yaml and start Arion:
arion --config arion.yaml
Export a token from your IdP, then initialize an MCP session:
MCP_ENDPOINT=http://127.0.0.1:8080/mcp
TOKEN=<a JWT from your IdP>
curl -i -s "$MCP_ENDPOINT" \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}'
The response advertises arion-mcp-gateway and carries an mcp-session-id header. Reuse it on every follow-up request:
SESSION_ID=<session-id-from-initialize>
curl -s "$MCP_ENDPOINT" \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H "mcp-session-id: $SESSION_ID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
curl -s "$MCP_ENDPOINT" \
-H "authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H "mcp-session-id: $SESSION_ID" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"api.get_status","arguments":{}}}'
With an admin token, tools/list returns both tools; with any other valid token it returns only api.get_status. The tools/call result contains the body of GET /v1/status from your backend as text content. A request without a token is rejected by the JWT filter before the gateway ever sees it.
Production notes
- Keep authentication in front of the gateway. Everything the MCP gateway exposes is invocable by an agent, so an unauthenticated MCP endpoint is an open API.
- Scope tools per listener. One gateway filter per listener with its own
server_info.namekeeps tenants' registries apart; tool names must be unique within a registry. - Tool catalogs change faster than listeners. Use tool discovery over xDS to add, replace and remove tools without touching the listener config.
- Watch upstream budgets: tool calls default to a 15 s upstream timeout and a 1 MiB response cap, which is usually what an agent should wait for — not more.
Next
- MCP gateway overview
- Tool authorization
- Code mode for agents, to let agents compose these tools in a single script