Production, Versioning & Ecosystem

Difficulty

It's easy to see "tools," "function calling," and "MCP" all in the same sentence and assume they're competing for the same job. They mostly aren't.

What each one actually is

ScopePortability
OpenAI function callingA specific API's request/response schema for letting a model emit structured function callsTied to that vendor's API shape
LangChain tools/agentsA framework's own abstraction for defining and orchestrating tools within LangChain-based appsTied to that framework, though it can wrap other things
MCPAn open protocol for how a server exposes tools/resources/prompts, and how any client discovers/invokes themWorks across any MCP-aware host or framework

Where they compose rather than compete

MCP Server (tools/list) --> LangChain MCP adapter --> LangChain Tool object
                                                            |
                                                            v
                                                     LangChain Agent
MCP Server (tools/list) --> Host reshapes inputSchema --> OpenAI function-calling schema
                                                                |
                                                                v
                                                      OpenAI API request

Both LangChain and OpenAI-based applications can consume an MCP server's tools without the server author writing anything framework- or vendor-specific. The adapter/reshaping work happens on the consuming side, once, rather than the tool author needing to publish a separate integration for every framework.

The actual tradeoff to weigh

If you're building a tool that only needs to work inside one specific app you control, using that framework's native tool abstraction directly can be simpler. There's no protocol overhead, no separate process or transport to manage. Building it as an MCP server instead trades a bit of that simplicity for reuse: the same server works in Claude Desktop, an IDE, a custom agent, or any other MCP-compatible host without modification. The decision generally comes down to whether you expect, or want to enable, more than one consumer for the same integration.

Related Resources

A REST API and an MCP server frequently sit on top of the exact same backend logic. The difference is who, or what, the interface is designed to be consumed by.

What a REST API assumes about its consumer

GET /api/v2/orders/{id}
Docs: "Returns order details. Requires an API key. See schema.json for the response shape."

A human developer reads this documentation once, writes integration code, and ships it. There's no standard, runtime-queryable way for an LLM-driven client that has never seen this API before to discover this endpoint exists, what it needs, or how to call it correctly. That knowledge has to be hand-encoded by a developer ahead of time, per app, per API.

What MCP adds on top

// tools/list response — discoverable and model-readable at runtime
{"tools": [{
  "name": "get_order",
  "description": "Fetch order details by order ID.",
  "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]}
}]}

Any MCP client, without prior knowledge of this specific API, can fetch this list at connection time and immediately know what's callable and how. The underlying implementation might literally just call GET /api/v2/orders/{id} internally. MCP hasn't replaced the REST API — it's wrapped it in a self-describing, model-consumable interface.

The analogy worth reaching for

This is similar in spirit to what an OpenAPI/Swagger spec does for human tooling: auto-generating clients, interactive docs. MCP is purpose-built for the specific patterns an LLM-driven agent needs instead: model-controlled tools with natural-language descriptions optimized for a model's reasoning, application-controlled resources, and a lifecycle designed around a conversational, multi-turn interaction rather than a one-shot request/response.

When plain REST/GraphQL is still the right choice

If the consumer is always going to be human-written application code, not an LLM deciding at runtime what to call, a conventional REST/GraphQL API remains simpler and better-tooled for that job. There's no reason to wrap it in MCP if no LLM-driven client will ever need to discover and call it dynamically. MCP earns its overhead specifically when the caller is an LLM that needs runtime discoverability, not compile-time-known integration code.

Related Resources

MCP versions by release date rather than semver, so it helps to have a rough timeline of what actually changed and when. "Does MCP support X" often depends on which spec revision you mean.

A rough timeline of major revisions

RevisionNotable additions/changes
2024-11-05Original public spec: core lifecycle, tools/resources/prompts, sampling, the original two-endpoint HTTP+SSE transport
2025-03-26OAuth 2.1-based authorization framework for HTTP transport, tool annotations (readOnlyHint etc.), Streamable HTTP transport replacing HTTP+SSE, audio content support
2025-06-18Structured tool output (structuredContent/outputSchema), elicitation, resource links in tool call results, removal of JSON-RPC batching, expanded security best-practices guidance

Why this matters practically

If a tutorial or blog post describes MCP behavior without mentioning a spec date, it may be describing an earlier revision. Assuming batching is supported, or that Streamable HTTP doesn't exist yet, can lead to writing code against assumptions that no longer hold for the current spec.

How negotiation handles the gap

// A client built against 2025-06-18 connecting to an older server
{"method": "initialize", "params": {"protocolVersion": "2025-06-18", ...}}

// Server, only supporting an earlier revision, responds with what it has
{"result": {"protocolVersion": "2025-03-26", ...}}

