LetterMCP

Building a Minimal MCP Client in Python

Learn how transport choice and OAuth implementation shape security in minimal MCP clients.

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

MCP is an open standard for connecting AI applications to external tools, APIs, files, and services through one shared interface, released by Anthropic in November 2024. That scale means the patterns in this SDK are plumbing running through a large share of agentic AI being built right now.

A minimal client's jobs happen while the agent appears to be doing something else entirely on the surface. It sends JSON-RPC 2.0 requests to a server. It maintains a shared context window across a sequence of tool calls, so the model can act as though it remembers what it just did. It handles session state. It propagates credentials from one call to the next, so an authenticated session stays authenticated as the agent moves from tool to tool. None of this requires much code. A working client can be built from a small number of SDK calls, and that is part of what makes MCP attractive: low ceremony, fast integration, one protocol instead of a custom connector for every tool and every model.

How the client talks to the server in the first place is the decision each of those jobs depends on, made before the first request ever goes out. Two transport modes exist, and they are not interchangeable. One runs the server locally, as a subprocess. The other connects to a server somewhere else, over a network, using OAuth 2.1, a mode the spec marks as a "should," not a strict requirement. That fork, stdio against Streamable HTTP, is the first real decision a builder of a minimal client has to make, and it shapes everything that follows.

Why the transport choice is the first load-bearing decision

Picking stdio or Streamable HTTP is not a convenience setting the way choosing a web framework might be. It sets the entire threat model the client has to defend against for the rest of its life.

stdio runs the server as a subprocess of the client. That means command execution isn't an accidental side effect of the design; it's the intended interface, and the client is meant to spawn a process and talk to it over standard input and output. Every implementation built on this transport inherits that surface, whether the builder thinks about it or not. And the risk here isn't theoretical. A flaw found in every official MCP SDK, across Python, TypeScript, Java, and Rust, lets the STDIO transport execute a passed-in command regardless of whether a legitimate MCP server ever actually starts. Anthropic classified that behavior as expected, rather than shipping a fix for it. Command injection, rooted in unsafe use of shell execution functions on unsanitized input, affects an estimated 43% of servers, and that's the exact condition a minimal client creates the moment it passes a server command through without validating it first.

Streamable HTTP trades one surface for another, so it doesn't remove the problem. Moving to a network connection introduces OAuth credential exposure, the risk of a server impersonating a trusted one, and DNS-level redirect risks that a local subprocess never has to deal with. Florencio Cano Gabarda, Red Hat's Principal Product Security Architect, draws the line cleanly: local servers execute OS commands or custom code locally, and remote servers carry their own security risks, but because remote execution doesn't happen on the local machine, a lot of the common local risks simply don't apply.

For a minimal client, that leaves a narrow but clear standard: stdio only makes sense if you fully control the server binary and validate its path before it's ever invoked. HTTP mode only makes sense when OAuth is implemented correctly, not just turned on. Neither transport is safe left at its defaults. Which raises the next question directly: what does implementing OAuth correctly actually require, and what happens when a client gets it wrong?

OAuth credential theft in a Python client built on the SDK

The clearest evidence that this risk lives in the SDK itself, not just in careless application code, is a flaw in the official Python SDK's own OAuth flow. It handed the client secret, the authorization code, and the PKCE proof key over to attacker-controlled endpoints. Tracked as GHSA-qx49-fqc8-xw99 with a CVSS score of 7.5, the flaw hit HTTP-based MCP clients using OAuth to connect to servers they didn't fully control.

The mechanism works like this: when a client authenticates, it asks the server where its authorization server lives. Affected SDK versions took that answer on faith, without validating it. A malicious server could return a 404 on the standard discovery endpoint, which triggered an unsafe fallback path that skipped issuer validation entirely. From there, the attacker walks away with the OAuth client secret, the authorization code, and the PKCE proof key, and handing over the proof key defeats the one-time protection PKCE was designed to provide. Handing over that proof key defeats the entire point of PKCE, a protection designed around the idea that it only works once.

