Skip to content

Build a small MCP server in Python, start to finish

By SunnyKumar Jonwal 11 min read

Writing your first Model Context Protocol server takes less time than reading about the protocol. With the official Python SDK, a working server is a single file, and by the end of this walkthrough you'll have one that stores notes, searches them, and plugs into a real AI app.

If you want the concepts first, MCP explained for developers covers hosts, clients, servers, and transports. Here we'll stay practical and build something.

What we're building

A tiny notes server with three capabilities:

  • A tool to add a note.
  • A tool to search notes by keyword.
  • A resource to read one note by id.
  • A prompt that starts a weekly review.

Notes live in a JSON file in your home directory, so there's no database to set up. It's deliberately small, because the goal is to see every moving part rather than build a product.

Setup

You'll need Python 3.10 or newer. Create a folder, a virtual environment, and install the SDK with its command-line extras:

mkdir notes-mcp && cd notes-mcp
python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "mcp[cli]"

The SDK's FastMCP class does the heavy lifting: it turns ordinary Python functions into MCP tools, reading names, type hints, and docstrings to build the definitions the model will see.

The server

Create notes_server.py:

import json
from datetime import datetime, timezone
from pathlib import Path

from mcp.server.fastmcp import FastMCP

DATA_FILE = Path.home() / ".notes-mcp" / "notes.json"
DATA_FILE.parent.mkdir(exist_ok=True)

mcp = FastMCP("notes")


def load_notes() -> list[dict]:
    return json.loads(DATA_FILE.read_text()) if DATA_FILE.exists() else []


def save_notes(notes: list[dict]) -> None:
    DATA_FILE.write_text(json.dumps(notes, indent=2))


@mcp.tool()
def add_note(title: str, body: str) -> str:
    """Save a new note. Use this when the user wants to remember something.
    Returns the id of the saved note."""
    notes = load_notes()
    note_id = max((n["id"] for n in notes), default=0) + 1
    notes.append({
        "id": note_id,
        "title": title.strip(),
        "body": body.strip(),
        "created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
    })
    save_notes(notes)
    return f"Saved note {note_id}: {title}"


@mcp.tool()
def search_notes(query: str, limit: int = 5) -> str:
    """Search notes by keyword in the title or body (case-insensitive).
    Returns up to `limit` matches, newest first, as 'id | date | title'.
    Use read_note details via the notes://<id> resource for the full text."""
    q = query.lower()
    hits = [n for n in load_notes() if q in n["title"].lower() or q in n["body"].lower()]
    hits.sort(key=lambda n: n["created"], reverse=True)
    if not hits:
        return f"No notes match '{query}'."
    lines = [f'{n["id"]} | {n["created"][:10]} | {n["title"]}' for n in hits[:limit]]
    extra = f"\n(showing {limit} of {len(hits)}; narrow the query)" if len(hits) > limit else ""
    return "\n".join(lines) + extra


@mcp.resource("notes://{note_id}")
def get_note(note_id: str) -> str:
    """The full text of one note."""
    for n in load_notes():
        if str(n["id"]) == note_id:
            return f'# {n["title"]}\n\n{n["body"]}\n\n(created {n["created"]})'
    raise ValueError(f"No note with id {note_id}")


@mcp.prompt()
def weekly_review() -> str:
    """Kick off a review of the past week's notes."""
    return (
        "Search my notes from the past week, group them by theme, "
        "and give me a short summary plus any follow-ups I should do."
    )


if __name__ == "__main__":
    mcp.run()   # speaks MCP over stdio by default

That's the whole server. A few things to notice before we run it.

The docstrings are the tool descriptions. FastMCP sends them to the model, so write them the way you'd write any tool description: what it does, when to use it, what it returns. Designing tools for LLM agents explains why this matters so much.

Type hints become the schema. title: str and limit: int = 5 turn into a JSON schema with a required string and an optional integer. Wrong types get rejected before your function runs.

Results are short and readable. search_notes returns compact lines with a hint about how to get more, not a dump of full note bodies. The full text is available through the resource when the model needs it.

One rule for stdio servers

Local servers talk to the host over standard input and output. That means anything you print to stdout corrupts the protocol. A stray print("loaded notes") will confuse the client, and the failure looks baffling: the server starts, then the host reports a parse error or drops the connection.

If you need debugging output, send it to stderr:

import sys
print("loaded notes", file=sys.stderr)

or use the logging module configured to write to stderr. This one gotcha accounts for a large share of "my server won't connect" questions.

Test it before connecting anything

You don't need an AI app to check your server works. The MCP Inspector is a small web tool that connects to a server and lets you call its tools by hand. With Node installed:

npx @modelcontextprotocol/inspector python notes_server.py

The Inspector opens in your browser. Connect, open the Tools tab, and you should see add_note and search_notes with their descriptions and schemas. Try adding a note, then searching for it. Switch to Resources and read notes://1. Check the Prompts tab for weekly_review.

This loop is worth doing every time you change a description or schema: confirm the tool list looks right, call each tool with good and bad input, and read the error messages. If the Inspector output looks confusing to you, it will look confusing to a model.

Connect it to an AI app

Once it behaves in the Inspector, connect it to a real host.

Claude Code. Register the server with a command along these lines (run claude mcp --help to confirm the current syntax for your version):

claude mcp add notes -- python /full/path/to/notes_server.py

Use the absolute path to your virtual environment's Python if the SDK isn't installed globally. After that, start a session and ask "Add a note titled 'ideas' saying I should write about MCP." You'll be asked to approve the tool call, and then it runs.

Claude Desktop and other config-file hosts. These read a JSON file listing servers. The shape is typically:

