MCP tools
Any interface can be exposed as a Model Context Protocol
tool, letting AI agents call it directly. Opt in with an mcp: block on the
interface. Full fields: McpTool reference.
Example
interfaces:
searchOrders:
summary: Search orders by customer
mcp:
enabled: true
tool_name: search_orders # optional, defaults to the interface name
description: Find orders for a customer by email
actions:
- name: Validate
input: a|body|
assert:
tests:
- value: a|body::email|
is_not_empty: true
- name: Query
database: main
query: SELECT * FROM orders WHERE customer_email = $1
params: [a|body::email|]
Input schema
The tool's inputSchema is generated automatically from the interface's
assert tests against a|body| / a|params| — the test vocabulary (types,
patterns, min/max, is_uuid, semver, …) maps to JSON Schema. Write clear
assertions and agents get an accurate tool signature for free.
Pair MCP tools with network access control and JWT verification so agent-callable endpoints stay authorized.
What a call returns
An HTTP caller gets every step's data, errors and timings. An MCP tool call gets the answer (engine ≥ 3.17.0):
- On success, the
dataof the last step that ran. Steps withhide_data_on_successare skipped, so a token check or an input check never becomes the answer. Aresponse_on_successbody is sent as written. An empty result stays[]. - On failure,
isError: trueand what failed:
{
"error": "Task not found",
"status": 404,
"step": "GetTask",
"errors": ["query failed: 42P01: relation \"tasks\" does not exist"]
}
error is the failing step's error_message. errors are its failed checks, kept
whole because they often hold the real cause. detail is added when the failing
step is an http call: the upstream's response body, which usually says what was
wrong with the request. A step that never ran because an earlier one failed is not
reported; the step it waited on is.
When several steps ran and failed, the first one leads, and its status is the
call's. Every other one follows in other_failures with its own error, errors
and detail (engine ≥ 3.17.1):
{
"error": "out of stock",
"status": 409,
"step": "CheckStock",
"errors": ["in_stock::is_equal_to: expected true, got false"],
"other_failures": [
{
"step": "CheckCredit",
"error": "credit limit reached",
"errors": [
"credit_ok::is_equal_to: expected true, got false",
"limit::is_greater_than: expected target 500 not greater than 1000"
]
},
{
"step": "CheckAddress",
"error": "http status 422 (expected 2xx or 304)",
"detail": { "msg": "postcode 0000 does not exist" }
}
]
}
``` `hide_errors` and `hide_data_on_error` are
honored. The failed step's own data is never included, since for a token check
that data is the caller's token.
### Typed results
Declare the result's schema with `produces.schema`. The tool then advertises it as
`outputSchema` and returns `structuredContent` alongside the text:
```yaml
interfaces:
taskTitles:
mcp:
enabled: true
tool_name: task_titles
produces:
schema:
type: array
items:
type: object
properties:
id: { type: integer }
title: { type: string }
MCP requires an object at the root, so a schema that is not an object (like this
array) is wrapped: the tool advertises {"result": <your schema>} and returns
{"result": [...]}. A produces.example on its own documents the endpoint but
advertises no outputSchema. Clients reject every result that does not match an
advertised schema, and a schema guessed from one example is too strict to rely on.
The whole envelope
Set result: full to send what an HTTP caller gets. No outputSchema is
advertised, since the envelope matches none:
mcp:
enabled: true
tool_name: get_order
result: full # send every step's data, errors and timings, as HTTP does
Server identity
Every MCP client calls initialize before it lists or calls anything, and that
response carries the server's name and description. Declare them with a top-level
mcp_servers block (engine ≥ 1.38.0):
mcp_servers:
tasks:
title: Tasks
instructions: >-
Task management backed by Postgres, scoped to the caller's tenant. Use
list_tasks to read tasks and create_task to add one. Every tool requires
the bearer token issued by the operator.
default: true
| Field | Effect |
|---|---|
title | serverInfo.title — the display name in the client's UI. |
instructions | The initialize result's instructions. MCP registries (mcp.so, Glama, Smithery, PulseMCP) read a remote server's listing description straight off this — there is no other place to write one. |
default | Adopt every tool that names no server, and serve them on the bare endpoint. At most one per org + environment. |
Without a declaration, tools still publish — under the built-in server name, and a registry listing renders as a bare name plus a wall of tool descriptions.
Named servers
Declaring a server and contributing tools to it are separate things. An MCP server is a named group of tools, not a property of one config: interfaces in any config in the same org + environment join a server by id, so one identity can cover tools spread across many files, and one deployment can publish several distinct MCP offerings from different subsets of its configs.
The id is a route segment, so each declared server is its own endpoint:
| Server | Self-hosted | Managed |
|---|---|---|
the default one | /mcp | /<org>/<env>/mcp |
id tasks-admin | /mcp/tasks-admin | /<org>/<env>/mcp/tasks-admin |
A tool joins a named server with mcp.server:
mcp_servers:
tasks:
title: Tasks
default: true
tasks-admin:
title: Tasks (admin)
interfaces:
purgeTasks:
summary: Delete every completed task
mcp:
enabled: true
tool_name: purge_tasks
server: tasks-admin # published only at /mcp/tasks-admin
actions:
- name: Purge
database: main
query: DELETE FROM tasks WHERE status = 'done'
Rules worth knowing:
- Ids are URL segments — 1–64 characters of
a-z,0-9or-. - Only one server may set
default: true. Deploy-time validation rejects a second one in the same config; across configs the first by config name wins and the engine warns. - A tool naming a server nothing declares is published on no server — never on the default one — and a request to an undeclared route segment is refused rather than served the default server's tools.
- A config declaring
mcp_serversmay only reference its own ids. Naming an id declared elsewhere is fine if this config declares nothing itself; mixing the two is a typo often enough that validation treats it as an error. - Keep the declaration in its own config when tools live across several files
(
interfaces: {}is valid). The identity then survives renaming or deleting whichever config happened to host it, and there is one source of truth.
Gating discovery
tools/call is protected by the interface's own actions — a ValidateJwt step,
is_valid_jwt, org/role checks. But tools/list (how a client discovers
what exists) runs no interface, so those actions never fire for it. By default an
mcp.enabled tool is therefore publicly discoverable: anyone who can reach
the MCP endpoint sees its name, description, and input schema (calling it still
requires whatever auth the interface enforces).
When the tool catalog itself is sensitive — for example a managed endpoint you
offer to your own customers, where different callers should see different tools —
set list_authorizer to the name of an interface that decides visibility. When a
client lists tools, AirPipe runs that interface with the caller's forwarded
credential; the tool appears only if it returns 2xx. It reuses the exact same
auth you already write for calls (your JWT, your secret, your claims):
interfaces:
searchOrders:
summary: Search orders by customer
mcp:
enabled: true
description: Find orders for a customer by email
list_authorizer: AuthorizeDiscovery # <- run to decide if this tool is listed
actions:
- name: Query
database: main
query: SELECT * FROM orders WHERE customer_email = $1
params: [a|body::email|]
# Not a tool (no mcp block). Just authorizes discovery: passes = 2xx, denies = non-2xx.
AuthorizeDiscovery:
method: POST
actions:
- name: ValidateJwt
input: a|headers|
hide_data_on_success: true
assert:
http_code_on_error: 401
error_message: invalid or missing token
tests:
- value: airpipe-jwt
is_not_null: true
is_valid_jwt: a|var::JWT_SECRET|
How it behaves:
- Tools without a
list_authorizerstay public — mix public and gated tools freely in one endpoint. - An anonymous caller (no credential) never triggers an authorizer run and sees only public tools — gated tools can't be enumerated.
- Different callers see different tools — the authorizer can inspect the JWT's org/role/scope, so discovery is scoped to each caller's claims.
- Fail-closed — an unknown authorizer interface, a resolve error, or any
non-
2xxhides the tool.
The credential is forwarded from the request's Authorization: Bearer … or
airpipe-jwt header and reaches the authorizer as a|headers::airpipe-jwt|.
Each distinct authorizer runs at most once per tools/list.