Geef het model geen rijen: MCP-tools ontwerpen voor je eigen productdata
De meeste MCP-servers zijn een REST-API met een ander jasje aan. Waarom dat niet werkt, en hoe je tools ontwerpt rond de vragen die mensen stellen — met C#-voorbeelden uit de MCP-server van SurveyLane.
Jean-Pierre Broeders
Freelance .NET Developer
De vraag die elke SaaS-bouwer nu krijgt
"Kunnen we hier geen AI op zetten?"
Als je een product hebt met een database erachter, heb je die vraag inmiddels gekregen. Van een klant, van een collega, of van jezelf om drie uur 's nachts. En sinds MCP een gedeelde standaard is, is het antwoord technisch gezien: ja, in een middag.
Dat is precies het probleem. Een MCP-server bouwen is makkelijk. Een MCP-server bouwen die een model daadwerkelijk goed kan gebruiken, is een ontwerpvraagstuk dat weinig lijkt op het bouwen van een REST-API. Ik ben er de afgelopen maanden ingelopen bij SurveyLane, en de fout die ik maakte is dezelfde die ik nu overal terugzie.
Dit stuk gaat over drie ontwerpregels die ik eruit heb overgehouden. Ze gelden voor elke MCP-server op productiedata, of je nu vragenlijsten, facturen of tickets ontsluit.
Twee manieren om het fout te doen
Er zijn twee reflexen, en ze zijn allebei verkeerd.
Reflex één: één krachtige tool.
[McpServerTool(Name = "run_query")]
[Description("Voert een SQL-query uit op de database.")]
public static Task<string> RunQuery(string sql) => _db.QueryAsync(sql);
Dit voelt elegant. Eén tool, oneindige mogelijkheden, geen onderhoud. Het is ook een ramp. Het model kent je schema niet, dus het verzint kolomnamen. Het schrijft joins die je database op zijn knieën krijgen. Elke prompt-injectie in je eigen data wordt ineens een SQL-injectie, want de tekst die het model leest bepaalt de query die het schrijft. En je krijgt tienduizend rijen terug die als tokens door het contextvenster gaan, waar ze het model juist dommer maken.
Reflex twee: je REST-API één op één omzetten.
Je hebt al veertig endpoints. Je zet er [McpServerTool] op, roept WithToolsFromAssembly() aan en klaar. Dit is de fout die ik maakte.
Wat er dan gebeurt: het model krijgt veertig beschrijvingen binnen die zijn geschreven voor developers, niet voor een model. GET /surveys/{id}/responses heet "haalt de antwoorden op". Prima documentatie voor een mens die de context al kent. Waardeloos voor een model dat moet beslissen of dit de tool is die het nodig heeft om "waarom haken mensen af bij vraag zeven" te beantwoorden.
Het model kiest dan de verkeerde tool, of het ketent er zes achter elkaar om iets te reconstrueren wat één goede tool in één keer had kunnen leveren. Je endpoints zijn ontworpen rond je datamodel. Vragen van mensen zijn dat niet.
Ontwerp op vragen, niet op tabellen
De regel die alles oploste: een tool hoort te corresponderen met een vraag die iemand daadwerkelijk stelt, niet met een resource in je datamodel.
Concreet betekent dat: ga niet in je schema zitten kijken. Ga in je supporttickets zitten kijken. Wat vragen mensen? Bij ons kwam daar zoiets uit:
- Wat is onze NPS, en hoe is die verdeeld?
- Waar haken mensen af?
- Verschillen managers van uitvoerenden op deze vraag, en is dat verschil echt of toeval?
- Welke vraag hangt het sterkst samen met een lage eindscore?
- Wat zeggen de mensen die een 6 gaven, letterlijk?
Dat zijn vijf tools. Vijf vragen, geen vijf endpoints. get_funnel beantwoordt de tweede, en respecteert intern de vertakkingen van de vragenlijst, want anders klopt de uitval niet. key_drivers beantwoordt de vierde en rangschikt correlaties tegen een doelscore. get_verbatims beantwoordt de vijfde en levert citaten gefilterd op score.
Elke tool doet het werk dat een analist zou doen. De naam en de beschrijving zijn geschreven voor het model, niet voor mij. Dat laatste is geen detail: de [Description] is de enige informatie waarop het model zijn keuze baseert. Schrijf daarin wanneer je deze tool gebruikt en wanneer niet, niet wat hij technisch doet.
Het model kiest, jouw server rekent
De tweede regel volgt uit een observatie die je zelf kunt controleren: een taalmodel is uitstekend in het bepalen dat er een t-toets nodig is, en beroerd in het uitvoeren ervan op ruwe data.
Vraag een model om uit 1.284 antwoorden een chi-kwadraat te berekenen en je krijgt een getal terug dat plausibel oogt. Soms klopt het. Dat "soms" is het probleem, want je ziet het verschil niet aan het antwoord.
Dus splits het: het model kiest de methode, jouw server voert hem uit. Statistiek is deterministisch, dus die hoort in C#, niet in een contextvenster.
[McpServerToolType]
public sealed class SignificanceTools
{
[McpServerTool(Name = "test_significance")]
[Description("""
Toetst of het verschil tussen groepen op één vraag statistisch significant is.
Kiest zelf t-toets, ANOVA of chi-kwadraat op basis van het vraagtype.
Gebruik dit wanneer iemand vraagt of een verschil 'echt' is of toeval.
Gebruik get_crosstab wanneer alleen de verdeling gevraagd wordt.
""")]
public static async Task<SignificanceResult> TestSignificance(
IResultService results,
ClaimsPrincipal user,
[Description("Id van de vragenlijst")] Guid surveyId,
[Description("Vraag waarop getoetst wordt")] Guid questionId,
[Description("Vraag waarop de groepen gesplitst worden")] Guid splitBy,
CancellationToken ct)
{
var accountId = user.RequireAccountId();
return await results.TestSignificanceAsync(accountId, surveyId, questionId, splitBy, ct);
}
}
Drie dingen om op te letten in dat blok.
De beschrijving vertelt het model wanneer het deze tool niet moet gebruiken. Dat scheelt meer verkeerde aanroepen dan welke andere ingreep dan ook.
De ClaimsPrincipal staat gewoon in de signatuur. De C#-SDK vult die automatisch, net als geregistreerde services, en hij verschijnt niet in het JSON-schema dat het model ziet. Het model kan het account dus niet meegeven, en dus ook niet vervalsen.
Het retourtype is een record, geen string met JSON erin. Je krijgt er typecontrole mee en een schema dat de SDK zelf genereert.
Registreren is verder onspannend:
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithToolsFromAssembly();
app.MapMcp();
De winst zit niet in de correctheid alleen. Een aggregaat is een paar honderd tokens waar de ruwe data er tienduizenden was. Dat scheelt geld, en het scheelt vooral kwaliteit. Zie mijn eerdere stuk over context rot voor waarom een groter contextvenster je niet redt.
Read-only is geen beveiligingsmodel
De derde regel is degene waar ik het langst over heb nagedacht, en het is de reden dat ik dit stuk schrijf.
"De MCP-server is alleen-lezen" klinkt als een afdoende antwoord op de securityvraag. Dat is het niet. Alleen-lezen voorkomt dat er iets kapotgaat. Het voorkomt niet dat er iets weglekt.
Zodra jouw data in een contextvenster staat, staat hij in een proces dat jij niet beheert, naast tekst die jij niet hebt geschreven. Als er ergens in die context een instructie staat, in een document dat de gebruiker erbij haalde, in een webpagina die de agent ophaalde, of in de vrije-tekstantwoorden van je eigen respondenten, dan is jouw alleen-lezen tool het instrument waarmee die instructie wordt uitgevoerd. Ik schreef eerder over prompt injection voorkomen en over wat er gebeurt als een agent uit zijn sandbox breekt. Dit is dezelfde klasse probleem, nu met jouw database eraan.
Wat wel helpt, in volgorde van belang.
Scope het token, niet de tool. Eén token hoort bij één account en dat account wordt server-side afgedwongen uit de ClaimsPrincipal, nooit uit een parameter. Een tool die een accountId accepteert, is een tool die je gaat lekken.
Geef aggregaten, geen rijen. Een tool die percentages en toetsuitslagen teruggeeft, kan geen persoonsgegevens lekken die er niet in zitten. Bij vrije tekst is dat lastiger, dus daar geldt een aparte afweging: onze get_verbatims levert citaten, en dat is precies het punt waarop je moet beslissen of je respondenten dat redelijkerwijs hadden kunnen verwachten.
Behandel je eigen data als niet-vertrouwd. Antwoorden van respondenten zijn gebruikersinvoer. Dat ze uit jouw database komen, maakt ze niet veilig. Escape ze, markeer ze als data, en ga er niet van uit dat een model onderscheid maakt tussen jouw instructies en de tekst die het via jouw tool binnenkrijgt.
Log elke aanroep met tool, argumenten en account. Bij een REST-API weet je meestal wel wie wat deed. Bij een agent die zelf zijn ketens bouwt, is je audittrail het enige wat je achteraf nog hebt.
Hoe dat er in SurveyLane uitziet
Ik heb dit gebouwd voor SurveyLane, mijn enquêteplatform. De server draait op https://mcp.surveylane.app, is alleen-lezen en beperkt tot het account van het token.
Koppelen is één commando:
claude mcp add surveylane https://mcp.surveylane.app \
--header "Authorization: Bearer slk_…"
Daarna praat je gewoon Nederlands tegen je data. "Waar haken mensen af, en verschilt dat tussen managers en uitvoerenden?" leidt tot een keten van get_funnel, get_crosstab en test_significance, waarbij het model zelf bepaalt welke het nodig heeft en in welke volgorde. Wat eruit komt is een uitgeschreven bevinding met de p-waarde erbij, niet een tabel die je zelf nog moet lezen.
De onderliggende tools zijn er inmiddels ruim twintig, en ze zijn allemaal op dezelfde manier ontstaan: iemand stelde een vraag die het platform nog niet kon beantwoorden, en die vraag werd een tool. Niet andersom.
Wat het opleverde, en wat niet
Dit heeft me meer tijd gekost dan het bouwen van de rest van het analysegedeelte. Niet het protocol, de C#-SDK doet het zware werk. Het ontwerp. Bepalen welke twintig vragen de moeite waard zijn, en die beschrijvingen zo schrijven dat een model consequent de juiste kiest, is werk dat je pas kunt doen als je weet wat mensen vragen.
Dat is meteen het advies waar ik zelf het meeste aan had gehad: bouw je MCP-server niet voordat je je supporttickets hebt gelezen. Je datamodel vertelt je hoe je data is opgeslagen. Je tickets vertellen je welke tools je nodig hebt. Die twee lijsten overlappen minder dan je denkt.
Wil je zien wat eruit komt zonder zelf iets te bouwen: het gratis plan van SurveyLane bevat de MCP-koppeling. Maak een vragenlijst, verzamel een paar antwoorden en vraag je AI-client wat er opvalt. Dat is binnen tien minuten te doen en het is leerzamer dan dit hele artikel.
In een volgend stuk ga ik in op het deel dat ik hier heb overgeslagen: hoe je tools versioneert zonder dat bestaande agentketens omvallen, en waarom het toevoegen van een tool riskanter is dan het wijzigen van een endpoint.
Veelgestelde vragen over MCP-servers op productiedata
Kan ik mijn bestaande ASP.NET Core-API hergebruiken als MCP-server?
Technisch wel: je kunt de attributen op je controllers zetten en het werkt. Maar je krijgt dan tools die zijn ontworpen rond je datamodel, en dat is precies de fout uit dit artikel. Beter is een aparte laag naast je API, die je bestaande services aanroept maar zijn eigen tool-ontwerp heeft.
Hoeveel tools zijn te veel?
Er is geen hard getal. De vuistregel is dat elke tool een vraag moet beantwoorden die je in je tickets kunt aanwijzen. Loopt het aantal op zonder dat je per tool zo'n vraag kunt noemen, dan ben je endpoints aan het exporteren. Bij ruim twintig tools met scherpe beschrijvingen zien we in de praktijk geen verwarring. Bij tien vage beschrijvingen wel.
Moet ik statistiek echt server-side doen?
Ja, als de uitkomst ertoe doet. Een model kan een significantietoets plausibel benaderen zonder hem correct uit te voeren, en aan het antwoord zie je dat niet. Alles wat deterministisch is en waarvan de uitkomst een beslissing stuurt, hoort in je eigen code.
Is alleen-lezen genoeg om dit veilig te noemen?
Nee. Alleen-lezen voorkomt schade aan je data, niet het weglekken ervan. De werkelijke grens ligt bij scoping op accountniveau, aggregatie in plaats van ruwe rijen, en het besef dat alles wat een tool teruggeeft in een omgeving belandt die jij niet beheert.
Werkt dit alleen met Claude?
Nee. MCP is een open protocol, dus elke client die het ondersteunt kan koppelen. Claude Code, Claude Desktop en Cursor zijn de clients waarmee ik het zelf heb getest.
Verder lezen
- Context rot: waarom een groter context window je LLM-feature niet redt
- Prompt Injection Voorkomen: LLM-Applicaties Beveiligen
- Toen een AI-agent inbrak bij Hugging Face: machine-speed aanvallen en je .NET-infra
- AI-agent leest je .env: waarom .codexignore telt
Dit artikel komt uit de praktijk van het bouwen van SurveyLane, een enquêteplatform met een gehoste alleen-lezen MCP-server. Vragen over MCP-servers, .NET of Azure-integraties? Neem contact op.
