# Agents and MCP

> Goal: make your APIs and your existing MCP servers callable by AI agents, behind one authorization, one quota and common metrics - without writing an MCP…

Guide - Since version 0.1.0 - Flexway v1.6.3 - https://flexway.io/en/docs/operations-agents-mcp/

> Operations guide: configure, deploy and observe the gateway. What the capability does, product side: [product guide](https://flexway.io/en/docs/product-overview/); calling it: [developer guide](https://flexway.io/en/docs/developer-overview/).

**Goal**: make your APIs and your existing MCP servers callable by AI agents, behind **one**
authorization, **one** quota and common metrics - without writing an MCP server.

## Three modes, one surface

```mermaid
%%{init: {'theme':'base','themeVariables':{'fontSize':'13px','lineColor':'#94a3b8'}}}%%
graph LR
  A["AI agent<br/>MCP client"]:::ext
  subgraph FW["Flexway - a host's /mcp surface"]
    S["Token issued for the surface<br/>tools/list filtered by scopes<br/>the caller's quota"]:::hub
    P["Mode 1 - projected tool<br/>a contract operation"]:::hub
    F["Mode 2 - federated tool<br/>an existing MCP server"]:::hub
  end
  R["REST service"]:::spoke
  M["Existing MCP server"]:::spoke
  I["Identity provider"]:::secret

  A -->|"MCP"| S
  S --> P -->|"the REST decision, replayed"| R
  S --> F -->|"MCP"| M
  P -->|"exchanged token, if the upstream needs one"| I
  F -->|"exchanged token, if the upstream needs one"| I

  classDef hub    fill:#4f46e5,stroke:#3730a3,color:#fff,font-weight:bold
  classDef spoke  fill:#0891b2,stroke:#155e75,color:#fff,font-weight:bold
  classDef secret fill:#a855f7,stroke:#7e22ce,color:#fff,font-weight:bold
  classDef ext    fill:#64748b,stroke:#334155,color:#fff,font-weight:bold
```

> The token exchange happens only when the upstream needs it (`token_exchange`). Everything drawn is built (`0.1.0`).

| You have | Mode | What you write | Example |
| --- | --- | --- | --- |
| REST services described in OpenAPI | **1 - projected tools** from the contract | `x-flexway-tool` on each operation to offer | [Agent tools (MCP)](https://flexway.io/en/docs/operations-examples-agents/#agent-tools-mcp) |
| MCP servers already running | **2 - federation** | an upstream `protocol: mcp` and `agents.mcp.servers` | [Existing MCP server, declared list](https://flexway.io/en/docs/operations-examples-agents/#existing-mcp-server-declared-list), [Existing MCP server, everything it announces](https://flexway.io/en/docs/operations-examples-agents/#existing-mcp-server-everything-it-announces) |
| Both | **3 - mixed** | both, on the same surface | [Projected and federated tools together](https://flexway.io/en/docs/operations-examples-agents/#projected-and-federated-tools-together) |
| Agents talking to each other in A2A | **A2A** (not MCP: outside the `/mcp` surface) | a declared card and a `protocol: a2a` upstream | [A2A agents](https://flexway.io/en/docs/operations-a2a/), [A2A agent behind the gateway](https://flexway.io/en/docs/operations-examples-a2a/) |

**Common to all three**, once per host: the surface's public URL, which is the audience an agent's
token must carry (RFC 8707). It is declared, never guessed from the request - a token issued for
another MCP server must not open this one.

```yaml
agents:
  mcp:
    resources:
      api.example.com: https://api.example.com/mcp
```

## Mode 1 - tools projected from the contract

Nothing to build: an operation already served over REST becomes a tool.

```yaml
      x-flexway-tool: true                           # or { name: get_order, description: "…" }
```

| Step | What the gateway does |
| --- | --- |
| `tools/list` | Name (`operationId`, or `name`), description (`summary`), input schema from the declared parameters and body |
| `tools/call` | Builds the operation's REST request and runs it, in memory, through **the same decision** as a REST call: the contract's scopes, quota, schema validation, policies, budgets, breaker, metrics |
| Result | The response body; `isError` for a status `>= 400`, with the RFC 9457 problem; bounded to 1 MiB |

An unmarked operation is never offered. An operation answering a stream cannot be a tool: refused at
load.

## Mode 2 - existing MCP servers (federation)

Nothing to change on the server or in its agents: the gateway stands in front of it.

```yaml
upstreams:
  orders-mcp:
    url: http://order-service:8080/mcp               # its MCP endpoint
    protocol: mcp
agents:
  mcp:
    servers:
      orders:
        upstream: orders-mcp
        tools: [get_order, create_order]             # or "*"
```

| Setting | Effect |
| --- | --- |
| `tools: [...]` | **Declared list** - the default, closed: a tool added on the server does not appear without a decision |
| `tools: "*"` | Everything the server announces - an explicit choice |
| `rename` | Another name on the surface, against a clash for instance |
| `scopes`, `tool_scopes` | Scopes required of every tool, or of one; empty: an **authenticated** caller - never anonymous |
| `rate_classes` | A tool's rate class; otherwise `read` when the server announces it read-only (`readOnlyHint`), else `write` |
| `hosts` | The surfaces serving it; default: all |
| `discovery.issuer` | The server needs a token even to list: the gateway obtains one for itself (`client_credentials`) |
| `discovery.refresh` | How often tools are listed again; default 5 min |

| How it works | Rule |
| --- | --- |
| **Discovery** | In the background: the server's `tools/list`, retried, refreshed. The start **never** waits for a server; when unreachable its tools are absent, and appear once it answers |
| **Names** | Original. A clash is refused at load for a declared list; with `"*"`, the clashing tool is not served, and it is said (`/config`, log) |
| **Call** | Identity required, scopes, the caller's quota, the upstream's bulkhead and breaker, the upstream's budget, a bounded result |
| **State** | `/config` (admin port), `mcp_federation`: discovered or unreachable, tools served, tools not served and why |

Transformation policies (headers, path) do not apply to federated tools: they are MCP calls, not REST
requests of the contract. Only the `rate-limit` policy (global and host levels) applies.

## Mode 3 - mixed

Both modes on one surface: the agent sees **one** list, sorted by name. Names must be unique on a
host - checked at load for declared tools.

## The token the upstream receives

The agent's token is **never** relayed: it was issued for the surface, not the upstream, and the MCP
specification forbids it. What the upstream receives depends on what it expects:

| The upstream | Configuration | It receives |
| --- | --- | --- |
| Trusts the gateway (private network, mesh) | Nothing | No `Authorization`; the tenant in the header the gateway sets (projected tools) |
| Verifies tokens itself | `token_exchange: { audience }` on the upstream, `client` on the issuer | A token **issued for it**, on the caller's behalf, by the caller's provider (RFC 8693) - [Token exchanged toward the upstream](https://flexway.io/en/docs/operations-examples-agents/#token-exchanged-toward-the-upstream) |

```yaml
upstreams:
  orders-mcp:
    token_exchange: { audience: order-service }      # the token the server expects
auth:
  issuers:
    - name: customers
      client: { id: flexway, secret: "file:/etc/flexway/secrets/client" }   # a reference, read at each exchange
```

When the provider refuses the exchange, the call is refused; when it cannot be reached, the call
fails - never a fallback to the agent's token.

## Bounded agent chain

A real chain is `human -> agent A -> agent B -> service`. The identity provider writes it in the token,
as nested `act` claims (RFC 8693): `sub` stays the human, each `act` names an agent. The gateway reads
it and bounds it:

```yaml
agents:
  delegation:
    max_depth: 2               # agents at most; 0: no delegated token accepted
    max_age: 15m               # since the human authenticated (auth_time, else iat)
    introspection:
      cache: 10s               # the longest a revocation goes unseen
```

```yaml
# contract - a sensitive operation tightens, never widens
      x-flexway-delegation: { max_depth: 1, max_age: 5m }
```

| Rule | Behaviour |
| --- | --- |
| **Direct token** (no `act`) | Not a delegation: no bound, no introspection |
| **Depth, age** | Past the bound -> `403`. An operation's bound is the lower of the configuration's and its own |
| **Revocation** | A delegated token is checked at its issuer (RFC 7662 introspection, with the issuer's `client`); revoked -> `401` within the `cache` delay; issuer unreachable -> `503`, never a pass without a check |
| **Narrowing** | A token exchange ([The token the upstream receives](#the-token-the-upstream-receives)) asks for no more than the caller's scopes; a provider granting more -> `403`, the upstream is not called |
| **Everywhere** | REST, connecting to the MCP surface, projected tools (the operation's bounds) and federated ones |
| **Traces** | `delegation.depth`, `delegation.actors` - the agents, never the human |

Full example: [Bounded agent chain](https://flexway.io/en/docs/operations-examples-agents/#bounded-agent-chain).

## Authorization, quota, observability

| Topic | Rule |
| --- | --- |
| **Agent's token** | Issued for the surface's URL (resource indicator `resource=` on the token request); a `401` gives `resource_metadata`, and `GET /.well-known/oauth-protected-resource/mcp` says where to obtain it |
| **`tools/list`** | Shows only what the caller may call |
| **Projected tool** | The contract's requirements (`security`), as over REST |
| **Federated tool** | `scopes` / `tool_scopes`; an identity always |
| **Quota** | One bucket per caller, whatever the door - REST, projected tool, federated tool |
| **Metrics** | `flexway_mcp_calls_total` (tool, outcome, client, tenant, unattended, cause of a refusal), `flexway_mcp_connections_refused_total` (reason), `flexway_mcp_federated_duration_seconds` (tool), `flexway_mcp_discoveries_total` (server, outcome), `flexway_token_exchanges_total` (upstream, outcome), `flexway_delegation_refused_total` (reason: `depth`, `age`, `revoked`, `widened`, `unverifiable`) |
| **`/config`** | `mcp_tools` (projected tools per host), `mcp_federation` (each server's state, live); `agents.mcp.clients` and `tool_unattended` |

**Which clients, with or without a person** (`1.0.0`) - by default the surface admits any client
whose token is valid. `agents.mcp.clients` closes it to the clients declared, named by the URL of
their metadata document (MCP revision 2026-07-28); a tool can be kept from **unattended** agents - a
client acting for itself, whose token's subject is the client:

```yaml
agents:
  mcp:
    clients: ["https://agents.example.com/*", "https://partner.example/agent.json"]
```

```yaml
      x-flexway-tool: { name: create_order, unattended: false }   # hidden and refused with no person
```

A provider that gives a service account a subject of its own (Keycloak: a user id) is declared on
the issuer: `service_accounts: { claim: preferred_username, prefix: service-account- }`.

Declaring `agents.mcp.clients` is also what makes each agent visible in the console: without a list,
every client counts as `_undeclared`.

## Check

- `GET /.well-known/oauth-protected-resource/mcp` returns the resource and its authorization server;
- an MCP client without a token gets `401` with `resource_metadata`;
- with a token issued for the surface's URL, `tools/list` shows the allowed tools - projected and
  federated;
- a call counts against the same quota as the REST call;
- `/config` shows `mcp_tools` and `mcp_federation`;
- `flexway_mcp_calls_total` carries the agent's `client` and, on a refusal, its cause.

toward the upstream; the bounded agent chain. API keys arrive with the next change; a federated
server's MCP resources and prompts are not relayed.*