Cycode demonstrated the full exchange in a test. The token you get at the end carries whatever permissions the application itself was granted, and the client secret behind it is long-lived, so it keeps working until someone actively changes it. The flaw reached across provider classes in the SDK: OAuthClientProvider, ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider, and the deprecated 1.x RFC7523OAuthClientProvider.

The fix shipped in version 1.30.0 on the 1.x line and version 2.2.0 on the 2.x line, validating the expected issuer before fetching anything the server provides and refusing the connection on a mismatch. Upgrading, by itself, is not enough for ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider. For ClientCredentialsOAuthProvider and PrivateKeyJWTOAuthProvider, the advisory requires also passing an issuer= parameter to explicitly name the correct authorization server. Without it, an upgraded client will still follow whatever issuer the MCP server happens to name. On 1.30.0, the warning about this gap arrives as a standard Python deprecation warning, which Python suppresses by default, so it's easy to miss in exactly the kind of minimal implementation this article is building. Beyond upgrading, the advisory also tells you to clear stored OAuth client registrations, rotate the client secret, and revoke tokens at the identity provider if there's any chance the client connected to a server it shouldn't have trusted.

The flaw existed because the SDK assumed a server could be trusted to correctly name its own authorization server. That assumption is precisely what the current specification now closes off.

MCP specification requirements for authentication in a Python client

Remote MCP servers are required to implement OAuth 2.1 with PKCE for authentication, and the July 2026 revision of the spec hardened that requirement with three specific additions. SEP-2352 binds registered credentials to the issuing authorization server, which addresses the exact issuer-validation gap that GHSA-qx49-fqc8-xw99 exposed. The other two additions formalize refresh tokens through an offline_access scope and clarify the well-known discovery endpoint for locating a server's authorization server, so builders get a documented path instead of an assumption to code against.

For client registration, the spec now recommends Client ID Metadata Documents, or CIMD. In practice that means the client hosts a CIMD document along with a JWKS endpoint for its public keys, starts an Authorization Code plus PKCE login against the MCP server's authorization server, and exchanges the resulting code at the token endpoint using private_key_jwt. That's a concrete sequence a builder can implement today.

Enterprise deployment gets its own answer: Enterprise-Managed Authorization, or EMA, reached stable status in June 2026. EMA lets an organization control access to MCP servers through its identity provider, replacing the old per-server consent prompt with a zero-touch flow where a user signs in once and gets access to every approved server from that point forward. That zero-touch sign-in removes a real friction point, but only at the point of connection. It says nothing about what happens to a tool call after the agent is already inside the system and already trusted, which is exactly where the next threat lives.

Why tool-call validation cannot be delegated to the server

A session that authenticated correctly, with a valid issuer, a valid token, and a properly scoped credential, can still be turned against its own user through the tools it's connected to. The protocol trusts the server's tool descriptions and tool responses by design.

Tool poisoning is the clearest version of this. It's an indirect prompt injection: a tool description carries hidden instructions that land inside the model's context window and get treated as trusted input, the same as any legitimate instruction, which can lead the model to call restricted tools, leak data, or step around its own system prompt. The OWASP MCP Top 10 ranks tool poisoning as MCP03 and prompt injection as MCP06, so both sit inside the handful of risks the security community considers most pressing for this protocol.

A variant called a rug pull stretches this attack across time instead of a single moment. A server passes approval at connection time with a clean, benign tool definition, then later changes that tool's behavior silently, without triggering any new approval step, because definitions get reviewed once while the responses that stream in afterward get no equivalent scrutiny. A package called postmark-mcp showed exactly this pattern in practice: fifteen clean version releases built up legitimacy before a single line of exfiltration code got quietly added. Client-side validation watching for behavioral divergence between versions would have caught that change as it happened. The Sandworm_Mode campaign in February 2026 showed how far this scales: npm typosquatting aimed at AI coding assistants including Claude Code, Cursor, Continue, and Windsurf installed rogue MCP servers through malicious packages that pulled SSH keys, AWS credentials, npm tokens, and LLM API keys straight out of developer environments, combining prompt injection with direct credential harvesting.

