DEV Community

Cover image for I Built My Own MCP Client Instead of Using Claude Desktop
Sheen
Sheen

Posted on

I Built My Own MCP Client Instead of Using Claude Desktop

Two hours. That's how long I stared at Connection refused before I gave up trying to debug Claude Desktop and just wrote my own client instead.

The server was running. I'd verified the config. I'd restarted everything twice. The problem was I had no idea where in the chain it was actually failing. Transport? Handshake? Session init? I was guessing. And I hate guessing.

Writing my own client didn't fix that specific incident (turned out to be a path issue in a JSON config file, obviously), but it meant I'd never be in that position again. Now when something breaks, I can read the stack trace instead of filing a mental bug report against a black box.

This is what I built, what I learned, and what the official docs don't tell you.


First: What a client actually is

I'll keep this short because most tutorials either skip it entirely or spend three paragraphs on it. MCP has three roles:

  • Host: your application. A pipeline, a script, a product.
  • Server: exposes tools, resources, and prompts to the model.
  • Client: opens exactly one connection to exactly one server, negotiates the protocol, and shuttles tool calls back and forth.

The one-to-one constraint is deliberate. A client doesn't fan out to multiple servers. It's scoped to one. If you want multiple servers, you run multiple clients (or use a gateway, more on that later). The reason this matters: it keeps connections sandboxed. A misbehaving server can't reach sideways into another server's session because there's no shared session to reach into.

The client itself has no capabilities to advertise. All it does is translate:

model output → tool call → server → result → back to model
Enter fullscreen mode Exit fullscreen mode

The spec changed in July 2026 and most tutorials haven't caught up

This is the thing I wish I'd known before I started reading examples.

Tutorials from early 2026 and before show clients that open with an initialize handshake and then pass an Mcp-Session-Id header on every request after that. That session ID tied your client to one specific server instance. Fine for a single machine, painful for anything behind a load balancer.

The July 28, 2026 spec update dropped both. No initialize handshake. No session ID. Protocol version and capabilities now travel in _meta on every request, and remote server discovery moved to a new server/discover call.

For local stdio (which is the whole tutorial below), none of this changes your code right now. But if you copy an older tutorial and plan to extend it to Streamable HTTP later, you'll be starting from patterns that are already deprecated. Worth knowing before you build on top of them.


Why bother when Claude Desktop exists

Fair question. Claude Desktop works. Cursor works. For the use case they're designed for (a human, a chat window, some tools), they're fine.

I needed something different.

My specific problem was a data pipeline that runs on a schedule. There's no chat window. No human in the loop. The pipeline needs to call MCP tools, process the output, and move on. You can't shoehorn Claude Desktop into that. The MCP connection has to live inside the pipeline code itself.

Beyond my specific case, there are a few other situations where building your own client is the only real option:

When you need to intercept tool output before it hits the model. Prebuilt clients are pass-through by design. If a tool returns a payload with PII, internal metadata, or something oversized for your context budget, you have no way to filter it. A custom client gives you that interception point.

When you're embedding MCP into a product someone else will use. You can't ship a product that requires users to install and configure Claude Desktop. The client logic has to be inside your application.

When you need actual error messages. This was my original motivation. I wanted a stack trace, not a mystery.


What you need

The official MCP SDK covers Python, TypeScript, Java, Kotlin, C#, Ruby, and Rust. I'm going with Python. Lower friction for a tutorial, and the uv toolchain makes dependency management painless.

You'll need:

  • Python (latest stable) and uv installed
  • An Anthropic API key. Get one from console.anthropic.com
  • An MCP server to point it at. Your own works, Anthropic's weather quickstart server works, or browse mcp360.ai/mcps if you want a real production server to test against instead of a demo

Step 1: Set up the project

uv init mcp-client
cd mcp-client
uv venv
source .venv/bin/activate
uv add mcp anthropic python-dotenv
rm main.py
touch client.py
Enter fullscreen mode Exit fullscreen mode

Before you write a single line of application code, deal with the API key:

echo "ANTHROPIC_API_KEY=your-key-here" > .env
echo ".env" >> .gitignore
Enter fullscreen mode Exit fullscreen mode

I'm putting this first because a leaked key is the most common MCP security failure I've seen discussed in the community. Not clever protocol exploits. Just keys ending up in repos.


Step 2: The client class skeleton

