n4nAI

Building your first MCP server in Python

A hands-on tutorial for building your first MCP server in Python, covering prerequisites, project setup, resource and tool implementation, and testing with the MCP inspector.

n4n Team3 min read625 words

Audio narration

Coming soon — every post will get a voice note here.

If you want to build MCP server Python tutorial projects that actually work in production, start with the official SDK and a clear understanding of the protocol’s request-response lifecycle. This tutorial walks through creating a functional MCP server that exposes both resources and tools, with runnable code at every step and checkpoints you can verify before moving on.

Prerequisites

You need Python 3.10 or newer and uv for dependency management. The MCP Python SDK requires anyio for async I/O, so make sure your environment supports it.

python --version
# Python 3.11.9

pip install uv

Create a new project directory and initialize it:

mkdir mcp-fileserver && cd mcp-fileserver
uv init --name mcp-fileserver
uv add mcp

The mcp package pulls in the SDK, which includes the server base classes, protocol types, and the stdio transport used by most clients today.

Project structure

Keep it flat for now. You’ll add a single server module and a small test script.

mcp-fileserver/
├── pyproject.toml
├── server.py
└── test_client.py

The server skeleton

Every MCP server implements three core capabilities: resources (read-only data), tools (executable functions), and prompts (templated interactions). Start with a minimal server that declares its capabilities and handles initialization.

# server.py
import asyncio
import json
import os
from pathlib import Path
from typing import Any

from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import (
    Resource,
    Tool,
    TextContent,
    CallToolResult,
    ReadResourceResult,
)

app = Server("fileserver")

ROOT = Path(os.getenv("MCP_ROOT", ".")).resolve()


@app.list_resources()
async def list_resources() -> list[Resource]:
    """List files under the configured root as resources."""
    resources = []
    for path in ROOT.rglob("*"):
        if path.is_file():
            rel = path.relative_to(ROOT)
            resources.append(
                Resource(
                    uri=f"file://{rel}",
                    name=str(rel),
                    description=f"File: {rel}",
                    mimeType="text/plain",
                )
            )
    return resources


@app.read_resource()
async def read_resource(uri: str) -> ReadResourceResult:
    """Read a file resource by URI."""
    if not uri.startswith("file://"):
        raise ValueError(f"Unsupported URI scheme: {uri}")

    rel_path = uri[7:]  # strip "file://"
    abs_path = (ROOT / rel_path).resolve()

    # Security: prevent directory traversal
    if not str(abs_path).startswith(str(ROOT)):
        raise ValueError("Path traversal attempt blocked")

    if not abs_path.exists() or not abs_path.is_file():
        raise FileNotFoundError(f"Resource not found: {uri}")

    content = abs_path.read_text(encoding="utf-8")
    return ReadResourceResult(
        contents=[TextContent(type="text", text=content)]
    )


@app.list_tools()
async def list_tools() -> list[Tool]:
    """Declare available tools."""
    return [
        Tool(
            name="write_file",
            description="Write text content to a file under the server root",
            inputSchema={
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "Relative path from root"},
                    "content": {"type": "string", "description": "Text content to write"},
                },
                "required": ["path", "content"],
            },
        ),
        Tool(
            name="list_directory",
            description="List entries in a directory",
            inputSchema={
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "Relative path from root"},
                },
                "required": ["path"],
            },
        ),
    ]


@app.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult:
    """Execute a tool by name."""
    if name == "write_file":
        return await _write_file(arguments)
    elif name == "list_directory":
        return await _list_directory(arguments)
    else:
        raise ValueError(f"Unknown tool: {name}")


async def _write_file(args: dict[str, Any]) -> CallToolResult:
    path = args["path"]
    content = args["content"]

    abs_path = (ROOT / path).resolve()
    if not str(abs_path).startswith(str(ROOT)):
        return CallToolResult(
            content=[TextContent(type="text", text="Path traversal blocked")],
            isError=True,
        )

    abs_path.parent.mkdir(parents=True, exist_ok=True)
    abs_path.write_text(content, encoding="utf-8")
    return CallToolResult(
        content=[TextContent(type="text", text=f"Wrote {len(content)} bytes to {path}")]
    )


