---
title: "MCP tools"
description: "Give an agent tools from MCP servers: what the definition records about each server, what it never records, and what's refused."
source: "Guardian Agent SDK"
---
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](./index.mdx#install)).

This example loads its tool from a small MCP server, run over stdio from `servers/docs_server.py` beside it:

{/* generated file sdks/python/snippets/servers/docs_server.py python */}

```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")
```

{/* end generated */}

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`.

{/* generated file sdks/python/snippets/mcp_tools.py python */}

```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())
```

{/* end generated */}

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 `ToolNode`s, and `tools=`, bring theirs ([MCP tools in a graph](./langgraph-graphs.mdx#mcp-tools)); 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](./langgraph-graphs.mdx#what-isnt-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](./langchain-agents.mdx#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](../guides/run-an-agent.mdx#calls-the-sdk-cant-see)).