EQTY LabDocs

MCP tools

Give an agent tools from MCP servers: what the definition records about each server, what it never records, and what's refused.

View as Markdown

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:

python
"""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.

python
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:

python
[{'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’s httpx_client_factory and auth, session_kwargs callbacks (sampling_callback, say) and the client’s on_elicitation. They’re fingerprinted by their code, as tools are, not by the values they hold. Code from httpx and mcp themselves, such as httpx.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
    python
    {"docs": {"transport": "streamable_http", "url": "https://mcp.internal/mcp",
              "headers": {"Authorization": f"Bearer {token}"}}}
    is recorded as {"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 in headers or env. 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 a connection too. Load it with MultiServerMCPClient, or with load_mcp_tools(None, connection=..., server_name=...).
  • Stdio args that aren’t a list of strings.
  • Interceptors given as anything but a list or other sequence.
  • A websocket server, which a definition can’t record yet.
  • Two servers of the same name that differ: ga.DefinitionInvalid rather than TypeError, 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 exclude says. Give the tool to the agent, or list it in the middleware’s tools.
  • A graph node or router holding an MCP tool the graph doesn’t offer, in no ToolNode and not in describe_graph’s tools=, whatever exclude says. Put the tool in a ToolNode, or pass it in tools=.
  • 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).