Skip to content

Build an MCP gateway

Expose REST backends as MCP tools behind one authenticated endpoint, with per-tool access control from JWT claims.

Request path

What you will build

ARION 1listener2jwt3mcp gateway agent tools

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 api namespace, backed by the tools_api cluster: api.get_status calls GET /v1/status, and api.delete_user calls DELETE on a templated path. Both are listed in tools/list.
  • JWT authentication in front of everything; api.delete_user is visible and callable only when the token carries role: admin.

Prerequisites

  • An Arion binary (cargo build in the source checkout produces target/debug/arion).
  • An identity provider that issues JWTs with issuer https://idp.example.com and audience mcp, 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 the tools_api endpoint 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/list and tools/call on /mcp, enforcing per-tool RBAC against those claims.
  • Each tools/call is 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.crt

Walkthrough

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.name keeps 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

Next

Take the configuration further

Start typing to search the site.

↑ ↓ to move Enter to open Esc to close