import asyncio
from typing import Optional
from contextlib import AsyncExitStack
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()

class MCPClient:
    def __init__(self):
        self.session: Optional[ClientSession] = None
        self.exit_stack = AsyncExitStack()
        self.anthropic = Anthropic()
Enter fullscreen mode Exit fullscreen mode

The one thing here that every tutorial glosses over: AsyncExitStack.

It's not boilerplate. It's the thing that makes cleanup reliable when something goes wrong mid-connection. Without it, failed connections leave hung processes behind that you can't kill without hunting PIDs. I learned this the annoying way.


Step 3: Connecting to a server

async def connect_to_server(self, server_script_path: str):
    is_python = server_script_path.endswith('.py')
    is_js = server_script_path.endswith('.js')
    if not (is_python or is_js):
        raise ValueError("Server script must be a .py or .js file")

    command = "python" if is_python else "node"
    server_params = StdioServerParameters(
        command=command,
        args=[server_script_path],
        env=None
    )

    stdio_transport = await self.exit_stack.enter_async_context(
        stdio_client(server_params)
    )
    self.stdio, self.write = stdio_transport
    self.session = await self.exit_stack.enter_async_context(
        ClientSession(self.stdio, self.write)
    )

    await self.session.initialize()

    response = await self.session.list_tools()
    tools = response.tools
    print("\nConnected. Available tools:", [tool.name for tool in tools])
Enter fullscreen mode Exit fullscreen mode

This spins up the server as a subprocess and talks to it over stdio. That's the right call when client and server are on the same machine.

If your server is remote, the transport changes to Streamable HTTP, not SSE. I see a lot of tutorials still using SSE. The current spec supersedes it, so if you copy SSE-based transport code anywhere, flag it for replacement before you ship anything.


Step 4: The part that actually does things

async def process_query(self, query: str) -> str:
    messages = [{"role": "user", "content": query}]

    response = await self.session.list_tools()
    available_tools = [{
        "name": tool.name,
        "description": tool.description,
        "input_schema": tool.inputSchema
    } for tool in response.tools]

    response = self.anthropic.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1000,
        messages=messages,
        tools=available_tools
    )

    final_text = []
    assistant_message_content = []

    for content in response.content:
        if content.type == 'text':
            final_text.append(content.text)
            assistant_message_content.append(content)
        elif content.type == 'tool_use':
            tool_name = content.name
            tool_args = content.input

            result = await self.session.call_tool(tool_name, tool_args)
            final_text.append(f"[{tool_name} called]")

            assistant_message_content.append(content)
            messages.append({"role": "assistant", "content": assistant_message_content})
            messages.append({
                "role": "user",
                "content": [{
                    "type": "tool_result",
                    "tool_use_id": content.id,
                    "content": result.content
                }]
            })

            response = self.anthropic.messages.create(
                model="claude-sonnet-4-6",
                max_tokens=1000,
                messages=messages,
                tools=available_tools
            )
            final_text.append(response.content[0].text)

    return "\n".join(final_text)
Enter fullscreen mode Exit fullscreen mode

The thing to pay attention to in that loop: the client doesn't decide whether to call a tool. Claude does. All the client does is execute what Claude asked for, package the result as a tool_result message, and send everything back up for a final response.

If you find yourself writing client-side logic to override or second-guess Claude's tool choice, that's almost always a sign the tool descriptions are unclear, not that the client logic is wrong.


Step 5: Chat loop, cleanup, entry point

async def chat_loop(self):
    print("\nRunning. Type 'quit' to exit.")
    while True:
        try:
            query = input("\nQuery: ").strip()
            if query.lower() == 'quit':
                break
            response = await self.process_query(query)
            print("\n" + response)
        except Exception as e:
            print(f"\nError: {str(e)}")

async def cleanup(self):
    await self.exit_stack.aclose()

async def main():
    if len(sys.argv) < 2:
        print("Usage: python client.py <path_to_server_script>")
        sys.exit(1)

    client = MCPClient()
    try:
        await client.connect_to_server(sys.argv[1])
        await client.chat_loop()
    finally:
        await client.cleanup()

if __name__ == "__main__":
    import sys
    asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode

The try/except in the loop isn't optional. MCP tool calls fail for reasons the client has no control over: slow server responses, missing env vars on the server, malformed arguments. Without catching those, one bad query takes out the whole session. You want the session to survive a bad query, not restart from scratch.

