DEV Community

DOS AI
DOS AI

Posted on

Putting an MCP server in front of a real product: four things I got wrong first

If you ship an MCP server for a product that already has a REST API, the tempting move is to wire the tools straight into your service layer. Skip the network hop, call the function, return the object. I did the opposite, and after a full audit of the surface I am glad I did. Here are the four decisions that turned out to matter, three of which I only got right on the second pass.

I build a platform where an AI assistant answers customers in WhatsApp and Telegram, and the owner manages projects, conversations and leads from a dashboard. The MCP server exposes a slice of that to AI agents.

1. Every tool calls your own HTTP route, not your service layer

This looks like pure waste. You are inside the same process, you have the client, you could call listConversations(projectId) directly and save 20ms.

Do not.

Our API keys carry a read-only flag, and the gate that enforces it reads the request method from a header that middleware sets. An in-process call has no request, no header, and therefore no gate. The same is true for role checks, project scoping and rate limits: all of them live on the route. Bypassing the route means reimplementing four security controls in a second place, where they will drift.

// tool definition, simplified
{
  name: "list_conversations",
  method: "GET",
  path: (args) => `/api/projects/${args.project_id}/conversations`,
  project: (args) => args.project_id,
}
Enter fullscreen mode Exit fullscreen mode

The tool layer becomes a projection: build a path, forward auth, shape the result. Nothing else. When we later added a new permission rank, no MCP code changed.

2. A 200 response is not a successful operation

Our send-message route returns HTTP 200 with { ok: false, error: "channel refused" } when the messaging provider rejects the payload. The route did its job, so 200 is correct.

The first version of the tool reported that as success. An agent told a human "message sent" about a message that never left the building. That is the worst class of bug in an agent product, because the agent is confident and the user has no reason to check.

So tools carry an explicit failure predicate:

{
  name: "send_message",
  failWhen: (body) => body?.ok === false,
  failMessage: (body) => `Channel refused the message: ${body?.error ?? "unknown"}`,
}
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: if your route can express failure inside a 200 body, your tool layer must know about it. HTTP status is a transport signal, not a business one.

3. Unwrap your envelope, or your confirmations arrive empty

Most of our routes answer with an envelope: { data: ... }, and a couple with { success: true, data: ... }. Human clients unwrap it without thinking. The tool projection did not.

The result was subtle. update_prompt returned { ok: true } and nothing else, because the interesting part sat one level deeper and got dropped. The agent had no way to confirm what it had written, so it either stayed vague or invented the detail.

const body = await res.json();
const payload = tool.unwrap ? unwrap(body) : body;
Enter fullscreen mode Exit fullscreen mode

Test this per tool. A single shared unwrap is not enough if some routes wrap and some do not, which is exactly the state most real APIs are in.

4. Idempotency keys must be built from meaning only

Our send tool deduplicates repeated calls. The first key looked like this:

const key = `${conversationId}:${text}:${Math.floor(Date.now() / 60_000)}`;
Enter fullscreen mode Exit fullscreen mode

The minute bucket felt harmless. It is not. Two identical calls that straddle a bucket boundary produce two different keys, so the duplicate goes through. The failure is rare, non-deterministic and impossible to reproduce on demand, which is another way of saying it will happen in front of a customer.

const key = `${conversationId}:${sha256(text)}`;
Enter fullscreen mode Exit fullscreen mode

If time belongs in the key, it should come from the domain (a booking slot, a billing period), never from the clock at call time.

The thing I decided on purpose: no destructive tools

There is no delete-project tool, no payment tool, no remove-member tool. Not because they are hard, but because an agent that can read a customer message can be talked into acting on it, and the blast radius of a confused delete is not recoverable by an apology.

A test enumerates the registry and fails if a tool name matches a destructive verb. That test exists so that a future me, in a hurry, has to argue with a red build instead of quietly adding one.

What I would tell someone starting today

Treat the MCP layer as a thin, dumb projection of an API you already trust. Every piece of intelligence you put in it is a piece of intelligence that now exists twice.

Check the three failure modes above with a real agent, not with curl. Curl reads a raw JSON body and you fill in the meaning yourself. An agent reads what your tool returns and reports it to a human as fact, so an empty confirmation or a false success becomes a lie with your name on it.

The spec of what we expose is public if you want to compare notes: dosai.pro/llms.txt. Happy to go into the auth details in the comments.

Top comments (0)