raduloff.dev


home blog

mcp in production: the good, the bad and the ugly

Boris Radulov
published on 2025-04-11T00:00:00.000Z

I genuinely believe technologies like MCP will change the way we interact with computers. After using it in production, I’m just not sure MCP itself will be the one to do it.

preface

I was recently contracted by a construction materials company in Bulgaria to build a custom web app for their traveling salespeople. Basically, these people drive around the entire country, looking for construction sites where they try to sell insulation and other construction materials.

The app has what one would expect: the ability to log sites you’ve visited, schedule follow-up visits, write down what materials they have on-site already, what competitors are present, etc. Currently, all this work was either being done on paper or in different phone note apps. This made data gathering and analytics impossible. The head of sales tried to make them standardize the data input in spreadsheets, but those proved very clunky to use on the go.

the problem

The main issue that I had to solve when creating this app was building a UI that they can easily navigate and that will force them to input all the data the head of sales wants in a standardized way. This is what I came up with (excuse the fact that it’s not in English; the individual fields are not relevant):

what the salesperson sees throughout the site visit

While both the head of sales and I think this is a good interface, the actual salespeople hated it:

mcp: the good

So what to do? Scrap the entire interface and give them a chatbot.

The development of this project coincided almost perfectly with the release of MCP. It seemed like a perfect candidate to solve all the problems above:

All of this, plus the LLM can ask follow-up questions to make sure the salesperson didn’t forget anything.

In MCP’s credit, it kind of does all of the above. In an afternoon, I was able to spin up a quick demo where Claude Desktop could access the MongoDB instance where we keep information about clients, accounts, and people, and have it write stuff down.

claude writing down the name of a new client

As someone who really hates designing forms, this felt like magic. This is what digitalization of services should be about, not just duplicating paper forms in digital format. You can easily extrapolate the rest from here. Give some more information about the products, prices, etc., and it just jots it all down, calling the appropriate tools in the backend.

It was also fairly easy to set up too, thanks to the python sdk:

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.server import Settings
from models import Entity, PersonData, CompanyData
from typing import Optional
from pydantic_mongo import PydanticObjectId

mcp = FastMCP("plutus-ai", instructions="""
    you're an assistant to a traveling salesman that sells construction materials.
    you note down all the information he takes in from visits to job sites.
    your job is to make sure no sales data gets left unused.
    you must record every person he meets with and every company he interacts with.
    make sure you link people to companies wherever possible. this is extremely important.
              
    you must also record all the information he takes in from visits to job sites.
    
    creating duplicate records is an extremely bad thing and will decimate the data quality.

    if you feel like you're missing any information, ask the user for more details.
              
    try to be proactive. don't ask for confirmation all the time. it's better to write something down
    than to miss out on important information. don't ask the user to confirm every single thing.

    the user can also ask you questions. this might require you to search the database for information.
    if you need to search the database, use the search tools. be as useful as possible.
    """, settings=Settings(debug=True), )


@mcp.tool("create_person", description="you can use this to register people the user has met in sales meetings.")
async def create_person(
    first_name: str,
    last_name: str,
    email: Optional[str] = None,
    phone: Optional[str] = None,
    company_id: Optional[str] = None,
):
    entity = Entity(
        data=PersonData(
            first_name=first_name,
            last_name=last_name,
            email=email,
            phone=phone,
            company_id=PydanticObjectId(company_id) if company_id else None,
        )
    )
    return entity.create().to_dict()

@mcp.tool("create_company", description="you can use this to register companies the user has met in sales meetings.")
async def create_company(name: str, address: Optional[str] = None, uic: Optional[str] = None):
    entity = Entity(
        data=CompanyData(name=name, address=address, uic=uic)
    )
    return entity.create().to_dict()

@mcp.tool("link_person_to_company", description="you can use this to link a person to a company. this is extremely important.")
async def link_person_to_company(person_id: str, company_id: str):
    entity = Entity.get_by_id(PydanticObjectId(person_id))
    if entity is None:
        raise ValueError("Person not found")
    
    entity.data.company_id = PydanticObjectId(company_id)
    return entity.update().to_dict()

@mcp.tool("get_entity_by_id", description="you can use this to get an entity by id")
async def get_entity_by_id(entity_id: str):
    entity = Entity.get_by_id(PydanticObjectId(entity_id))
    if entity is None:
        raise ValueError("Entity not found")
    return entity.to_dict()

@mcp.tool("search_person", description="you can use this to search for a person by name, email, or phone number")
async def search_person(query: str):
    return [entity.to_dict() for entity in Entity.search_person(query)]

@mcp.tool("search_company", description="you can use this to search for a company by name, address, or uic")
async def search_company(query: str):
    return [entity.to_dict() for entity in Entity.search_company(query)]

if __name__ == "__main__":
    mcp.run(transport="stdio")

mcp: the bad

While my overall experience with letting Claude touch my database was surprisingly positive, I had a lot of issues with all the extraneous matters.

MCP Inspector and Claude Desktop

The official way to debug MCP servers is through MCP Inspector. It’s horrible. It breaks constantly, loses connection to the MCP server, and loses your logs. I don’t recommend it, but also there’s not a lot of alternatives.

