LetterMCP

Building a Minimal MCP Client in Python

Building an MCP client forces security choices long before deployment matters.

Staff Writer · · 11 min read
Cover illustration for “Building a Minimal MCP Client in Python”
Agentic AI Foundations · August 27, 2026 · 11 min read · 2,519 words

MCP, short for Model Context Protocol, standardizes how large language models find and call external tools. Anthropic shipped it in November 2024, and OpenAI, Google, and Microsoft have all added support for it since. I've built and reviewed enough of these clients now to notice a pattern: the ones that turn into security headaches almost always went wrong in the first hour of coding, not six months later. Nobody plans to build an insecure client, yet it happens quietly, in the choices you make before you've run your first tool call.

Building a minimal client isn't hard, honestly. Connect to a server, list what it can do, pass tool calls back and forth between the model and the server, return results. That's roughly the whole job description. The surface area stays small on purpose, but small surface doesn't mean small stakes; every structural choice you make in that first sitting becomes either a governance win six months out or a retrofit project nobody wants to own.

The current baseline is the v2 SDK, built against the 2026-07-28 spec. It collapses what used to be three separate layers, transport setup, ClientSession, and initialize, into one Client(target) object that negotiates protocol version on its own. That's real convenience. It also buries the moment where trust decisions actually happen a little deeper in the stack, easier to walk past if you're not already looking for it.

Three transport choices exist. Stdio is the default for local servers; it runs as a subprocess that inherits your parent process's privileges, which matters more in practice than it looks like on paper. Streamable HTTP is the right call for anything networked. SSE is deprecated, so don't build anything new on it, and if you've inherited code running on it, that's a conversation worth having soon rather than later.

The handshake itself is short, almost anticlimactic. The client calls server/discover, or falls back to initialize on older servers, and gets back protocolVersion, capabilities, and serverInfo. That response is the first place trust gets extended, or withheld. Most minimal clients take it at face value, because pausing to question it feels like paranoia over nothing. I'd argue it's just the job.

Scale is why any of this matters past one script on one laptop. The official MCP registry counted 3,012 unique servers as of March 2026, up from roughly 2,500 six months before. That's a lot of code your client might end up talking to, written by strangers, reviewed by nobody you could name if someone asked you to.

The structural choices a minimal client makes before any tool is called

Session setup decides access scope whether you mean it to or not. Three questions get answered right there, before a single tool runs: which servers this client can reach, which tools inside each server it's allowed to call, and under what identity those calls execute.

In the older v1 pattern, ClientSession.initialize() runs before tool discovery starts, and that's your natural window to inject credentials and filter capabilities. In v2, Client(target) wraps that plumbing in a tidier interface, but the window is still there underneath. You just have to go looking for it now, since nothing in the API points you toward it anymore.

Habits that feel harmless at small scale turn into liabilities at real scale. Hardcoding a server URL is fine when you have one server, until you have twenty and wish you'd built a registry from day one. Passing credentials through environment variables works fine on your own laptop, right up until a second team inherits the codebase and can't figure out where secrets are supposed to live. Accepting every tool a server advertises, instead of checking against an explicit allowlist, saves maybe ten minutes of setup. Until one of those tools does something nobody signed off on, and then it costs a lot more than ten minutes.

The handshake response hands you the full tool list, and taking it wholesale is the default behavior. That default is itself a permissions decision, whether or not anyone actually made it on purpose. A minimal client is the cheapest place in the whole system to enforce least privilege. Skip it here and you haven't dodged the work; you've pushed it downstream, where it costs more and touches more code.

Why MCP's own specification history makes authentication a build-time concern

MCP launched in November 2024 with no authentication framework at all, and that absence still echoes through the ecosystem. The spec has added auth mechanisms since, but any client written before those revisions, or any new client that just ignores them, runs unauthenticated by default. The protocol grew up in public, in real time, and a lot of early code never got the memo that things had changed.

In May 2026, the NSA published formal guidance titled "Model Context Protocol: Security Design Considerations for AI-Driven Automation." It didn't pull punches. Adoption has outpaced the safeguards meant to support it, the agency found, and the spec, in the NSA's own words, "falls short on key security and privacy protections." Fixing that means reworking the whole lifecycle: design, runtime, integration, monitoring, not patching one weak spot at the edge. Two gaps stood out above the rest: uncontrolled automated actions, and a lack of screening on what comes in as input.

