---
title: "Build an MCP gateway"
url: "/guides/mcp/mcp-gateway/"
kind: "guide"
summary: "Expose REST backends as MCP tools behind one authenticated endpoint, with per-tool access control from JWT claims."
---

> Arion site index: [/llms.txt](/llms.txt)

# Build an MCP gateway

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

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

````yaml
# 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](@/docs/security/jwt.md).

### 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](@/guides/mcp/code-mode-for-agents.md) 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](@/docs/mcp-gateway/tool-search.md)); an `input_schema` that validates call arguments; and a `rest_backend` naming the cluster, method and path. Arguments substitute into path templates:

{% raw %}
```yaml,arion=fragment
rest_backend:
  cluster: tools_api
  method: DELETE
  path: /v1/users/{{id}}
```
{% endraw %}

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](@/docs/mcp-gateway/tools-and-rest-backends.md).

### 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](@/docs/mcp-gateway/tool-authorization.md).

### 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:

```bash
arion --config arion.yaml
```

Export a token from your IdP, then initialize an MCP session:

```bash
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:

```bash
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](@/docs/mcp-gateway/tool-discovery-tds.md) 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](@/docs/mcp-gateway/overview.md)
- [Tool authorization](@/docs/mcp-gateway/tool-authorization.md)
- [Code mode for agents](@/guides/mcp/code-mode-for-agents.md), to let agents compose these tools in a single script