Using Claude Desktop is not much better. While it seems to be the most readily available client for MCP that supports the most amount of features, installing MCP servers on it requires manually modifying JSON configuration files. This makes it impossible to deploy directly to users that are not technically savvy. Additionally, to restart the MCP server, you need to restart Claude Desktop (and CD is one of those apps that don’t close when you close the window).

authentication / authorization

Authentication and authorization seem to be messy. Most examples online show MCP running locally and communication between server and client happening through stdio (i.e., process pipes). For you to actually go to prod, you need to deal with OAuth discovery, Dynamic Client Redirection, iDP, etc. I’ll leave an article by Aaron Pareceki for those that want to go down this rabbit hole.

compatibility

Here’s a table from the docs on which MCP clients support which feature:

mcp client x feature support matrix

I’m not sure how much I have to elaborate on this. I’m sure this will get better with time, but currently, there’s about a zero chance to write an MCP server and have it work everywhere. Thankfully, most of them at least support Tools, which is the feature that matters the most as we’ll see next.

mcp: the ugly

My main issue with MCP is that it tries to do too many things at once and therefore the protocol itself feels stitched together and wonky. This is what they present as the core of the protocol in the documentation:

MCP servers can provide three main types of capabilities:

These three things are presented as having equal weight and usefulness. I’d argue they’re not.

Prompts

Starting in reverse with Prompts — I’ve not seen a single good explanation or use case for them. They’re essentially prompt templates where you can pre-define variables to put in. This is an example from the docs:

PROMPTS = {
    "git-commit": types.Prompt(
        name="git-commit",
        description="Generate a Git commit message",
        arguments=[
            types.PromptArgument(
                name="changes",
                description="Git diff or description of changes",
                required=True
            )
        ],
    ),
    "explain-code": types.Prompt(
        name="explain-code",
        description="Explain how code works",
        arguments=[
            types.PromptArgument(
                name="code",
                description="Code to explain",
                required=True
            ),
            types.PromptArgument(
                name="language",
                description="Programming language",
                required=False
            )
        ],
    )


@app.get_prompt()
async def get_prompt(
    name: str, arguments: dict[str, str] | None = None
) -> types.GetPromptResult:
    if name not in PROMPTS:
        raise ValueError(f"Prompt not found: {name}")

    if name == "git-commit":
        changes = arguments.get("changes") if arguments else ""
        return types.GetPromptResult(
            messages=[
                types.PromptMessage(
                    role="user",
                    content=types.TextContent(
                        type="text",
                        text=f"Generate a concise but descriptive commit message "
                        f"for these changes:\n\n{changes}"
                    )
                )
            ]
        )

    if name == "explain-code":
        code = arguments.get("code") if arguments else ""
        language = arguments.get("language", "Unknown") if arguments else "Unknown"
        return types.GetPromptResult(
            messages=[
                types.PromptMessage(
                    role="user",
                    content=types.TextContent(
                        type="text",
                        text=f"Explain how this {language} code works:\n\n{code}"
                    )
                )
            ]
        )

    raise ValueError("Prompt implementation not found")
}

It feels extremely superficial and overengineered. They say you can use it for things like / commands and whatnot, but you could just do that with string interpolation.

Resources

Then, Resources. They are a much more meaningful concept, at least on paper. The documentation compares them to the GET verb in HTTP. Basically an instrument to give access to external data to the LLM. There is a catch though: they’re designed to be application-controlled. This means that the LLM cannot get them whenever it wants based on the thinking it’s doing. The application must feed them in. In practice (at least in Claude Desktop), this means that the user must go in some UI tab/form/whatever and pick them manually.

For me, this defeats the whole point. At least in my use case, if I need to make a UI for the person to use MCP, then I might as well just do the whole thing manually. If you want the LLM to GET data whenever it deems it necessary, you have to use tools. Again, it feels like a very overengineered way to provide context.

Tools

Tools are actually the heart of the MCP protocol. They’re intended to be LLM-controlled, meaning that the LLM can call them whenever it sees fit.

Also, despite what you may read, they don’t necessarily change data or do an action; they can also retrieve data. This is actually how all my GETs are implemented. I want the LLM to be able to say “Oh, gee, I don’t know, let me check” without me having to predict when that happens and feeding it in manually. While this works in practice, it feels wrong conceptually. There’s essentially two different, almost conflicting ways to provide information to the LLM.

Their main purpose is still to modify data or create some sort of an action. They can have parameters (as you saw above) and can have their input data validated. The options here are limitless: external APIs, database read/writes, etc.

Basically, my server exposes 90% just tools. I truly believe the value is in the LLM deciding when to use external services and that’s the only way to do that.

conclusion

To wrap this up: seeing Claude figure out what I want to do first try and interacting with my database was a great experience emotionally. As I run a software engineering agency that deals with a lot of data input software, I was filled with dreams of a future where I won’t have to worry about UI layouts, flows, and design.

Getting to that point, however, is not such a great experience. The tooling is unfinished, clients are rough around the edges, and the protocol itself just doesn’t feel like it’s it, in the same way that, for example, HTTP does.

I remain hopeful and optimistic for a future where people interact with machines exclusively through free-input text and hopefully speech, but I just don’t think MCP will bring us there. At least not in its current form.