thoughts · llm · agentic-ai
I Tried Building an Agent Pipeline With MCP — Here’s What Worked and What Didn’t
By Daffa Albari · 31 March 2026 · 16 min read

The protocol is solid. The developer experience will humble you.

If you read my last article on building multi-agent systems, you know I’m not in the business of sugarcoating things. So when I started building with the Model Context Protocol — the open standard everyone’s calling “the USB-C of AI” — I expected some friction.
I didn’t expect to spend an entire Saturday afternoon debugging OAuth flows that had nothing to do with the actual tool I was trying to build.
MCP is one of those technologies where the pitch is so clean that you assume the implementation will be too. Universal standard for connecting AI to external tools. Write your server once, and any compliant client can talk to it. No more custom integrations for every model provider.
That part is true. And it matters. But between “this is a great idea” and “this is running in production” lies a swamp of gotchas that nobody warned me about.
Let me walk you through the whole journey — with actual code.
First, What MCP Actually Is (Without the Marketing)
I’ll keep this brief because you probably already know the elevator pitch.
Before MCP existed, connecting an LLM to your database or your API or your file system meant writing custom glue code for every combination. Using Claude? Write an integration. Switching to GPT? Rewrite it. Want to use both? Maintain two. It was an N×M problem that scaled terribly.
MCP standardizes that connection. You build an MCP server that exposes your tools, and any MCP-compatible client — Claude, Cursor, various coding assistants — can talk to it using the same protocol. It borrows the architectural ideas from the Language Server Protocol that already solved a similar problem for code editors, and runs on JSON-RPC 2.0.
Anthropic launched it in late 2024, and since then, pretty much every major player — OpenAI, Google, Microsoft, Amazon — has adopted it. In December 2025, Anthropic donated MCP to the Linux Foundation under the Agentic AI Foundation, which tells you the industry is serious about this becoming a real standard, not just one company’s pet project.
That’s the background. Now let me tell you what actually happened when I tried to build with it.

The First Hour Was Magic
I’m not going to pretend the initial experience was bad. It wasn’t. It was great.
pip install fastmcp
Then, in a file called server.py:
from fastmcp import FastMCP
mcp = FastMCP("Internal Tools")
@mcp.tool
def search_docs(query: str, max_results: int = 5) -> list[dict]:
"""Search internal documentation by keyword.
Use this when the user asks about internal processes,
company policies, or engineering guidelines.
Returns matching documents with title and summary.
"""
# Your actual search logic here
results = document_store.search(query, limit=max_results)
return [
{"title": doc.title, "summary": doc.summary, "updated": doc.updated_at}
for doc in results
]
@mcp.tool
def query_orders(
customer_id: str | None = None,
date_from: str | None = None,
date_to: str | None = None
) -> list[dict]:
"""Query the orders database by customer ID or date range.
Use this when the user asks about specific transactions,
order history, or purchase amounts.
Returns order_id, amount, status, and created_at fields.
"""
filters = {}
if customer_id:
filters["customer_id"] = customer_id
if date_from:
filters["date_gte"] = date_from
if date_to:
filters["date_lte"] = date_to
orders = db.orders.find(filters)
return [
{
"order_id": o.id,
"amount": float(o.amount),
"status": o.status,
"created_at": o.created_at.isoformat()
}
for o in orders
]
if __name__ == "__main__":
mcp.run()
That’s it. Two tools, exposed over MCP, with automatic schema generation from the type hints. FastMCP reads your function signatures, your type annotations, and your docstrings, then generates all the MCP metadata for you. No manual JSON schema writing. No protocol boilerplate.
Within an hour, I had Claude calling my server and pulling live data from our internal systems. I remember sitting back and thinking, “Okay, this is why everyone is excited about this.”
Where Things Got Painful
The Auth Swamp
This is the one everybody hits.
The MCP authorization spec is actually well-designed. It mandates OAuth 2.1, requires PKCE, supports Dynamic Client Registration. On paper, it’s solid.
In practice, implementing it is a different kind of project entirely. You need to serve Protected Resource Metadata at a well-known endpoint, set up authorization and token endpoints, handle client registration, manage token refresh — and none of this has anything to do with the actual tool your MCP server exposes.
I spent hours reading RFC 9728 trying to understand the discovery flow while debugging PKCE code challenges that kept failing without telling me why. Silent failures. The best kind.
Eventually, I did what apparently a lot of developers are doing: I shipped it with a static API key and told myself I’d “fix the auth later.” That’s not a great feeling for someone who just wrote an article about AI security being important.
But here’s the uncomfortable reality: a security research team analyzed over 5,000 open-source MCP server implementations and found that more than half rely on static API keys. So at least I’m in good company. Terrible, vulnerable company, but company nonetheless.