Waiting for the spec to close these gaps isn't a plan; it's a delay tactic dressed up as patience. The protocol's own stewards admit the gaps exist, so the client you write has to do that work itself. One piece of decent news is buried in here: the v2 SDK ships OpenTelemetry tracing turned on by default. The SDK is drifting toward auditability as a baseline expectation, and a client built with that in mind from day one gets it for free instead of bolting it on later under pressure, at 11pm, right before an audit nobody scheduled with enough lead time.

The threat surface a minimal client inherits the moment it connects

Table: Core Threat Types and Client-Side Mitigations. Compares How It Works, Protocol Gap and Client-Side Fix by Tool Poisoning, Rug Pull Attacks, Indirect Prompt Injection and Credential Sprawl.

Tool poisoning is the sharpest of these risks, and the mechanics are worth sitting with for a second. A server's tool description, the plain-text explanation the LLM reads to decide how to use a tool, can carry hidden instructions the model treats as legitimate system context. OWASP classified this as MCP03:2025, with a DREAD risk score of 46.5 out of 50, a critical rating by any measure. Invariant Labs ran benchmarks across more than 45 real-world MCP servers and found attack success rates above 60%, with the strongest-performing agent model hitting 72.8%. The attack lives entirely in metadata your client trusts by design, since trusting tool descriptions is basically the whole point of the protocol. No user clicks anything wrong, no network exploit needed, just a description doing more than it says it's doing.

Rug pull attacks exploit a different gap. MCP has no cryptographic content-addressing or version pinning for tool descriptions, so you can audit a tool today, approve it, and have it behave differently tomorrow, with nothing in the protocol forcing the server to tell you it changed. CVE-2025-54136, carrying a high CVSS score, confirmed exactly this in a production AI development environment. Approval given once doesn't survive a server-side change made later, quietly, without notice. The fix on the client side is to detect and flag description changes before re-invoking a tool, rather than trusting a cached approval that might not describe reality anymore.

Indirect prompt injection comes from outside the tool entirely. A support ticket, a file sitting in a repository, any external content the agent reads can carry instructions that hijack what happens next. In the Supabase incident in 2025, an attacker planted a malicious instruction inside a support ticket, and the agent acted on it through MCP. A separate GitHub MCP vulnerability that same year let crafted repository content pull private data out of other users' sessions.

Then there's the quieter problem of credential sprawl: every new server your client connects to is a fresh place a secret can leak, and an ungoverned client just keeps piling them up, with no clean way to revoke anything once you've lost track of what's where.

The tooling itself hasn't been immune. MCP Inspector had a remote code execution flaw from unverified inputs, fixed in version 0.14.1. CVE-2025-6514 found OS command injection in mcp-remote, opening the door to RCE if the client connects to a malicious server. Recent security sweeps turned up hundreds of publicly reachable MCP servers, misconfigured, exposing sensitive interfaces to anyone who bothered to go looking.

Wiring in authentication and scoped permissions during the session setup

Authentication happens at session initialization, before tool discovery, not after. That ordering isn't a style preference; it's the whole mechanism. Inject credentials before the client sends its first capability request, and use OAuth wherever the server supports it. Hardcoded tokens in source code are a bad habit that turns into an incident report eventually, usually the kind that gets forwarded around with everyone's name cc'd. Keep secrets in a credential manager, or inject them at runtime, never inline in the code itself.

Transport shapes what actually needs locking down. With stdio, the subprocess inherits your parent process's privileges, so scope those privileges before launching anything, not after. With Streamable HTTP, TLS isn't optional, and you need to validate server certificates properly. Disabling verification during development gets you moving fast; forgetting to turn it back on gets you a postmortem with your name on it.

Once list_tools() comes back, don't hand the raw list straight to the LLM. Filter it against an explicit allowlist first, by tool name and, where you can manage it, by expected argument schema too. If a tool's description changed since your last approved session, reject it or flag it for review. A stale approval shouldn't carry forward on autopilot just because that's easier.

