MCP Adapters

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against langchain-mcp-adapters 0.3.2, langchain 1.4.3, langchain-core 1.6.5 and langgraph 1.2.12 with the mcp 1.30.0 SDK on Python 3.12, plus langchain[mcp] 1.4.3 with FastMCP 4.0.10 and mcp 2.2.0 (in a second virtual environment) for the newer langchain.mcp namespace, and langgraph-cli 0.4.32 with langgraph-api 0.15.1 for the Agent Server section, as verified on 2026-09-29. The running example is DocsAssistant, which answers questions over this site’s documentation with Ollama’s llama3.1:8b chat model over the pgvector store built in Integrating with LangChain. Ollama was not available while the snippets were checked, so every agent run below used a scripted fake chat model (a GenericFakeChatModel subclass that replays prepared messages, including the tool calls). The MCP servers, the adapters, the agents and the Agent Server were the real packages, running locally; no output comes from a live model.

MCP is the protocol that lets one tool implementation serve many agent frameworks. Its architecture, primitives, transports and security rules are covered in the Model Context Protocol sub-section (start with MCP Architecture) and are not repeated here. This page is the LangChain side of the contract: how a create_agent agent (see Agents with create_agent) consumes the tools, resources and prompts of an MCP server, and how a LangGraph agent becomes an MCP server itself. The equivalent Python, Java and TypeScript clients, and the protocol-level handling of input_required results, are on Building MCP Clients; the same LangChain4j capability is on LangChain4j Agents & MCP.

Two packages, one job

LangChain currently has two MCP integrations, and a search for "LangChain MCP" returns both:

Table 1. The two LangChain MCP client integrations (2026-09-29)
langchain-mcp-adapters langchain.mcp

Install

pip install langchain-mcp-adapters

pip install "langchain[mcp]"

Entry point

MultiServerMCPClient

MCPAdapter (an async context manager)

Built on

the first-generation mcp SDK (mcp below 2.0)

FastMCP 4 (and mcp 2)

Tools

yes

yes

Resources and prompts

yes (get_resources, get_prompt)

not wrapped yet; read them with the FastMCP client

Status

maintained; its repository points to the migration guide

beta: importing it raises LangChainBetaWarning

The LangChain documentation now teaches langchain.mcp and describes langchain-mcp-adapters as replaced. The adapters package still installs and works, it is what most existing agents and the LangSmith deployment examples use, and it is the only one of the two that also loads resources and prompts. This page therefore teaches MultiServerMCPClient first and MCPAdapter in its own section, with the differences stated in a table. See the LangChain MCP documentation and the migration guide.

flowchart LR subgraph agent[LangChain agent process] A[create_agent] --> T[MCP tools as BaseTool] C[MultiServerMCPClient] --> T C --> R[Blobs: resources] C --> P[Messages: prompts] end C -- stdio --> S1[MCP server subprocess] C -- Streamable HTTP --> S2[Remote MCP server] G[Agent Server /mcp] -. graph exposed as a tool .-> C

A local DocsAssistant MCP server

Every snippet below talks to the same small server, so the outputs can be compared. It has two tools, one resource, one resource template and one prompt over a three-page stand-in corpus (the real DocsAssistant would query pgvector), and runs over stdio by default and over Streamable HTTP with --http. It uses the mcp 1.30 SDK’s FastMCP class, the same first-generation SDK that langchain-mcp-adapters 0.3.2 requires. The servers themselves are built in Python MCP Servers, which also explains why the v2 SDK renamed that class to MCPServer.

docs_server.py — tools, a resource and a prompt over stdio or Streamable HTTP
import sys

from mcp.server.fastmcp import FastMCP

CORPUS = {
    "chunking": "Split documents into overlapping chunks before embedding them.",
    "pgvector": "pgvector stores embeddings in a vector column and supports HNSW indexes.",
    "tool-design": "Design narrow, typed tools and return actionable errors.",
}

