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 invoice, and the date models get wrong.
Now-Next builds SaaS products and takes over existing ones. What we build
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 31 October 2026.
This article is about tool design: which tools, how they are described, and how an assistant is kept from acting on a guess. How Pilot-Next handles 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 can serve every client that speaks the same revision. 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 do I add an MCP server to an existing SaaS product?
To add an MCP server to an existing SaaS product, build it as a thin layer in front of the services the product already has, start with tools that only read, and add a write tool only where the user can see the outcome first. For a hosted SaaS, the server speaks Streamable HTTP on one endpoint and is built on an official SDK. In order:
- List the questions users already ask. Each one becomes a read tool. Start from support tickets, not your API reference: a ticket says what someone wanted to know.
- Pick the protocol revision. Check what your SDK and your target clients speak before you design anything; revision 2026-07-28 has no handshake and no sessions; earlier revisions use an
initializehandshake, with optional sessions on Streamable HTTP. - Register tools that call your existing services, not new queries, so the assistant can never quote a number the app does not show.
- Decide who the assistant acts for. Authorization is optional in the protocol, but a hosted server that supports it should follow the authorization chapter of the specification, which builds on OAuth 2.1. Resolve the user from the access token on every request and leave the permission checks to your existing services.
- Put a preview in front of every write tool that costs the user money, running the real calculation and saving nothing.
- Make prerequisites required arguments. An instruction in a description can be ignored; a missing required argument cannot.
- Measure the tool list. Many clients put all of it in front of the model on every turn.
- Test with every client your users have. The protocol is shared, the behaviour is not: see what Gemini did, further down.
The rest of this article shows how most of those steps turned out on a server that runs in production; access control stays out of it.
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?
A client asks for the tool list (tools/list) and usually gives it to the model in full, so the model knows what it can call. Not always: Claude Code loads only the names and fetches a definition when it needs one. 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 |
|---|---|
the 17 tools in the tools/list answer | 11,389 bytes of JSON |
| all 17 descriptions together | 3,572 characters |
| all 17 input schemas together | 6,011 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 — 6,011 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 in a client that loads the full list, 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 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 invoice lines.
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. One Pilot-Next tool description still carries the warning that came out of this, word for word: Do NOT guess the year (e.g. 2024). Two others carry a variant of it.
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, a malformed request) from tool execution errors, such as a failed input check, which are returned as a normal result with isError: true. Pilot-Next uses the second kind in 29 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, z.coerce.boolean() uses JavaScript’s Boolean(), so the string "false" becomes true — we checked it again on 31 October 2026. The fix is a boolean schema of your own that maps "true" and "false" explicitly and refuses other strings. Fixing one tool does not fix the next: keep the schema in one place and test every flag. 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 is the HTTP transport of the current specification, revision 2026-07-28. That revision also removed protocol sessions and the initialize handshake and requires every server to implement server/discover, which clients may call but do not have to. A server built only for that revision keeps no session per client; state that must last between calls goes into a handle passed as a tool argument. The older HTTP+SSE transport is listed as deprecated.
Pilot-Next is not on that revision. It runs version 1 of the official TypeScript SDK, which speaks 2025-11-25, and the SDK’s README places support for 2026-07-28 in v2. If you start now, first check which revision your SDK and your target clients speak: the answer decides whether you need the handshake, and a server that speaks only 2026-07-28 cannot be reached by older clients.
Pilot-Next serves two transports under /mcp: 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 2025-11-25 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 — revision 2025-11-25 prescribes a 404, after which the client must start a new session. Our server did that until 29 October 2026, when Gemini turned out not to follow it: it kept sending the old session ID and reported that it could not connect. A POST with an unknown session is now answered once without a session, and only the long-lived GET stream still gets the 404. A client that ignores the specification is still your problem if your users use it. 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 201 commits that have touched the MCP module since, 124 were made in December 2025, on ten days. After that it went quiet: one commit in January, then 9, 4 and 3 in March, April and May. It picked up again with 30 commits each in September and October 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.
The official SDK takes care of the protocol, not the habits of each client. On 29 October 2026 the module got ten commits for one client. Besides the session behaviour above, in our tests Gemini showed the name and icon of the domain in the server URL, not those in the server’s own metadata, which is why the address became mcp.pilot-next.com. Seven of those ten commits went on the name and the icon.
Today the module is 3,608 lines of code, with 178 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 that costs the user money 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. About half of our first month’s commits 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. Where a client loads the full list, every parameter is read before every answer.
- Test string booleans in every tool.
"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.