Home / Articles / MCP for AI Agents: Standardizing Tool Integration in LangGraph

This article is published in English.

MCP for AI Agents: Standardizing Tool Integration in LangGraph

This article explains what MCP actually standardizes in agentic AI systems, comparing ad hoc tool integrations to MCP-based ones within a LangGraph orchestrator.

4323 words

Introduction & Recap

By the end of Part 2, the system had reached a genuinely coordinated state: a set of specialized agents, each responsible for a narrow slice of work, operating on a shared state under an orchestrator that decided what should run next. In every example, though, the assumption was that each agent already had access to whatever it needed inside that shared state, available to read whenever it was required.

That assumption rarely holds in production. An agent typically needs something that lives outside the graph: a row from a database, a response from an external API, a passage pulled from a knowledge base, or some other resource that sits beyond the system's own boundaries. Whenever an agent needs to reach outside like this, it needs its own path for doing so, and if every one of those paths is built by hand, you end up repeating the same kind of integration work, slightly differently, for each agent and each external resource.

That repetition is exactly the problem this installment addresses, and it explains why MCP has become such a recurring topic over the last year. Before deciding whether adopting it is worthwhile, it helps to pin down precisely what problem it addresses, and to look at what hooking a tool up to an agent involved before MCP existed at all.

The Problem MCP Claims to Solve

Before MCP, giving an agent access to something external meant hand-building a custom integration tailored to that one resource, shaped however happened to make sense at the time. One resource might be reachable through a REST API, another through a database client, another through an SDK that came with its own rules for authentication and error handling. Each of these differences had to be absorbed directly into the agent's own code.

That's a fine tradeoff when you have a single agent talking to a single tool. It stops being fine as soon as the system grows in either dimension. Add a second agent that needs the same resource, and you either copy the integration or someone eventually pulls it out into a shared module, usually only after the duplication has already crept in. Add a second tool instead, and now the agent's code has to hold two entirely different integration patterns in its head at once.

These integrations rarely resemble each other, because nothing required them to. One wrapper might retry failed calls automatically; another might not retry at all. One might surface failures as thrown exceptions; another might bury them in a status field the caller has to remember to check. There's no common language for what it actually means to "connect an agent to a tool" — every integration ends up answering that question on its own terms.

This is precisely the gap MCP sets out to close. It isn't introducing new capabilities that agents lacked before; it's standardizing the mechanism for reaching capabilities that already existed, so that wiring a new agent into an existing tool, or a new tool into an existing agent, no longer means writing another bespoke integration from the ground up. Whether it lives up to that promise in practice becomes much easier to judge once you've seen what the ad hoc approach actually looks like in code — which is where the discussion picks up next.

Before MCP: Wiring Up a Tool the Improvised Way

Consider a fairly ordinary integration: an agent that needs to query an external system, held together by whatever glue code makes the connection work.

import requests
class LookupToolClient:
    def __init__(self, base_url: str, api_key: str):
        self.base_url = base_url
        self.api_key = api_key
    def lookup(self, query: str) -> dict:
        response = requests.get(
            f"{self.base_url}/search",
            params={"q": query},
            headers={"Authorization": f"Bearer {self.api_key}"},
        )
        if response.status_code != 200:
            return {"error": f"lookup failed: {response.status_code}"}
        return response.json()
def agent_node(state: GraphState) -> dict:
    client = LookupToolClient(base_url="https://internal-tool.example.com", api_key="...")
    result = client.lookup(state["extracted_fields"]["query"])
    return {"tool_result": result}

There's nothing wrong with this on its own terms. It's a compact HTTP client, a bit of error handling, and a function that invokes it from inside a node. The trouble starts once a second tool enters the picture — not another REST API this time, but a database client with an entirely different shape:

import psycopg2
class RecordsClient:
    def __init__(self, connection_string: str):
        self.conn = psycopg2.connect(connection_string)
    def fetch_record(self, record_id: str) -> dict:
        with self.conn.cursor() as cur:
            cur.execute("SELECT * FROM records WHERE id = %s", (record_id,))
            row = cur.fetchone()
            if row is None:
                raise ValueError(f"no record found for {record_id}")
            return dict(zip([desc[0] for desc in cur.description], row))

