← Writing
Protocols··12 min read

MCP from the wire up

What the Model Context Protocol actually is: hosts, clients, servers, the JSON-RPC handshake, and the design decisions I've learned building MCP clients for enterprise data.

Samith Deshai Siddo

Forward Deployed AI Engineer, Data Color AI

Over the past year I've built three MCP clients at Data Color AI. Atlas Co-Pilot talks to Salesforce and Informatica MDM servers at once. The AI Data Steward runs on top of the Reltio AgentFlow MCP server and sends every merge to a person for approval. The third project put MCP servers behind AWS Cognito OAuth and the Bedrock AgentCore runtime, so outside clients can reach them without anyone handing out credentials.

Most explanations of the Model Context Protocol stop at "it's like USB-C for AI." That comparison is fine for a slide, but it won't help you debug a client that hangs during the handshake or a model that keeps calling the wrong tool. This post covers what actually goes over the wire, and then what I've learned building on it.

The problem it solves

Before MCP, connecting an LLM application to a system meant writing an integration for that exact pair. Your chat app needed a Salesforce connector. Your IDE agent needed its own. Your internal co-pilot needed a third, with slightly different auth and slightly different ideas about what an "account" is. With N applications and M systems you end up maintaining N×M connectors, and each one drifts in its own way.

MCP turns this into an N+M problem. Each application implements the client side of the protocol once. Each system exposes a server once. Any client can then use any server, because both sides agree on how to discover capabilities, call tools, read data and report errors.

Fig. 1 · Integration count
Without MCP3 × 4 = 12
Chat appIDE agentCo-PilotSalesforceInformaticaReltioPostgres

Each app writes a custom connector for each system. Every pair is its own auth, schema and maintenance problem.

With MCP3 + 4 = 7
MCPChat appIDE agentCo-PilotSalesforceInformaticaReltioPostgres

Each app implements a client once. Each system ships a server once. Anything can talk to anything.

Three apps and four systems. Point-to-point wiring needs twelve connectors. With a shared protocol you build seven pieces, and adding a fifth system costs one server instead of three connectors.

Hosts, clients and servers

The spec defines three roles, and it's easy to mix them up.

  • Host. The application the user actually uses, such as Claude Desktop, an IDE, or in my case Atlas Co-Pilot. The host owns the LLM, the conversation and the user's trust. It decides which servers to connect to and what the model is allowed to do.
  • Client. A connector inside the host. Each client keeps a stateful 1:1 session with exactly one server. If a host talks to three servers, it runs three clients.
  • Server. A program that exposes capabilities (tools, resources, prompts) for one system or domain. It knows nothing about the model or the other servers.

That isolation is deliberate. A server only sees the requests sent to it, never the full conversation, and never what the Salesforce server returned a moment ago. The host is where context gets combined, so the host is also where security decisions belong.

Fig. 2 · Atlas Co-Pilot
HostLLM
Clientsalesforce
Clientinformatica
Clientreltio
Salesforceserver
Informatica MDMserver
Reltio AgentFlowserver
request responseOne client per server connection, all inside one host.
One host, one model, three clients. Each client holds its own session with its own server. The host combines the results before anything reaches the model.

It's JSON-RPC underneath

Every MCP message is a JSON-RPC 2.0 message, and there are only three kinds. A request has an id and a method and expects a reply. A response carries the same id with either a result or an error. A notification has a method but no id, and nobody replies to it.

Requests can go in both directions. The client calls the server's tools, but the server can also send requests to the client, such as asking it to run a completion or collect input from the user. Once you stop thinking of MCP as a REST API with extra steps, the rest of the spec is much easier to follow.

The handshake

Every session starts the same way. The client sends initialize with the protocol version it speaks, the capabilities it supports, and who it is. The server answers with the version it agrees to, its own capabilities, and its identity. The client then sends a notifications/initialized notification, and normal operation begins.

client → serverjson
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "roots": { "listChanged": true },
      "sampling": {},
      "elicitation": {}
    },
    "clientInfo": { "name": "atlas-copilot", "version": "1.4.0" }
  }
}
server → clientjson
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "tools": { "listChanged": true },
      "resources": { "subscribe": true, "listChanged": true },
      "prompts": {},
      "logging": {}
    },
    "serverInfo": { "name": "informatica-mdm", "version": "0.9.2" },
    "instructions": "Search before you read. Entity IDs look like ent_XXXX."
  }
}

