Contents
Figure 1: Build an MCP Client in Python: Connect Any LLM to MCP Tools — a connector, a translator and a careful messenger
An MCP server is only half the story. Someone has to connect to it, show its tools to a model, run the model's requests, and hand back results. That someone is the client, and it is a small program once you see the shape.
I read the Python SDK's client docs and the Anthropic and OpenAI tool docs, then built a bridge and tested it against two copies of a demo server. A scripted stand-in played the model, so I tested the bridge, not any LLM.
What the Client Does
An MCP client has four jobs:
- Connect to one or more servers.
- List their tools.
- Translate those tools into the format your LLM's API expects.
- Run each tool call the model requests and return the result.
The model never talks to MCP. It talks to your client, which talks to MCP. That is why this works with any LLM: you only need a converter for each provider's tool format.
| Job | SDK surface | Output shape |
|---|---|---|
| Connect | Client(...) in an async with block |
Live session or subprocess |
| List | client.list_tools() |
Name, title, description, input_schema |
| Translate | Your adapter | Anthropic or OpenAI tool dict |
| Run | client.call_tool() |
content, structured_content, is_error |
Step 1: Connect
The SDK's Client picks the transport from what you hand it. A URL string means Streamable HTTP, a StdioServerParameters launches a local subprocess, and a server instance connects in-process (the docs say that last one is mainly for tests). You use it with async with: entering connects, and leaving disconnects. The docs say there is no separate connect or close call, and a client cannot be reused after its block ends.
from mcp import Client, StdioServerParameters
server = StdioServerParameters(command="python", args=["server.py"])
async with Client(server) as client:
tools = await client.list_tools()
Two details from the docs and my tests will save you an afternoon:
- The child does not inherit your environment. The docs say a stdio server gets only a short allow-list (on POSIX:
HOME,LOGNAME,PATH,SHELL,TERM,USER) plus whatever you pass inenv=. I set a variable in my own process. With noenv=, the server saw it as unset. Withenv={...}, it saw the value I passed.HOMEwas set both times. Pass API keys explicitly. - HTTP timeouts have a trap. The built-in client uses 30 seconds for connect and write and 300 for reads. If you build your own
httpx2.AsyncClientfor auth headers and forget a timeout, the docs say it gets a 5-second default, and longer tool calls fail. Passtimeout=httpx2.Timeout(30.0, read=300.0).
Step 2: List the Tools
list_tools() returns tools with a name, optional title, description, and input_schema. Results can be paginated, so loop on next_cursor until it is None. The docs say MCPServer returns everything in one page, so the loop runs once, but a server you do not control may not.
Step 3: Translate for Your LLM
The schema is already JSON Schema, so conversion is mostly renaming fields.
- Anthropic wants
name,descriptionandinput_schema. That is what MCP gives you, nearly one to one. - OpenAI's guide lists
type,name,description,parametersandstrict. I mappedinput_schematoparametersand leftstrictoff. Strict mode needs schemas that meet extra rules (the guide's examples setadditionalProperties: false), and I did not verify or enforce that.
My converter produced both shapes from the same schema object. I could not run either against a live API, so the shapes follow the docs' field lists and nothing more.
Names deserve care. The MCP spec says tool names should use letters, digits, underscore, hyphen and dot, up to 128 characters. I could not confirm any provider's exact limits: Anthropic's API reference page I read did not state them for tool names. So my bridge uses a narrower rule, [A-Za-z0-9_-] and 64 characters, which is my cautious choice, not a verified rule. It also prefixes each name with its server, like notes_a__add_note. The spec itself notes that tools from several servers can collide and suggests prefixing.
Step 4: Run the Calls
When the model asks for a tool, look it up, call it, and convert the result. call_tool returns three things:
| Field | Use |
|---|---|
content |
Blocks meant for the model |
structured_content |
The same result as JSON, for your application code |
is_error |
Check it before trusting anything else |
In my tests the server mirrored its structured result into the text content, so a result arrived as readable JSON text. My bridge joins the text blocks and replaces images or audio with a placeholder. Handle those properly if your tools return them.
Then you send the result back in the shape your provider wants. For Anthropic, that is a tool_result with the matching tool_use_id, and is_error when it failed. The order rules matter: results must immediately follow the tool-use message, and they must come before any text. My scripted loop ran three tool turns, produced 8 messages, and passed an order check.
Failure: Return Errors, Don't Raise Them
The SDK helps here. A tool that raises does not raise in your client; it returns is_error=True with the message in content. An unknown tool name does the same, and I confirmed that (Unknown tool: does_not_exist). The client raises MCPError only when the server answers with a protocol-level error.
Still, wrap every call. My bridge turns all of these into error results the model can read:
- An unknown name (the message lists available tools).
- A wrong or missing argument (the SDK's validation message came through).
- A plain exception (the model sees only "Error executing tool", as in my server article).
- A
ToolError(the model sees the full, actionable message). - A timeout.
For the timeout, I set 0.5 seconds against a tool that sleeps 3 seconds. The bridge returned an error result after 0.50 seconds, and the same server answered the next call normally. What I did not test is whether the abandoned call keeps running on the server, so make long tools safe to abandon.
Anthropic's docs say to "write instructive error messages," and tell the model what to try next. Your bridge writes those for failures the server cannot explain, like timeouts.
Several Servers at Once
You can write your own aggregator, as I did, or use the SDK's ClientSessionGroup. It merges every connected server's tools into one dictionary, and group.call_tool finds the right server for you.
- Names must be unique by default. When I connected a second copy of my server, the group raised
MCPError(the message named duplicate prompts, since my server also has a prompt). - A
component_name_hooklets you rename every component. My test produced keys likeNotes.add_note. - The docs add that only the dictionary key changes, and the original name goes to the server.
My own bridge's prefixing and the SDK's hook solve the same problem. Use the hook if you want fewer lines. Use your own if you need provider-safe names.
Cleanup
Leaving the async with block closes stdin, waits, and kills the process if needed, per the docs. I checked: the server's process was alive inside the block and gone half a second after leaving it.
Safety: You're the Gatekeeper
Your client sits between a model and real actions, so the spec's guidance lands on you. It says clients should:
- Prompt for confirmation on sensitive operations.
- Show tool inputs to the user before calling.
- Validate results before passing them to the model.
- Set timeouts.
- Log tool usage.
Anthropic's docs add that tool results often carry content from outside your control, so keep them inside tool_result blocks, not in your system prompt. In my experience, confirmation prompts and logging are the two items people skip and later regret.
Common Pitfalls
- Relying on your shell environment for a stdio server's API keys.
- Forgetting the read timeout when building your own HTTP client for auth headers.
- Reading only the first page of
list_tools(). - Passing MCP's
input_schemastraight through to a provider that expectsparameters. - Raising on failure instead of returning
is_error. - Duplicate names once a second server joins, with no prefix or hook.
- No iteration limit, so a tool loop never ends.
- Putting tool output in the system prompt where it can be read as instructions.
- Trusting
structured_contentwithout checkingis_errorfirst.
One Subscription Instead of Five AI Tools
Partner offer
GPTAstra Max: one subscription instead of five AI tools
Chat model, image model and video tool behind one interface with a lifetime licence — cheaper than stacking three subscriptions while you build a client that talks to all of them.
Affiliate link — we may earn a commission at no extra cost to you.
The client and the server are free to build; the only recurring cost is whichever model API you point the bridge at.
Wrapping Up
An MCP client is a connector, a translator and a careful messenger. Connect with Client, list every page of tools, convert them to your provider's shape, and run each call so that failures come back as error results the model can read. Prefix tool names, set timeouts, and pass environment variables explicitly.
Build the smallest version first: one server, one provider, one loop with an iteration limit. Then add a second server and a confirmation prompt for anything that changes data.
If the server side is still open, MCP server development in Python covers writing one, and connecting a server to Claude Desktop is the shortcut when a host already implements this client for you. The transports comparison explains why the connect step behaves differently over stdio and HTTP.
Frequently Asked Questions
What does an MCP client actually do?
Four jobs. It connects to one or more servers, lists their tools, translates those tools into the format your provider's API expects, and runs each tool call the model requests before returning the result. The model never talks to MCP directly: it talks to your client, which talks to MCP. That is why the same client works with any LLM, as long as you write a converter for that provider's tool format.
Why doesn't my MCP stdio server see my environment variables?
Because a stdio server launched by the SDK does not inherit your shell environment. The child gets a short allow-list, which on POSIX is HOME, LOGNAME, PATH, SHELL, TERM and USER, plus anything you pass in env=. A variable set in your own process shows up as unset in the server unless you forward it explicitly. That is why API keys and file paths belong in the env= mapping rather than in your shell profile.
How do I convert MCP tools into Anthropic or OpenAI tool format?
The MCP tool already carries a JSON Schema in input_schema, so conversion is mostly renaming fields. Anthropic wants name, description and input_schema, which is nearly one to one. OpenAI wants type set to function plus name, description and parameters, so you map input_schema to parameters. Leave strict mode off unless your schemas meet its extra rules, such as additionalProperties: false, because that has to be verified rather than assumed.
How should an MCP client handle tool errors?
Return them to the model instead of raising them. The SDK already converts a raising tool, an unknown tool name and a schema validation failure into a result with is_error set to true, and the client only raises MCPError for a protocol-level failure. Wrap every call anyway so that unknown names, bad arguments, plain exceptions and timeouts all come back as text the model can act on, with a suggestion about what to try next.
Can one MCP client connect to several servers at once?
Yes. You can write your own aggregator and prefix each tool name with its server, or use the SDK's ClientSessionGroup, which merges every connected server's tools into one dictionary and finds the right server when you call a tool. Names must be unique by default, and connecting two copies of the same server raises an error, so use the component_name_hook to rename components per server.
Is it safe to let an LLM call MCP tools on its own?
Only if your client acts as a gatekeeper. The spec asks clients to confirm sensitive operations, show tool inputs to the user before calling, validate results before passing them on, set timeouts and log tool usage. Tool results also often carry content from outside your control, so keep them in tool_result blocks rather than in the system prompt, and add an iteration limit so a loop cannot run away.
Sources
[1]MCP Python SDK, Clients —Client, theasync withlifecycle,list_toolspagination andcall_toolresults: py.sdk.modelcontextprotocol.io/client[2]MCP Python SDK, Client transports — stdio subprocess parameters, the environment allow-list, HTTP timeout defaults: client/transports[3]MCP Python SDK, Session groups —ClientSessionGroup, unique names andcomponent_name_hook: client/session-groups[4]MCP specification, Tools — name character rules, collisions across servers, client confirmation and logging guidance: specification/2026-07-28/server/tools[5]Anthropic, Tool use overview —tool_useandtool_resultshape,tool_use_id, ordering and instructive error messages: docs.anthropic.com