The client then knows not to expect 2025-06-18-only features, like elicitation or structured output, for this particular connection, and should behave accordingly. This is exactly what the capability negotiation covered in the fundamentals topic is for.

The practical habit worth building

When picking an SDK, or reading MCP documentation and tutorials, check which spec revision it targets before assuming a specific feature — sampling, elicitation, a particular transport — is available. The protocol's backward-compatible negotiation model means old and new implementations can still talk to each other. That only works, though, if both sides are honest about, and respect, which revision they actually understand.

Related Resources

An MCP server is still a regular backend service under the hood. The same observability fundamentals apply, plus a few signals specific to how MCP calls actually get triggered.

Structured logging per call

import time, logging

logger = logging.getLogger("mcp-server")

async def handle_tool_call(name, arguments):
    start = time.time()
    try:
        result = await execute_tool(name, arguments)
        logger.info("tool_call", extra={
            "tool": name, "duration_ms": (time.time() - start) * 1000,
            "is_error": result.get("isError", False)
        })
        return result
    except Exception as e:
        logger.error("tool_call_failed", extra={"tool": name, "error": str(e)})
        raise

Always redact secrets and potentially sensitive user data before logging arguments. A logged api_key argument — which shouldn't exist per the security topic's guidance, but might slip in — or a logged customer record is a real data-exposure risk in your own logging pipeline.

Metrics worth tracking

  • Per-tool invocation count and latency (p50/p95/p99). A slow tool degrades the whole conversation's responsiveness, even without ever technically failing.
  • isError: true rate, per tool. A sudden spike usually means an upstream API changed shape, credentials expired, or a new client is sending malformed arguments your validation didn't anticipate.
  • Session duration and disconnect patterns (for HTTP servers). An unusual rate of abrupt disconnects can indicate a client-side bug or a server-side crash under specific conditions.
  • Authorization failure rate (HTTP servers). Spikes can indicate either a misconfigured client rollout or a genuine abuse/probing attempt worth investigating.

Tracing for multi-session HTTP servers

Trace ID: abc-123
  -> initialize (session xyz)
  -> tools/call: search_orders (120ms)
  -> tools/call: get_order (45ms)
  -> resources/read: order-invoice-42.pdf (300ms)

Correlating every request within one session under a shared trace ID makes debugging a specific user's reported issue far faster than grepping raw logs.

Why this matters more than it might seem

Tool calls are triggered by model decisions, not a fixed, predictable call pattern a developer wrote. Production issues often show up as "the assistant behaved oddly" rather than a clean stack trace. Good observability is frequently the only way to reconstruct which tool call, with which arguments, actually caused a confusing downstream result.

Related Resources

An MCP server's performance problems usually trace back to treating an external dependency as if it were always fast and always available. It's the same class of mistake that shows up in any backend integration, just with an LLM on the other end feeling the latency directly.

The blocking-call pitfall

# Risky: no timeout, blocks the event loop/thread indefinitely if the API hangs
@mcp.tool()
def get_stock_price(symbol: str) -> str:
    response = requests.get(f"https://slow-api.example/price/{symbol}")
    return response.text
# Better: explicit timeout, async so other requests aren't blocked
import httpx

@mcp.tool()
async def get_stock_price(symbol: str) -> str:
    async with httpx.AsyncClient(timeout=5.0) as client:
        response = await client.get(f"https://slow-api.example/price/{symbol}")
        return response.text

A hung external dependency with no timeout doesn't just fail that one call slowly. On a naive synchronous implementation, it can tie up the whole process, stalling unrelated concurrent tool calls, or for a stdio server, the one session it's serving, indefinitely.

Other common pitfalls

  • No caching for slow, rarely-changing data. A tool that re-fetches a company's entire product catalog from a slow upstream API on every single call, when that catalog only changes once a day, adds unnecessary latency to every invocation.
  • Unbounded result sizes. A list_customers tool with no pagination or limit can return thousands of rows. That's slow to produce, expensive to transmit, and, once fed back into the model's context as a tool result, can consume a large fraction of the available context window for a single call.
  • No progress feedback on genuinely long operations. A tool that takes 30+ seconds with no notifications/progress (covered in the transports topic) leaves the user staring at what looks like a frozen or crashed assistant.

A practical checklist for any tool wrapping an external API

  • Set an explicit timeout, shorter than whatever default the HTTP client library uses.
  • Use async I/O (or a thread/worker pool) so a slow call doesn't block unrelated requests.
  • Cache anything genuinely cacheable, with a sensible TTL.
  • Cap result size, and offer pagination for anything that could plausibly return a lot of data.
  • Emit progress notifications for anything expected to take more than a couple of seconds.

Related Resources