Capability negotiation is the important part. A feature only exists in a session if both sides declared it. If the server didn't advertise resources, the client shouldn't call resources/list. If the client didn't advertise sampling, the server can't ask it for completions. The optional instructions field is easy to overlook, but it's a good place to give the model short, server-wide guidance.

Fig. 3 · Session lifecycle
Client
Server
Initialization
initializeprotocolVersion · capabilities · clientInfo
resultprotocolVersion · capabilities · serverInfo
notifications/initializedno id, no reply
Operation
tools/listmaybe paginated with a cursor
resulttools[] with inputSchema
tools/callname · arguments
resultcontent[] · isError
notifications/tools/list_changedclient re-lists
request (has an id) response (same id) notification
Initialization happens once per session. After that, requests flow both ways, and the server can tell the client its tool list changed without being asked.

What a server offers

Servers expose three primitives. The difference between them comes down to who decides when each one is used.

Tools: the model decides

Tools are functions the model can call. The client discovers them with tools/list. Each one comes with a name, a description and a JSON Schema inputSchema, plus optional annotations like readOnlyHint and destructiveHint. When the model picks one, the host sends tools/call:

tools/calljson
// request
{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "search_entities",
    "arguments": { "query": "Acme", "entity_type": "Organization", "limit": 5 }
  }
}

// response
{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "content": [
      { "type": "text", "text": "3 matches: ent_1a2B3c Acme Corporation (Dallas) ..." }
    ],
    "isError": false
  }
}

Pay attention to isError. If a tool fails in a way the model can react to, like a bad argument, no results or a permission problem, return a normal result with isError: true and a readable message. The model will read it and try again. Save JSON-RPC error responses for protocol-level failures such as an unknown tool or malformed params. With that split, an agent can recover from a bad call on its own instead of failing the whole run.

Resources: the application decides

Resources are read-only data addressed by URI, like entity://ent_1a2B3c or file:///reports/q3.csv. The client finds them with resources/list (or with URI templates) and fetches them with resources/read. If the server supports subscriptions, the client can ask to be notified when a resource changes. The host usually decides which resources go into context, often because a user picked or attached them, so the model isn't fetching data on a whim.

Prompts: the user decides

Prompts are reusable templates with arguments, discovered with prompts/list and filled in with prompts/get. Hosts usually show them as slash commands. They're the least discussed primitive, but they're a good way to ship a workflow, such as "review this match rule," together with the server that knows how to run it.

Here's what a small server looks like with the official Python SDK. Type hints become the input schema, and the docstring becomes the description the model reads.

mdm_server.pypython
import json
from mcp.server.fastmcp import FastMCP

from mdm_client import mdm  # your own API wrapper

mcp = FastMCP("mdm-tools")

@mcp.tool()
def search_entities(query: str, entity_type: str = "Organization", limit: int = 10) -> str:
    """Find master data entities by name, ID or attribute.
    Returns at most `limit` compact matches with their entity IDs.
    Use get_entity for the full record."""
    return json.dumps(mdm.search(query, entity_type, limit))

@mcp.resource("entity://{entity_id}")
def entity(entity_id: str) -> str:
    """The full golden record for one entity, as JSON."""
    return json.dumps(mdm.get(entity_id))

if __name__ == "__main__":
    mcp.run(transport="streamable-http")

What a client offers back

MCP isn't a one-way street. Clients can declare capabilities that let servers ask them for things:

  • Sampling (sampling/createMessage). The server asks the host's model to generate a completion. The server gets LLM reasoning without its own API key, and the host stays in control of the model, the cost and the user's approval.
  • Roots. The client tells the server which locations it may work in, usually file:// directories. When they change, it sends notifications/roots/list_changed.
  • Elicitation (elicitation/create). In the middle of an operation, the server asks the user for structured input with a small JSON schema, such as "which of these two regions did you mean?" The host shows the form, and the user can accept, decline or cancel.

