AI development
An MCP server for an existing SaaS: 17 tools in production
What we learned from an MCP server on Pilot-Next: 14 read and 3 write tools, an 11 KB tool list, a preview before every write, and the date models get wrong.
Pilot-Next is a booking and billing system for flying clubs, and it has an MCP server — the first code for it dates from December 2025. A member asks an assistant such as Claude what a flight to Lelystad will cost, whether the aircraft is free on Saturday, and then asks it to book — in the same conversation, without opening the app.
Guides about adding MCP to a SaaS product tend to describe a server in general terms. This one describes a server that runs, that we built and that we still maintain. Every number below was measured on its own code on 24 September 2026.
This article is about tool design: which tools, how they are described, and how an assistant is kept from acting on a guess. Access control is deliberately left out.
What does an MCP server add to an existing SaaS?
An MCP server lets an AI assistant outside your product use your product: look something up, calculate something, and carry out an action on behalf of the user who is talking to it. The Model Context Protocol standardises how the assistant discovers those tools and calls them, so one server works with every client that speaks the protocol. For an existing SaaS it adds a new front door, not a new product: the business logic stays where it was, and the MCP server is a thin layer that translates a tool call into the same service call your own screens make.
How many tools does an MCP server need?
Pilot-Next has 17 tools: 14 that only read and 3 that write. The write tools are the three actions that change something a member cares about — creating a booking, cancelling it, and settling a flown flight into an invoice. Everything else answers a question.
| tool | what it does | reads or writes |
|---|---|---|
get_dispatch_context_and_time | fleet, defaults, preferences and today’s date | reads |
get_weather | METAR and TAF for one or more airfields | reads |
get_my_balance | current account balance | reads |
get_my_bookings | upcoming and recent bookings | reads |
get_unsettled_bookings | flown but not yet settled | reads |
get_pilot_activity | flight activity for a pilot | reads |
get_aircraft_availability | when an aircraft is free | reads |
find_available_slots | a free slot of a given length | reads |
get_aircraft_hours | current Hobbs and Tacho readings | reads |
get_maintenance_forecast | when maintenance is due, based on usage | reads |
get_flight_statistics | flight hours per aircraft per year | reads |
estimate_flight_cost | cost of a flight before it is booked | reads |
get_booking_settlement_info | start readings and rates for a flight to settle | reads |
preview_settlement | the invoice as it would be, without saving it | reads |
create_booking | books an aircraft | writes |
cancel_booking | cancels a booking | writes |
settle_booking | saves the flight log and creates the invoice | writes |
Next to the tools the server offers one prompt (a flight briefing) and no resources. There used to be one resource: a “club regulations” text that turned out to be written in code and served to every club as if it were theirs. It was removed rather than fixed, because there is no regulations data to serve. A resource that sounds authoritative and is not is worse than no resource: the assistant quotes it with the same confidence as a real balance.
The number that matters is not 17 but the ratio. An assistant is useful the moment it can answer questions, and every write tool is a place where a wrong guess costs something. Start with the questions users already ask your support desk, and add a write tool only for an action whose consequence you can show before it happens.
How large is the tool list, and why does that matter?
Every client that connects asks for the tool list (tools/list) and gives it to the model, so the model knows what it can call. We measured that list by starting the server’s tool registration against an in-memory client and saving what came back:
| part of the tool list | size |
|---|---|
complete tools/list answer, 17 tools | 11,319 bytes of JSON |
| all 17 descriptions together | 3,572 characters |
| all 17 input schemas together | 5,941 characters |
| parameters across all tools | 44, every one with its own description |
| longest description | estimate_flight_cost, 421 characters |
| largest input schema | settle_booking, 937 characters |
Two things stand out. The input schemas weigh more than the descriptions — 5,941 against 3,572 characters — because every parameter carries a type and a description of its own. And the two settlement tools alone account for 1,852 characters of schema, because they take the same eight parameters twice: once to preview, once to save.
That is a deliberate trade-off, not an accident. But it means every parameter you add is text the model reads before every answer, whether or not the user is talking about that tool. A tool list is a prompt you do not see.
How do you keep an assistant from writing without asking?
With a preview tool that computes exactly what the write tool would do, and saves nothing. In Pilot-Next, settling a flight takes three steps:
get_booking_settlement_inforeturns the recorded start readings. The description tells the model to ask the pilot whether they are correct, and to ask for nothing else in the same message.preview_settlementbuilds the flight log in memory, runs it through the same rate and invoice calculation the application uses for its real invoices, and returns the invoice lines — then asks “Shall I finalize this invoice?”.settle_bookingsaves the flight log and creates the invoice. Its description starts withFINAL STEPand says it must not be called unless the pilot has seen and confirmed the preview.
The value of the preview is that the confirmation means something. “Shall I settle this flight?” is a question anyone says yes to. “This comes to these three invoice lines — shall I finalize it?” is a question a pilot actually checks, because the numbers are the numbers that will be invoiced.
The protocol itself says there should always be a human in the loop who can deny a tool call, and that clients should ask for confirmation on sensitive operations. It also defines hints such as readOnlyHint and destructiveHint — but the same specification says a client must treat those hints as untrusted unless the server is trusted. A hint in a tool list is not a confirmation. A preview that shows the real outcome is.
Why does an assistant need a context tool?
Because the model does not know what day it is, and will guess. “Tomorrow” and “next Saturday” mean nothing without a date, and a model that has to fill one in picks a year from its training data. Several Pilot-Next tool descriptions still carry the warning that came out of this: Do NOT guess the year (e.g. 2024).
The fix came in three steps, and the history is the lesson:
- 18 December 2025 — a context tool that returns the fleet, the user’s default aircraft, their preferences, and the current date and time. The idea was that the assistant should make sensible assumptions instead of asking “robotic” questions about today’s date.
- 22 December 2025 — renamed to
get_dispatch_context_and_time, “for better discoverability” in the words of the commit. A name that says time gets picked when the question is about time. - 25 December 2025 — the context tool now returns a token containing today’s date, and the tools that search or book require that token as an argument.
Today five tool descriptions tell the model to call the context tool first, and three tools cannot be called without its token. The instruction alone was not enough; making the call a required input was. When something must happen before a tool runs, put it in the schema, not only in the prose.
Should the MCP server contain its own logic?
As little as possible, and we learned that the slow way. The tools receive the application’s existing services — bookings, rates, invoices, balances — and call them. create_booking saves through the same booking service the web app uses; the preview calculates with the same rate and invoice services as a real invoice.
The clearest case of a tool doing its own query was the balance. It read the ledger itself, sorted slightly differently from the web app, and could therefore show an intermediate balance that the screen never showed. In September 2026 it was changed to make exactly the same call as the web app, with a comment explaining why: one source for the balance, so the assistant can never quote a number the user cannot find in the app. Any logic in the MCP layer is a second implementation, and a second implementation drifts.
How do errors reach the model?
As text the model can pass on. The protocol distinguishes protocol errors (an unknown tool, invalid arguments) from tool execution errors, which are returned as a normal result with isError: true. Pilot-Next uses the second kind in 28 places, and the text is written for the user who will hear it: an overlapping booking or an aircraft that is due for maintenance comes back as a sentence the assistant can repeat, not as a stack trace.
One error was not an error at all, and it is the most useful thing in this article. Models send booleans as strings surprisingly often. In Zod 4.1.13, z.coerce.boolean() uses JavaScript’s Boolean(), so the string "false" becomes true — we checked it again on 24 September 2026. The server has its own boolean schema that maps "true" and "false" explicitly and rejects everything else. If your tool has a flag that decides whether an invoice is created, this is not a detail.
Which transport?
Streamable HTTP on one endpoint, /mcp, is the current HTTP transport of the specification; it replaced the HTTP+SSE transport of protocol version 2024-11-05. Pilot-Next serves both: Streamable HTTP for current clients, and the old SSE endpoints for clients that have not moved yet — Home Assistant is the example the code names. The specification describes exactly that as the backwards-compatible route.
Two details from production. When a session is unknown — after a restart, or after it expired — the server answers 404, which the specification prescribes and which makes a client start a new session. A 410 Gone would look more precise, and the code comment records why it is not used: some clients treat it as permanent. And requests within one session are handled strictly in order, except the long-lived GET stream, which would otherwise block every request after it.
How long did it take?
The first code dates from 12 December 2025. Of the 163 commits that have touched the MCP module since, 124 were made in December 2025, on nine days. After that it went quiet: one commit in January, then 9, 4 and 3 in March, April and May, and 22 in September 2026.
So a first version really is a matter of days — but it was not the tools that took the month. A keyword count of the 124 December commit messages gives 60 about the connection (SSE streams, heartbeats, handshakes, the order of requests, a client that dropped the first turn) and 35 about dates and context; nine touch both. Different clients broke in different ways, and a hand-built SSE transport was patched for each of them until, on 22 December 2025, the server moved to the official TypeScript SDK and later to its Streamable HTTP transport. The lesson we would take from that month: use the official SDK from the first commit, and spend the days you save on the tool descriptions.
Today the module is 3,493 lines of code, with 73 tests on the tools, the protocol handling and the listing. One of those tests fails if the tools the server registers drift from the list that is published in MCP registries, so the description a user reads before connecting can never disagree with the server they connect to.
What we would tell anyone starting
- Start with the questions, not the actions. Fourteen of our seventeen tools read. That is where the assistant is useful on day one, and where a mistake costs nothing.
- Give every write tool a preview that runs the real calculation and saves nothing. Confirmation is only as good as what the user is shown.
- Put prerequisites in the schema. An instruction in a description is a request; a required argument is a rule.
- Call your existing services, not your own queries. Where we did not, the assistant could show a different balance from the app.
- Use the official SDK from day one. Half of our first month went into connection problems in a transport we had built by hand.
- Measure your tool list. Ours is 11 KB and more than half of it is input schema. Every parameter is read before every answer.
- Test string booleans.
"false"is true in more places than you think.
This is the kind of feature we mean by AI that actually takes work off the table: not a chat box next to the product, but the product itself, operable from the conversation the user is already having. How Pilot-Next is built beyond the MCP server is on the Pilot-Next case page.