Skip to content

Writing an MCP Client, Not Just a Server

10 min read · updated August 4, 2026

Nearly every MCP tutorial builds a server. The client is the harder and less documented half: it negotiates capabilities, decides which tools the model may see, gates the calls that have consequences, and reconciles two servers that both export a tool called search. If you are putting MCP inside your own product, the client is what you are writing.

Why write a client

The protocol’s value is that a tool implemented once is usable by any host. Existing hosts — editors, desktop assistants — already have clients. You write your own when the host is your product: an internal agent that should reach the same servers your developers use, a customer-facing assistant with a curated server set, or a test harness that exercises a server you ship. Background on the protocol itself is in the Model Context Protocol explained, and the other side in building a server.

What you are building is a translator with a conscience. It maps the protocol’s tool definitions into the tool format your model expects, executes the calls the model asks for, maps the results back — and decides which of those calls should happen at all.

Transports and process lifetime

TransportDescription
Standard input/outputThe client launches the server as a child process and speaks newline-delimited JSON-RPC over its pipes. Local, no ports, no authentication layer because the trust boundary is the process launch. The server's lifetime is yours to manage — including killing it, and reaping it when your process exits.
HTTPThe server is a remote service reached over HTTP, with streaming for server-initiated messages. This is where authentication, network errors, retries and timeouts become your problem, and where an ordinary HTTP client's concerns apply unchanged.

Three lifetime details cause most of the practical trouble with the local transport. A server that writes anything to standard output that is not a protocol message corrupts the stream — server logging belongs on standard error, and this is the first thing to check when a connection dies immediately. A server that crashes leaves your client waiting on a pipe that will never produce a response, so every request needs a timeout. And a client that exits without terminating its children leaves orphaned processes behind, which on a developer machine accumulates until something notices.

The handshake

Before anything useful happens, both sides state what they support. This is not ceremony: it is what lets a client written today work with a server written against a later revision.

  1. The client sends an initialise request carrying the protocol version it wants, its own capabilities, and its name and version. The name is not decoration; servers log it and some behave differently for known hosts.
  2. The server responds with the protocol version it will actually use, its capabilities, and its own identity. The returned version may not be the requested one; if it is a version your client cannot speak, the correct action is to close the connection with a clear message rather than to continue and fail oddly later.
  3. The client sends an initialised notification. Only after this may normal requests flow. Sending a request before it is a protocol error, and a common bug in hand-written clients.

Capabilities run in both directions, which is the part server-oriented tutorials omit. The server declares whether it offers tools, resources or prompts, and whether its lists can change at runtime. The client declares what it can offer the server — notably whether it can service a model call on the server’s behalf, and whether it can put a question to the user. A server may not use a capability the client did not declare, so declaring one you have not implemented produces a failure at the worst moment.

Discovery, and staying in sync

After initialisation the client asks for what is available: the tool list, the resource list, the prompt list. Each returns entries with a name, a description and — for tools — a JSON Schema for the arguments. Lists may be paginated, so a client that reads only the first page silently loses tools on any server with many.

Lists are not static. A server that declared its lists can change will send a notification when they do, and a client that ignores those notifications ends up offering the model a tool that no longer exists. Handle the change notifications by re-fetching, or do not declare that you handle them.

Client lifecycle, in order:

  connect  ──▶ initialize (version, capabilities, identity)
           ◀── result     (negotiated version, server capabilities)
           ──▶ initialized notification
           ──▶ tools/list ──▶ resources/list ──▶ prompts/list
           ◀── ... entries, possibly paginated ...

  then, per model turn:
           map tool entries into the model's tool format
           model requests a call
           ──▶ [permission decision happens here]
           ──▶ tools/call { name, arguments }
           ◀── content blocks, or an error result
           append result to the conversation, continue the loop

  and at any time:
           ◀── notifications: list changed, progress, log messages
           ──▶ re-fetch the affected list

Calling a tool

A call takes the tool name and an arguments object, and returns content blocks — text, images, embedded resources — plus a flag indicating whether the call failed. That flag is the detail worth highlighting: a tool that fails is not a protocol error, it is a successful response carrying an error result.

The reason is deliberate and useful. A tool failure is information for the model, which can read the message and try different arguments. A protocol error is information for you, and means the connection or the request was malformed. A client that conflates them either crashes on a recoverable failure or swallows a genuine bug. Handle them separately, and pass tool-level errors back into the conversation as content the model can act on.

Two more client obligations. Validate arguments against the tool’s schema before sending — the model will occasionally produce arguments that do not conform, and catching that locally gives a better error than a server rejection. And enforce a timeout per call, since a tool that hangs will otherwise hang your agent loop with no upper bound.

The permission model

This is the section that matters most, and the one no server tutorial contains. An MCP client is deciding, on a user’s behalf, to run code with side effects, on arguments chosen by a language model, from a description written by a third party. Four properties follow.

  • Tool descriptions are untrusted input that reaches the model. They arrive from the server and are placed in the model’s context. A malicious or compromised server can put instructions there. This is indirect prompt injection with an unusually direct delivery path, and the same applies to tool results.
  • The protocol’s safety annotations are hints, not guarantees. Tools may be annotated as read-only or destructive, and those annotations are supplied by the same server whose behaviour they describe. They are useful for arranging a user interface and worthless as a security control. Never grant automatic approval on the strength of an annotation from an untrusted server.
  • Consent needs the arguments, not just the name. “Allow write_file?” is not a decision anyone can make. “Allow write_file to /etc/hosts?” is. Show the resolved arguments, and show the server the tool came from.
  • Scope approvals narrowly and expire them. Per session, per server, per tool. “Always allow” on a server whose tool list can change at runtime is a standing grant to something that may not exist yet.

A workable default policy: read-only tools from servers the user explicitly configured run without prompting; anything that writes, spends, sends or deletes prompts every time with arguments displayed; and anything from a server added during this session prompts regardless. Then log every call with its arguments, because the audit trail is what makes a bad grant recoverable. The wider argument for gating tools is in secure tool calls.

More than one server

The moment there are two, three problems appear at once, and none of them are in the protocol’s remit — they are the client’s to solve.

Names collide. Two servers exporting search cannot both be presented to the model under that name. Prefix tool names with a server identifier when mapping into the model’s tool format, and keep a table mapping the prefixed name back to the server and original name. Do this from the first server, not the second, because retrofitting it changes every tool name the model has learned to use.

The tool list grows past usefulness. Five servers with a dozen tools each is sixty tool definitions in every request: expensive in tokens and measurably worse for selection accuracy. Filter to what the current task plausibly needs, or expose a discovery step rather than the whole catalogue — the problem is too many tools and the token cost is tool schema token cost.

One slow server degrades everything. Discovery against several servers should be concurrent and individually timed out, and a server that fails to initialise should be reported and skipped rather than blocking startup. A client that will not start because one optional server is down is a worse product than one that starts with a warning.