There is a question I keep coming back to when I build tools for AI agents, and it is not what can this agent do?
It is what have I made it structurally incapable of doing?
Those are different questions, and the difference matters. The first is answered by a prompt. The second is answered by architecture — and only one of them survives contact with a model that has misread the situation.
I recently published duffel-mcp,
a Model Context Protocol server that gives an AI assistant access to live travel inventory: flights,
hotels and hire cars, through the Duffel
API. Real availability, real prices, real airlines.
It cannot book anything. Not because I told it not to. Because the capability does not exist in the server.
Here are five decisions that went into it, and why I would make the same ones again.
1. Make it incapable, not instructed
Duffel is a booking API. It can create orders, take payment, issue tickets. Wiring all of that into an MCP server would have been the obvious move — full coverage, maximum capability.
I exposed four tools: resolve_place (name to IATA code and coordinates),
search_flights, search_stays and search_cars. Every one of
them reads. None of them writes. There is no create_order, no confirm_payment,
no issue_ticket — not disabled, not permission-gated, not hidden behind a flag. Absent.
This matters because "the model was instructed not to book" is not a security control. It is a hope. A tool that does not exist cannot be called by a confused model, a badly worded user request, or a prompt injection buried in a web page the agent happened to read.
The blast radius of this server is bounded by what I chose to build, not by what the model chooses to do.
There is a second reason, and it is the one I would flag to anyone building agent tooling in a regulated market. The moment that server can issue a ticket, it is not a coding project any more. In UK travel it is potentially an ATOL matter, with payment handling and consumer-protection obligations attached. I left a note in the source to that effect:
/**
* Single Duffel client for the whole tool layer.
* Use a TEST token (duffel_test_...) — real API shape, no money,
* no booking/compliance surface. Swap to a live token only if you ever move
* past the demo (which pulls in ATOL / payment obligations — out of scope here).
*/
Capability boundaries are compliance boundaries. Draw them deliberately, and write down why.
2. The tool description is an API — for the model
The consumer of an MCP tool description is not a developer reading docs. It is a language model deciding, right now, which tool to call and in what order.
Travel search has a dependency that is invisible unless you say so: flights need IATA codes, but
hotels and cars need latitude and longitude. Ask a model to find a hotel in Lisbon and it will
happily invent a plausible coordinate. So resolve_place says this:
description:
"Resolve a city or airport name to IATA code(s) and geographic coordinates. " +
"Call this FIRST: flights need the IATA code, while stays and cars need the " +
"latitude/longitude. Picking an airport result usually gives you both."
"Call this FIRST" is not documentation. It is control flow, expressed in the only language the caller reads. That one line is the difference between a chained, grounded search and a model guessing that Lisbon is at 38.7, -9.1 and being roughly right in a way you will not notice until it is wrong.
Write descriptions for the model that will read them, not for the human who will review them.
3. The schema is a guardrail, not a formality
Every input is a zod schema, and I use it to make invalid calls unrepresentable rather than merely detectable:
inputSchema: {
origin: z.string().length(3).describe("Origin IATA code, e.g. MAN"),
destination: z.string().length(3).describe("Destination IATA code, e.g. BCN"),
departure_date: z.string().describe("YYYY-MM-DD"),
return_date: z.string().optional().describe("YYYY-MM-DD; omit for one-way"),
adults: z.number().int().min(1).default(1),
cabin_class: z
.enum(["economy", "premium_economy", "business", "first"])
.default("economy"),
}
.length(3) rejects "Manchester" where MAN belongs — the most common failure I saw in
testing. The enum means the model cannot invent a cabin class. The defaults mean an under-specified
call still succeeds rather than erroring back into a retry loop that burns context.
A schema that merely validates catches mistakes. A schema that constrains prevents them.
4. Fail at startup, not at the first tool call
if (!process.env.DUFFEL_API_KEY) {
// stdout is reserved for the MCP protocol — log only to stderr.
console.error(
"[orbixio-duffel-mcp] DUFFEL_API_KEY is not set. Add your Duffel TEST token to the environment.",
);
process.exit(1);
}
Without this, a missing token produces a server that starts cleanly, connects, advertises four tools, and then fails on the first search with whatever the SDK happens to throw — surfaced to the user as a model apologising vaguely for a problem it cannot see.
I spend my working days in a network operations centre, where I own live incidents on enterprise infrastructure. That job teaches one lesson relentlessly: the cost of a failure is set by how far it travels before someone notices. A process that refuses to start is a thirty-second fix. The same fault discovered three layers up, through an agent's polite confusion, is an afternoon.
Fail early, fail loudly, and put the fix in the error message.
5. stdout belongs to the protocol
Note the comment above that console.error. On a stdio transport, MCP speaks JSON-RPC
over stdout. Anything else written there — a stray console.log, a debug line, a
dependency's startup banner — is injected directly into the protocol stream and corrupts it.
The failure mode is genuinely unpleasant: the server appears to run, the client cannot parse it, and
nothing in the error points at the console.log you added while debugging something
unrelated. All logging goes to stderr. All of it, permanently, no exceptions.
I enforce the same discipline in testing. The smoke test refuses to run against anything but a test token:
const key = process.env.DUFFEL_API_KEY ?? '';
if (!key.startsWith('duffel_test_')) {
console.error('FAIL: DUFFEL_API_KEY missing or not a TEST token (must start with duffel_test_).');
process.exit(1);
}
You cannot accidentally point the test suite at production. The check costs three lines.
One more: give the model less
Duffel's flight responses are large — deeply nested offers, segments, fare conditions, baggage allowances. I return five:
.sort((a, b) => parseFloat(a.total_amount) - parseFloat(b.total_amount))
.slice(0, 5)
.map((o) => ({ /* price, airline, times, stops */ }))
Sorted by price, trimmed to the fields a traveller actually weighs. This is partly context economics — raw payloads are enormous and the window is finite. But mostly it is that models reason better over small, predictable shapes than over complete ones. Twenty fields of nested truth produce worse answers than six fields of relevant truth.
Normalising responses is not a convenience layer. It is part of the reasoning surface you are designing.
What I would take to the next one
The framing that has been most useful to me is this: an MCP server is not an API wrapper. It is an interface between a probabilistic caller and a deterministic system, and every design decision either narrows or widens the gap where the probability leaks through.
Tool descriptions steer the caller. Schemas constrain it. Absent capabilities bound it. Startup checks catch operator error before the model ever inherits it. Trimmed responses give it less room to reason badly. None of that is exotic. It is ordinary engineering discipline, applied at a boundary that is new enough that we are all still deciding what the conventions should be.
The one I would argue hardest for is the first. Before you build an agent tool, decide what it must never be able to do — and then make that true in the architecture, not in the prompt. An instruction is a preference. An absent code path is a guarantee.
The server is open source under MIT, in TypeScript on the official Anthropic MCP SDK: github.com/AhmedAliQadir/duffel-mcp. I build AI systems for regulated and compliance-sensitive UK sectors through Orbixio Ltd. If you are working on agent safety or MCP tooling, I would be glad to compare notes.