mcp = FastMCP("docs-assistant", host="127.0.0.1", port=8765)


@mcp.tool()
def search_docs(query: str, top_k: int = 2) -> list[dict]:
    """Search the DocsAssistant corpus and return matching pages."""
    words = query.lower().split()
    hits = [
        {"page": page, "snippet": text}
        for page, text in CORPUS.items()
        if any(w in page or w in text.lower() for w in words)
    ]
    return hits[:top_k]


@mcp.tool()
def open_issue(title: str) -> str:
    """Open a GitHub issue (simulated: nothing leaves this process)."""
    if not title.strip():
        raise ValueError("title must not be empty")
    return f"created issue: {title}"


@mcp.resource("docs://index")
def page_index() -> str:
    """The list of corpus pages."""
    return "\n".join(CORPUS)


@mcp.resource("docs://pages/{page}")
def read_page(page: str) -> str:
    """Full text of one corpus page."""
    return CORPUS.get(page, "not found")


@mcp.prompt()
def summarize_page(page: str) -> str:
    """Prompt template that asks for a summary of a corpus page."""
    return f"Summarize the page '{page}' in two sentences for a new contributor."


if __name__ == "__main__":
    mcp.run(transport="streamable-http" if "--http" in sys.argv else "stdio")

Started with python docs_server.py --http, it listens on http://127.0.0.1:8765/mcp. The open_issue tool is a write action: it appears in the examples so the approval and scoping patterns have something to guard, and it only formats a string. See the MCP Python SDK documentation.

MultiServerMCPClient

MultiServerMCPClient takes a dictionary that maps a server name to a connection. A connection is a plain dictionary whose transport key selects the shape: "stdio" (with command, args and optionally env and cwd) launches the server as a subprocess, and "streamable_http" (with url and optionally headers and timeout) reaches a remote one. The installed connection types also include "sse" and "websocket"; the HTTP+SSE transport is deprecated in the protocol, as explained on MCP Transports, so use Streamable HTTP for anything new.

Two servers behind one client: stdio and Streamable HTTP
import asyncio
import sys

from langchain_mcp_adapters.client import MultiServerMCPClient


async def main() -> None:
    client = MultiServerMCPClient(
        {
            "docs": {"transport": "stdio", "command": sys.executable, "args": ["docs_server.py"]},
            "docs_http": {"transport": "streamable_http", "url": "http://127.0.0.1:8765/mcp"},
        },
        tool_name_prefix=True,
    )
    tools = await client.get_tools()
    print(sorted(t.name for t in tools))
    search = next(t for t in tools if t.name == "docs_http_search_docs")
    print(search.args_schema["properties"])
    print(await search.ainvoke({"query": "chunking"}))
    print(sorted(t.name for t in await client.get_tools(server_name="docs")))


asyncio.run(main())

Output (server log lines omitted):

['docs_http_open_issue', 'docs_http_search_docs', 'docs_open_issue', 'docs_search_docs']
{'query': {'title': 'Query', 'type': 'string'}, 'top_k': {'default': 2, 'title': 'Top K', 'type': 'integer'}}
[{'type': 'text', 'text': '{\n  "page": "chunking",\n  "snippet": "Split documents into overlapping chunks before embedding them."\n}', 'id': 'lc_3aea1089-d04e-4dd1-9061-7b1d00c43aad'}]
['docs_open_issue', 'docs_search_docs']

Four things to notice:

  • get_tools() returns ordinary LangChain tools (BaseTool), whose name, description and args_schema come from the MCP tool definition, so they work anywhere a tool from Tools works.

  • tool_name_prefix=True prefixes each tool with its server name (docs_http_search_docs). Without it, two servers that both expose search_docs collide, and the model sees ambiguous names. The design rules for names are on Tool Design.

  • get_tools(server_name="docs") loads the tools of one server only.

  • The tool result is a list of content blocks (type: "text"), not a bare string. Structured output from the server arrives as JSON text in the block; the agent’s model reads it as-is.

