At the end of June I built an MCP server over a clinic booking platform. It was the obvious version: the tools mapped onto the platform's API, everything was read-only, and it took about two days. I meant it as a demo, and as a demo it worked. Kind of.
I switched it off a few weeks later, because it did not prove useful. This week I dusted it off and rebuilt it on a different foundation, and that version took a fraction of the effort and is the one now in use. The gap between the two is worth writing down, because the first version was not badly built. It was pointed at the wrong layer, and the symptom did not look like an architecture problem at all.
It died of chattiness
The reason I stopped using it was mundane. It was too slow to be worth opening.
A question a person would ask in one breath, something like whether a particular service can be done at a particular site in the next fortnight, does not map onto one API call. It maps onto a sequence of them: resolve the service, resolve the site, check what that site can actually deliver, then go and look at availability. The API exposes the platform's nouns, correctly, because that is an API's job. A model driving it has to assemble the answer out of a dozen small requests, and it has to do that every time, because it is reasoning from scratch rather than following a path someone designed.
That collides with a real constraint. The upstream platform gives us a modest per-minute ceiling, and the MCP server was not the only thing using it. I throttled the server well below the ceiling so it could not starve the assistant that was already running there, and once throttled, the round trip on an ordinary question got long enough that I would rather have looked the answer up by hand. Which is what I did, which is how I noticed.
There was a second reason, worth its own piece: connecting a client's production system to a chat client raises a question about whose account the connection lives on and what that account's terms permit. I was not willing to leave a live clinic system wired to a personal, non-commercial account while I worked it out.
The problem I had not got to yet
If the speed had been tolerable I would have hit something worse a month later.
The assistant already running on that platform had a booking path with a lot of accumulated judgement in it. Which services can actually be delivered at which site. What has to be resolved before anything is created, and what has to be confirmed by a person. What happens when the request is not really a booking and needs recording a different way. None of that lives in the platform's API, because none of it is the platform's business. It lives in the middleware I had built in front of the platform, which is where the rules about one particular operation belong.
So the direct-API version could never have grown a booking tool without me rebuilding all of that judgement a second time, in a second codebase, where the two copies would have started drifting apart about a week after I shipped. I have written before about what changes when a bespoke integration becomes a reusable core. This was that lesson arriving backwards: I had already built the core, then built something beside it that could not reach it.
Why the second attempt was easy
The middleware was not designed for an MCP server. It was designed because an assistant needed to answer patient questions without making twenty calls per question, and needed the rules to live in one place. Those are the same two things a chat client needs, for the same reason, because there is a language model on the other end of both.
So the second version was mostly adaptation rather than construction. The operations already existed at the right size, each one answering a whole question instead of exposing a noun. The rules already sat behind them. The rate ceiling was already being managed by the component that could see all the traffic, rather than by two callers guessing about each other. What I had to add was an identity for the new channel and a gate on the write. The chattiness problem never came back, because the layer I was now calling had been shaped by someone who had already lost that argument once.
Two details from that version I would keep in any build. Every booking records which channel created it, with this one recorded as its own source rather than dressed up as a member of staff, so months later the record can answer honestly how a given appointment came to exist. And the write is two steps: the tool records a draft and reaches nothing, the person driving it sees what is about to happen and says yes explicitly, and only then is anything created. Consent is never inferred from somebody pasting a request into a chat window. That is the same bar I set before an assistant earns write access to a live system, and a general-purpose chat client is a reason to raise it rather than relax it, because a general client is very good at sounding confident about something it has half understood.
What I do not know yet
It has been smooth, and I want to be careful about what that word is doing. It has been running for days rather than months, and it has been handling the straightforward cases. The interesting ones are still ahead: the ambiguous request, the slot that goes while you are still talking about it, the thing that looks like a booking and is not one.
The approach also has a cost I am paying right now. The next thing I want to build over this connection serves a different group of users, and the middleware has no path for it yet. Under the direct-API approach I could have something demonstrable this week, and it would be as unusable as the first version was. Under this one I build the path first, after which the tool is nearly trivial. That is the right order and it is slower to start, and I would rather say so than pretend the rule is free.
If you are about to build one
- Ask what a real question costs in API calls before you pick where the tools point. A model reasons from scratch every time, so the count you get in testing is the count you get forever.
- Find the layer that holds your rules about this particular client's operation. If one exists, the tools belong on top of it. If none exists, you are about to build the first of two copies.
- Check the upstream's rate ceiling early, and check who else is already spending it. Two independent callers on one ceiling is a design decision, whether or not anybody made it deliberately.
- Settle which account the connection lives on, and what its terms allow, before you wire a client's production system to it.
- Treat the chat client as a channel rather than an administrator. Give it its own identity in your records so you can tell later what it did.
If you are at this fork with a client's production system on the other side of it, the useful question is not which transport or which tools. It is whether the layer you are about to call was built for something that reasons, or for something that already knows what it wants.
Related reading: what changes when a bespoke integration becomes a reusable core on building the layer this piece depends on, and the security pass an assistant earns before write access on the bar a write has to clear.