All posts
AI

MCP Got My Agents Connected. Here's Everything I Had to Build Around It

MCP made it easy to describe a tool once and let the model call it; making those tool calls hold up in production was a separate job, and that is where most of my time went.

On this page · 6 sections
  1. Demo vs production
  2. Invalid arguments need strict validation
  3. Slow calls need timeouts
  4. Retries can repeat actions
  5. Per-user credentials, not a shared account
  6. What I’d still like the ecosystem to handle

MCP solved a real problem for me. Before it, every tool I wanted an agent to use needed its own glue code, its own way of describing inputs, and its own way of returning results. With MCP, I describe a tool once, run a server, and the model can discover it and call it. That part got easy. Making those tool calls hold up in production was a separate job, and that is where most of my time went.

I build agentic AI workflows at EGS Enggist & Grandjean Software for Nooko.ai, an AI culinary and recipe platform. I work in Python and FastAPI, and tool calling and structured outputs do a lot of the work. My MCP servers run locally as Docker containers with local access. This post is about the distance between “the agent can call the tool” and “I trust the agent to call the tool for real users.”

Demo vs production

A demo is a single happy path. You ask the agent to save a recipe, it calls the tool with sensible arguments, the tool responds quickly, and the recipe shows up. Everyone nods.

Production is every other path. The model sends arguments that are almost right. A downstream call takes far longer than it should. A step gets retried and the same action runs twice. The tool needs to act on behalf of a specific user, with that user’s permissions, and you need to be able to say afterwards who did what.

MCP standardizes how a client and a server talk. It defines how a tool is described, how it is called, and how a result comes back. It does not decide what happens when the arguments are wrong, when a call hangs, or when the same request arrives twice. I don’t see that as a flaw. A message protocol shouldn’t try to be an entire runtime. But it does mean those problems are still yours to solve.

Invalid arguments need strict validation

The most common failure I see is the model sending arguments that don’t match what the tool expects. A required field is missing. A number arrives as a string. A date is in the wrong format. An ID looks plausible but doesn’t exist. The tool schema helps the model get it right most of the time, and most of the time is not enough when a tool writes data.

My rule is that nothing executes until the input has been validated strictly. In Python, Pydantic makes this easy. Something like this, using a meal plan tool as an example:

Python
from datetime import date
from pydantic import BaseModel, ConfigDict, Field, ValidationError
 
class AddToMealPlan(BaseModel):
    model_config = ConfigDict(extra="forbid")
 
    recipe_id: str = Field(min_length=1)
    plan_date: date
    servings: int = Field(ge=1, le=20)
 
def add_to_meal_plan(raw_args: dict) -> dict:
    try:
        args = AddToMealPlan.model_validate(raw_args)
    except ValidationError as e:
        return {
            "ok": False,
            "error": "invalid_arguments",
            "details": e.errors(include_url=False),
        }
    # Only now do any real work with args
    ...

I forbid extra fields on purpose. If the model invents a parameter, I want to know, not silently ignore it.

The other half of validation is the error message. A vague “bad request” gives the model nothing to work with. A message like “servings must be between 1 and 20, received 0” lets it correct the call on the next attempt. I treat error messages as part of the tool’s interface, written for a reader who will act on them right away. That also applies to checks a schema can’t express. If the recipe ID is well formed but doesn’t exist, or belongs to someone else, the error should say exactly that.

Slow calls need timeouts

The second problem is slow tool calls. A tool that calls an external service or does heavy processing can take much longer than expected, and the agent loop waits for it. The user waits too. Without a limit, a single stuck call can hold up an entire run.

Every tool call I run has a timeout. I set it per tool, based on what the tool actually does, rather than using one number for everything. A lookup should be quick. A heavier operation gets more room. In an async FastAPI service, the basic shape is something like this:

Python
import asyncio
 
async def call_with_timeout(tool, args, seconds: float) -> dict:
    try:
        return await asyncio.wait_for(tool(args), timeout=seconds)
    except asyncio.TimeoutError:
        return {
            "ok": False,
            "error": "timeout",
            "message": f"Tool did not finish within {seconds} seconds. "
                       "The outcome is unknown; check state before retrying.",
        }

The wording of that timeout result matters. For a read, a timeout just means try again or move on. For a write, it means you don’t know whether the operation happened. Telling the model “it failed” when the truth is “it may have succeeded” is how you end up with duplicates, which brings me to the next problem.

Retries can repeat actions

This is the one I think about most. Retries come from several places. The model may call a tool again after an unclear result. The client may retry after a timeout. The orchestration layer may replay a step. For reads, that is harmless. For writes, a retry can repeat the action. As an example, a recipe gets saved twice, or the same dinner gets added to a meal plan twice for the same night.

My main safeguard is idempotency keys. Each write carries a key that identifies the intended operation. The server stores the key with the result. If the same key arrives again, it returns the original result instead of doing the work a second time. I don’t rely on the model to come up with these keys, because it can’t be trusted to keep them stable across retries. I’d rather derive them in the orchestration layer, from the run and the step, or from the content of the operation itself.

Where a key isn’t practical, I check whether the write already happened before doing it. Before adding a recipe to a meal plan for a given date, look for that exact entry first. It is less clean than a key, but it catches a lot. It also pairs well with timeouts. After a write times out, the safe move is to check the current state, then decide whether to retry.

Per-user credentials, not a shared account

The last piece is how tools authenticate. The easiest setup is one service account with broad access that every tool call uses. It works in a demo. In production it means every agent action runs with the same, usually large, set of permissions, and your logs can’t tell you which user an action was for.

What I use and recommend is passing each user’s own token through to the tool call. The tool acts as that user, with that user’s permissions and nothing more. If the model tries to reach something the user can’t access, the downstream system refuses, the same way it would for the user directly. Every action is tied to a real person, which makes auditing straightforward. Some older parts of the system still use a shared service account, and that is legacy we are moving away from.

Passing tokens through takes some care. I keep tokens out of anything the model sees, so they never appear in prompts or in tool arguments the model writes. The server attaches them at the transport layer, and they stay out of logs.

What I’d still like the ecosystem to handle

None of this is exotic. Validation, timeouts, idempotency and scoped credentials are ordinary engineering. The issue is that every team building on MCP writes its own version of them. I’d like more of it to be standard: a common way to pass per-user credentials, a convention for idempotency keys on write tools, a shared shape for errors, and a way for a tool to declare whether it is safe to retry. Until that exists, I treat connecting the tool as the easy part and plan my time for everything around it.

Written by

Salih Yildirim

Senior Software Engineer

Senior software engineer across backend, cloud and front end, with AI where it helps. Currently building Nooko.ai, an AI culinary platform, at EGS.

Comments

Join the discussion

{ ideas }
<thoughts/>
// discuss
</>{ }( )=>&&||
Gathering thoughts

All posts on the blog