A minimal client needs concrete defenses against this, not just awareness of it. Auto-approval of tool invocations should be off, with explicit confirmation required before any high-impact action runs. Tool descriptions need validation at connection time and again on every reconnect, not just once at first approval. Content coming back from a server should never be rendered directly to the model or the user without sanitization first. Tool definitions should be pinned by version, with an alert triggered the moment one changes between sessions. None of these controls mean anything without a record of what actually happened, and that record is the audit trail covered next.

The complete audit trail for MCP tool calls

Without a structured audit trail, an MCP client cannot detect a rug pull in progress, cannot trace a leaked credential back to its source, and cannot show a regulator or an internal reviewer that its controls worked. Logging here is not a feature to add after launch: it's the foundation the other controls in this article depend on.

A complete audit trail for an AI agent has to capture several things together, not any one of them in isolation: the identity of whoever, or whatever service account, initiated the interaction; the exact prompt and context sent to the model; every API call, database query, or external action the agent took, each stamped with a timestamp; which guardrails fired and when; what the agent actually returned or executed; and the token usage and execution time behind each call. Skipping any one of these leaves an investigation after an incident with a gap it can't close.

Credential hygiene has to be built into this same structure. Each agent specialization needs its own service account, so a support agent and a financial reporting agent never share an API key or a database credential, even inside the same organization. That separation is what makes attribution possible, what makes an investigation tractable, and what limits the blast radius if one credential leaks. Pay close attention to error paths too, because they leak credentials more often than builders expect. Production Python clients handle this with regex-based stripping patterns that catch GitHub personal access tokens, OpenAI-style keys, bearer tokens, and password= or secret= strings, scrubbing all of it out of error text before that text ever reaches the model's context.

This requirement isn't just good engineering practice circulating informally. Singapore's IMDA Model AI Governance Framework for Agentic AI, from January 2026, requires that each agent carry a verifiable digital identity along with an audit trail showing which agent acted under whose authorization. NIST's Center for AI Standards and Innovation, through its AI Agent Standards Initiative launched in February 2026, names the underlying gap directly: agents are commonly treated as generic service accounts, without the dedicated identity, authorization, and accountability controls that would make an audit trail meaningful. Every team building a client like this has to construct this same logging, credential separation, and sanitization machinery from scratch, which is the real cost behind the word "minimal."

Putting the minimal client together: the sequence and the decisions that must not be skipped

You can build a working minimal MCP client in Python from a small number of SDK calls. But every structural decision covered above has to be made on purpose, not defaulted past, because defaulting is how a minimal client turns into a liability.

The sequence starts with the SDK version itself. Install Python SDK 1.30.0 or later on the 1.x line, or 2.2.0 or later on the 2.x line, and pin that version in requirements rather than trusting PyPI's latest-by-default behavior to protect anyone. From there, the transport has to be chosen and justified, not assumed. If you choose stdio, you validate the server binary's path before it's ever invoked and refuse any user-supplied or environment-sourced path that hasn't been sanitized, because command execution is the interface itself, not an incidental risk off to the side.

Choosing Streamable HTTP means treating OAuth as a implementation to get exactly right rather than a checkbox to flip on. That means confirming the SDK version closes GHSA-qx49-fqc8-xw99, passing an explicit issuer= parameter where the provider class requires it, and following the CIMD registration flow the current spec recommends rather than an older or looser pattern found in a tutorial. EMA handles identity at the point of connection, but a live session still requires its own authorization checks on each individual tool call.

Those defenses are the tool-call validation controls: no auto-approval for high-impact actions, revalidation of tool descriptions on every reconnect, sanitization of anything a server sends back before it reaches the model, and version pinning with change alerts on tool definitions. And the client needs the audit trail running from the first request onward, built on top of all of it, logging identity, prompt, every external action with its timestamp, triggered guardrails, outputs, and token usage, built on service accounts that never get shared across agents with different jobs.

None of these pieces are large on their own. Put together, they turn a client that merely works in a demo into one that's safe to point at a server someone else controls.

Sources

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

More in Agentic AI Foundations