Agents and MCP
GuideOperationsSince version 0.1.0
On this page
Operations guide: configure, deploy and observe the gateway. What the capability does, product side: product guide; calling it: developer guide.
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
%%{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) |
| MCP servers already running | 2 - federation | an upstream protocol: mcp and agents.mcp.servers |
Existing MCP server, declared list, Existing MCP server, everything it announces |
| Both | 3 - mixed | both, on the same surface | 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, A2A agent behind the gateway |
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.
agents:
mcp:
resources:
api.example.com: https://api.example.com/mcpMode 1 - tools projected from the contract
Nothing to build: an operation already served over REST becomes a tool.
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.
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 |
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 exchangeWhen 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:
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# 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) 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.
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:
agents:
mcp:
clients: ["https://agents.example.com/*", "https://partner.example/agent.json"] x-flexway-tool: { name: create_order, unattended: false } # hidden and refused with no personA 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/mcpreturns the resource and its authorization server;- an MCP client without a token gets
401withresource_metadata; - with a token issued for the surface's URL,
tools/listshows the allowed tools - projected and federated; - a call counts against the same quota as the REST call;
/configshowsmcp_toolsandmcp_federation;flexway_mcp_calls_totalcarries the agent'sclientand, 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.*