async def _list_directory(args: dict[str, Any]) -> CallToolResult:
    path = args["path"]
    abs_path = (ROOT / path).resolve()

    if not str(abs_path).startswith(str(ROOT)):
        return CallToolResult(
            content=[TextContent(type="text", text="Path traversal blocked")],
            isError=True,
        )

    if not abs_path.exists() or not abs_path.is_dir():
        return CallToolResult(
            content=[TextContent(type="text", text=f"Not a directory: {path}")],
            isError=True,
        )

    entries = []
    for entry in abs_path.iterdir():
        entries.append(
            {
                "name": entry.name,
                "type": "directory" if entry.is_dir() else "file",
                "size": entry.stat().st_size if entry.is_file() else None,
            }
        )

    return CallToolResult(
        content=[TextContent(type="text", text=json.dumps(entries, indent=2))]
    )


async def main():
    async with stdio_server() as (read_stream, write_stream):
        await app.run(read_stream, write_stream, app.create_initialization_options())


if __name__ == "__main__":
    asyncio.run(main())

Run the server to verify it starts without errors:

uv run python server.py

You should see no output — the server is waiting on stdio for a client connection. Press Ctrl+C to stop it.

Test client

The MCP inspector is the standard way to exercise a server during development, but a tiny script lets you verify the protocol flow programmatically.

# test_client.py
import asyncio
import json
from mcp.client.stdio import stdio_client
from mcp.client.session import ClientSession
from mcp.types import InitializeRequestParams


async def main():
    # Launch the server as a subprocess
    server_params = {
        "command": "uv",
        "args": ["run", "python", "server.py"],
    }

    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            # Initialize
            init_result = await session.initialize(
                InitializeRequestParams(
                    protocolVersion="2024-11-05",
                    capabilities={},
                    clientInfo={"name": "test-client", "version": "1.0.0"},
                )
            )
            print(f"Initialized: {init_result.serverInfo.name} v{init_result.serverInfo.version}")

            # List resources
            resources = await session.list_resources()
            print(f"\nResources ({len(resources.resources)}):")
            for r in resources.resources:
                print(f"  {r.uri}{r.name}")

            # Read a resource if any exist
            if resources.resources:
                first_uri = resources.resources[0].uri
                content = await session.read_resource(first_uri)
                print(f"\nContent of {first_uri}:")
                print(content.contents[0].text[:200] + ("..." if len(content.contents[0].text) > 200 else ""))

            # List tools
            tools = await session.list_tools()
            print(f"\nTools ({len(tools.tools)}):")
            for t in tools.tools:
                print(f"  {t.name}: {t.description}")

            # Call write_file tool
            write_result = await session.call_tool(
                "write_file",
                {"path": "hello.txt", "content": "Hello from MCP!"},
            )
            print(f"\nWrite result: {write_result.content[0].text}")

            # Call list_directory tool
            list_result = await session.call_tool("list_directory", {"path": "."})
            print(f"\nDirectory listing:")
            print(list_result.content[0].text)


if __name__ == "__main__":
    asyncio.run(main())

Run it:

uv run python test_client.py

Expected output (abridged):

Initialized: fileserver v0.1.0

Resources (1):
  file://hello.txt — hello.txt

Content of file://hello.txt:
Hello from MCP!

Tools (2):
  write_file: Write text content to a file under the server root
  list_directory: List entries in a directory

Write result: Wrote 16 bytes to hello.txt

Directory listing:
[
  {
    "name": "hello.txt",
    "type": "file",
    "size": 16
  },
  {
    "name": "server.py",
    "type": "file",
    "size": 3421
  },
  {
    "name": "test_client.py",
    "type": "file",
    "size": 1892
  }
]

If you see this, your server correctly implements the initialization handshake, resource listing and reading, tool declaration, and tool execution.

Using the MCP inspector

