Part 1 of the LangChain series covered models and messages, which is everything you need to make a model produce text. This post is a detour from that sequence, because the more interesting question is what happens when the model needs to do something rather than say something.
The Model Context Protocol is the current answer. An MCP server exposes a set of tools over a standard wire format, and any MCP client can discover and call them without knowing anything about the implementation. LangChain speaks MCP through the langchain-mcp-adapters package, which converts remote MCP tools into ordinary LangChain StructuredTool objects.
The server used here is hier-config-mcp, which wraps hier_config. It compares a running network device configuration against an intended one and generates the exact commands needed to close the gap. That makes it a good subject, because the answers are deterministic and verifiable. When something goes wrong you can tell immediately whether the tool was wrong or the model was.
Everything runs locally. The server runs in Docker, the model runs in Ollama, and nothing leaves the machine. Versions used throughout: langchain 1.3.16, langchain-core 1.6.0, langchain-ollama 1.1.0, langchain-mcp-adapters 0.3.2, langgraph 1.2.11, and mcp 1.29.0. The server is hier-config-mcp at commit 89fea8e.
Putting the Server in a Container
MCP defines two transports you will actually use. stdio launches the server as a subprocess and talks to it over standard input and output. Streamable HTTP connects to a server already listening on a port. Claude Desktop and most editor integrations use stdio. For a container you want both, and it is worth building both to understand the tradeoff.
Clone the repository first:
git clone https://github.com/netdevops/hier-config-mcp.git
cd hier-config-mcp
The stdio image is the shorter of the two, because the packaged entry point already runs stdio:
FROM python:3.13-slim
RUN pip install --no-cache-dir poetry
WORKDIR /app
COPY . /app
RUN poetry config virtualenvs.create false \
&& poetry install --only main
CMD ["hier-config-mcp"]
The HTTP image needs one extra file, and the reason is a detail that will cost you twenty minutes if you skip it. FastMCP reads its host and port from FASTMCP_HOST and FASTMCP_PORT environment variables, but only when the FastMCP constructor did not receive explicit values. It always receives them, because the constructor declares host: str = "127.0.0.1" as a default and passes it straight through to the settings object. Setting FASTMCP_HOST=0.0.0.0 in the Dockerfile does nothing at all. The server binds to loopback inside the container and every connection from the host is refused.
Override the settings in code instead. Write serve_http.py in the repository root:
"""Run the hier-config MCP server over streamable HTTP."""
from hier_config_mcp.server import mcp
mcp.settings.host = "0.0.0.0"
mcp.settings.port = 8000
mcp.run(transport="streamable-http")
Then the HTTP image:
FROM python:3.13-slim
RUN pip install --no-cache-dir poetry
WORKDIR /app
COPY . /app
RUN poetry config virtualenvs.create false \
&& poetry install --only main
EXPOSE 8000
CMD ["python", "serve_http.py"]
Build both and start the HTTP one:
docker build -f Dockerfile.stdio -t hier-config-mcp:stdio .
docker build -f Dockerfile.http -t hier-config-mcp:http .
docker run -d --name hier-config-mcp -p 8000:8000 hier-config-mcp:http
docker logs hier-config-mcp
INFO: Started server process [1]
INFO: Waiting for application startup.
INFO StreamableHTTP session manager started
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
If that last line says 127.0.0.1 instead of 0.0.0.0, your settings override did not take effect. The streamable HTTP endpoint lives at /mcp/ by default, so the full URL is http://localhost:8000/mcp/.
Connecting LangChain
Install the adapter alongside the packages from Part 1:
pip install langchain langchain-ollama langchain-mcp-adapters langgraph
langgraph is not optional in practice. LangChain 1.x builds its agent loop on it, and create_agent will not import without it.
The client class is MultiServerMCPClient. It takes a dictionary of named connections, and the whole API is async:
import asyncio
from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient(
{
"hier-config": {
"transport": "streamable_http",
"url": "http://localhost:8000/mcp/",
}
}
)
async def main():
tools = await client.get_tools()
for tool in tools:
print(f"{tool.name}: {type(tool).__name__}")
asyncio.run(main())
list_platforms: StructuredTool
parse_config: StructuredTool
compare_configs: StructuredTool
generate_remediation: StructuredTool
generate_rollback: StructuredTool
predict_config: StructuredTool
unified_diff_configs: StructuredTool
Seven tools, discovered over the network, converted into native LangChain objects. Nothing in your code names them, imports them, or declares their signatures. That is the entire point of MCP, and it is worth sitting with for a second. Adding a tool to the server makes it available to the agent on the next restart, with no client change.
What the Adapter Actually Builds
A StructuredTool needs a name, a description, and an argument schema. The adapter takes all three from the MCP tool definition, which FastMCP generated from the Python function on the server side:
tool = next(t for t in tools if t.name == "generate_remediation")
print("description:", tool.description)
print(json.dumps(tool.args_schema, indent=2))
description: Generate remediation commands to transform running config to intended config.
Args:
platform: Network platform name (e.g., "CISCO_IOS", "ARISTA_EOS").
running_config: The current device configuration.
intended_config: The desired/target configuration.
Returns:
The remediation commands as text.
{
"properties": {
"platform": {"title": "Platform", "type": "string"},
"running_config": {"title": "Running Config", "type": "string"},
"intended_config": {"title": "Intended Config", "type": "string"}
},
"required": ["platform", "running_config", "intended_config"],
"title": "generate_remediationArguments",
"type": "object"
}
The description is the server function’s docstring, verbatim. That docstring is now a prompt. It is the only thing the model reads when deciding whether to call this tool and what to put in each field, and it travels from a Python source file on a container, through the MCP wire format, into the tool block of an Ollama request. If you write MCP servers, write the docstrings as instructions to a model rather than as notes to a colleague, because that is the job they are doing.
Note also that args_schema here is a plain dictionary of JSON Schema, not a Pydantic model. LangChain supports both. That distinction becomes important at the end of this post.
Calling a Tool With No Model
The tools work on their own. You do not need a model in the loop to use them, and starting here is the fastest way to confirm the container is healthy:
RUNNING = """\
interface GigabitEthernet0/1
description uplink to core
ip address 10.0.0.1 255.255.255.252
no shutdown
"""
INTENDED = """\
interface GigabitEthernet0/1
description uplink to core-a
ip address 10.0.0.1 255.255.255.252
ip access-group EDGE-IN in
no shutdown
"""
async def main():
tools = {t.name: t for t in await client.get_tools()}
result = await tools["generate_remediation"].ainvoke(
{
"platform": "CISCO_IOS",
"running_config": RUNNING,
"intended_config": INTENDED,
}
)
print(result)
[{'type': 'text', 'text': 'interface GigabitEthernet0/1\n description uplink to core-a\n ip access-group EDGE-IN in', 'id': 'lc_8c5013a5-5d69-4b74-a107-5aeb40f46410'}]
That is the correct answer. The IP address is unchanged so it does not appear, the description changed so it is reissued, and the new access group is added. Two lines of real configuration change under one interface header.
The return value is a list of content blocks rather than a string, which is the MCP result shape carried through unchanged. Pull the text out yourself:
def text_of(blocks):
return "\n".join(b["text"] for b in blocks if b["type"] == "text")
Handing the Tools to an Agent
Now add the model. create_agent builds the loop that lets the model call tools, read results, and decide what to do next:
from langchain.agents import create_agent
from langchain_ollama import ChatOllama
async def main():
tools = await client.get_tools()
model = ChatOllama(model="llama3.2:3b", temperature=0.0)
agent = create_agent(model, tools)
result = await agent.ainvoke(
{"messages": [("user", "Which network platforms does the hier-config server support?")]}
)
for message in result["messages"]:
message.pretty_print()
================================ Human Message =================================
Which network platforms does the hier-config server support?
================================== Ai Message ==================================
Tool Calls:
list_platforms (16d24e0a-f203-4123-a0cf-8ab15742de3c)
Args:
================================= Tool Message =================================
Name: list_platforms
[{'type': 'text', 'text': 'Supported platforms:\n\n CISCO_IOS: Cisco IOS ...'}]
================================== Ai Message ==================================
The hier-config server supports the following network platforms:
1. CISCO_IOS: Cisco IOS (classic IOS for routers and switches)
2. CISCO_NXOS: Cisco NX-OS (Nexus switches)
...
13. GENERIC: Generic (platform-agnostic parsing)
A three billion parameter model running on a laptop CPU picked the right tool out of seven, called it with no arguments, and reported the result accurately. The plumbing works end to end.
Where It Breaks
Then ask it to do the actual job. Same agent, same tools, and a prompt that contains both configurations:
PROMPT = f"""\
This is a CISCO_IOS device.
Running configuration:
{RUNNING}
Intended configuration:
{INTENDED}
Generate the remediation commands.
"""
================================== Ai Message ==================================
Tool Calls:
generate_remediation (8d432135-22a7-41fc-9406-5472178a5a80)
Args:
platform: CISCO_IOS
running_config: interface GigabitEthernet0/1 description uplink to core ip address 10.0.0.1 255.255.255.252 no shutdown
intended_config: interface GigabitEthernet0/1 description uplink to core-a ip address 10.0.0.1 255.255.255.252 ip access-group EDGE-IN in no shutdown
================================= Tool Message =================================
Name: generate_remediation
[{'type': 'text', 'text': 'no interface GigabitEthernet0/1 description uplink to core ip address 10.0.0.1 255.255.255.252 no shutdown\ninterface GigabitEthernet0/1 description uplink to core-a ...'}]
Read the tool arguments carefully. The newlines are gone. The model flattened each configuration into a single line, so hier_config parsed each one as a single top-level command with no children and no hierarchy. The remediation it produced is correct for the input it received and catastrophic for the device it describes, because the first command it wants you to paste is no interface GigabitEthernet0/1.
The server did nothing wrong. The tool call was well formed, the schema validated, and the protocol worked exactly as designed. The model corrupted the payload on the way in, and every layer downstream faithfully processed the corruption. A deterministic tool does not protect you from a model that garbles its own arguments, and this failure is quiet. There is no error, no warning, and no validation failure. There is only output that looks plausible.
Trying a larger model produces a different failure rather than a fix. Here is qwen2.5-coder:7b on the identical prompt:
================================== Ai Message ==================================
{"name": "generate_remediation", "arguments": {"platform": "CISCO_IOS", "running_config": "interface GigabitEthernet0/1\n description uplink to core\n ...
The newlines survived this time, which is the improvement you would expect from a larger model. But it emitted the call as plain message text instead of using the tool calling API, so tool_calls was empty, the agent saw a final answer, and the loop ended without calling anything. Two small models, two different ways to fail at the same task.
Interceptors: Take the Payload Away From the Model
langchain-mcp-adapters 0.3 added tool call interceptors, which wrap every MCP call and let you rewrite the request before it goes out. This is the direct fix for the corruption above. Your code already has the authoritative configuration text, so stop asking the model to retype it:
CONFIGS = {"running": RUNNING, "intended": INTENDED}
async def inject_configs(request, handler):
"""Replace model-supplied config text with the authoritative copy."""
overrides = {}
if "running_config" in request.args:
overrides["running_config"] = CONFIGS["running"]
if "intended_config" in request.args:
overrides["intended_config"] = CONFIGS["intended"]
if overrides:
print(f"[intercept] {request.name}: replacing {sorted(overrides)}")
request = request.override(args={**request.args, **overrides})
return await handler(request)
client = MultiServerMCPClient(
{
"hier-config": {
"transport": "streamable_http",
"url": "http://localhost:8000/mcp/",
}
},
tool_interceptors=[inject_configs],
)
The interceptor signature is (request, handler). Call handler(request) to continue the chain, or skip it entirely to short circuit. request.override() returns a modified copy rather than mutating in place. Multiple interceptors compose from the outside in, so the same hook works for logging, caching, retries, and header injection.
With that in place the tool receives clean input:
[intercept] generate_remediation: replacing ['intended_config', 'running_config']
================================= Tool Message =================================
Name: generate_remediation
[{'type': 'text', 'text': 'interface GigabitEthernet0/1\n description uplink to core-a\n ip access-group EDGE-IN in'}]
Correct output, from an agent driven by a 3B model. Then read what the model said about it:
================================== Ai Message ==================================
Based on the provided output, here is a remediation plan for GigabitEthernet0/1:
1. Remove the description "uplink to core-a" as it is not necessary and may cause confusion.
2. Remove the ip access-group EDGE-IN in line as it is not necessary and may cause security issues.
The tool result was right and the summary inverted it. The model read commands that add a description and an access group, then recommended removing both. Fixing the input did not fix the output, because the model is still in the path twice: once to build the call and once to explain the result.
The Division of Labor That Works
Both failures share a cause. The model was asked to move exact text, in one direction or the other, and it approximated. So stop asking it to. Let your code own every byte that has to be exact, and give the model only the job it is good at:
async def main():
tools = {t.name: t for t in await client.get_tools()}
blocks = await tools["compare_configs"].ainvoke(
{
"platform": "CISCO_IOS",
"running_config": RUNNING,
"intended_config": INTENDED,
}
)
plan = json.loads(text_of(blocks))
model = ChatOllama(model="llama3.2:3b", temperature=0.0)
summary = model.invoke(
[
SystemMessage(
"You write change-ticket summaries for network engineers. "
"Describe what the commands below do. Invent nothing. "
"One sentence."
),
HumanMessage(plan["remediation"]),
]
)
remediation:
interface GigabitEthernet0/1
description uplink to core-a
ip access-group EDGE-IN in
rollback:
interface GigabitEthernet0/1
no ip access-group EDGE-IN in
description uplink to core
summary: This command configures the GigabitEthernet0/1 interface as an uplink to the core
network, and assigns the EDGE-IN access control list (ACL) to filter incoming traffic on
this interface.
The commands are byte-for-byte what hier_config produced and the model never touched them. The summary is accurate, because describing three lines of configuration in English is a language task. That summary goes in the change ticket, the commands go to the device, and the two never mix.
Note the json.loads. Tools that return a Python dictionary, like compare_configs, come back as a single JSON text block rather than a structured object, so you parse it yourself.
MCP does not change the rule that a model belongs at the edges of a system. It makes it easier to forget, because handing an agent a well-described, correctly-implemented tool feels like handing the problem to something competent.
Sessions Cost More Than You Think
Every get_tools() and every ainvoke() on a MultiServerMCPClient opens a new connection, runs the MCP initialize handshake, lists tools, makes the call, and tears the connection down. Over HTTP that is a few network round trips. Over stdio, where the command is docker run, it is a container start per call.
The stdio connection looks like this:
client = MultiServerMCPClient(
{
"hier-config": {
"transport": "stdio",
"command": "docker",
"args": ["run", "-i", "--rm", "hier-config-mcp:stdio"],
}
}
)
Three calls to unified_diff_configs, measured both ways:
| Transport | Connection per call | One shared session |
|---|---|---|
stdio via docker run | 6.58s | 1.43s |
| Streamable HTTP | 0.26s | 0.06s |
Open the session yourself and load the tools inside it:
from langchain_mcp_adapters.tools import load_mcp_tools
async with client.session("hier-config") as session:
tools = {t.name: t for t in await load_mcp_tools(session)}
for _ in range(3):
await tools["unified_diff_configs"].ainvoke(ARGS)
Four times faster on stdio and four times faster on HTTP. The stdio numbers are the ones that will bite you, because a multi-step agent making eight tool calls starts eight containers. Use HTTP for anything long running, and hold a session open when you know you will make more than one call.
More Than One Server
The class is named MultiServerMCPClient for a reason. Give it several connections and it merges the tools into one list, which is how you assemble a toolbox from independently maintained servers:
client = MultiServerMCPClient(
{
"prod": {"transport": "streamable_http", "url": "http://localhost:8000/mcp/"},
"lab": {"transport": "streamable_http", "url": "http://localhost:8002/mcp/"},
},
tool_name_prefix=True,
)
all: 14
prod_list_platforms
prod_parse_config
prod_compare_configs
prod_generate_remediation
lab only: ['lab_list_platforms', 'lab_parse_config'] ...
Set tool_name_prefix=True whenever two servers might expose the same tool name, which they will. Without it the names collide and the model has no way to express which one it wants. get_tools(server_name="lab") narrows the list when you want to scope an agent to one server.
Tool Errors
By default a failing MCP call returns the error to the model as a normal tool result, which gives the agent a chance to correct itself:
tool = {t.name: t for t in await client.get_tools()}["parse_config"]
await tool.ainvoke({"platform": "CISCO_IOSXE", "config": "hostname r1\n"})
[{'type': 'text', 'text': 'Error executing tool parse_config: ...'}]
Pass handle_tool_errors=False to MultiServerMCPClient and the same call raises _MCPToolExecutionError instead. Use the default for agents, where recovery is the point, and the strict setting for deterministic pipelines, where a silent error becomes bad output.
A Bug Worth Knowing About
That last example hides a real problem, and it took a while to find. The parse_config tool takes an argument named config. It cannot be called through LangChain at all.
args = {"platform": "CISCO_IOS", "config": "interface Gi0/1\n description x\n"}
await tool.ainvoke(args)
[{'type': 'text', 'text': "Error executing tool parse_config: 1 validation error for
parse_configArguments\nconfig\n Field required [type=missing,
input_value={'platform': 'CISCO_IOS'}, input_type=dict]"}]
The server reports that config is missing and shows you the arguments it received, which contain only platform. Passing the same arguments as a full tool call dictionary fails identically. The server itself is fine, which you can prove by going around LangChain with the raw MCP client:
async with streamablehttp_client("http://localhost:8000/mcp/") as (r, w, _):
async with ClientSession(r, w) as s:
await s.initialize()
out = await s.call_tool("parse_config", {"platform": "CISCO_IOS", "config": "interface Gi0/1\n description x\n"})
print(out.content[0].text)
interface Gi0/1
description x
The cause is in langchain-core, in BaseTool.arun. Before dispatching, it checks whether the target function accepts a RunnableConfig parameter, and if so assigns the run configuration to that keyword:
if config_param := _get_runnable_config_param(func_to_check):
tool_kwargs[config_param] = config
StructuredTool._arun declares config: RunnableConfig as a keyword-only parameter, so config_param is the string "config", and tool_kwargs["config"] gets overwritten. Your tool argument named config is replaced by LangChain’s internal run configuration, and it never reaches the wire. The name is reserved and nothing tells you so.
This is not specific to hier-config-mcp. Any MCP server with a tool argument named config has the same hole, and config is not an exotic name for a parameter. If you write MCP servers, avoid it, along with callbacks and run_manager, which the same dispatch path also treats specially. If you consume a server you do not control, an interceptor can rename the field on the way out, since interceptors run after the LangChain dispatch has already done the damage. The reliable workaround is to call that one tool through the raw MCP client.
Deterministic tooling reached through a well-specified protocol still has to survive the client library in the middle.
What This Adds Up To
MCP is the least interesting part of this stack, which is a compliment. It is a wire format, it works, and langchain-mcp-adapters turns remote tools into local objects in one call. The container took two Dockerfiles and one settings override.
Everything difficult was above that layer. A model that will not reproduce a configuration file verbatim. A model that reads a correct answer and describes the opposite. A session model that starts a container per tool call if you let it. An argument name that a client library silently claims for itself.
Give the model the decisions and the language. Keep the payloads, the exactness, and the blast radius in your own code. MCP makes it trivially easy to hand an agent something sharp, and it does nothing to make the agent careful.
Part 2 of the LangChain series returns to prompt templates and output parsers.
Cleanup
docker rm -f hier-config-mcp hier-config-lab