Tool Descriptions Are Harder Than They Look
Here’s something I didn’t appreciate until I got burned by it: the quality of your tool descriptions determines whether your entire system works or falls apart.
When you write a REST API, your endpoint names and documentation are for human developers. They can read between the lines, check examples, look at the types. An LLM reading your MCP tool description doesn’t have that luxury. It has exactly what you wrote, and it will interpret it literally.
Look at my first attempt versus what actually worked:
# ❌ My first attempt — vague, no guidance on when to use it
@mcp.tool
def search(query: str) -> list[dict]:
"""Search the database for relevant records."""
...
# ✅ What actually worked - specific, with usage context
@mcp.tool
def query_orders(
customer_id: str | None = None,
date_from: str | None = None,
date_to: str | None = None
) -> list[dict]:
"""Query the PostgreSQL orders table by customer ID or date range.
Use this when the user asks about specific transactions,
order history, or purchase amounts.
Do NOT use this for product information - use search_products instead.
Returns order_id, amount, status, and created_at fields.
"""
...
Night and day difference. The first version? The LLM had no idea when to use this tool versus any other search tool. It would pick one at random, or worse, call both and try to merge conflicting results.
The fix was being painfully specific. Not just what the tool does, but when to use it, when not to, what kind of input it expects, and what the output looks like.
The counterintuitive lesson: don’t mirror every REST endpoint as its own MCP tool. If your API has separate endpoints for creating, updating, and fetching a customer record, resist the urge to make three MCP tools. An LLM drowning in dozens of granular tool options will make worse choices than one with a curated set of higher-level tools. Combine related operations. Keep the toolset small and meaningful.
Resources: The Feature I Wish I’d Used Earlier
I spent my first two weeks building everything as tools. Every piece of data the LLM might need? Make a tool for it. Need the database schema? Tool. Need the API docs? Tool. Need the company style guide? Believe it or not, tool.
That was wrong.
MCP has a concept called Resources — they’re read-only data that the LLM can access directly, like files or reference documents. Think of it this way: tools are for actions (query, create, update), resources are for context (documentation, schemas, configuration).
@mcp.resource("docs://api-schema")
def get_api_schema() -> str:
"""The current API schema for our internal services."""
return json.dumps(load_api_schema(), indent=2)
@mcp.resource("docs://style-guide")
def get_style_guide() -> str:
"""Company coding standards and naming conventions."""
with open("docs/style_guide.md") as f:
return f.read()
@mcp.resource("config://database-tables")
def get_table_info() -> str:
"""List of all database tables with their columns and types."""
tables = db.inspect_tables()
return "\n".join(
f"Table: {t.name}\n Columns: {', '.join(c.name + ' (' + c.type + ')' for c in t.columns)}"
for t in tables
)
Once I moved reference data from tools to resources, the LLM’s tool selection got noticeably better. It wasn’t wasting tool calls just to fetch context it needed before making a decision. The context was already there.
The stdio Transport Trap
Most MCP examples and tutorials use stdio transport — the server communicates through standard input/output. For local development on your machine, this works fine:
if __name__ == "__main__":
mcp.run() # defaults to stdio
Then you try to deploy it.
stdio doesn’t work in most production environments. It doesn’t work behind load balancers. It doesn’t work in containerized setups. It doesn’t work when you need multiple instances handling concurrent requests.
I wasted a full day trying to get a stdio-based server running in a Docker container before realizing I needed to switch to HTTP transport. Fortunately, with FastMCP the switch itself is trivial:
if __name__ == "__main__":
mcp.run(transport="http", port=8000)
Or from the CLI:
fastmcp run server.py:mcp --transport http --port 8000
The migration wasn’t terrible, but the fact that almost every tutorial and example I’d followed was built around stdio meant I was essentially re-testing everything with a different transport.
The 2026 roadmap acknowledges this problem directly. The maintainers are working on evolving the HTTP transport to support stateless operation across multiple server instances, proper behavior behind load balancers, and scalable session handling. But that’s future work. Today, you deal with it yourself.
My advice: start with HTTP from day one, even for local development. The initial setup takes slightly longer, but you won’t have to rewrite your transport layer when you’re ready to deploy.

