← Back to Learning Hub

MCP Clients

The 2025 standard for agentsIntermediate12 min

By: Anacodic Team

TL;DR

  • The client is the half of MCP that lives inside the host (the AI app). There is a 1:1 client-per-server relationship: connect to three servers, hold three clients.
  • The client's job: connect → negotiate capabilities → discover (tools/list, resources/list, prompts/list) → invoke (tools/call, resources/read) → feed results back to the LLM.
  • Every session opens with the initialize handshake: the client sends its protocol version + capabilities, the server replies with its capabilities. Neither side may use a feature the other didn't advertise — that's capability negotiation.
  • Discovery is dynamic: the client learns the tool catalog at runtime from tools/list, so servers can add/remove tools (announced via notifications/tools/list_changed) without the client redeploying.
  • In practice you rarely write a raw client — frameworks do it: langchain-mcp-adapters (MultiServerMCPClient) converts MCP tools into LangChain/LangGraph tools; OpenAI Agents SDK, Google ADK, Strands, and others have equivalents.
  • The client also implements host→server capabilities the LLM may need: sampling (server borrows the host's model) and elicitation (server asks the user for more input) — always user-gated.

Simple explanation + analogy

If the server is a travel desk with a catalog, the client is your travel agent. When you begin planning (the host starts a session), the agent:

  1. Introduces themselves and checks what the desk offers today (handshake + capability negotiation).
  2. Brings you the catalog (tools/list — discovery).
  3. Takes your request to the desk and brings the booking back (tools/call — invocation).

You (the traveler = the LLM) never step behind the desk. You only ever talk to the travel agent. The agent also handles the awkward moments: if the desk needs to ask "window or aisle?" mid-booking, the agent relays that back to you (elicitation); if the desk wants the traveler's own preference to decide a substitution, the agent asks you (sampling).

Key insight: the client is a thin, standardized relay. Its value is that every travel agent behaves identically, so any host can consume any server.


Diagram

 HOST (AI app)                                          SERVER (github-mcp)
 ┌──────────────────────────────┐                       ┌───────────────────┐
 │   LLM  ◄──── tool results ───┐│                       │                   │
 │    │                        ││                       │  tools:           │
 │    │ "I want to call        ││   1) initialize  ───► │   create_issue    │
 │    │  create_issue"         ││   ◄── capabilities    │   get_commit      │
 │    ▼                        ││                       │                   │
 │  ┌────────────────────────┐ ││   2) notifications/   │  resources:       │
 │  │      MCP CLIENT        │─┼┼──── initialized  ───► │   repo://schema   │
 │  │  (1 per server)        │ ││                       │                   │
 │  │  • handshake           │ ││   3) tools/list  ───► │  prompts:         │
 │  │  • capability neg.      │ ││   ◄── [tool schemas]  │   /triage         │
 │  │  • discovery/list       │ ││                       │                   │
 │  │  • tools/call           │ ││   4) tools/call  ───► │  (runs the tool,  │
 │  │  • sampling/elicitation │ ││   ◄── result          │   calls GitHub    │
 │  └────────────────────────┘ ││                       │   API)            │
 └──────────────────────────────┘                       └───────────────────┘

How it works (deep)

1) Connect + the initialize handshake

The client first establishes a transport connection (spawns a stdio subprocess, or opens a Streamable HTTP connection). Then the mandatory first message is initialize:

// client -> server
{ "jsonrpc":"2.0","id":1,"method":"initialize",
  "params":{
    "protocolVersion":"2025-06-18",
    "capabilities":{ "sampling":{}, "elicitation":{}, "roots":{"listChanged":true} },
    "clientInfo":{"name":"my-agent","version":"1.0.0"} } }

The server replies with its capabilities:

// server -> client
{ "jsonrpc":"2.0","id":1,
  "result":{
    "protocolVersion":"2025-06-18",
    "capabilities":{ "tools":{"listChanged":true}, "resources":{"subscribe":true}, "prompts":{} },
    "serverInfo":{"name":"github-mcp","version":"0.4.0"} } }

The client finishes with a notifications/initialized notification. This exchange is capability negotiation. If the server didn't declare prompts, the client won't call prompts/list. If the client didn't declare sampling, the server won't request a completion. This prevents both sides from invoking features the other can't handle, and lets the protocol evolve without breaking older peers.

2) Discovery — the client learns the catalog at runtime

