Skip to main content

MCP Agent Tools

Category: AI & Agents

Get this pack →

This page is generated from the Air Pipe marketplace. Browse it live to install into your organization.

Two interfaces published as MCP tools, and an agent that discovers and calls them over the wire. It runs on a self-hosted engine with no database and no credential beyond a model key, so you can watch the whole loop before deciding to wire it to anything real.

POST /assistant/ask   →  agent  ──tools/list──▶  POST /mcp  ──▶  tools/order-status
──tools/call──▶ ──▶ tools/courier-cutoff

Publishing a tool is one field

tools/order-status:
summary: Look up the delivery status of an order by its id
mcp:
enabled: true
tool_name: order_status
actions:
- name: ReadInput
input: a|body|
assert:
tests:
- value: order_id
is_not_null: true
is_not_empty: true

That is the entire opt-in. There is no separate tool definition, because the tool's inputSchema is derived from the interface's own assert tests — the same schema that already powers the OpenAPI docs. The asserts above produce:

{ "type": "object",
"properties": { "order_id": { "type": "string", "minLength": 1 } },
"required": ["order_id"] }

So the validation you wrote to reject bad requests is also what tells the model how to call you, and the two cannot drift apart. An interface without an mcp: block is not exposed at all, which is why admin routes need no extra guarding.

Consuming tools is one list

- name: Ask
agent:
url: a|var::model_url|
model: a|var::model_name|
input: a|ReadQuestion::question|
max_iterations: 5
mcp:
- url: a|ap_var::MCP_URL|
only: ["order_status", "courier_cutoff"]

Tools are discovered at run time via tools/list, so there is no second copy of anybody's schema in this config. Set only: deliberately. Without it the agent is offered whatever the server publishes at the moment it runs — which means someone else adding an interface silently changes what your agent can do.

What a run actually looks like

Asking "Where is order ORD-1002, and what time is that courier's cutoff?" takes three turns:

turn 1   order_status(order_id="ORD-1002")   via mcp  →  in transit, AusPost
turn 2 courier_cutoff(courier="AusPost") via mcp → 15:00
turn 3 "Order ORD-1002 is in transit with AusPost, and AusPost's cutoff is 15:00."

Note turn 2: the agent took the courier out of the first tool's result and used it as the second tool's argument. Nothing in this config says to do that — the chaining is the model's, and the config only decides which tools are on the table.

Coming from n8n

n8n models this as several nodes: an MCP Client Tool node per server, each wired into an agent node. Here every server is one entry in a single mcp: list on one action, so adding a server is a line rather than a node plus an edge.

The direction matters when you convert. n8n's MCP Client Tool is the client half and maps to agent.mcp[]. n8n's MCP Server Trigger is the server half and maps to mcp.enabled on the interface you want to expose. A workflow that has both becomes one config with both.

Running it

Requires an engine with the agent: action and MCP transport (3.5.0+).

OPENAI_API_KEY   key for the model endpoint
MCP_URL this engine's own /mcp endpoint

MCP_URL is resolved by the engine, not by your browser, so it has to be reachable from the engine itself. http://127.0.0.1:<port>/mcp is right for a single box and wrong the moment the agent and the server are not the same box — use the engine's external URL there.

On managed Air Pipe both are variables you set in the app. Self-hosted, supply them from the environment instead — the same a|ap_var::NAME| markers resolve locally, so the config below is the shipped one, unedited:

export AIRPIPE__AP_VAR__OPENAI_API_KEY=sk-...
export AIRPIPE__AP_VAR__MCP_URL=http://127.0.0.1:8080/mcp
airpipe server --config-dir ./configs --api-key <key>

Or put them in a file and point one variable at it, which keeps keys out of ps and out of your shell history:

cat > vars.yml <<'EOF'
OPENAI_API_KEY: sk-...
MCP_URL: http://127.0.0.1:8080/mcp
EOF
AIRPIPE__AP_VAR_FILE=./vars.yml airpipe server --config-dir ./configs --api-key <key>

The environment wins over the file where both define a name. Keep the file outside your config directory — anything in there is loaded as a config, and a variables map is not one, so the engine refuses to start, naming the file. Needs 3.6.0+; on an older build a self-hosted ap_var marker without an account silently becomes the text null.

Try it:

curl -X POST https://<your-engine>/assistant/ask \
-H 'content-type: application/json' \
-d '{"question":"Where is order ORD-1002, and what time is that courier'\''s cutoff?"}'