These two clients share no common interface, no naming convention, not even a consistent way of signaling failure: one hands back an error dictionary, the other throws an exception outright. Any agent that needs to use both has to learn these quirks individually and account for each on its own terms. Now multiply that by every additional tool the system eventually needs — its own client, its own authentication scheme, its own failure mode — and what began as a couple of small integrations turns into a genuine maintenance burden, one with no shared structure tying it together.

That's the baseline worth holding onto here: not a poorly written integration, just a typical one, built the way most tool integrations end up looking when nothing enforces a common shape for them.

What MCP Actually Standardizes

With that ad hoc example still in mind, it becomes much easier to describe precisely what MCP does, without resorting to the broader, vaguer claims often made about it.

At its core, MCP defines a shared protocol for exposing tools to an agent, regardless of what the tool does internally or which language or framework it was built with. Rather than each tool shipping its own bespoke client with its own conventions, it's exposed through an MCP server that advertises its capabilities in a predictable, standard format: a name, a description, an input schema, and an output schema. Any agent that understands the protocol can discover that tool and call it the same way it would call any other tool, whether the thing underneath is a REST endpoint, a database, or something else entirely.

That standardization covers exactly three areas, and it's worth being specific about which three, since it's tempting to assume MCP's scope is broader than it is.

  • Discovery: An agent can query an MCP server for the list of tools it makes available and get back a structured response, instead of relying on that information being hardcoded somewhere or documented separately from the actual implementation.
  • Invocation: Every tool call follows the same pattern no matter what the tool is — a request with a defined shape, a response with a defined shape — rather than each client defining its own method signature and its own return type.
  • Error handling: Failures are reported in a uniform format, so an agent doesn't need to track whether a given tool throws an exception, returns an error field, or fails in some other way entirely. The shape is consistent every time.

What MCP doesn't do is eliminate the work of actually writing a tool's underlying logic, nor does it guarantee that a tool will behave correctly simply because it's wrapped in the protocol. It standardizes the contract between an agent and a tool, not the quality or dependability of whatever sits behind that contract — a point the article returns to directly later on, once the comparisons ahead have made the distinction concrete.

Anatomy of an MCP Server

Given the standardization just described, it's worth looking at what actually implements it. Structurally, an MCP server is little more than a declared set of tools, each carrying its own schema, packaged inside a protocol layer that lets an agent discover and invoke them in a consistent way.

At minimum, defining a tool on an MCP server requires declaring three elements: a name the agent uses to refer to it, a schema describing the expected input, and the function that runs when the tool is actually invoked.

from mcp.server import Server
from mcp.types import Tool
server = Server("lookup-tools")
@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="lookup",
            description="Search for a record by query string",
            inputSchema={
                "type": "object",
                "properties": {
                    "query": {"type": "string"}
                },
                "required": ["query"],
            },
        )
    ]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> dict:
    if name == "lookup":
        return perform_lookup(arguments["query"])
    raise ValueError(f"unknown tool: {name}")

Two details stand out here when you compare this to the ad hoc clients discussed earlier. First, the input schema is declared explicitly upfront, rather than being inferred from whatever parameters a function happens to take — which means both an agent and a human reviewer can see exactly what a tool requires without digging into its implementation. Second, the server only needs to expose two entry points, list_tools and call_tool, regardless of how many tools it contains or how differently they behave under the hood. Whether the lookup tool talks to a REST API, queries a database, or does something else entirely remains fully hidden behind that same two-function surface.

On the agent's side, connecting to this server looks identical no matter which tools it exposes:

from mcp.client import ClientSession
async def call_lookup_tool(query: str) -> dict:
    async with ClientSession(server_params) as session:
        result = await session.call_tool("lookup", {"query": query})
        return result

Compare this to the two ad hoc clients from earlier, one built on requests, the other on psycopg2, each with its own distinct shape and conventions. Here, the agent's code stays the same regardless of what a tool does internally — it calls session.call_tool with a name and a set of arguments, and receives a result back in the same structure every single time. That uniformity is the real payoff of the server architecture, not the tool logic itself, which still has to be written by someone either way.