After the handshake the client calls the */list methods for whatever the server advertised:

  • tools/list → array of {name, description, inputSchema}.
  • resources/list (+ resources/templates/list) → readable URIs.
  • prompts/list → available prompt templates.

Discovery is dynamic, and that's a headline feature. The client didn't hardcode the tools — it asked. If the server later gains a tool, it emits notifications/tools/list_changed, and a well-behaved client re-lists. This is how tool catalogs change without redeploying the host.

3) Expose tools to the LLM + invoke

The client converts each discovered tool's inputSchema into whatever tool format the host's LLM expects (Anthropic/OpenAI tool JSON, a LangChain BaseTool, etc.). The LLM then decides to call a tool; the client translates that decision into a tools/call:

{ "jsonrpc":"2.0","id":7,"method":"tools/call",
  "params":{ "name":"create_issue",
             "arguments":{"repo":"acme/api","title":"Fix flaky test"} } }

The result's content (text/JSON/images) is fed back to the LLM as a tool message, and the agent loop continues. The client is the bridge between "the model wants to act" and "JSON-RPC on the wire."

4) Host→server capabilities the client must handle

  • Sampling — a server can send sampling/createMessage asking the host to run an LLM completion on its behalf (e.g., a server that needs summarization but has no model). The client surfaces this for user approval, runs the model, returns the completion. This keeps model access and cost under the host's control.
  • Elicitation — a server can request structured input from the user mid-operation (e.g., "confirm the destination account"). The client renders the request and returns the user's response.
  • Roots — the client can tell the server which filesystem/URI roots it's allowed to operate within, scoping the server's reach.

5) Lifecycle and cleanup

A session is stateful. The client owns connection lifecycle: open → operate → shut down (close stdio pipes / HTTP connection). Long-lived agents keep sessions warm and pool clients across many servers; short-lived ones open/close per run.


The math

Not applicable — the client is protocol plumbing, not an algorithm. The only quantitative note is connection count: for a host consuming N servers, it maintains N clients (1:1), each with its own negotiated capability set. There's no aggregation shortcut in the protocol itself — frameworks like MultiServerMCPClient manage N clients for you and present a merged tool list, but under the hood it's still N sessions.


Real code

Low-level Python client (official SDK) — shows the raw lifecycle

import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main():
    # 1) Describe how to launch the server (stdio subprocess).
    params = StdioServerParameters(command="uv", args=["run", "server.py"])

    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            # 2) Handshake + capability negotiation.
            await session.initialize()

            # 3) Discovery.
            tools = await session.list_tools()
            print("Discovered:", [t.name for t in tools.tools])

            # 4) Invocation (normally driven by the LLM's chosen tool call).
            result = await session.call_tool("add", {"a": 2, "b": 3})
            print("Result:", result.content)

asyncio.run(main())

Framework client — langchain-mcp-adapters (how real agents consume MCP)

# pip install langchain-mcp-adapters langgraph "langchain[anthropic]"
import asyncio
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.prebuilt import create_react_agent

async def main():
    # One object manages MANY MCP servers (1 client each) and merges their tools.
    client = MultiServerMCPClient({
        "math": {                       # local stdio server
            "command": "uv",
            "args": ["run", "math_server.py"],
            "transport": "stdio",
        },
        "flights": {                    # remote streamable-http server
            "url": "https://tools.example.com/mcp",
            "transport": "streamable_http",
        },
    })

    # get_tools() runs the handshake + tools/list on every server and returns
    # LangChain-compatible tools (MCP inputSchema -> LangChain tool schema).
    tools = await client.get_tools()

    # Hand the tools to any agent; the LLM now sees every MCP tool uniformly.
    agent = create_react_agent("anthropic:claude-sonnet-4-5", tools)
    res = await agent.ainvoke(
        {"messages": [{"role": "user",
                       "content": "Find a flight from Boston to Lisbon, then add 2+3."}]}
    )
    print(res["messages"][-1].content)

asyncio.run(main())

What the adapter did for you: opened one client per server, ran each initialize, called tools/list, converted every MCP tool schema into a LangChain tool, and routed the LLM's tools/call decisions back over JSON-RPC. If a tool errors, by default the error is returned to the model as a tool message with status="error" (set handle_tool_errors=False to raise instead).

TypeScript client (official SDK)

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const transport = new StdioClientTransport({ command: "node", args: ["server.js"] });
const client = new Client({ name: "my-agent", version: "1.0.0" });