Testing: Where the Tooling Gaps Hurt
With REST, I’ve got Postman, Swagger, OpenAPI specs, automated test suites, mock servers — decades of tooling. MCP has been around for about a year and a half. The tooling gap is real.
FastMCP does give you the MCP Inspector, which helps:
fastmcp dev server.py:mcp
This opens a browser UI where you can see your tools, fill in parameters, and call them manually. It shows raw JSON-RPC messages, which is invaluable when you’re debugging protocol-level issues.
But here’s what you don’t get: automated tests for how an LLM will interpret your tool descriptions. You can’t easily write a unit test that says “given this user prompt, the model should pick tool X with parameters Y.” You end up doing a lot of manual testing — connect a client, ask the LLM to do something, observe what happens.
What I ended up building was a simple test harness using FastMCP’s client:
import asyncio
from fastmcp import Client
client = Client("http://localhost:8000/mcp")
async def test_tool_calls():
async with client:
# Test: does query_orders return expected structure?
result = await client.call_tool(
"query_orders",
{"customer_id": "cust_123"}
)
assert isinstance(result, list)
assert all("order_id" in r for r in result)
# Test: does search_docs handle empty results?
result = await client.call_tool(
"search_docs",
{"query": "xyznonexistent", "max_results": 3}
)
assert isinstance(result, list)
assert len(result) == 0
asyncio.run(test_tool_calls())
It’s not elegant. It doesn’t test the LLM’s tool selection behavior. But it catches the basics — does the tool return the right shape, does it handle edge cases, does it blow up on bad input. Better than nothing.
Error Messages From Another Dimension
I want to talk about developer experience for a second, because it matters and nobody is being honest about it.
When things go wrong with MCP — and they will — the error messages are often useless. I’ve stared at cryptic failures that turned out to be environment setup issues with Node.js or Python’s uv package manager. The SDK would just silently fail, or give me an error that pointed somewhere completely unrelated to the actual problem.
One specific nightmare: I had a tool that worked perfectly in the Inspector but failed silently when called from Claude Desktop. No error. No log. Just… nothing happened. Turned out the issue was a datetime serialization problem — my tool was returning a Python datetime object instead of an ISO string, and the JSON serialization was silently swallowing it.
The fix was stupid simple:
# ❌ Breaks silently
return {"created_at": order.created_at}
# ✅ Actually works
return {"created_at": order.created_at.isoformat()}
Two hours of debugging for a .isoformat() call. Welcome to MCP development.
What Actually Worked Well
I’ve spent a lot of words on the pain points. Let me balance that out, because the things that work well about MCP work really well.
Internal Tooling Was the Sweet Spot
The use case where MCP clicked for me was internal tooling. Building an MCP server that gave AI assistants access to our internal documentation, our knowledge base, our library of code examples.
One of the most useful servers I built was surprisingly simple:
from fastmcp import FastMCP
import json
mcp = FastMCP("Internal Libraries")
@mcp.tool
def find_library(use_case: str) -> list[dict]:
"""Find the right internal library for a given use case.
Use this when writing new code that might benefit from
an existing internal library instead of a third-party package.
"""
matches = library_index.search(use_case)
return [
{
"name": lib.name,
"description": lib.description,
"install": lib.install_command,
"docs_url": lib.docs_url
}
for lib in matches
]
@mcp.resource("docs://library-catalog")
def library_catalog() -> str:
"""Complete catalog of internal libraries with descriptions."""
catalog = library_index.get_all()
return "\n\n".join(
f"## {lib.name}\n{lib.description}\nInstall: `{lib.install_command}`"
for lib in catalog
)
@mcp.tool
def get_library_examples(library_name: str) -> str:
"""Get code examples for a specific internal library.
Use this after find_library to show the user how to use
the recommended library.
"""
examples = library_index.get_examples(library_name)
return "\n\n---\n\n".join(
f"### {ex.title}\n```python\n{ex.code}\n```\n{ex.explanation}"
for ex in examples
)
if __name__ == "__main__":
mcp.run(transport="http", port=8000)
When connected to a coding assistant, the quality of code suggestions jumped noticeably. The AI wasn’t guessing about internal conventions anymore. It actually knew what libraries we had and how we used them.
This pattern — surfacing internal knowledge to AI tools — turns out to be one of the most common and most successful MCP use cases. It’s not flashy, but it solves a real problem.
The “Write Once, Use Everywhere” Promise Is Real
I built that library server once, and it works across different clients without modification. Claude Desktop, Cursor, other MCP-compatible tools — same server, same tool definitions, different clients. That interoperability is not just marketing. It’s the real deal.
Community Momentum Counts
Something I appreciate about MCP that’s harder to quantify: the community is active and growing. The protocol is now governed under the Linux Foundation with proper working groups, spec enhancement proposals, and a roadmap driven by production feedback. OpenAI, Microsoft, Google, and Amazon are all participating.
That kind of multi-vendor buy-in is rare, and it gives me confidence that building on MCP isn’t going to be a wasted investment.