{
  "mcpServers": {
    "notes": {
      "command": "/full/path/to/.venv/bin/python",
      "args": ["/full/path/to/notes_server.py"]
    }
  }
}

Restart the app and the tools appear. Absolute paths matter here, because the app launches your server from its own working directory, not yours. Relative paths and a bare python from an unactivated environment are the usual reason a config "does nothing."

What the model actually sees

It helps to picture the result from the model's side. Your server contributes two tool definitions to the prompt, each with a name, a description, and a schema. The resource and the prompt appear in the host's interface rather than as tools, so the user can attach a note to a conversation or pick "weekly review" from a menu.

When you ask to save an idea, the model reads the add_note description, decides it matches, and emits a call with title and body. The host asks you to approve, sends tools/call to your server, and your function runs. The string you return goes back into the conversation, and the model tells you what happened. Nothing about your code is model-specific, and that's the payoff of the protocol.

Design choices that matter

A few decisions in this tiny server generalize to bigger ones.

Keep tools narrow. Two tools with clear jobs beat one manage_notes(action=...) tool. The model chooses more reliably when each option means one thing.

Cap output. The limit parameter and the "showing 5 of 12" note prevent a big result set from flooding the context. Do this for every tool that can return a list.

Fail with useful messages. No notes match 'x'. is better than an empty string, and a ValueError naming the missing id is better than a stack trace. The host passes those messages to the model, which can then recover.

Don't trust arguments. Here the arguments are just strings we store, so the risk is small. In a server that touches files, shells, or databases, validate everything the model sends. The path check in the first-agent tutorial shows the idea.

Separate reading from writing. add_note changes state and search_notes doesn't. Hosts can auto-approve safe reads and ask before writes, but only if your tools make the distinction clear.

When it won't connect

Most first-time failures fall into a short list, so check these before anything exotic.

  1. The command doesn't run on its own. Copy the exact command and arguments from your config and run them in a terminal. If that errors, the host will too. Missing modules usually mean the wrong Python: the host isn't using your virtual environment.
  2. Something wrote to stdout. Search for stray print calls, including ones inside libraries you imported. Redirect logs to stderr.
  3. The path is relative or the working directory differs. Use absolute paths for the script and for any data files.
  4. The server crashes at startup. Run it directly with python notes_server.py and read the traceback. A server waiting silently for input is healthy; a traceback isn't.
  5. The host cached an old tool list. Restart the host or reconnect after changing tool names or schemas.
  6. Permissions. The data folder may not be writable from the environment the host uses.

Hosts usually keep log files for their MCP connections, and reading those beats guessing. The Inspector is the other big time-saver: if a server works there but not in your host, the problem is configuration, not code.

A safer write tool

add_note is low risk, but many real servers expose actions that shouldn't fire without a person's say-so. Two patterns help.

Make the intent explicit. Split a risky operation into a preview and a commit. preview_delete(ids) returns what would be removed and a short-lived confirmation token, and confirm_delete(token) performs it. The model can't skip the preview, and a human reading the host's approval prompt sees exactly what's about to happen.

Return enough to verify. After a write, echo back what changed: "Deleted 3 notes: 4, 7, 9." That gives the model, and you in the logs, a clear record instead of a bare "ok."

Hosts generally ask users to approve tool calls, but don't lean on that as your only safeguard. People click through prompts, and some setups approve calls automatically. Put the guardrails in the server.

Testing without a model

Once a server grows beyond a toy, add ordinary automated tests. The tool functions are plain Python, so you can call add_note and search_notes directly from a test suite, point DATA_FILE at a temporary folder, and assert on the strings they return. Test the awkward inputs: empty query, huge limit, a note id that doesn't exist, Unicode titles.

Then add one integration test that starts the server and connects with the SDK's client, listing tools and calling one. It's slower, so run it less often, but it catches schema mistakes that unit tests skip. Since the strings your tools return are what the model reads, treat them as part of the interface and pin them down in tests.

Going remote

A stdio server runs on the user's own machine. If you want to host it for others, you switch transports and put it behind HTTP. The SDK supports a Streamable HTTP mode, and the change on your end is small. The real work is everything around it:

  • Authentication. Remote servers need to know who's calling. The protocol builds on OAuth-style flows so that users can approve access without sharing passwords.
  • Multi-user data. Our JSON file assumes one user. A hosted server needs per-user storage and isolation.
  • Rate limits and quotas. Models can call tools quickly and repeatedly.
  • Observability. Log every call with a user, tool, arguments, and outcome.
  • Versioning. Clients cache tool lists, and a breaking change to a tool can confuse them.

Start local, prove the tools are useful, and go remote only when there's a reason. The protocol's specification is versioned by date and still moving, so check the current version before committing to a design.

Security checklist for anything you publish

If other people will install your server, treat it as software that runs with their privileges.

  • Ask for the least access you need, and document exactly what your server reads and writes.
  • Never log secrets, and don't put them in tool descriptions.
  • Keep descriptions honest and stable. Silent changes to what a tool does after users approved it erode trust.
  • Pin your dependencies and publish source.
  • Assume tool arguments and any data you fetch from elsewhere can contain hostile instructions. Prompt injection applies to servers as much as to agents.

Where to go from here

You've built the full cycle: define capabilities, test them in isolation, connect them to a host, and see a model use them. To extend the example, add a delete_note tool with a confirmation step, or swap the JSON file for SQLite and add pagination. Then try wrapping something you use every day, like your issue tracker or a folder of Markdown files, and see how it changes what you can ask an assistant to do.

If you're weighing whether to build a server or just write in-process tools, the MCP overview has a quick decision guide.