Run it:

uv run client.py path/to/your/server.py
Enter fullscreen mode Exit fullscreen mode

First response will take 20–30 seconds. The server subprocess is cold-starting, and Claude's first completion takes a moment. Subsequent queries are faster.


Errors I hit (and what they actually meant)

Error What I thought What it actually was
FileNotFoundError Import problem Wrong path to the server script
Connection refused Port conflict Server subprocess wasn't launching at all
Tool execution failed Client bug Server missing an env var
Timeout Server crashed Server was just slow; raising the timeout fixed it

The one that got me most: I was running the same server through Claude Desktop while testing my client. Claude Desktop has its own config file with its own paths. A stale path in one of them produces a FileNotFoundError that looks like it's coming from your code. It isn't.

If you're stuck on connection errors after double-checking paths, the problem is almost always server-side config, not the client code above. The MCP360 docs have a solid breakdown of the server-side setup issues that client-side debugging won't help you find.


The security stuff nobody puts in tutorials

An MCP client hands a language model the ability to trigger real actions on real systems. That's the whole point, and it's also the risk.

A threat-modeling paper from May 2026 found prompt injection attacks hiding inside tool descriptions across seven major clients. Not in tool output. In the description field itself. The attack tells the model to execute something it normally wouldn't, and it works because most clients pass tool metadata straight to the model without any inspection. Across 13,875 servers analyzed, that's a real problem.

What I actually do:

  1. Read tool descriptions before connecting. A tool called get_weather that describes write access to the filesystem doesn't make sense. That's a red flag.
  2. Minimum-permission credentials. Don't hand a server an API key that can do more than that server needs.
  3. Human confirmation before write/delete/send. I add one input("Confirm? y/n: ") before any tool call that's irreversible. One line. Worth it.
  4. .env in .gitignore before first commit. Not after.

What breaks next and how to fix it

The client above handles one server. The moment you need two, you're copy-pasting the connection block. Three servers, you're doing it again. At some point that becomes its own maintenance problem.

The pattern most people land on is a gateway: one connection point that sits in front of multiple servers, handles auth centrally, and exposes everything through a single endpoint. Your client code above doesn't change at all. You just point it at the gateway URL instead of individual server scripts. MCP360 is built around that model if you want something you can set up quickly rather than build from scratch.

The other thing that breaks: config file sprawl. Claude Desktop, Cursor, and Windsurf all store MCP server configs in different paths. If you're running servers across more than one of those clients, keeping configs in sync gets tedious. mTarsier is an open-source desktop tool that reads all of them from one place. It's from the MCP360 team and it's free.

For transport, the next step after stdio is Streamable HTTP. When you make that move, strip out any session ID assumptions from your connection logic. The July 2026 spec removed session pinning entirely, so anything that depends on Mcp-Session-Id will fail against a compliant server.


That's the full build. The code above is about 80 lines total and handles the complete client lifecycle. From here the interesting problems aren't in the client. They're in what you connect it to and what you do with the results.

What are you building this for? Curious what use cases people are actually running MCP against.

Top comments (2)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

Useful walkthrough. I would push back on one point: the model may choose intent, but the client owns authority. Overriding or denying a tool call is not usually a description problem; deterministic allow/deny policy, schema validation, effective identity, budgets, and confirmation for side effects belong at this boundary precisely because tool descriptions are untrusted input.

There is also a correctness edge in the sample: it handles one tool round and assumes response.content[0].text, while a valid response can contain multiple tool_use blocks or request another tool after seeing results. A production loop should iterate on stop_reason, execute every allowed call, append one complete assistant turn plus matching results, cap rounds, propagate timeouts/cancellation, and preserve structured tool errors. Tests for parallel calls, partial failure, repeated calls, and idempotency catch most of the painful cases.

Collapse
 
sheen_417f0f3a7f4 profile image
Sheen

Fair point on the loop - content[0]. text after one round breaks on parallel tool_use blocks and I should have flagged that as a simplification rather than letting it read as complete. Will add a caveat.

On the authority side I'd push back a little. I wasn't arguing against client-side policy, that stuff belongs at the boundary regardless. The narrower thing I was saying is that if you're constantly fighting Claude's tool selection, descriptions are usually worth auditing first before you layer in deny rules. Doesn't mean skip the policy, just means check the descriptions aren't the actual problem.