By default every tool call opens a new session and closes it afterwards, so a stdio server is launched again for each call, and no state survives between calls. That is the safe default for stateless tools. When a server keeps state (a database connection, a warmed-up index), hold one session open yourself and load the tools from it with load_mcp_tools:

One session for many calls
import asyncio
import sys

from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_mcp_adapters.tools import load_mcp_tools


async def main() -> None:
    client = MultiServerMCPClient(
        {"docs": {"transport": "stdio", "command": sys.executable, "args": ["docs_server.py"]}}
    )
    async with client.session("docs") as session:  # one subprocess for the whole block
        tools = await load_mcp_tools(session)
        search = next(t for t in tools if t.name == "search_docs")
        print(await search.ainvoke({"query": "pgvector", "top_k": 1}))
        print(await search.ainvoke({"query": "tool", "top_k": 1}))


asyncio.run(main())

Both calls returned their hits (pgvector, then tool-design) from the same server process. The tools loaded from a session stop working when the block exits. See the langchain-mcp-adapters repository and the LangChain MCP documentation.

Protocol era and the 2026-07-28 revision

langchain-mcp-adapters 0.3.2 sits on the first-generation Python SDK, so it opens each session with the initialize handshake and speaks protocol revision 2025-11-25 at most. It does not use the server/discover probe or the sessionless request shape introduced by the 2026-07-28 revision; a server built on the v2 SDK or FastMCP 4 still serves it, because those servers keep answering the older handshake. The consequence that matters in practice is elicitation. A v1 client receives a server’s question as an elicitation/create request pushed down the session, answered by a callback registered with Callbacks(on_elicitation=…​), exactly as shown on Building MCP Clients. Without a callback the client declares no elicitation capability and a tool that asks a question fails. On the 2026-07-28 revision the same question is an input_required result that the client answers by calling again; see Tools, Resources, Prompts & Elicitation and MCP Transports. langchain.mcp (below) targets that newer shape.

The adapters also require mcp below 2.0.0. Installing them next to mcp 2.x downgrades the SDK, so give an agent that uses langchain-mcp-adapters its own virtual environment rather than sharing one with a v2 MCP server.

MCP tools in create_agent

MCP tools are LangChain tools, so the agent code does not change: pass them in the tools list. They are asynchronous only, so run the agent with ainvoke or astream. The script below combines the MCP tools with HumanInTheLoopMiddleware (see Human-in-the-Loop & Time Travel) so the one write tool, open_issue, pauses for approval while search_docs runs freely. The model is the scripted fake: first a search_docs call, then an open_issue call, then the final answer.

An agent that searches freely and asks before opening an issue
import asyncio

from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command


class ScriptedChat(GenericFakeChatModel):
    def bind_tools(self, tools, **kwargs):
        return self


def call(name, args, id_):
    return AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": id_, "type": "tool_call"}])


async def main() -> None:
    client = MultiServerMCPClient(
        {"docs": {"transport": "streamable_http", "url": "http://127.0.0.1:8765/mcp"}}
    )
    tools = await client.get_tools()
    model = ScriptedChat(messages=iter([
        call("search_docs", {"query": "chunking"}, "c1"),
        call("open_issue", {"title": "Explain overlap in chunking"}, "c2"),
        AIMessage(content="I opened an issue about chunk overlap."),
    ]))
    agent = create_agent(
        model,
        tools,
        middleware=[HumanInTheLoopMiddleware(interrupt_on={"open_issue": True, "search_docs": False})],
        checkpointer=InMemorySaver(),
    )
    cfg = {"configurable": {"thread_id": "t1"}}
    out = await agent.ainvoke({"messages": [{"role": "user", "content": "Is chunking documented?"}]}, cfg)
    req = out["__interrupt__"][0].value["action_requests"][0]
    print("paused on:", req["name"], req["args"])
    out = await agent.ainvoke(Command(resume={"decisions": [{"type": "approve"}]}), cfg)
    for m in out["messages"]:
        print(type(m).__name__, "|", getattr(m, "name", None), "|", str(m.content)[:90].replace("\n", " "))


