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.
Each app writes a custom connector for each system. Every pair is its own auth, schema and maintenance problem.
Each app implements a client once. Each system ships a server once. Anything can talk to anything.
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.
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.
{
"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" }
}
}{
"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.
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:
// 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.
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 sendsnotifications/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
Streamable HTTP
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.
These two records share a DUNS number and tax ID. I'd like to merge them. This can't be undone automatically.
- Survivorship rules resolve every attribute
- No open data quality exceptions
- Caller holds the data steward role
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.