About this article This article is part of Building with Claude — A Practitioner's Guide to the Anthropic API, a study-notes-plus-commentary series based on Anthropic's official "Building with the Claude API" course (hosted on Coursera) and the public Anthropic API documentation at docs.anthropic.com.
Original course and documentation material is © Anthropic. Direct quotes are cited inline. MCP specification material is from modelcontextprotocol.io. Commentary, code adaptations, and examples are © DataMy. This series is independent and not affiliated with or endorsed by Anthropic.
Companion notebook:
D1_mcp_client.ipynbSetup: seeREADME.md— requiresmcp>=1.0.0,nest_asyncio>=1.5.0Companion server:mcp_data_server.py(launched automatically by the notebook)
From tool use to tool protocols
C1 and C2 built a custom tool suite by hardcoding tool schemas and execution logic inside your application. That pattern works well for a single application. It breaks down when many applications need the same tools: every team reimplements the same database connectors, the same file readers, the same API wrappers — each slightly differently, none interoperable.
The Model Context Protocol (MCP) is the solution:
"MCP is an open protocol that standardizes how applications provide context to LLMs. Think of MCP like a USB-C port for AI applications. Just as USB-C provides a standardized way to connect your devices to various peripherals and accessories, MCP provides a standardized way to connect AI models to different data sources and tools." — Model Context Protocol specification, modelcontextprotocol.io.
Concretely: an MCP server packages tools, data, and prompt templates into a standardized interface. Any MCP-compatible client — Claude, other models, IDEs, custom applications — can connect to that server and use everything it exposes, without the client knowing anything about the server's internals.
This article covers MCP from the client perspective: how to connect to an MCP server, discover what it offers, and use Claude to interact with it. D2 covers the server perspective: building your own MCP server that wraps custom tools.
1. MCP architecture
Three components form every MCP interaction:
MCP server — a process that exposes capabilities over the MCP protocol. A server publishes three kinds of primitives: tools (callable functions), resources (addressable data), and prompts (reusable prompt templates). Servers are domain-specific: a database server exposes query tools; a file system server exposes read/write tools; the mcp_data_server.py companion to this article exposes Snowflake warehouse analytics tools.
MCP client — a process that connects to one or more servers and uses their capabilities. In this series, the client is your Python code using the mcp library to connect Claude to the server.
Transport — the communication mechanism between client and server. Two transports are defined by the protocol:
- stdio (standard input/output): client launches the server as a subprocess and communicates over stdin/stdout. Used for local servers. This is what D1 and D2 use.
- HTTP/SSE (Server-Sent Events over HTTP): server runs as a network process; client connects via HTTP. Used for remote or multi-client deployments.
Your notebook (MCP client)
|
| stdio transport
| (subprocess, stdin/stdout)
|
mcp_data_server.py (MCP server)
-- tools: get_warehouse_summary, get_failing_jobs, check_job_sla
-- resources: data://warehouse-runbook, data://dataset-schema
-- prompts: incident_analysis
The client never reads the server's source code. It discovers everything — which tools exist, their schemas, which resources are available, which prompts are defined — through the protocol itself. This is the key architectural difference from C2's hardcoded tool definitions.
2. The three MCP primitive types
Tools: callable functions
Tools in MCP serve the same role as custom tools in C2: callable functions with a name, description, and input schema that Claude can request. The critical difference is where the definition lives. In C2, tool schemas are hardcoded in your client application. In MCP, tool definitions are served by the server and discovered at runtime by the client via session.list_tools().
This means: when the server adds a new tool or updates a description, every client benefits immediately without a code change.
Resources: addressable data
Resources are the MCP primitive without a direct C2 analogue. A resource is a piece of data that the server exposes at a URI address, which clients can read. Resources are appropriate for content that does not need to be computed on each call — reference documents, schema information, configuration files, runbooks.
# The client addresses a resource by its URI
result = await session.read_resource("data://warehouse-runbook")
content = result.contents[0].text # the runbook markdown text
Resources are not tools. You do not call them; you read them. Claude can use resource content by injecting it into a message, while tools are invoked via the tool use loop.
"Resources represent any kind of data that an MCP server wants to make available to clients. Each resource is identified by a unique URI and can contain either text or binary data." — MCP specification, "Core architecture — Resources", modelcontextprotocol.io, accessed 2026-06-10.
Prompts: reusable templates
Prompts are parametrised message templates stored on the server. A client retrieves a prompt by name, passes arguments, and receives a ready-to-use prompt string (or list of messages). They are useful when you want to standardise how a class of questions is posed to Claude, and you want the template to live centrally on the server rather than being duplicated across client applications.
result = await session.get_prompt(
"incident_analysis",
arguments={"warehouse_name": "WH_BI_M", "date_range": "2025-07-20 to 2025-07-25"},
)
prompt_text = result.messages[0].content.text
3. The client connection pattern
The mcp Python library provides an async context manager that handles the full connection lifecycle: launching the server subprocess, establishing the protocol handshake, and cleaning up when the context exits.
import sys
from pathlib import Path
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
# In a notebook: Path.cwd() resolves relative to the notebook's directory
# In a script: Path(__file__).resolve().parent resolves relative to the script
SERVER_SCRIPT = Path.cwd() / "mcp_data_server.py" # notebook context
# SERVER_SCRIPT = Path(__file__).resolve().parent / "mcp_data_server.py" # script context
params = StdioServerParameters(
command=sys.executable, # same interpreter as the calling process
args=[str(SERVER_SCRIPT)],
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# session is now live -- use it for any MCP operations
Three steps:
stdio_client(params)— launches the server subprocess and opens the stdio transport.ClientSession(read, write)— wraps the transport in the MCP session protocol.session.initialize()— performs the protocol handshake; the server returns its capabilities and the session is ready.
Everything inside the nested async with block has a live, initialized session. When the block exits, the session closes cleanly and the server subprocess terminates.
Notebook environment note: Jupyter runs its own asyncio event loop, which blocks
asyncio.run(). The companion notebook usesnest_asyncio(pip install nest_asyncio>=1.5.0) to patch the loop, then callsasyncio.get_event_loop().run_until_complete(coro)from each cell. In a plain Python script,asyncio.run()works directly and no patch is needed.
4. Tool discovery
The defining feature of MCP from the client side: you do not hardcode tool schemas. You discover them from the server at runtime:
tools_result = await session.list_tools()
for tool in tools_result.tools:
print(f" {tool.name}: {tool.description}")
print(f" Schema: {tool.inputSchema}")
The server returns the same JSON Schema objects that C2 clients hardcode. The difference is provenance: the schema is defined and maintained by the server, and the client reads it fresh on every connection.
The practical implication for integration with Claude: you translate the server's tool list to Anthropic's tool format dynamically, not statically:
anthropic_tools = [
{
"name": tool.name,
"description": tool.description or "",
"input_schema": tool.inputSchema,
}
for tool in tools_result.tools
]
Pass anthropic_tools to client.messages.create() and the execution loop runs exactly as in C2 — the only change is how tool results are executed: instead of calling a local Python function, you call session.call_tool():
result = await session.call_tool(block.name, block.input)
tool_output = result.content[0].text
The rest of the execution loop — appending tool_use to messages, returning tool_result, cycling until end_turn — is identical to C2.
5. Calling tools directly
Before wiring tools to Claude, it is useful to call them directly through the MCP session. This is both a debugging pattern and a way to understand what the server exposes:
result = await session.call_tool(
"get_warehouse_summary",
{"days": 14},
)
print(result.content[0].text)
Direct tool calls bypass Claude entirely. They exercise the server's logic and let you verify the output format before introducing the model. In production debugging, calling MCP tools directly is equivalent to unit-testing tool functions before attaching them to an agent loop.
6. Reading resources
Reading a resource is a single call with a URI:
resources_result = await session.list_resources()
# resources_result.resources is a list of Resource objects (uri, name, mimeType)
content_result = await session.read_resource("data://warehouse-runbook")
runbook_text = content_result.contents[0].text
To use resource content with Claude, inject it into a user message:
messages = [
{
"role": "user",
"content": (
f"<runbook>\n{runbook_text}\n</runbook>\n\n"
f"Based on the runbook above, what procedure should be followed "
f"when WH_BI_M credits exceed 1.5x the baseline?"
),
}
]
response = anthropic_client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=messages,
)
This pattern is the MCP equivalent of RAG: retrieve content from the server (via read_resource), inject it into the prompt, generate a grounded response. The difference from B4/B5 RAG is that the server owns the content — the client does not need to know where the runbook lives or how to fetch it.
7. Using prompts
Prompts retrieved from the server are a complete, parameterised instruction to Claude. The incident_analysis prompt in mcp_data_server.py, for example, instructs Claude to call multiple tools in sequence, consult the runbook resource, and produce a structured incident report — all in one template:
prompt_result = await session.get_prompt(
"incident_analysis",
arguments={
"warehouse_name": "WH_BI_M",
"date_range": "2025-07-20 to 2025-08-10",
},
)
prompt_text = prompt_result.messages[0].content.text
Run Claude with the retrieved prompt and the server's tools:
messages = [{"role": "user", "content": prompt_text}]
# run the tool loop as in C2 / section 8 below
Server-side prompts centralise the "how to ask Claude about X" knowledge with the domain expert who built the server. Any client application gets the same, tested prompt without reimplementing it.
8. Claude with MCP tools: the full loop
Putting it together — connecting to the server, discovering tools, and running Claude with the execution loop:
import asyncio
import anthropic
import nest_asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
nest_asyncio.apply()
anthropic_client = anthropic.Anthropic()
async def run_with_mcp(question: str) -> str:
params = StdioServerParameters(command="python", args=["mcp_data_server.py"])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# Discover tools dynamically
tools_result = await session.list_tools()
anthropic_tools = [
{
"name": t.name,
"description": t.description or "",
"input_schema": t.inputSchema,
}
for t in tools_result.tools
]
# Run the execution loop
messages = [{"role": "user", "content": question}]
for _turn in range(10):
response = anthropic_client.messages.create(
model="claude-sonnet-4-5",
max_tokens=4096,
tools=anthropic_tools,
messages=messages,
)
if response.stop_reason == "end_turn":
for block in response.content:
if block.type == "text":
return block.text
break
if response.stop_reason == "tool_use":
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = await session.call_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result.content[0].text,
})
messages.append({"role": "user", "content": tool_results})
return "(no text response)"
Compare this to the C2 execution loop. The structure is identical. The only change is one line:
# C2: local function call
result = TOOL_DISPATCH[block.name](block.input)
# D1: MCP server call
result = await session.call_tool(block.name, block.input)
The tool use loop itself is protocol-agnostic. MCP replaces the execution backend; the Claude-facing logic does not change.
9. MCP vs custom tool use: when to use which
| Dimension | Custom tools (C2) | MCP server (D1/D2) |
|---|---|---|
| Tool definitions | Hardcoded in client | Served by server, discovered dynamically |
| Tool execution | Your function in client code | Server process, any language or environment |
| Reusability | Tied to one application | Any MCP client can connect |
| Setup overhead | Low (Python functions) | Higher (server process, transport) |
| Transport | In-process (no network) | stdio (local) or HTTP/SSE (remote) |
| Multiple clients | Requires duplicating the tool code | Connect multiple clients to one server |
| Versioning | Client controls | Server controls; clients pick up changes automatically |
Use custom tools (C2 pattern) when: you are building a single application, the tools are tightly coupled to that application, and reusability is not a goal.
Use MCP when: tools are shared across multiple applications or teams, the tool execution environment is separate from the client (e.g. a sandboxed service), or you want domain experts to own and maintain the tool definitions independently from the AI application layer.
Practitioner Notes
- Test the server before connecting Claude. Run
python mcp_data_server.pydirectly in a terminal and verify it starts without errors (it will wait for stdin input). Any dataset path issues surface here rather than inside the notebook execution loop. - Use
list_tools()output as your type system. TheinputSchemafield returned bylist_tools()is a JSON Schema. Read it to understand what each tool accepts; it is the server's contract with clients, independent of any source code. - Resources are for static-ish content; tools are for computed content. A runbook that changes weekly is a resource. A credit summary that needs to be computed for a specific date range is a tool. If you find yourself building a "get runbook" tool that returns static text, refactor it to a resource.
- One session per conversation, not per tool call. Opening and closing a
ClientSessionstarts and stops the server subprocess. Open the session once at the start of a conversation and reuse it across all tool calls and resource reads in that conversation. - Server startup time matters in production. stdio transport starts a new process per session. For low-latency production deployments, run the server persistently with the HTTP/SSE transport and keep a pool of open sessions.
Beyond the Docs
MCP is the answer to a specific architectural problem: who owns the tool definitions? In C2, the client application owns them. In MCP, the server owns them. This is not just an engineering convenience — it is an organizational boundary.
When a data engineering team publishes an MCP server, they are publishing a contract: "here is how AI systems should interact with our data platform." The tool descriptions, the output formatting, the resource addresses — these become part of the data platform's API surface, maintained by the people who know the domain best. Every downstream AI application that connects to that server inherits the domain knowledge without reimplementing it.
The second consequence: MCP makes Claude's tool use testable in isolation. Because tool execution happens in a separate process with a defined protocol, you can test the server independently from any AI application, mock it in client tests, or swap it for a test double. The clear boundary between "Claude's reasoning" and "tool execution" that C1 described becomes architecturally enforced.
Previous: C2 — Custom Tools & Function Calling Next: D2 — Building Your Own MCP Server Series index: Building with the Claude API — A Practitioner's Guide
Based on Anthropic's "Building with the Claude API" course (Coursera), public API documentation at docs.anthropic.com, and the Model Context Protocol specification at modelcontextprotocol.io. Commentary © 2026 DataMy. Not affiliated with Anthropic.