Rewiring the Same Integration Through MCP

The best way to see the practical difference is to take the exact same integration built earlier, the lookup tool and the records client, and reconstruct it using MCP instead. The functionality stays identical, the underlying systems stay identical, only the interface changes.

The lookup tool, which started life as a standalone requests-based client, now becomes a tool declaration registered on an MCP server:

@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="lookup",
            description="Search for a record by query string",
            inputSchema={
                "type": "object",
                "properties": {"query": {"type": "string"}},
                "required": ["query"],
            },
        ),
        Tool(
            name="fetch_record",
            description="Fetch a record by ID",
            inputSchema={
                "type": "object",
                "properties": {"record_id": {"type": "string"}},
                "required": ["record_id"],
            },
        ),
    ]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> dict:
    if name == "lookup":
        return perform_lookup(arguments["query"])
    elif name == "fetch_record":
        return fetch_record_from_db(arguments["record_id"])
    raise ValueError(f"unknown tool: {name}")

None of the internal logic was touched, perform_lookup still hits the same external API, and fetch_record_from_db still runs the same database query. What's different is that these two tools, even though they sit on top of entirely unrelated systems, are now declared next to each other, sharing an identical schema shape, and exposed through the same two entry points.

The bigger visible change happens in the node responsible for calling them, since it no longer needs any awareness of requests or psycopg2:

async def agent_node(state: GraphState) -> dict:
    async with ClientSession(server_params) as session:
        result = await session.call_tool(
            "lookup", {"query": state["extracted_fields"]["query"]}
        )
    return {"tool_result": result}

Set that against the earlier version, which imported a specific HTTP library, parsed a specific error format, and would have required an entirely separate code path just to call fetch_record in place of lookup. In this version, invoking a second tool is a matter of swapping a string and a dictionary of arguments, not authoring a second client with its own quirks.

Nothing about what these tools actually accomplish has changed. What has changed is that the agent calling them no longer needs to understand their implementation at all, only their name and declared schema. That's the standardization discussed earlier in the series, now something you can point to in actual code instead of taking on faith.

Side-by-Side: Ad Hoc vs. MCP

With both approaches fully built, you can stop reasoning about theoretical benefits and instead look directly at what actually differs between the ad hoc version and the MCP version.

  • Effort required per new tool: In the ad hoc approach, each additional tool brought its own client, its own authentication logic, its own error shape, and its own set of conventions to learn before you could use it safely. Under MCP, adding a tool means adding one more entry to list_tools and one more branch inside call_tool, each following the identical pattern of the tool before it.
  • What the calling code has to understand: The ad hoc agent node had to import a particular client library and handle that library's particular error format. The MCP agent node imports nothing tool-specific whatsoever, it simply calls session.call_tool with a name and a dictionary of arguments, and that call behaves the same regardless of whether the underlying tool is a REST endpoint, a database, or anything else.
  • How reusable the setup is across agents: In the ad hoc design, if a second agent needs the same lookup capability, it either imports the same client directly and becomes tied to that specific implementation, or someone eventually pulls out a shared wrapper to avoid duplication. With an MCP server, a second agent simply connects to that server and inherits the same set of tools, discoverable and callable in the same way, without needing any knowledge beyond what the first agent already relied on.
  • How much there is to maintain: Adjusting how the lookup tool behaves, adding a retry policy or tweaking a timeout, meant editing LookupToolClient directly, and every agent depending on it picked up the change automatically. MCP doesn't remove that maintenance burden, but it does consolidate it: every tool's behavior now sits behind the same two functions, list_tools and call_tool, instead of being scattered across as many distinct client shapes as there are tools.

None of this simplifies the actual tool logic. perform_lookup and fetch_record_from_db still need to be written and kept working correctly either way. What changes is everything surrounding that logic: how it gets discovered, how it gets invoked, how failures are surfaced, and how much of that burden the agent's own code has to carry versus getting handled automatically by adhering to one shared protocol.

Integrating an MCP Tool into the LangGraph System (from Part 2)