Notifications fill in the rest. When a server's tool set changes, it sends notifications/tools/list_changed and the client lists the tools again. Long operations can report progress against a progressToken, and either side can cancel a request that's still in flight.

Transports

The message format is the same everywhere. Only the pipe underneath changes, and the spec defines two.

stdio

The host launches the server as a subprocess. Messages go over stdin and stdout as newline-delimited JSON, and stderr is free for logs. There's no network or auth to set up, and the server runs with the user's own permissions. It's the right choice for local tools and developer machines.

Streamable HTTP

The server listens on a single HTTP endpoint. The client POSTs each message, and the server replies with plain JSON or opens an SSE stream when it has more to say. An Mcp-Session-Id header ties requests to a session. It's the right choice for remote, multi-user servers, and it's what everything I ship in production uses.

Authorization for remote servers

Once a server sits behind HTTP, it needs real auth. MCP builds on OAuth 2.1. The MCP server acts as a resource server. It publishes protected resource metadata that points to its authorization server, returns 401 with a WWW-Authenticate header when a request has no token, and accepts bearer tokens after that. Clients go through a standard authorization code flow with PKCE, and tokens are meant to be issued for that specific server. A server should never pass a user's token along to some other API.

For our servers, an AWS Cognito user pool is the authorization server. Third-party clients get app clients with narrow scopes, and each MCP server runs on the Bedrock AgentCore runtime with a JWT authorizer. The authorizer checks every token against the pool's signing keys, issuer, allowed clients and expiry before a request reaches our code. Inside the server we check scopes again for each tool, so a token that can search can't also merge.

Lessons from building clients

Don't mirror your REST API

The tempting first version turns every endpoint into a tool. The model then faces forty near-identical functions, and a simple question takes six calls. Design tools around what the user is trying to do. find_duplicate_candidates(entity_id) beats making the model chain search, fetch and compare itself. In my experience, fewer and higher-level tools with clear descriptions do more for reliability than rewriting the system prompt.

Budget every result

Whatever a tool returns goes into the context window. A search that returns 500 full records will crowd out the conversation. Return compact summaries with IDs, cap the result count, and let the model ask for details. For list operations, the spec's cursor/nextCursor pagination gives you a standard way to keep pages small.

Put a person in front of writes

Read tools can run on their own. Anything that changes data shouldn't. The AI Data Steward can investigate, simulate a merge and check governance rules without asking. The actual merge only happens after a data steward looks at the proposal and approves it. Tool annotations like destructiveHint help the host decide what needs confirmation. Treat them as hints, though, and enforce the rule in the host.

Fig. 4 · Human in the loop
AI Data StewardAwaiting approval

These two records share a DUNS number and tax ID. I'd like to merge them. This can't be undone automatically.

reltio__merge_entitiesdestructive
winner: entities/1a2B3c
loser: entities/9xY8zW
WinnerLoser
NameAcme CorporationACME Corp.
DUNS04-812-332104-812-3321
Address1200 Main St, Dallas1200 Main Street, Dallas
Tax ID75-123456775-1234567
  • Survivorship rules resolve every attribute
  • No open data quality exceptions
  • Caller holds the data steward role
RejectApprove merge
The agent does the investigation. A person makes the one decision that's hard to undo. Everything the reviewer needs is on one card: the call, both records, and the checks that passed.

Namespace tools across servers

Connect two servers that both expose search and you'll get a collision. Even without one, the model has to guess which system a tool belongs to. A simple fix is to prefix every tool with its server name, like salesforce__get_account and reltio__merge_entities, and to say in each description which system it touches. It's a small change, and it removes a whole class of routing mistakes.

Watch the latency you add

Every MCP hop is a network round trip on top of model inference. Connect to servers in parallel when the host starts, cache tools/list until you get a list_changed notification, and run independent tool calls at the same time. For slow operations, send progress notifications so the user sees something happening instead of a spinner that looks frozen.

Where this leaves you

MCP is a small protocol: JSON-RPC, a handshake, three server primitives, a few client capabilities and two transports. You could read the whole spec in an afternoon. The hard part is everything around it: deciding which tools to expose, how much each result should cost in context, where a person has to sign off, and how auth maps to what the model is allowed to do. The protocol lets you connect any model to any system. Whether the result is actually useful comes down to those design choices.