await client.connect(transport);                 // initialize handshake
const { tools } = await client.listTools();      // discovery
const result = await client.callTool({           // invocation
  name: "add", arguments: { a: 2, b: 3 },
});
console.log(result.content);

Real-world example

A LangGraph agent that reads GitHub and posts to Slack — with zero bespoke integration code. You point MultiServerMCPClient at the official GitHub and Slack MCP servers. On startup the client discovers create_issue, get_pull_request, slack_post_message, etc., and exposes them to the LLM. When the user says "summarize the open PRs and post to #eng," the model calls get_pull_request (routed to the GitHub client) then slack_post_message (routed to the Slack client). You wrote agent logic, not integrations — the clients handled discovery, schema translation, and transport.

Grounding in an orchestrator + review pipeline. A trip-planning orchestrator built on Strands already routes tool calls among sub-agents; swapping its in-process tools for MCP clients would let it consume external MCP servers (e.g., a shared weather database) the same way it consumes its own. A multi-step review pipeline could run an MCP client to pull evidence from a hospital's internal MCP server without importing that team's code.


Interview questions companies actually ask

1. What exactly is the MCP client and where does it live? [easy] A protocol connector inside the host, maintaining a 1:1 session with one server. It handles handshake, discovery, and invocation; the LLM lives in the host, not the client. (Tenable FAQ)

2. Walk me through capability negotiation. [medium] On initialize, client and server each declare their capabilities; the response fixes what's allowed for the session. Neither side may invoke un-advertised features. This enables forward/backward-compatible protocol evolution. (apxml capabilities, MCP lifecycle)

3. How does a client discover tools, and why is dynamic discovery valuable? [medium] Via tools/list after the handshake; the catalog is learned at runtime, so servers can add/remove tools (announced by notifications/tools/list_changed) without the client being rebuilt. (MCP-Zero / discovery)

4. What are the core JSON-RPC methods a client calls? [medium] initialize, notifications/initialized, tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get. (MCP protocol handbook)

5. Explain sampling and how the client mediates it. [hard] A server sends sampling/createMessage to borrow the host's LLM. The client surfaces it for user approval, runs the completion, and returns it — keeping model access, cost, and safety under host control rather than the server's. (modelcontextprotocol.io)

6. How do frameworks like LangChain consume MCP servers? [medium] langchain-mcp-adapters MultiServerMCPClient manages one client per server, calls tools/list, and converts each MCP tool schema into a LangChain tool via get_tools() / load_mcp_tools(), so the agent treats MCP tools like native tools. (langchain-mcp-adapters, LangChain MCP docs)

7. Client connects to 5 servers — how many sessions/clients exist? [easy] Five — the relationship is 1:1. A manager like MultiServerMCPClient presents a merged tool list but still runs five underlying sessions. (Tenable FAQ)

8. A tool call fails — what should the client do? [hard] Surface a structured error back to the model (e.g., tool message status="error") so it can retry/adapt, rather than crashing the agent; frameworks let you flip to raising exceptions for hard failures (handle_tool_errors=False). Also enforce timeouts and user consent for destructive calls. (langchain-mcp-adapters)


When to use / tradeoffs

Use a framework client (recommended default): MultiServerMCPClient, OpenAI Agents SDK MCP support, Google ADK, or Strands — they handle lifecycle, schema translation, and multi-server merging for you.

Write a low-level client only when: you're building a host itself, need custom consent/UX around sampling/elicitation, or must control connection pooling and reconnection precisely.

Tradeoffs:

  • Dynamic discovery is flexible but means the tool set can change under you — pin versions and re-validate descriptions (a rug pull is a client-side risk; see security article).
  • Multi-server merging is convenient but can flood the model with too many tools, hurting selection accuracy — filter/namespace tools per task.
  • Sampling/elicitation add power but require careful user-consent UX in the client; skip advertising them if you don't need them.

  • The client lives in the host, one per server, and drives handshake → capability negotiation → discovery → invocation, relaying results to the LLM.
  • Capability negotiation (at initialize) and dynamic discovery (tools/list + list_changed) are the defining behaviors.
  • Real agents consume MCP through framework clients like langchain-mcp-adapters' MultiServerMCPClient, which turn MCP tools into native agent tools.

Related articles (6-3):

Sources: MCP lifecycle spec · Capabilities negotiation · MCP protocol handbook · langchain-mcp-adapters · LangChain MCP docs