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.

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.