ORD-1001, ORD-1002 and ORD-1003 are delivered, in transit and lost respectively, so you can see the agent handle each. ORD-9999 exercises the not-found path.

Writing a tool a model can actually use

Two things here are load-bearing, and both were found by running the pack against real gpt-4o-mini rather than reasoning about it.

Say where an argument comes from. Asked for "that courier's cutoff", the model first issued BOTH tool calls in one turn and passed courier: "default" — a placeholder — because nothing told it the courier had to come from the order lookup. The tool's description now says so explicitly, and the system prompt says to call dependent tools in order and never guess an argument. With that, three runs out of three call courier_cutoff(courier="AusPost") and answer correctly.

Distinguish "not found" from "none". The tool used to return cutoff: null for a courier it did not recognise, which reads as a successful "there is no cutoff" — so the model reported "the cutoff is unknown" as fact. It now returns known: false alongside the list of couriers it does have, so a model can correct itself instead of confidently repeating an absence.

Both are the same lesson: a tool result that cannot express "I did not understand you" gets believed.

Making it yours

The two tools read from a fixed table in a code: action purely so the pack runs with nothing installed. Replace either Lookup action with a database: query or an http: call and nothing else moves — the tool name, its schema, and the agent wiring are all unchanged. That is the point of the pattern: a tool is just an interface you marked.

To add a tool, add an interface with mcp: { enabled: true } and put its name in the agent's only: list. To use somebody else's server, add another entry to mcp: with its URL and a headers: block for its credential.

Defaults

model_url and model_name are global.variables, set to OpenAI's endpoint and gpt-4o-mini. The agent speaks the OpenAI chat-completions shape, so any compatible endpoint works — Azure OpenAI, Groq, Together, vLLM, Ollama, or your own gateway — by changing those two values.

Configuration

config.yml

name: mcp_agent_tools
description: >
Both halves of MCP in one config: two interfaces published as MCP tools, and an
agent that discovers and calls them over the wire. Runs on a self-hosted engine
with no database and no credential beyond a model key.

docs: true

# Required managed variables:
# OPENAI_API_KEY — key for the model endpoint below
# MCP_URL — this engine's own /mcp endpoint, reachable FROM the engine
# (e.g. https://your-host/mcp). Not localhost once the agent
# and the server are not the same box.
#
# Optional:
# MODEL_URL / MODEL_NAME — point at any OpenAI-compatible endpoint.

global:
variables:
model_url: "https://api.openai.com/v1/chat/completions"
model_name: "gpt-4o-mini"

interfaces:

# ── the SERVER half: ordinary interfaces, published as MCP tools ───────────
#
# `mcp.enabled` is the whole opt-in. The tool's inputSchema is derived from the
# interface's own `assert` tests -- the same schema that powers the OpenAPI docs --
# so there is no second copy to keep in step. An interface without this field is
# simply not exposed, which is why admin routes need no extra guarding.
tools/order-status:
output: http
method: POST
summary: Look up the delivery status of an order by its id
mcp:
enabled: true
tool_name: order_status
description: >-
Look up an order's delivery status and courier by order id.
Ids look like ORD-1001.
actions:
# The asserts ARE the tool schema: `order_id` becomes a required string with
# a minimum length, and the model is told so before it ever calls.
- name: ReadInput
input: a|body|
assert:
error_message: "order_id is required, e.g. ORD-1001"
http_code_on_error: 400
tests:
- value: order_id
is_not_null: true
is_not_empty: true

# A fixed table stands in for your database so the pack runs with nothing
# installed. Swap this action for `database:` + a query and the tool, its
# schema and the agent wiring are all unchanged -- that is the point.
- name: Lookup
run_when_succeeded: [ReadInput]
input: a|ReadInput|
code:
language: js
source: |
const orders = {
"ORD-1001": { status: "delivered", courier: "DHL", eta: "2026-08-28" },
"ORD-1002": { status: "in transit", courier: "AusPost", eta: "2026-09-02" },
"ORD-1003": { status: "lost", courier: "DHL", eta: null },
};
const id = String($input.first().json.order_id || "").trim().toUpperCase();
const hit = orders[id];
return [{ json: hit
? { order_id: id, found: true, ...hit }
: { order_id: id, found: false, status: "unknown" } }];

