MCP tools
Give an agent tools from MCP servers: what the definition records about each server, what it never records, and what's refused.
Give an agent tools from MCP servers as you would any other. Each tool loaded with langchain-mcp-adapters brings its server, and the definition records the server in mcpServers. It needs the mcp extra, with langchain-mcp-adapters 0.3, beside langchain (Choose an integration).
This example loads its tool from a small MCP server, run over stdio from servers/docs_server.py beside it:
"""A small MCP server, over stdio, that the MCP example's agent loads tools from."""
from mcp.server.fastmcp import FastMCP
server = FastMCP("docs", log_level="WARNING")
@server.tool()
def search_docs(query: str) -> list[str]:
"""Search the internal documentation."""
return [f"No pages match {query!r} yet."]
if __name__ == "__main__":
server.run("stdio")The agent loads the server’s tools as it would any MCP client’s. The server starts with the python your shell finds, which must be one with mcp installed: activate the environment, or run the example with uv run python mcp_tools.py. Otherwise the client fails with McpError: Connection closed.
import asyncio
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_openai import ChatOpenAI
from guardian_agent.langchain import agent_of, create_agent
async def main() -> None:
client = MultiServerMCPClient(
{
"docs": {
"transport": "stdio",
"command": "python",
"args": ["servers/docs_server.py"],
},
}
)
# Each tool brings its server, which the definition records
researcher = create_agent(
ChatOpenAI(model="gpt-4.1"),
tools=await client.get_tools(),
system_prompt="You research questions using the internal docs.",
name="researcher",
)
document = agent_of(researcher).definition.document("researcher")
print(document["mcpServers"])
print(document["extensions"]["guardian"]["mcpTools"])
result = await researcher.ainvoke(
{"messages": [{"role": "user", "content": "Summarise the release notes."}]}
)
print(result["messages"][-1].content)
asyncio.run(main())It prints the server as the definition records it, and which tool calls which server:
[{'name': 'docs', 'transport': 'stdio', 'command': 'python', 'args': ['servers/docs_server.py']}]
{'search_docs': 'docs'}The same holds for create_deep_agent, where each sub-agent records the servers of its own tools; for describe_graph, whose ToolNodes, and tools=, bring theirs (MCP tools in a graph); and for guardian.agent() and ga.Definition(tools=...).
What’s recorded
- Each server’s name and transport, with its URL, or its command and arguments as you gave them.
- Only the servers the agent’s tools come from, not every server the client knows, with which tool calls which in
extensions.guardian.mcpTools. - The tools themselves, as the model sees them: name, the description the server gave, and input schema. A server that changes a tool’s description gives the agent a new definition.
- Code that changes what a call does, fingerprinted into each tool’s
sourceHash: tool interceptors, the connection’shttpx_client_factoryandauth,session_kwargscallbacks (sampling_callback, say) and the client’son_elicitation. They’re fingerprinted by their code, as tools are, not by the values they hold. Code fromhttpxandmcpthemselves, such ashttpx.BasicAuth, isn’t fingerprinted or recorded; a subclass of yours is fingerprinted. Logging and progress callbacks change nothing, and are left out.
What’s never recorded
- Headers,
auth’s credentials, environment variables, the working directory and timeouts. A server reached over HTTP with a token, given as is recorded aspython{"docs": {"transport": "streamable_http", "url": "https://mcp.internal/mcp", "headers": {"Authorization": f"Bearer {token}"}}}{"name": "docs", "transport": "streamable_http", "url": "https://mcp.internal/mcp"}. - A credential in the command, a URL or an argument is refused rather than recorded, with
ga.DefinitionContainsSecret. That includes a URL anywhere in an argument with a password (postgresql://user:pw@…) or a credential in its query, and a value after a credential-named flag (--api-key VALUE). Put it inheadersorenv. A user name alone (git@github.com) is fine.
A stdio command or argument that’s a path on one machine makes the definition differ between machines. Give it relative, as the example does: it resolves against the working directory, which isn’t recorded. Or leave the arguments out with exclude=["/mcpServers/<n>/args"].
What a tool calls is fixed
The adapter’s tools read their client’s connection, interceptors and callbacks live, so each is given its own copy of them when the agent is made, or the graph described. A change to client.connections, or to the client’s callbacks, afterwards changes nothing the tool calls, whoever runs it: the agent, or middleware calling it directly. This changes the tool object itself, so load the tools again to use a new connection. The code itself isn’t copied: as for any tool, a change to what an interceptor holds, or to a module global its code reads, isn’t seen.
With create_agent and create_deep_agent, each call is checked, as for any tool: middleware offering the model, or handing the agent, a tool from another server is refused with ValueError before anything is sent or run. With describe_graph, a request offering a tool the definition doesn’t describe is refused, as for any tool, but a change to the graph after it’s described isn’t seen (What isn’t recorded).
What’s refused
With TypeError, and what to do instead:
- A tool loaded from a session (
load_mcp_tools(session)), which calls whatever the session is connected to, even given aconnectiontoo. Load it withMultiServerMCPClient, or withload_mcp_tools(None, connection=..., server_name=...). - Stdio
argsthat aren’t a list of strings. - Interceptors given as anything but a list or other sequence.
- A
websocketserver, which a definition can’t record yet. - Two servers of the same name that differ:
ga.DefinitionInvalidrather thanTypeError, at/mcpServers/<n>. - Middleware holding an MCP tool it doesn’t offer the model, or that tool’s coroutine, which it could run itself, out of the check’s sight, whatever
excludesays. Give the tool to the agent, or list it in the middleware’stools. - A graph node or router holding an MCP tool the graph doesn’t offer, in no
ToolNodeand not indescribe_graph’stools=, whateverexcludesays. Put the tool in aToolNode, or pass it intools=. - An interceptor, or connection code, whose source can’t be read, such as one defined in a REPL.
- A tool that can reach a chat model that isn’t routed, through its interceptors, connection code, callbacks, fields or a wrapper of yours around it, whose calls would go out unsigned, as for any tool (Tools that call a model).
- A tool holding an MCP tool the agent isn’t given, such as an interceptor that runs another server’s tool, whose server the definition wouldn’t record. Give the agent that tool too.
What a definition can’t see
An MCP server your own tool code calls, what a server does with a call, and a model a sampling_callback calls (Calls the SDK can’t see).