Least privilege applies at call time too, not only at setup. Pass only the arguments the user's actual request needs; don't forward ambient context a tool never asked for. Validate arguments against expected types before anything goes out the door, and treat whatever comes back from a tool as untrusted, the same way you'd treat text pasted in from a stranger online, before it goes anywhere near the model again.

Every tool call should carry the identity of the authenticated user, not just the identity of the agent process running the show. That distinction is what makes an audit log worth reading six months from now. "The agent did it" tells you nothing useful when someone's asking what happened.

For teams running MCP clients at real scale, the durable pattern is a gateway layer: one place that enforces allowlists, handles OAuth and credential rotation, and surfaces tool-call telemetry, instead of every client on the team reinventing the same logic from scratch. A few platforms built for enterprise MCP deployment already handle this plumbing, and it's worth looking into one before your tenth client repeats the same auth code your first one wrote, badly, at 2am.

Building tool-call visibility into the client from the first invocation

The v2 SDK's default OpenTelemetry tracing means you're not starting from zero here. You still need to configure an exporter and decide what gets tagged, but the instrumentation itself is already sitting there, waiting to be switched on.

At every tool invocation, log the tool name and which server it came from, the authenticated user's identity (not the agent's service account), the arguments sanitized for personal data before they leave the process, the result status with a summary of the response, and a timestamp. Treat that as the floor, not a wish list you get to later.

Send those logs somewhere that actually survives past the moment: an OpenTelemetry collector, Datadog, whatever your stack already runs. A structured log sitting in a file on one machine isn't an audit trail; it's a diary nobody reads.

One detail trips up more people than it should, and I mean that from experience. With stdio, stdout belongs entirely to the protocol stream, so diagnostic and audit output has to go to stderr instead. A stray print() call inside a stdio server corrupts the message framing, and I've burned a whole afternoon chasing that exact bug before, the kind of thing that only shows up right before a demo, never during a slow week when you'd actually have time for it.

Audit trails for AI agent behavior are moving from nice-to-have toward regulatory expectation, and that shift is already underway, not some hypothetical down the road. Build the logging structure into your minimal client now and it scales with you as things grow. Skip it, and you're retrofitting under a deadline later, probably with someone from compliance reading over your shoulder while you do it.

Here's the plain version of the argument: a tool call that can't be traced to a specific user, replayed for review, or checked against an approved list isn't something an organization can stand behind when something goes wrong. Something eventually goes wrong; that's not pessimism, it's just the base rate on software at this scale. Visibility is the line between a governed agent and shadow MCP usage nobody signed off on.

What a governable minimal client looks like as a complete pattern

Authenticated, scoped, version-aware, identity-bound, observable: each one traces back to a specific decision in the code, not something bolted on after the fact. Credentials get injected at session initialization, never hardcoded. The tool list gets filtered against an explicit allowlist before the model ever sees it. The client detects changes to tool descriptions and refuses to silently re-approve them. Every call carries the authenticated user's identity, not just the agent's. And every invocation sends structured telemetry to a collector outside the process.

None of this demands a bigger client, and that's worth saying plainly because people assume otherwise. These are initialization and middleware decisions. They don't need a separate security review tacked on at the end; each one is a structural choice made once, while you're already writing the thing anyway.

The pattern scales because it's reusable, which is really the whole economic argument for doing it right the first time. A team that builds one well-governed client object and shares it across every agent they run skips rebuilding auth and audit logic every time a new integration shows up. That's the real case for treating the client as infrastructure you invest in once, rather than boilerplate you rewrite every sprint out of habit.

Organizations connecting dozens of agents to a registry sitting at 3,012 servers and climbing will eventually need centralized enforcement of these same properties: identity binding, allowlisting, credential lifecycle, tamper-proof logs. A minimal client built right from the start fits into that layer the day it shows up, instead of fighting it.

The NSA said it plainly: these problems "require reworking of the entire lifecycle from the design of the protocol and agent behaviors." That reworking doesn't start with a security team or a compliance audit six months in. It starts earlier than that, with the first few lines of code that connect to a server and decide, right there, what gets trusted and what doesn't.

Sources

  1. nsa.gov
  2. media.defense.gov
  3. stackoverflow.blog
  4. auth0.com

More in Agentic AI Foundations