Skip to main content

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.

tip

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 data of the last step that ran. Steps with hide_data_on_success are skipped, so a token check or an input check never becomes the answer. A response_on_success body is sent as written. An empty result stays [].
  • On failure, isError: true and 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
FieldEffect
titleserverInfo.title — the display name in the client's UI.
instructionsThe 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.
defaultAdopt 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:

ServerSelf-hostedManaged
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-9 or -.
  • 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_servers may 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_authorizer stay 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-2xx hides 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.