- name: Respond
run_when_succeeded: [Lookup]
json_output: |-
{
"order_id": "a|Lookup::0.json.order_id|",
"found": a|Lookup::0.json.found|,
"status": "a|Lookup::0.json.status|",
"courier": "a|Lookup::0.json.courier->default(unknown)|",
"eta": "a|Lookup::0.json.eta->default(unknown)|"
}

tools/courier-cutoff:
output: http
method: POST
summary: The daily dispatch cutoff time for a courier
mcp:
enabled: true
tool_name: courier_cutoff
description: >-
Return the daily dispatch cutoff time for a named courier. The courier must be a
real name such as DHL or AusPost — if you do not know it yet, call order_status
first and use the courier it returns. Never pass a placeholder.
actions:
- name: ReadInput
input: a|body|
assert:
error_message: "courier is required"
http_code_on_error: 400
tests:
- value: courier
is_not_null: true
is_not_empty: true

- name: Cutoff
run_when_succeeded: [ReadInput]
input: a|ReadInput|
code:
language: js
source: |
const cutoffs = { DHL: "16:00", AUSPOST: "15:00", FEDEX: "17:30" };
const c = String($input.first().json.courier || "").trim().toUpperCase();
const cutoff = cutoffs[c] ?? null;
// `known` matters more than it looks. Returning only `cutoff: null` for a courier
// that is not on the list reads as a successful "there is no cutoff", so a model
// reports it as fact. Saying the name was not recognised, and listing the ones
// that are, lets it correct itself instead.
return [{ json: {
courier: c,
cutoff,
known: cutoff !== null,
couriers: Object.keys(cutoffs),
} }];

# `known` and `couriers` are what let a caller tell "no cutoff on record" from "that
# is not a courier I have". Answering only `cutoff: "unknown"` conflates the two, and
# a model repeats it as fact — which is exactly what happened: asked for the cutoff of
# the courier carrying an order, gpt-4o-mini called this tool with a placeholder
# argument in the same turn as the order lookup, got "unknown" back, and reported it.
- name: Respond
run_when_succeeded: [Cutoff]
json_output: |-
{
"courier": "a|Cutoff::0.json.courier|",
"cutoff": "a|Cutoff::0.json.cutoff->default(unknown)|",
"known": a|Cutoff::0.json.known|,
"couriers": a|Cutoff::0.json.couriers|
}

# ── the CLIENT half: an agent that draws those tools over MCP ──────────────
#
# This is the shape an n8n "MCP client tool" node maps onto. In n8n each remote
# server is its own node wired into an agent; here the whole set is one `mcp:`
# list on one action, and the tools are discovered at run time via `tools/list`
# rather than restated in the config.
assistant/ask:
output: http
method: POST
summary: Ask a question; the agent decides which MCP tools to call
actions:
- name: ReadQuestion
input: a|body|
assert:
error_message: "question is required"
http_code_on_error: 400
tests:
- value: question
is_not_null: true
is_not_empty: true

- name: Ask
run_when_succeeded: [ReadQuestion]
# Agent turns are sequential model calls; the default 45s action timeout
# is a single-call budget, not a conversation's.
timeout: 120000
agent:
url: a|var::model_url|
model: a|var::model_name|
headers:
authorization: "Bearer a|ap_var::OPENAI_API_KEY|"
system: >-
You answer questions about orders and delivery. Use the tools you are
given rather than guessing: order_status for an order's state, and
courier_cutoff for a courier's dispatch time. If an order is not found,
say so plainly. Keep answers to a sentence or two.

When one answer depends on another, call the tools IN ORDER and wait for
the result before the next call — to get the cutoff for the courier
carrying an order, call order_status first and pass the courier it
returns. Never call a tool with a placeholder or guessed argument. If a
tool reports that it did not recognise a value, say so rather than
treating it as an absence.
input: a|ReadQuestion::question|
# Two tools, two turns, plus the final answer. A bound is required --
# without one a model that keeps calling tools never returns.
max_iterations: 5
mcp:
# This engine's own MCP endpoint. The URL is resolved BY THE ENGINE, so
# it must be reachable from the engine itself.
- url: a|ap_var::MCP_URL|
# Naming the tools is deliberate. Absent, the agent is offered whatever
# the server publishes at call time -- so someone else adding an
# interface silently changes what this agent can do.
only: ["order_status", "courier_cutoff"]

- name: Respond
run_when_succeeded: [Ask]
json_output: |-
{
"answer": a|Ask::content->double_quote|,
"turns": a|Ask::iterations|,
"tools_used": a|Ask::tool_calls|
}