asyncio.run(main())

Output:

paused on: open_issue {'title': 'Explain overlap in chunking'}
HumanMessage | None | Is chunking documented?
AIMessage | None |
ToolMessage | search_docs | [{'type': 'text', 'text': '{\n  "page": "chunking",\n  "snippet": "Split documents into ov
AIMessage | None |
ToolMessage | open_issue | [{'type': 'text', 'text': 'created issue: Explain overlap in chunking', 'id': 'lc_e682571b
AIMessage | None | I opened an issue about chunk overlap.

The approval gate is the client’s decision, not the server’s: nothing in the MCP protocol makes open_issue ask, and tool annotations such as destructiveHint are untrusted hints (see MCP Security). The approval lives in the agent, next to the tool list. See LangChain MCP and LangChain agents.

Tool interceptors, errors and authentication

MultiServerMCPClient takes tool_interceptors, a list of async callables that wrap every MCP tool call. An interceptor receives an MCPToolCallRequest (name, args, server_name, headers and the LangGraph runtime) and a handler; it may change the request with request.override(…​), call the handler more than once (retry), or skip it. Interceptors compose like onion layers, the first being outermost. The one below scopes the agent to read-only tools and adds an Authorization header read from the environment on every call:

An interceptor that scopes tools and adds a bearer header
import asyncio
import os

from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_mcp_adapters.interceptors import MCPToolCallRequest
from mcp.types import CallToolResult, TextContent

READ_ONLY = {"search_docs"}


async def guard(request: MCPToolCallRequest, handler):
    """Runs around every MCP tool call: scope the tools, add a per-call header."""
    if request.name not in READ_ONLY:
        return CallToolResult(
            content=[TextContent(type="text", text=f"{request.name} is not allowed for this agent")],
            isError=True,
        )
    token = os.environ.get("DOCS_MCP_TOKEN", "")
    print(f"[{request.server_name}] {request.name}({request.args})")
    return await handler(request.override(headers={"Authorization": f"Bearer {token}"}))


async def main() -> None:
    client = MultiServerMCPClient(
        {"docs": {"transport": "streamable_http", "url": "http://127.0.0.1:8765/mcp"}},
        tool_interceptors=[guard],
    )
    tools = {t.name: t for t in await client.get_tools()}
    print(await tools["search_docs"].ainvoke({"query": "pgvector", "top_k": 1}))
    print(await tools["open_issue"].ainvoke({"title": "x"}))


asyncio.run(main())

search_docs was forwarded with the header (the server ignores it) and printed its hit; open_issue never reached the server and returned open_issue is not allowed for this agent. Inside an agent the blocked call arrives as a ToolMessage with status="error" and the model’s own tool-call ID, so the model can react. The token is read from DOCS_MCP_TOKEN at call time; it is never written in the code, and the header is the place to put a token that the server issued for this client. What a server may accept, and why it must never forward that token to another API, is the subject of MCP Authorization. For a fixed header on every request of one server, set headers in its streamable_http connection instead.

Errors have their own switch. By default (handle_tool_errors=True), a tool that fails on the server becomes a ToolMessage with status="error" and the server’s message, so the agent can correct itself. With handle_tool_errors=False the client raises instead:

Server-side tool errors, both ways
plain = MultiServerMCPClient({"docs": {"transport": "streamable_http", "url": "http://127.0.0.1:8765/mcp"}})
tool = {t.name: t for t in await plain.get_tools()}["open_issue"]
message = await tool.ainvoke({"id": "c", "name": "open_issue", "args": {"title": " "}, "type": "tool_call"})
print(type(message).__name__, message.status, message.content)

strict = MultiServerMCPClient(
    {"docs": {"transport": "streamable_http", "url": "http://127.0.0.1:8765/mcp"}}, handle_tool_errors=False
)
tool = {t.name: t for t in await strict.get_tools()}["open_issue"]
try:
    await tool.ainvoke({"title": " "})
except Exception as e:
    print("raised", type(e).__name__, e)

Output:

ToolMessage error [{'type': 'text', 'text': 'Error executing tool open_issue: title must not be empty', 'id': 'lc_d14d696d-da98-4e93-bc3c-e3d589954b8b'}]
raised _MCPToolExecutionError Error executing tool open_issue: title must not be empty

The general error-handling patterns for tools, and wrap_tool_call middleware that applies to every tool (MCP or not), are on Tools and Middleware. The guardrails around tool boundaries in general are in Guardrails & Security. See LangChain MCP — Tools.

Resources and prompts

Tools are the primitive most servers offer, but MCP servers also publish resources (readable data addressed by URI) and prompts (parameterised message templates); the difference in who controls each is explained on Tools, Resources, Prompts & Elicitation. MultiServerMCPClient maps them onto LangChain types: get_resources returns Blob documents from langchain-core (URI in metadata["uri"]), and get_prompt returns a list of HumanMessage and AIMessage objects ready to put into a conversation.

Reading resources and a prompt
import asyncio

from langchain_mcp_adapters.client import MultiServerMCPClient


async def main() -> None:
    client = MultiServerMCPClient(
        {"docs": {"transport": "streamable_http", "url": "http://127.0.0.1:8765/mcp"}}
    )
    blobs = await client.get_resources("docs", uris=["docs://index", "docs://pages/pgvector"])
    for b in blobs:
        print(b.metadata["uri"], "|", b.as_string()[:60].replace("\n", ", "))
    print(len(await client.get_resources("docs")), "resources listed")

    messages = await client.get_prompt("docs", "summarize_page", arguments={"page": "chunking"})
    print(type(messages[0]).__name__, messages[0].content)

    async with client.session("docs") as session:
        listed = await session.list_prompts()
        print([p.name for p in listed.prompts])
        tpl = await session.list_resource_templates()
        print([t.uriTemplate for t in tpl.resourceTemplates])


asyncio.run(main())

Output:

docs://index | chunking, pgvector, tool-design
docs://pages/pgvector | pgvector stores embeddings in a vector column and supports HNSW indexes.
1 resources listed
HumanMessage Summarize the page 'chunking' in two sentences for a new contributor.
['summarize_page']
['docs://pages/{page}']

Three details follow from the output. get_resources without uris lists only the server’s concrete resources (docs://index), so a resource template such as docs://pages/{page} has to be read by naming a full URI, or discovered through the raw session (list_resource_templates). get_prompt needs the prompt name in advance, which the raw session’s list_prompts supplies. And resources and prompts are not tools: an agent does not see them unless your code puts them in front of it. The usual choices are to append the Blob text to the system prompt or to a HumanMessage (which is what makes a resource "context" rather than "action"), to start a conversation from the prompt’s messages, or to wrap get_resources in a small @tool of your own when the model should decide what to read. Reading arbitrary resource URIs on the model’s behalf gives it a file reader, so restrict the wrapper to the URIs you intend to expose. See langchain-mcp-adapters for the API.

The newer langchain.mcp namespace

langchain[mcp] 1.4 ships langchain.mcp.MCPAdapter, which collapses MultiServerMCPClient into one async context manager built on FastMCP. The adapter infers the transport from its target: an http(s) URL string is Streamable HTTP, a Path to a script is launched over stdio, an in-process FastMCP server connects in memory, and a dictionary in the standard {"mcpServers": {…​}} shape puts several servers behind one adapter (with automatic {server}_{tool} prefixes). The example below uses a FastMCP 4 stdio server (fm_server.py, a two-line search_docs tool) next to the Streamable HTTP server started earlier, and was run in a separate virtual environment because it needs mcp 2:

MCPAdapter over a multi-server config
import asyncio
from pathlib import Path

from langchain.mcp import MCPAdapter


async def main() -> None:
    config = {
        "mcpServers": {
            "local": {"command": "python", "args": ["fm_server.py"]},
            "remote": {"url": "http://127.0.0.1:8765/mcp"},
        }
    }
    async with MCPAdapter(config) as adapter:
        tools = await adapter.list_tools()
        print(sorted(t.name for t in tools))
    async with MCPAdapter(Path("fm_server.py")) as adapter:
        print([t.name for t in await adapter.list_tools()])
    async with MCPAdapter("http://127.0.0.1:8765/mcp") as adapter:
        print([t.name for t in await adapter.list_tools()])


asyncio.run(main())

Output (the FastMCP banner and the beta warning omitted). Passing these tools to create_agent with the scripted model ran the local_search_docs call and returned the chunk text with a success status, as in the agent example above:

['local_search_docs', 'remote_open_issue', 'remote_search_docs']
['search_docs']
['search_docs', 'open_issue']

The first server was a FastMCP 4 (modern era) server and the second the first-generation server, and one adapter served both; the LangChain documentation notes that a shared MCPConfig fleet negotiates a single protocol era for all its members, so it falls back to the older era as soon as one member only speaks that one, and that a ClientGroup keeps each server’s own era. The differences that matter when migrating:

Table 2. Migrating from MultiServerMCPClient to MCPAdapter
langchain-mcp-adapters langchain.mcp

get_tools()

adapter.list_tools(), inside async with

transport key per connection

inferred from the target (URL, Path, config entry)

tool_name_prefix

automatic for a multi-server config

tool_interceptors

LangChain @wrap_tool_call middleware, which sees every tool, MCP or not

handle_tool_errors

fixed: isError=True becomes a ToolMessage(status="error"), transport failures raise

Callbacks(on_elicitation=…​)

elicitation is answered through a LangGraph interrupt and resumed with Command

get_resources, get_prompt

no wrapper yet; use the FastMCP client’s read_resource and get_prompt

headers and OAuth per connection

set on a fastmcp.Client (auth=) passed to the adapter

Sampling and roots requests from a server are not answered by langchain.mcp: a tool call that triggers one raises NotImplementedError. The modern protocol era has no back-channel for them, which is consistent with those features being deprecated in the 2026-07-28 revision (see Tools, Resources, Prompts & Elicitation). Because the namespace is in beta, pin langchain and re-read the pages linked below before upgrading. See the migration guide and LangChain MCP — Connections.

Exposing a LangGraph agent as MCP

The reverse direction turns DocsAssistant into a tool that other hosts (an IDE, another agent, Claude Desktop) can call. There are two routes.

The Agent Server /mcp endpoint

Every LangGraph Agent Server (the runtime behind langgraph dev and LangSmith Deployment, described on Deployment) serves an MCP endpoint at /mcp and exposes each graph in langgraph.json as one tool. The tool name is the graph’s name, the description comes from the description key of the graph entry, and the input schema is the graph’s input schema. The official page warns that the default MessagesState is too general to hand to a model, and recommends a small explicit input and output schema. This graph does that for DocsAssistant (the node stands in for a real create_agent call):

docs_graph.py — a graph with an explicit input and output schema
from langgraph.graph import END, START, StateGraph
from typing_extensions import TypedDict


class InputState(TypedDict):
    question: str


class OutputState(TypedDict):
    answer: str


class OverallState(InputState, OutputState):
    pass


def answer_node(state: InputState) -> dict:
    # a real DocsAssistant would call create_agent(...) here
    return {"answer": f"DocsAssistant would answer: {state['question']}"}


builder = StateGraph(OverallState, input_schema=InputState, output_schema=OutputState)
builder.add_node("answer_node", answer_node)
builder.add_edge(START, "answer_node")
builder.add_edge("answer_node", END)
graph = builder.compile()
langgraph.json
{
  "dependencies": ["."],
  "graphs": {
    "docs_assistant": {
      "path": "./docs_graph.py:graph",
      "description": "Answers questions about this documentation site."
    }
  }
}

After langgraph dev --no-browser (which serves on port 2024), the same adapters client that read docs_server.py consumed the agent as a tool:

Calling the Agent Server as an MCP server
import asyncio

from langchain_mcp_adapters.client import MultiServerMCPClient


async def main() -> None:
    client = MultiServerMCPClient(
        {"agent_server": {"transport": "streamable_http", "url": "http://127.0.0.1:2024/mcp"}}
    )
    tools = await client.get_tools()
    for t in tools:
        print(t.name, "|", t.description, "|", list(t.args_schema["properties"]))
    print(await tools[0].ainvoke({"question": "How do I chunk documents?"}))


asyncio.run(main())

Output:

docs_assistant | Answers questions about this documentation site. | ['question']
[{'type': 'text', 'text': "{'answer': 'DocsAssistant would answer: How do I chunk documents?'}", 'id': 'lc_15736db2-2e9f-4fe7-bf07-777faed79fa8'}]

According to the official page the endpoint is stateless (each /mcp request is independent, so there are no MCP sessions) and uses the same authentication as the rest of the Agent Server API; you turn it off with "http": {"disable_mcp": true} in langgraph.json. It needs langgraph-api 0.2.3 or later. For a deployed server the Authorization or API-key header goes in the client’s headers, from a secret store, as in the interceptor above. This is the "agent as a tool" building block of the multi-agent patterns in Multi-Agent & Deep Agents, with a network boundary in between. See MCP endpoint in Agent Server.

Wrapping a graph in your own MCP server

Without an Agent Server, a compiled graph or create_agent agent can be a tool of an ordinary MCP server, which is the right route when the agent should run over stdio inside a host such as an IDE. Any server SDK works; this one uses the same FastMCP class as before:

graph_as_mcp.py — a compiled graph behind a stdio MCP server
from mcp.server.fastmcp import FastMCP

from docs_graph import graph  # any compiled graph or create_agent(...) agent

mcp = FastMCP("docs-assistant-agent")


@mcp.tool()
async def ask_docs_assistant(question: str) -> str:
    """Ask DocsAssistant a question about this documentation site."""
    result = await graph.ainvoke({"question": question})
    return result["answer"]


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

Run through MultiServerMCPClient with a stdio connection to graph_as_mcp.py, it printed ask_docs_assistant → [{'type': 'text', 'text': 'DocsAssistant would answer: What is HNSW?', …​}]. Design the tool like any other (Tool Design): one typed question in, one string out, a docstring the calling model can decide from. A remote deployment of such a server needs the authorization rules of MCP Authorization and the hardening of MCP Security; how to host it is covered in Deploying Remote MCP Servers. See LangChain MCP and the Python SDK documentation.

Choosing and operating

  • New project, tools only: langchain.mcp MCPAdapter, accepting its beta status.

  • Resources or prompts needed, or an existing adapters-based agent: MultiServerMCPClient, in its own virtual environment, and plan the migration.

  • Java: the LangChain4j MCP client on LangChain4j Agents & MCP.

  • Trust: connect only to servers whose owners you trust, keep write tools behind approval, scope tools with an interceptor or a filtered tool list, and keep tokens in environment variables or a secret store, never in the code or the stdio args. Tool descriptions from a server enter the model’s context, so a hostile server can attempt prompt injection; see MCP Security and Agent Guardrails & Safety.

  • Testing: the in-memory and stdio transports make MCP-backed agents testable without a network; see Testing and Testing & Debugging MCP Servers.