Intro
Going over the basics of servers and how to build them.
Composition
An MCP server is just a process that speaks (implements) the MCP protocol.
You can write it in any language you like. However, Python and TypeScript are the only two with a SDK available, the kit handles the plumbing so you build tools…
Parts
- A server instance — declares the name and version
- Tool definitions — name, description, input schema (JSON Schema)
- A handler — the function that runs when the tool is called
- A transport — how it listens (
stdiofor local, http for remote sources)
Client Interface
Your tool’s description, input schema, and docs are the interface the MCP client will interact with, not the server’s code. Therefore it’s important to be as explicit and detailed as possible so the client knows when to to call it.
Architecture Example

For example when the user asks “what’s in the report.pdf doc?”, this is what happens:
- Your application uses the client to get available tools
- These tools are sent to Claude along with your question
- Claude decides to use the read_doc_contents tool
- Your application uses the client to execute that tool
- The result is returned to Claude, who then responds to you
Basic Example
This here is a simple, one file example.
It’s comprised of a single Python module, a TOML file, and a JSON schema for configuring the client.
In this example the client is Claude Desktop.
Server.py:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("my_tools")
# Define a tool using the @mcp.tool decorator
# The docstring becomes the description Claude reads.
# Type hints become the JSON Schema automatically.
@mcp.tool()
def word_count(text: str) -> str:
"""
Count the number of words in a text block
"""
count = len(text.split())
return f"This text contains {count} words."
# run the MCP server using Standard I/O as the transport layer.
if __name__ == "__main__":
run mcp.run(transport="stdio") # use "sse" for HTTP & Server-Sent Events i,e webhooksBreakdown:
- This Python module comprises an entire MCP server
- The actual implementation is abstracted but the FastMCP package which is the standard Py framework
- We define the client using the SDK
- Define a function that our MCP clients can invoke by annotating it as a tool using
@mcp.tool(). - The decorator (annotation) can specify the tool’s name and description for the model.
@mcp.tool(
name="Do the thingy",
description="Explains what the thingy is"
)- Run the MCP when the Python module is ran and define which transport layer to work on
Using the SDK removes the need to maintain JSON schemas, provides clear parameter validation, error handling (internally, you still need to handle your logic’s), and instant tool registration.
Notice how normal this function is, the models don't do the computing and handling, the server does!
pyproject.toml
# pyproject.toml — dependencies and entry point
[project]
name = "my-tools"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = ["mcp[cli]"]
# This lets `uv run my-tools` start the server
[project.scripts]
my-tools = "server:mcp.run"Breakdown:
- Defines metadata about your server including dependencies
- Informs the package manager of choice on how to run the server. In this example it’s using uv but can also be Node.js or Bun if using TypeScript
claude_desktop_config.json
// claude_desktop_config.json
// Location on Mac: ~/Library/Application Support/Claude/claude_desktop_config.json
// Location on Win: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"my-tools": {
"command": "uv",
"args": [
"--directory",
"/path/to/your/project",
"run",
"server.py"
]
}
}
}
Breakdown:
- This file defines how your client interacts with the server
- Lists its name, which command to run it, args, and other metadata
- In the case of HTTP transport layer for remote servers
{
"mcpServers": {
"my-tools": {
"url": "https://your-server.com/sse"
}
}
}Running It
# For remote deployment, change the last line to:
if __name__ == "__main__":
mcp.run(transport="sse") # HTTP + Server-Sent EventsThen you deploy it like any other Python web service:
- A basic
Dockerfilepointing at your entry point, deployed to Railway, Fly.io, or any VPS. - Clients connect via
https://your-server.com/sseinstead of a local process.
Flow looks like this
Claude (remote) ──HTTPS POST──▶ your server (Fly/Railway/VPS)
◀──SSE stream──
Resources
Now that you have a simple server up and running, you can add resources to make it useful for the agent.
resources are how the server exposes readable content to the client’s model, i,.e, data!
Including files, database records, API responses, etc.
These are things the model should be able to read but NOT act upon!
This data that’s useful in a prompt context for the LLM but without the need for calling tools. It’s more efficient
Structure
Resources follow a Req/Res pattern and have a URI scheme that you define in your project.
Examples include:
file:///notes/standup.mddb://customers/42config://app/settings.
Clients like Claude can list available resources and fetch any of them.
Types
Static/Direct
Defined using a fixed URI, provides fixed content. Suitable for config files, docs, and lookup tables. // Anything that doesn't require a parameter
@mcp.resource("config://superApp/config")
def get_config() -> str:
"""
Return the app's config
"""
try:
with open("config.json") as settings_file:
cofig_content = settings_file.read()
return config_content
except IOError as e:
print(f"File operation failed: {e}")Clients can fetch this using “config://superApp/config” and shows up in its resource list.
Dynamic
A URI template with a variable filled in by the client
@mcp.resource("notes://{slug}")
def get_note(slug: str) -> str:
"""
Fetch a note by its slug (e.g. notes://standup-2024-03).
"""
path = NOTES_DIR / f"{slug}.md"
if not path.exists():
raise FileNotFoundError(f"No note: {slug}")
return path.read_text()Like most dynamic definitions, this allows for many resources to be covered. For example notes://standup-march, notes://project-alpha, etc.
Details
Resources can serve different types of data to the client, so it’s best to annotate the resource functions with the URI and the mime_type.
Here’s a few:
"application/json"for structured data"text/plain"for plain text"application/pdf"for binary files
The SDK automatically serialized JSON, no need to do it by hand.
Understanding the Response Structure
When a resource is requested, the server returns a result with a contents list.
Yypically only need one resource is needed at a time, so it’s usually the first element that’s accessed.
The response includes:
- The actual content (text or data)
- A MIME type that tells us how to parse the content
- Other metadata about the resource
Content Type Handling
The function checks the MIME type to determine how to process the content:
- If it’s
application/json, parse the text as JSON and return the parsed object - Otherwise, return the raw text content
This approach handles both structured data (like JSON) and plain text documents seamlessly.
Multi-tool server with Resources
Each tool is independent of the others.
Example server:
# Full server — resources + tools together, realistic structure
from pathlib import Path
from mcp.server.fastmcp import FastMCP
NOTES_DIR = Path.home() / "notes"
NOTES_DIR.mkdir(exist_ok=True)
mcp = FastMCP("notes-server")
# --- Resources (read-only) ---
@mcp.resource("notes://{slug}")
def get_note(slug: str) -> str:
"""Read a note by slug."""
return (NOTES_DIR / f"{slug}.md").read_text()
# --- Tools (actions) ---
@mcp.tool()
def list_notes() -> list[str]:
"""List all note slugs."""
return [p.stem for p in NOTES_DIR.glob("*.md")]
@mcp.tool()
def create_note(slug: str, content: str) -> str:
"""Create or overwrite a note. Use a short hyphenated slug."""
(NOTES_DIR / f"{slug}.md").write_text(content)
return f"Saved {slug}"
@mcp.tool()
def search_notes(query: str) -> list[str]:
"""Search note contents. Returns matching slugs."""
return [
p.stem for p in NOTES_DIR.glob("*.md")
if query.lower() in p.read_text().lower()
]
if __name__ == "__main__":
mcp.run(transport="stdio")Tools Vs Resources
- When the content is the focus, use a resource to expose the data to your client
- When the action is the focus, use a tool to invoke that action and the server executes it on behalf of the server
// Anything with parameters that affect what you get back is better as a tool.
In practice, most real servers have both: tools to discover and act, resources to read.
Tool Descriptions Enable Client Routing
Clients like Claude have no other determining factors. If two tools have overlapping descriptions, they’ll guess…don’t want that!
Be specific about when to use each one, not just what it does.
Return Types Matter
Return plain strings or simple dicts/lists. Avoid deep nesting. clients process your return value as context, the simpler and more readable, the better. // Remember, they're not actually capable of thinking.
Don’t Skip Error Handling
It MUST be part of the interface, handle exceptions when they arise otherwise the client wouldn’t know how to deal with the generic response or error it gets!
Try responding with the error code and a suggestion on what to do when the they do happen, gives the model a course of action.
Context Object (advanced but useful)
FastMCP passes an optional ctx parameter to tools if you include it in the parameters.
It lets your tool log messages that appear in clients’ (like Claude) UI. This can be handy for reporting progress on long operations, and calling other tools on the server. // Not needed for sinmple projects
Example:
from mcp.server.fastmcp import FastMCP, Context
@mcp.tool()
async def slow_task(query: str, ctx: Context) -> str:
"""Does something that takes a while."""
await ctx.info("Starting search...") # these appear in client UI
await ctx.report_progress(0, 100)
# ... do work ...
await ctx.report_progress(100, 100)
return resultPrompts
Templated prompts that we curate, and are reused. Much more efficient than relying on user prompts for operations that are repetitive.
// This way we account fro the the BS non-deterministic behavior of the LLMs
Python FastMCP Inspector
Instead of testing the server by connecting to full apps, the SDK allows for web-based inspection for debugging and testing.
Check your project’s documentation for how to start the inspector.
Let’s assume it’s mcp dev my_mcp_server.py for the sake of education.
Open the local host URL on the specified port and you should see the inspector in the browser.
From the inspector’s UI you can:
- Use a Connect button to start your MCP server
- Explore Resources, Tools, Prompts, and other features using navigation tabs
- Test tools listing and testing
Tools can be tested in sequence to test complex workflows and how your MCP server handles them.
The Inspector also maintains server state, so you can debug and verify complete functionality.
While the Anthropic course tries to make this sound like a replacement for test scripts it very much isn't. Great for rolling development but not a replacement for a thorough test suite.
Building a Client
Most clients are comprised of two parts:
- MCP Client: the actual class or app
- Client Session: the connection to the server
The latter requires careful consideration and clean up, to avoid dangling connections.
// If one builds a `ConnectionManager` class should that be a singleton?

The client acts as a bridge between your app (and its users) and the MCP server and all it offers.