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