Start Debugging
2026-07-10 Updated 2026-07-10 migrationmcpai-agentsllm Edit on GitHub

Migrate a LangChain Agent to the MCP Tool-Calling Pattern

A step-by-step checklist for moving an existing LangChain agent off inline @tool functions onto a standalone MCP server that any client can call. Covers extracting tools into FastMCP, re-wiring the agent with langchain-mcp-adapters MultiServerMCPClient, the sync-to-async switch, transports, and the gotchas that bite mid-cutover.

If you built an agent on LangChain in 2025, your tools almost certainly live inline: a folder of functions decorated with @tool, imported into the same module as the agent, and handed to create_agent as a Python list. That is the fastest way to get a working agent, and it is fine right up until a second consumer wants the same tools. A colleague using Claude Code, a Cursor session, or a plain MCP client cannot import your @tool functions. They are a LangChain-only contract. MCP is the cross-tool contract, and moving your tools onto it is smaller than it looks because the tool shape barely changes: @tool becomes @mcp.tool(), the function body is identical, and langchain-mcp-adapters hands the migrated tools back to the same create_agent call you already have.

This post is the migration checklist for a real agent running LangChain 1.1.0 (with create_agent from langchain.agents), moving its tools to a standalone server built on the MCP Python SDK (mcp, FastMCP API, targeting the 2025-11-25 spec revision) and consuming them through langchain-mcp-adapters 0.3.0 (released June 10, 2026). Model calls in the examples use claude-sonnet-4-6. The short version: for a handful of tools this is a half-day job, the tool code moves almost verbatim, and the one thing that genuinely breaks is that your agent invocation has to become async.

Why move tools out of LangChain at all

The inline @tool model and MCP solve overlapping problems with different blast radii. A LangChain tool is first-class inside one Python process: the agent binds it, the model sees its schema, and the call happens on the same event loop. None of that travels. An MCP server is a separate process that any compliant client can launch over stdio or hit over HTTP, and the official SDKs in Python, TypeScript, and C# all emit the same wire traffic.

Concretely, the migration buys you:

The cost is that a tool call is no longer a local function call. It is a serialized request to another process, which changes the error model, the concurrency model, and what data can cross the boundary. That is what the rest of this post is about.

The LangChain agent we are migrating

Here is the shape almost every LangChain codebase has somewhere: two @tool functions that wrap an internal service, handed straight to create_agent.

# langchain 1.1.0, langgraph 1.x
from langchain.agents import create_agent
from langchain_core.tools import tool
import httpx

@tool
def get_stock_level(sku: str) -> int:
    """Look up stock level for a SKU across all warehouses."""
    resp = httpx.get(f"https://inventory.internal/stock/{sku}")
    resp.raise_for_status()
    return resp.json()["quantity"]

@tool
def reserve(sku: str, quantity: int) -> str:
    """Reserve stock for an order. Returns the reservation id."""
    resp = httpx.post(
        "https://inventory.internal/reserve",
        json={"sku": sku, "quantity": quantity},
    )
    resp.raise_for_status()
    return resp.json()["reservation_id"]

agent = create_agent("anthropic:claude-sonnet-4-6", [get_stock_level, reserve])
result = agent.invoke({"messages": "How many SKU-1042 are in stock?"})
print(result["messages"][-1].content)

Two things about this code decide the migration. First, the docstring is the tool description the model reads to decide when to call the tool, and the type hints become the argument schema. Both have to survive the cutover verbatim or the model starts guessing. Second, agent.invoke(...) is synchronous. That is the line that has to change.

What breaks

AreaChangeSeverity
Tool definition@tool in-process becomes @mcp.tool() in a separate server filehigh
Agent invocationSync .invoke() becomes async .ainvoke(); tools are awaited under the hoodhigh
Error propagationA raised exception no longer bubbles up; it returns to the model as a ToolMessage with status="error"medium
Data across the boundaryArguments and return values must be JSON-serializable; rich Python objects do not cross the wiremedium
Tool stateMultiServerMCPClient is stateless by default, so module-level caches or connection pools do not persist between callsmedium
Agent constructionBuilding the agent now needs an await on get_tools()low

The two high-severity rows are the whole migration. Everything else is a consequence.

Pre-flight checklist

