AI-ontwikkeling
Een MCP-server op een bestaande SaaS: 17 tools in productie
Wat we leerden van een MCP-server op Pilot-Next: 14 lezende en 3 schrijvende tools, een toollijst van 11 KB, voorbeeld vóór een factuur, de datum die AI gokt.
Now-Next bouwt SaaS-producten en neemt bestaande over. Wat we bouwen
Pilot-Next is een reserverings- en factuursysteem voor vliegclubs, en het heeft een MCP-server — de eerste code daarvoor is van december 2025. Een lid vraagt een assistent zoals Claude wat een vlucht naar Lelystad kost, of het toestel zaterdag vrij is, en laat hem daarna boeken — in hetzelfde gesprek, zonder de app te openen.
Handleidingen over MCP toevoegen aan een SaaS-product beschrijven meestal een server in het algemeen. Dit stuk beschrijft een server die draait, die wij bouwden en die we nog steeds onderhouden. Elk getal hieronder is op 31 oktober 2026 gemeten aan zijn eigen code.
Dit artikel gaat over het ontwerp van de tools: welke, hoe je ze beschrijft, en hoe je voorkomt dat een assistent handelt op een gok. Hoe Pilot-Next toegangsbeheer regelt laten we hier bewust buiten.
Wat voegt een MCP-server toe aan een bestaande SaaS?
Met een MCP-server kan een AI-assistent buiten je product je product gebruiken: iets opzoeken, iets uitrekenen en een handeling uitvoeren namens de gebruiker die met hem praat. Het Model Context Protocol legt vast hoe de assistent die tools vindt en aanroept, dus één server kan elke client bedienen die dezelfde revisie spreekt. Voor een bestaande SaaS is het een nieuwe voordeur, geen nieuw product: de bedrijfslogica blijft waar hij was, en de MCP-server is een dunne laag die een toolaanroep vertaalt naar dezelfde serviceaanroep die je eigen schermen doen.
Hoe voeg ik een MCP-server toe aan een bestaand SaaS-product?
Een MCP-server voeg je aan een bestaand SaaS-product toe als dunne laag voor de services die het product al heeft: begin met tools die alleen lezen, en voeg een schrijvende tool pas toe waar de gebruiker de uitkomst eerst kan zien. Voor een gehoste SaaS spreekt de server Streamable HTTP op één eindpunt en is hij gebouwd op een officiële SDK. Op volgorde:
- Zet de vragen op een rij die gebruikers al stellen. Elke vraag wordt een lezende tool. Begin bij supporttickets, niet bij je API-documentatie: een ticket zegt wat iemand wilde weten.
- Kies de protocolrevisie. Kijk wat je SDK en je beoogde clients spreken voordat je iets ontwerpt; revisie 2026-07-28 kent geen handshake en geen sessies; eerdere revisies gebruiken een
initialize-handshake, met optionele sessies op Streamable HTTP. - Registreer tools die je bestaande services aanroepen, geen nieuwe query’s, zodat de assistent nooit een getal noemt dat de app niet toont.
- Bepaal namens wie de assistent handelt. Autorisatie is in het protocol optioneel, maar een gehoste server die het ondersteunt hoort het autorisatiehoofdstuk van de specificatie te volgen, dat op OAuth 2.1 voortbouwt. Bepaal de gebruiker per verzoek uit het toegangstoken en laat de rechtencontrole aan je bestaande services.
- Zet een voorbeeld voor elke schrijvende tool die de gebruiker geld kost, dat de echte berekening draait en niets opslaat.
- Maak voorwaarden tot verplichte argumenten. Een instructie in een omschrijving kan het model negeren, een ontbrekend verplicht argument niet.
- Meet de toollijst. Veel clients leggen hem bij elke beurt helemaal aan het model voor.
- Test met elke client die je gebruikers hebben. Het protocol is gedeeld, het gedrag niet: zie wat Gemini deed, verderop.
De rest van dit artikel laat zien hoe de meeste van die stappen uitpakten op een server die in productie draait; toegangsbeheer blijft erbuiten.
Hoeveel tools heeft een MCP-server nodig?
Pilot-Next heeft 17 tools: 14 die alleen lezen en 3 die schrijven. De schrijvende tools zijn de drie handelingen die iets veranderen waar een lid om geeft: een reservering maken of annuleren, en een gevlogen vlucht afrekenen tot een factuur. Al het andere beantwoordt een vraag.
| tool | wat hij doet | leest of schrijft |
|---|---|---|
get_dispatch_context_and_time | vloot, standaardkeuzes, voorkeuren en de datum van vandaag | leest |
get_weather | METAR en TAF voor een of meer vliegvelden | leest |
get_my_balance | het huidige saldo | leest |
get_my_bookings | komende en recente reserveringen | leest |
get_unsettled_bookings | gevlogen maar nog niet afgerekend | leest |
get_pilot_activity | vliegactiviteit van een piloot | leest |
get_aircraft_availability | wanneer een toestel vrij is | leest |
find_available_slots | een vrij blok van een gegeven lengte | leest |
get_aircraft_hours | de actuele Hobbs- en Tacho-stand | leest |
get_maintenance_forecast | wanneer onderhoud nodig is, op basis van gebruik | leest |
get_flight_statistics | vlieguren per toestel per jaar | leest |
estimate_flight_cost | wat een vlucht kost vóór hij geboekt is | leest |
get_booking_settlement_info | beginstanden en tarieven van een vlucht die afgerekend wordt | leest |
preview_settlement | de factuur zoals hij zou worden, zonder op te slaan | leest |
create_booking | reserveert een toestel | schrijft |
cancel_booking | annuleert een reservering | schrijft |
settle_booking | slaat het vluchtlog op en maakt de factuur | schrijft |
Naast de tools biedt de server één prompt (een vluchtbriefing) en geen resources. Er was er ooit één: een tekst met “clubreglementen” die in de code bleek te zijn geschreven en aan elke club werd geserveerd alsof het de hunne was. Hij is weggehaald in plaats van gerepareerd, want er bestaan geen reglementsgegevens om te serveren. Een resource die gezaghebbend klinkt en het niet is, is erger dan geen resource: de assistent citeert hem met hetzelfde vertrouwen als een echt saldo.
Het getal dat ertoe doet is niet 17 maar de verhouding. Een assistent is nuttig zodra hij vragen kan beantwoorden, en elke schrijvende tool is een plek waar een verkeerde gok iets kost. Begin met de vragen die gebruikers nu aan je helpdesk stellen, en voeg alleen een schrijvende tool toe voor een handeling waarvan je het gevolg kunt laten zien voordat het gebeurt.
Hoe groot is de toollijst, en waarom doet dat ertoe?
Een client vraagt de toollijst op (tools/list) en geeft die meestal in zijn geheel aan het model, zodat het weet wat het kan aanroepen. Niet altijd: Claude Code laadt alleen de namen en haalt een definitie op zodra hij die nodig heeft. Wij hebben die lijst gemeten door de toolregistratie van de server te starten tegen een client in het geheugen en op te slaan wat terugkwam:
| deel van de toollijst | omvang |
|---|---|
de 17 tools in het tools/list-antwoord | 11.389 bytes JSON |
| alle 17 omschrijvingen samen | 3.572 tekens |
| alle 17 invoerschema’s samen | 6.011 tekens |
| parameters over alle tools | 44, elk met een eigen omschrijving |
| langste omschrijving | estimate_flight_cost, 421 tekens |
| grootste invoerschema | settle_booking, 937 tekens |
Twee dingen vallen op. De invoerschema’s wegen zwaarder dan de omschrijvingen — 6.011 tegen 3.572 tekens — omdat elke parameter een eigen type en omschrijving meedraagt. En de twee afreken-tools zijn samen al goed voor 1.852 tekens schema, omdat ze dezelfde acht parameters twee keer vragen: één keer voor het voorbeeld, één keer om op te slaan.
Dat is een bewuste afweging en geen ongeluk. Maar in een client die de hele lijst laadt, is elke parameter die je toevoegt tekst die het model leest vóór elk antwoord, of de gebruiker het nu over die tool heeft of niet. Een toollijst is een prompt die je niet ziet.
Hoe voorkom je dat een assistent schrijft zonder te vragen?
Met een voorbeeldtool die uitrekent wat de schrijvende tool zou doen, en niets opslaat. In Pilot-Next kost een vlucht afrekenen drie stappen:
get_booking_settlement_infogeeft de vastgelegde beginstanden. De omschrijving zegt het model de piloot te vragen of die kloppen, en in hetzelfde bericht niets anders te vragen.preview_settlementbouwt het vluchtlog in het geheugen, haalt het door dezelfde tarief- en factuurberekening die de applicatie voor haar echte facturen gebruikt, en geeft de factuurregels terug — met de vraag “Shall I finalize this invoice?”.settle_bookingslaat het vluchtlog op en maakt de factuur. Zijn omschrijving begint metFINAL STEPen zegt dat hij niet mag worden aangeroepen voordat de piloot het voorbeeld heeft gezien en bevestigd.
De waarde van het voorbeeld is dat de bevestiging iets betekent. “Zal ik deze vlucht afrekenen?” is een vraag waar iedereen ja op zegt. “Dit worden deze drie factuurregels — zal ik hem definitief maken?” is een vraag die een piloot echt nakijkt, omdat de bedragen de factuurregels zijn.
Het protocol zegt zelf dat er altijd een mens tussen hoort te zitten die een toolaanroep kan weigeren, en dat clients om bevestiging horen te vragen bij gevoelige handelingen. Het definieert ook hints zoals readOnlyHint en destructiveHint — maar dezelfde specificatie zegt dat een client die hints als onbetrouwbaar moet behandelen, tenzij de server vertrouwd is. Een hint in een toollijst is geen bevestiging. Een voorbeeld dat de echte uitkomst laat zien wel.
Waarom heeft een assistent een contexttool nodig?
Omdat het model niet weet welke dag het is, en gaat gokken. “Morgen” en “volgende zaterdag” betekenen niets zonder datum, en een model dat er een moet invullen, pakt een jaar uit zijn trainingsdata. Eén tool-omschrijving van Pilot-Next draagt nog letterlijk de waarschuwing die daaruit voortkwam: Do NOT guess the year (e.g. 2024). Twee andere dragen er een variant van.
De oplossing kwam in drie stappen, en juist die geschiedenis is de les:
- 18 december 2025 — een contexttool die de vloot, het standaardtoestel van de gebruiker, zijn voorkeuren en de actuele datum en tijd teruggeeft. Het idee: de assistent moet verstandige aannames doen in plaats van “robotachtige” vragen te stellen over de datum van vandaag.
- 22 december 2025 — hernoemd naar
get_dispatch_context_and_time, “for better discoverability” in de woorden van de commit. Een naam waar time in staat, wordt gekozen als de vraag over tijd gaat. - 25 december 2025 — de contexttool geeft nu een token terug met de datum van vandaag erin, en de tools die zoeken of boeken eisen dat token als argument.
Vandaag zeggen vijf tool-omschrijvingen dat het model eerst de contexttool moet aanroepen, en drie tools zijn niet aan te roepen zonder zijn token. De instructie alleen was niet genoeg; de aanroep een verplichte invoer maken wel. Moet er iets gebeuren voordat een tool draait, zet het dan in het schema, niet alleen in het proza.
Hoort de MCP-server eigen logica te bevatten?
Zo weinig mogelijk, en dat hebben we op de langzame manier geleerd. De tools krijgen de bestaande services van de applicatie mee — reserveringen, tarieven, facturen, saldo’s — en roepen die aan. create_booking slaat op via dezelfde reserveringsservice die de webapp gebruikt; het voorbeeld rekent met dezelfde tarief- en factuurservices als een echte factuur.
Het duidelijkste geval van een tool met een eigen query was het saldo. Die tool las het grootboek zelf, sorteerde net iets anders dan de webapp, en kon daardoor een tussenstand tonen die het scherm nooit liet zien. In september 2026 is dat veranderd naar precies dezelfde aanroep als de webapp, met een opmerking in de code waarom: één bron voor het saldo, zodat de assistent nooit een bedrag noemt dat de gebruiker in de app niet terugvindt. Elke regel logica in de MCP-laag is een tweede implementatie, en een tweede implementatie loopt uit de pas.
Hoe bereiken fouten het model?
Als tekst die het model kan doorgeven. Het protocol onderscheidt protocolfouten (een onbekende tool, een misvormd verzoek) van fouten bij het uitvoeren, zoals een mislukte invoercontrole, die als gewoon resultaat terugkomen met isError: true. Pilot-Next gebruikt die tweede soort op 29 plekken, en de tekst is geschreven voor de gebruiker die hem te horen krijgt: een overlappende reservering of een toestel dat aan zijn inspectie toe is, komt terug als een zin die de assistent kan herhalen, niet als een stacktrace.
Eén fout was helemaal geen fout, en het is het nuttigste van dit artikel. Modellen sturen booleans verrassend vaak als tekst. In Zod 4 gebruikt z.coerce.boolean() de Boolean() van JavaScript, dus de tekst "false" wordt true — op 31 oktober 2026 nog eens nagemeten. De oplossing is een eigen boolean-schema dat "true" en "false" expliciet vertaalt en andere teksten weigert. Eén tool repareren repareert de volgende niet: houd het schema op één plek en test elke vlag. Bepaalt een vlag in je tool of er een factuur wordt gemaakt, dan is dit geen detail.
Welk transport?
Streamable HTTP op één eindpunt is het HTTP-transport van de huidige specificatie, revisie 2026-07-28. Die revisie schrapte ook de protocolsessies en de initialize-handshake en verplicht elke server server/discover te bieden; clients mogen die aanroepen maar hoeven het niet. Een server die alleen voor die revisie gebouwd is houdt geen sessie per client bij; staat die tussen aanroepen moet blijven bestaan, gaat in een handle die als toolargument meekomt. Het oudere HTTP+SSE-transport staat als deprecated vermeld.
Pilot-Next zit niet op die revisie. Hij draait op versie 1 van de officiële TypeScript-SDK, die 2025-11-25 spreekt, en de README van de SDK plaatst ondersteuning voor 2026-07-28 in v2. Begin je nu, kijk dan eerst welke revisie je SDK en je beoogde clients spreken: het antwoord bepaalt of je de handshake nodig hebt, en een server die alleen 2026-07-28 spreekt is voor oudere clients onbereikbaar.
Pilot-Next biedt onder /mcp twee transporten: Streamable HTTP voor de huidige clients, en de oude SSE-eindpunten voor clients die nog niet zijn overgestapt — Home Assistant is het voorbeeld dat de code noemt. De specificatie van 2025-11-25 beschrijft precies dat als de achterwaarts compatibele route.
Twee details uit productie. Is een sessie onbekend — na een herstart, of omdat hij verlopen is — dan schrijft revisie 2025-11-25 een 404 voor, waarna de client een nieuwe sessie moet starten. Onze server deed dat tot 29 oktober 2026, toen bleek dat Gemini dat niet volgt: die bleef het oude sessie-id sturen en meldde dat hij niet kon verbinden. Een POST met een onbekende sessie krijgt nu één keer antwoord zonder sessie, en alleen de langlopende GET-stream krijgt nog de 404. Een client die de specificatie negeert blijft jouw probleem zolang je gebruikers ermee werken. En verzoeken binnen één sessie worden strikt op volgorde afgehandeld, behalve de langlopende GET-stream, die anders elk verzoek erna zou blokkeren.
Hoe lang duurde het?
De eerste code is van 12 december 2025. Van de 201 commits die de MCP-module sindsdien raakten, zijn er 124 gemaakt in december 2025, op tien dagen. Daarna werd het stil: één commit in januari, dan 9, 4 en 3 in maart, april en mei. In september en oktober 2026 kwamen er weer 30 per maand bij.
Een eerste versie is dus echt een kwestie van dagen — maar het waren niet de tools die de maand kostten. Een telling op trefwoorden in de 124 commitberichten van december geeft 60 over de verbinding (SSE-stromen, heartbeats, handshakes, de volgorde van verzoeken, een client die de eerste beurt liet vallen) en 35 over datum en context; negen gaan over allebei. Verschillende clients braken op verschillende manieren, en een zelfgebouwd SSE-transport werd voor elk van hen opgelapt tot de server op 22 december 2025 overstapte op de officiële TypeScript-SDK, en later op diens Streamable HTTP-transport. De les die we uit die maand meenemen: gebruik de officiële SDK vanaf de eerste commit, en besteed de dagen die je daarmee wint aan de tool-omschrijvingen.
De officiële SDK neemt je het protocol uit handen, niet de eigenaardigheden van elke client. Op 29 oktober 2026 kreeg de module tien commits voor één client. Naast het sessiegedrag hierboven toonde Gemini in onze proeven de naam en het pictogram van het domein in de server-URL, niet die uit de metadata van de server zelf, en daarom werd het adres mcp.pilot-next.com. Zeven van die tien commits gingen op aan de naam en het pictogram.
Vandaag is de module 3.608 regels code, met 178 tests op de tools, de protocolafhandeling en de registervermelding. Eén van die tests faalt zodra de tools die de server registreert afwijken van de lijst die in MCP-registers staat, zodat de omschrijving die een gebruiker leest vóór hij verbindt nooit kan verschillen van de server waarmee hij verbindt.
Wat we iedereen meegeven die begint
- Begin met de vragen, niet met de handelingen. Veertien van onze zeventien tools lezen. Daar is de assistent op dag één nuttig, en daar kost een fout niets.
- Geef elke schrijvende tool die de gebruiker geld kost een voorbeeld dat de echte berekening draait en niets opslaat. Een bevestiging is zo goed als wat de gebruiker te zien krijgt.
- Zet voorwaarden in het schema. Een instructie in een omschrijving is een verzoek; een verplicht argument is een regel.
- Roep je bestaande services aan, geen eigen query’s. Waar we dat niet deden, kon de assistent een ander saldo tonen dan de app.
- Gebruik de officiële SDK vanaf dag één. Ongeveer de helft van de commits uit onze eerste maand ging op aan verbindingsproblemen in een transport dat we zelf hadden gebouwd.
- Meet je toollijst. De onze is 11 KB en meer dan de helft daarvan is invoerschema. Laadt een client de hele lijst, dan wordt elke parameter gelezen vóór elk antwoord.
- Test in elke tool booleans die als tekst binnenkomen.
"false"is op meer plekken waar dan je denkt.
Dit is het soort functie dat we bedoelen met AI die werk overneemt: geen chatvenster naast het product, maar het product zelf, te bedienen vanuit het gesprek dat de gebruiker toch al voert. Hoe Pilot-Next verder gebouwd is, staat op de casepagina van Pilot-Next.