Up to now, every example has focused on a single agent calling a single tool in isolation. It's worth closing that gap by dropping an MCP-based tool back into the multi-agent graph built in Part 2, since that's where the threads from all three articles in this series actually converge.

Recall the graph from Part 2: an intake step, LLM interpretation, the rules engine introduced in Part 1, and conditional routing that sends the flow either to a notification node or to human review. Imagine that the rules engine now depends on a check against some outside system before it can settle on a decision, the kind of dependency that would once have meant building a dedicated ad hoc client, similar to what was built earlier in this article, wired directly into that node.

With an MCP server exposing that same lookup as a tool, the node's responsibilities barely change. It still reads from graph state and still writes its decision back into state, it just reaches the outside system by calling session.call_tool rather than going through a dedicated client:

async def rules_engine_node(state: GraphState) -> dict:
    async with ClientSession(server_params) as session:
        lookup_result = await session.call_tool(
            "lookup", {"query": state["extracted_fields"]["category"]}
        )
    decision = evaluate_rules(state["extracted_fields"], lookup_result)
    return {"decision": decision["decision"], "decision_reason": decision["reason"]}

The node's position within the overall flow is untouched by any of this. It continues to sit in the same spot, downstream of llm_interpretation and upstream of the conditional branch that was already in place back in Part 2, still limited by the permission scope defined in that article's guardrails discussion, and still recorded under the same trace ID described in its section on observability. The only real shift is in how the node talks to the outside world: rather than relying on a client built specifically for this one node, it now issues a call against a tool that any other node, whether in this graph or a future one, could invoke through that identical path.

This is really where the three threads of the series come together. An agent that only decides what it's explicitly permitted to decide, running inside a graph that coordinates it alongside other agents, reaching external systems through the same standardized interface every other agent shares. None of the three pieces, the rules engine, the graph, or MCP, needed detailed awareness of the other two. They only needed to follow the same discipline this series has emphasized throughout: narrow responsibilities, explicit contracts, and nothing left to assumption.

Cost & Latency: What Orchestration Actually Costs

Every layer added throughout this series buys some amount of structure, and none of that structure comes for free. Rather than leaving the cost buried, it helps to trace what actually happens to response time once a request moves through all the pieces described so far.

Consider a single request moving through the graph introduced in Part 2, now extended with the MCP-based lookup covered above. The path breaks down roughly like this: intake performs lightweight formatting with no outbound calls, so it costs a few milliseconds at most. The LLM interpretation step calls a model to pull structured fields out of the raw request, and this is typically the biggest single expense on the entire path, often adding several hundred milliseconds depending on model choice and prompt length. The rules engine's MCP call to the lookup tool contributes a network round trip on top of whatever time the tool itself needs to respond, a meaningful cost but generally smaller than the LLM step. Evaluating the rules themselves, since it's just deterministic logic, is close to free. Routing and the closing notification step add only a small amount on top of that.

Add these figures together and the picture is clear: a request that makes just one trip to the model and one call out to a tool has its total time set almost entirely by those two operations, with the graph's own coordination barely registering. The nodes, edges, and shared state introduced in Part 2 give you structure without imposing real latency of their own: reading and writing a shared state object is cheap. What actually costs time is reaching out to a model or an external system.

That reframes how you should think about performance tuning. Adding more nodes, more routing branches, or more guardrail checks to a graph barely affects speed, since those are just function calls and lookups against a dictionary. What actually slows a system down is every LLM call and every external tool call that sits on the request's critical path. A system built around three agents that each call a model one after another will run noticeably slower than one that calls a model a single time, no matter how clean the surrounding orchestration is.

The practical takeaway is straightforward: when latency matters for a workflow, don't start by inspecting the graph's shape. Start by tallying the model invocations and outside tool calls that a normal request has to pass through before it finishes, and ask whether any of them can run side by side instead of in sequence, or be left out entirely for the requests that don't need them.

What MCP Doesn't Solve