Migration steps

  1. Inventory the tools and confirm their contracts. Open the agent module and list every @tool. For each one, write down the exact docstring and the type hints on its parameters and return. This is the contract that has to move unchanged. If any tool returns a non-JSON type, add a step to convert it to a dict, a primitive, or a Pydantic model that serializes cleanly. Verify by confirming every tool has a docstring and full type hints; an untyped parameter gives the MCP server nothing to build a schema from.

  2. Stand up an MCP server and move the tool bodies. Create inventory_server.py. Instantiate FastMCP, swap each @tool for @mcp.tool(), and paste the function body verbatim. The signatures and docstrings do not change.

    # mcp (Python SDK), FastMCP API, MCP spec 2025-11-25
    from mcp.server.fastmcp import FastMCP
    import httpx
    
    mcp = FastMCP("inventory")
    
    @mcp.tool()
    def get_stock_level(sku: str) -> int:
        """Look up stock level for a SKU across all warehouses."""
        resp = httpx.get(f"https://inventory.internal/stock/{sku}")
        resp.raise_for_status()
        return resp.json()["quantity"]
    
    @mcp.tool()
    def reserve(sku: str, quantity: int) -> str:
        """Reserve stock for an order. Returns the reservation id."""
        resp = httpx.post(
            "https://inventory.internal/reserve",
            json={"sku": sku, "quantity": quantity},
        )
        resp.raise_for_status()
        return resp.json()["reservation_id"]
    
    if __name__ == "__main__":
        mcp.run(transport="stdio")

    Verify by running python inventory_server.py and confirming it starts without printing anything to stdout. The full server pattern, including resources and prompts, is covered in the Python MCP server guide.

  3. Smoke-test the server in isolation. Before touching the agent, prove the server answers. The quickest check is the MCP Inspector (npx @modelcontextprotocol/inspector python inventory_server.py), which lists the two tools with their generated schemas and lets you call get_stock_level by hand. Verify that both tools appear, that each shows the docstring as its description, and that sku and quantity show the correct types. If a tool is missing a description here, it will be missing it for the model too.

  4. Point the LangChain agent at the server. Replace the inline tool list with a MultiServerMCPClient that launches the server and a get_tools() call that fetches the migrated tools. This is where the agent stops importing your functions and starts calling them over MCP.

    # langchain-mcp-adapters 0.3.0, langchain 1.1.0
    import asyncio
    from langchain_mcp_adapters.client import MultiServerMCPClient
    from langchain.agents import create_agent
    
    async def main():
        client = MultiServerMCPClient(
            {
                "inventory": {
                    "command": "python",
                    "args": ["inventory_server.py"],
                    "transport": "stdio",
                }
            }
        )
        tools = await client.get_tools()
        agent = create_agent("anthropic:claude-sonnet-4-6", tools)
        result = await agent.ainvoke(
            {"messages": "How many SKU-1042 are in stock?"}
        )
        print(result["messages"][-1].content)
    
    asyncio.run(main())

    Verify by running the script and confirming the answer matches what the inline agent returned for the same prompt.

  5. Commit to async everywhere. The line that breaks most migrations is the invocation. MCP tools are awaited, so agent.invoke(...) becomes await agent.ainvoke(...), and any caller up the stack has to be async too. If your agent runs inside a sync framework, wrap the entry point with asyncio.run as above, or move the call onto the existing event loop. Verify by running your existing agent test suite; a RuntimeWarning: coroutine was never awaited means a call site is still sync.

  6. Optional: move to streamable HTTP for a shared server. Once the stdio version works, switching to a long-lived HTTP server is a two-line change. On the server, run mcp.run(transport="streamable-http"), which serves at http://localhost:8000/mcp. On the client, replace the command/args block with {"transport": "http", "url": "http://localhost:8000/mcp"}. Verify the agent still answers, and confirm a second client (Claude Code, the Inspector) can now reach the same server concurrently.

Verifying the cutover

Run this checklist after the migration before you delete the old code:

Rollback plan

This migration is fully reversible, which is the nice part. You did not delete the original @tool functions; you copied their bodies. If the MCP path misbehaves in production, flip the agent back to the inline list in one line:

# rollback: bypass MCP, use the original in-process tools
agent = create_agent("anthropic:claude-sonnet-4-6", [get_stock_level, reserve])

Keep both wiring paths behind a config flag during the first week so you can switch without a deploy. Once the MCP server has run clean for a few days, delete the inline definitions and the flag.

Gotchas during the cutover

A few things that bite when you run this on a real codebase.

Anything printed to stdout corrupts a stdio server. The stdio transport uses stdout for JSON-RPC frames. A stray print() for debugging, or a library that logs to stdout, will garble the protocol and the client will either hang or reject every frame. Route all logging to stderr. This is the single most common way a first MCP server fails, and it is the same trap in the TypeScript SDK.

Type hints are not optional anymore. In LangChain, a loosely typed @tool still worked because the model could often guess from the docstring. MCP builds the JSON Schema from your type annotations. A def reserve(sku, quantity) with no hints ships a schema with untyped arguments, and the model will pass strings where you expected ints. Every parameter needs a real annotation.

The docstring carries more weight than before. When you owned the agent prompt, you could nudge tool selection in the system prompt. Over MCP the client owns the prompt, so the tool description (your docstring) is often the only steering the model gets. Tighten vague docstrings before you migrate, not after.

Stateless means module-level state does not persist. MultiServerMCPClient opens a fresh session per tool call by default, so a connection pool or cache you stashed at module scope is rebuilt every call, and in stdio mode the subprocess may be relaunched. If a tool genuinely needs to share state across calls, use the stateful async with client.session("inventory") as session: form with load_mcp_tools(session), or move to a long-lived HTTP server.

Errors change shape. With inline tools, a raised exception propagated up your stack and you handled it. Over MCP, langchain-mcp-adapters returns tool failures to the model as a ToolMessage with status="error" so the agent can self-correct, and does not raise by default. That is usually what you want in an agent loop, but if you were relying on exceptions to abort the run, pass handle_tool_errors=False to get_tools() to restore the raising behavior.

Watch the tool count. Once tools live on a server, it is tempting to keep adding them, and a bloated tool list degrades model accuracy and can hit client tool-use limits. If your server grows past a dozen or so tools, read how to reduce the number of MCP tools an agent loads.

The migration is shorter than it looks because the hard part, deciding what the tools are, what they return, and what their descriptions say, was already done when you wrote the @tool functions. MCP is just a more interoperable place to run them, and moving from a bound tool list to a called-over-a-wire server is the same conceptual shift as choosing MCP over OpenAPI plugins or bespoke tool calling in the first place.

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back