The inspector provides a visual interface for exploring resources and invoking tools. Install it globally:

npm install -g @modelcontextprotocol/inspector

Then launch it pointed at your server:

npx @modelcontextprotocol/inspector uv run python server.py

A browser window opens at http://localhost:5173. Click “Connect” and you’ll see the Resources and Tools tabs populated with your server’s capabilities. You can read files, invoke write_file with custom arguments, and inspect the raw JSON-RPC messages in the “Messages” panel.

Adding prompts

Prompts let you expose templated interactions. Add a prompt that generates a file summary:

# Add to server.py, after the imports
from mcp.types import Prompt, PromptArgument, GetPromptResult, PromptMessage


@app.list_prompts()
async def list_prompts() -> list[Prompt]:
    return [
        Prompt(
            name="summarize_file",
            description="Generate a concise summary of a text file",
            arguments=[
                PromptArgument(
                    name="path",
                    description="Relative path to the file",
                    required=True,
                )
            ],
        )
    ]


@app.get_prompt()
async def get_prompt(name: str, arguments: dict[str, str] | None) -> GetPromptResult:
    if name != "summarize_file":
        raise ValueError(f"Unknown prompt: {name}")

    path = arguments["path"]
    abs_path = (ROOT / path).resolve()

    if not str(abs_path).startswith(str(ROOT)):
        raise ValueError("Path traversal blocked")

    content = abs_path.read_text(encoding="utf-8")

    return GetPromptResult(
        description=f"Summary of {path}",
        messages=[
            PromptMessage(
                role="user",
                content=TextContent(
                    type="text",
                    text=f"Summarize this file in 3 sentences:\n\n{content}",
                ),
            )
        ],
    )

Restart the server and the inspector will show the new prompt under the Prompts tab. Selecting it prompts for the path argument and returns a message ready to send to an LLM.

Configuration via environment

The server uses MCP_ROOT to determine the filesystem root. This lets you run the same binary against different directories without code changes.

MCP_ROOT=/path/to/project uv run python server.py

In production, you’d typically run the server under a process manager (systemd, supervisord, or your container orchestrator) with the environment variable set in the unit file or pod spec.

Error handling patterns

The SDK raises exceptions for protocol errors, but tool implementations should return CallToolResult(isError=True) for expected failures — invalid arguments, missing files, permission errors. This distinction lets clients present actionable feedback to users rather than generic “tool failed” messages.

# Good: expected failure, client can handle gracefully
return CallToolResult(
    content=[TextContent(type="text", text="File not found: config.yaml")],
    isError=True,
)

# Bad: raising an exception crashes the request
raise FileNotFoundError("config.yaml")

For resource reads, raising FileNotFoundError or ValueError is appropriate — the SDK converts these to proper JSON-RPC error responses.

Running over HTTP

The stdio transport works for local development and subprocess spawning. For networked deployments, use the streamable HTTP transport:

# http_server.py
from mcp.server.streamable_http import streamable_http_server
from server import app  # reuse the same app instance

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(streamable_http_server(app), host="0.0.0.0", port=8000)
uv add uvicorn
uv run python http_server.py

Clients connect via POST http://host:8000/mcp with JSON-RPC 2.0 payloads. The transport handles session management and SSE for server-initiated messages.

Next steps

You now have a working MCP server that exposes filesystem resources and tools. From here, consider:

  • Authentication: Add a middleware layer that validates tokens before dispatching to app.run()
  • Rate limiting: Wrap tool handlers with a token bucket per client session
  • Observability: Emit structured logs for each request/response pair; the SDK’s LoggingCapability lets clients subscribe to server logs
  • Caching: For expensive resource reads, implement ETag or Last-Modified semantics and honor If-None-Match from clients

The protocol is deliberately minimal — resources, tools, and prompts cover most integration patterns. When you need to build MCP server Python tutorial projects that connect LLMs to your internal APIs, databases, or legacy systems, this foundation scales without rewriting the transport layer.

Tagsmcppythontutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All model context protocol (mcp) posts →