It's worth being just as candid about MCP's boundaries as about its advantages covered earlier, since most writing on the topic leans heavily toward the advantages and rarely dwells on the limits. The same three areas discussed there, discovery, invocation, and error handling, deserve a second look from the opposite angle.

  • Standardizing discovery and invocation doesn't fix a poorly built tool: MCP standardizes how a tool gets located and called, not what happens inside it. A tool with a slow, flaky, or badly structured implementation is still slow, flaky, and badly structured once it sits behind an MCP server. The protocol relocates the inconsistency, moving it from the calling code into the tool's own implementation, but it doesn't make the inconsistency disappear.
  • A uniform error shape isn't the same as solved error handling: Failures still occur, tools still time out, and external systems still go down. MCP gives those failures a predictable, consistent shape when they arrive, but the calling code is still responsible for deciding what happens next, whether to retry, fall back, or propagate the error upward, just as it was responsible before. A consistent error format is not equivalent to the failure actually being handled.
  • Running the protocol carries its own operational cost: An MCP server is one more process you need to run, deploy, and keep healthy. For a single agent calling a single, simple tool, that's genuine overhead, and the ad hoc client approach described earlier in the series would have been quicker to set up and easier to reason about. This overhead is made worse by the ecosystem's youth: tooling, debugging support, and established conventions still lag behind something as mature as a plain REST API. In practice that means spending more time reading source code and the specification directly, with fewer proven patterns available to fall back on.

None of this argues against adopting MCP. It's a reminder that choosing a protocol doesn't replace the engineering work that still has to happen underneath it. The change MCP delivers is real, as shown throughout the earlier sections, but it's narrower than "agents connecting to tools" taken as a whole, and it's worth being precise about exactly where that boundary falls before deciding whether adoption makes sense, which is what the next section takes up.

When MCP Is Worth Adopting

Given the trade-offs laid out above, there's no blanket answer here—no clean "always adopt it" or "never bother." What matters instead is checking a handful of concrete conditions before committing to it in any given system.

  • More than one agent will need the same tools. Nearly all the payoff described earlier comes from reuse: a second agent can plug into a server that's already built, rather than duplicating a client or extracting one after the fact. When there's only one agent and one tool, that payoff simply doesn't exist yet—there's nothing to share—and the ad hoc approach covered earlier remains the simpler choice for that specific case.
  • The tool count is expected to grow. A standardized interface becomes more valuable as more tools sit behind it. With two or three tools, running a dedicated server may not be worth the overhead. Once you're dealing with ten or twenty—each of which would otherwise need its own custom client—the maintenance burden of the ad hoc approach starts to become a real liability.
  • The system needs to survive past its first release. Part of the value MCP offers is that adding a new agent or a new tool down the line doesn't force you to rewire everything already in place. That benefit accumulates over a system's lifetime, and it's easy to overlook if you're only weighing the cost of the initial setup.

On the other hand, this is a bad fit for a single agent talking to one stable, well-understood tool that isn't going to change. In that scenario, building an ad hoc client is quicker, leaves one fewer moving part to maintain, and the coordination MCP offers has no second consumer around to actually use it.

The practical test to apply here is close to the one used earlier for the rules engine itself: does adding this complexity solve a problem this particular system actually faces at its current stage, or is it being adopted simply because it's the fashionable answer to a problem the system doesn't yet have.

Conclusion: Closing the Series

Over the course of three articles, one system grew step by step, each layer building on the last. The first installment built a single agent trustworthy enough to be handed a real decision, by strictly separating interpretation from the act of deciding. The second gave that agent company, bringing several specialized agents together inside a graph that shared state between them, guarded by explicit permissions and traceable end to end so every request could be followed from the moment it entered to the moment it left. This article closed the remaining gap—how those agents reach beyond the graph—by standardizing that access through MCP in the situations where it genuinely pays for itself, while being equally direct about where it doesn't.

None of these three pieces is especially complex on its own. What actually makes the resulting system dependable is one habit, repeated at every layer without exception: responsibilities stay narrow, contracts stay explicit, and nothing is left to guesswork or allowed to happen quietly in the background. That habit is the real subject of this series, more than any particular tool—LangGraph and MCP just happened to be the frameworks used to put it into practice, but the underlying principles hold regardless of which tools you reach for.