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

  1. A server instance — declares the name and version
  2. Tool definitions — name, description, input schema (JSON Schema)
  3. A handler — the function that runs when the tool is called
  4. A transport — how it listens (stdio for 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:

  1. Your application uses the client to get available tools
  2. These tools are sent to Claude along with your question
  3. Claude decides to use the read_doc_contents tool
  4. Your application uses the client to execute that tool
  5. 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 webhooks

Breakdown:

  • 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 Events

Then you deploy it like any other Python web service:

  • A basic Dockerfile pointing at your entry point, deployed to Railway, Fly.io, or any VPS.
  • Clients connect via https://your-server.com/sse instead 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.md
  • db://customers/42
  • config://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 result

Prompts

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.


Resources