The Cheat Sheet I Wish Someone Gave Me
If I could go back and hand myself a note before starting this project, here’s what it would say:
Design tools for the AI, not for yourself. Your docstrings are UX design for a language model. Include when to use it, when not to, and what the output looks like.
Fewer tools, higher level. Five well-designed tools beat twenty granular ones. Combine related operations.
Use Resources for context, Tools for actions. Don’t make the LLM burn a tool call just to read your database schema. That’s a resource.
Skip stdio, start with HTTP. mcp.run(transport="http", port=8000) from day one. Save yourself the migration pain.
Return clean data. Always serialize your outputs explicitly. No raw Python objects. No datetime without .isoformat(). No Decimal without float(). The serializer won't warn you — it'll just silently break.
Use the Inspector religiously. fastmcp dev server.py:mcp is your best debugging friend. Use it before every client test.
Log everything obsessively. MCP doesn’t have standardized audit trails yet (it’s on the 2026 roadmap). Build your own logging now.
Start with internal tools. Your first MCP server should not be customer-facing. Build something that helps your own team.
Where This Is All Heading
The 2026 MCP roadmap tells you a lot about where the protocol’s gaps are, because the maintainers are basically listing all the problems I just described and saying “yes, we know.”
Transport evolution is a top priority — making HTTP transport work properly at scale with stateless operation, load balancers, and session resumption. Enterprise readiness is another focus area: standardized audit trails, SSO-integrated auth instead of static secrets, proper gateway patterns, and portable configuration.
There’s also MCP Server Cards — a standard way for servers to publish their capabilities at a well-known URL so clients can discover what’s available without connecting. Think of it like a machine-readable README for your MCP server.
My take? MCP is going through the same growing pains that every successful protocol goes through. REST was messy in its early days too. GraphQL had its painful adolescence. The fact that MCP’s maintainers are transparent about the gaps and actively soliciting production feedback gives me confidence that the rough edges will smooth out.
But we’re not there yet. And if you’re building with MCP today, you’re building with a protocol that’s still finding its footing in production environments. Go in with your eyes open — and your type hints tight.

Connecting the Dots
In my previous article, I talked about how multi-agent systems fail because of the boring stuff — data quality, security, observability. MCP is the next layer of that same argument.
The protocol itself is a genuine step forward for the AI ecosystem. It solves a real integration problem in an elegant way. FastMCP makes the Python side almost frictionless for getting started. But deploying it in production requires the same engineering discipline I keep harping on: proper auth, careful design, thorough logging, and realistic expectations about what’s ready today versus what’s on the roadmap.
The USB-C of AI is a nice metaphor. But remember: even USB-C had compatibility issues for years before it actually worked everywhere.
This is part of a series on building AI systems that survive contact with reality. If you’re an engineer who’s tired of reading AI content written by people who’ve never deployed anything, follow me for more.
Built something interesting with MCP? Hit a wall I didn’t mention? Tell me about it in the comments.
Share this piece
Originally published on Medium. View original →