Unless you spent the past year living under a rock, you have probably heard the foreign acronym MCP. It is a protocol that lets language models interact with external functions and services.
It works like Remote Procedure Call: a model can invoke a function that executes on another server or inside another application.
Instead of everyone inventing custom tools for every external resource and service, Anthropic decided to standardize the whole mess. You can now connect a pile of MCP tools to a coding agent — I wrote about the most useful MCP here — and let it interact with different systems while completing a task.
Using MCP is straightforward enough. But how should we build a custom MCP server?
The internet is full of startups and SaaS products that generate a ready-made MCP server from an OpenAPI specification — here is one example. The problem is that this often encourages a hard anti-pattern that is already widespread across the industry.
What could be easier? Wrap every existing REST endpoint in an MCP tool and call it a day. The budget has been spent, the stakeholders smile proudly, and someone adds an “AI copilot” badge to the product.
Why is this bad?
Imagine a project with users and teams. It exposes the following endpoints:
- GET /user/:id — retrieve a user by ID
- PUT /user/:id — update a user
- GET /teams — retrieve all teams
- GET /teams/:id — retrieve a team by ID
You wrap those endpoints one-to-one as MCP tools. Job done.
Now a user asks the agent: “Move Sasha from the marketing team to the engineering team.”
In the UI, you would select a person from a dropdown and click a button. Your agent, however, starts performing a five-act ballet with the REST wrappers:
1. Call GET /users to find every Sasha
2. Parse the response to identify the right Sasha
3. Call GET /teams to find marketing and confirm Sasha belongs there
4. Call GET /teams again to find engineering
5. Finally call PUT /user/:id with Sasha's UUID
Five calls instead of one. This is the simplified version; a real system may also have authorization checks, validation, logging, and all sorts of other baggage.
Then comes problem number two: UUIDs are terrible for LLMs. Language models hallucinate UUIDs with enthusiasm. A model may decide that
a1b2c3d4-e5f6-7890-abcd-ef1234567890
is a valid user ID simply because it looks like a UUID it saw in the context.
The agent tries to update a user who does not exist, gets a 404, starts retrying, burns tokens, and eventually gives up. Meanwhile, the user concludes that this whole AI thing is useless.
UUIDs also tokenize very inefficiently. One UUID takes roughly 23 tokens, depending on the model. If a response contains a hundred users and their UUIDs, you can imagine what that does to your context engineering.
You can read more about dealing with UUIDs here.
Return to our request: “Move Sasha from marketing to engineering.”
This is a concrete action with clear business logic. Your REST API, however, thinks in resources: users, teams, and their properties.
The agent wants to perform an action:
moveUserToTeam(user: "Sasha from marketing", targetTeam: "engineering")
Instead, it receives a collection of CRUD operations that it must orchestrate itself.
The agent now has to conduct the whole orchestra: check, retrieve, update, and verify again. What happens if something fails halfway through? The sequence of REST calls is not transactional. After several requests, you may end up with Sasha somehow halfway between marketing and engineering.
So what is the right approach?
The answer is in the comments — I ran out of space. 👇 @makebugger