Our team kept pasting the same context into AI assistants: snippets from internal runbooks, the current on-call notes, the last deploy's changelog. The Model Context Protocol (MCP) lets an assistant fetch that context itself through a small server we control. Writing such a server takes an afternoon; trusting it takes a test strategy.

This guide builds a local MCP server for a team notes store using the official Python SDK, exposes tools, resources and a prompt, and then tests it three ways: interactively with the MCP Inspector, automatically with a scripted client in pytest, and finally through a real client. By the end you will have a pattern you can copy for any internal data source.

What a local MCP server is

An MCP server is a process that speaks JSON-RPC to a client (Claude Desktop, Claude Code, an IDE extension or your own agent). It can offer three kinds of capability:

  • Tools: functions the model may call, with JSON Schema arguments. Use them for actions and queries.
  • Resources: read-only data addressed by URI, which the client can attach as context.
  • Prompts: reusable prompt templates the user picks explicitly.

A local server usually runs over the stdio transport: the client launches it as a subprocess and exchanges newline-delimited JSON-RPC messages over stdin and stdout. No ports, no auth layer, and the server inherits only the permissions of the user who launched it. The one rule that bites everyone: stdout belongs to the protocol, so logs must go to stderr. (We have a separate post on the failure mode when they do not.)

Project setup

We use uv for dependency management because the SDK's CLI and most client configs assume it.

uv init team-notes-mcp
cd team-notes-mcp
uv add "mcp[cli]"
uv add --dev pytest

The data lives in SQLite so the example stays self-contained. Point NOTES_DB at a real file in your setup.

The server

The SDK ships FastMCP, a high-level API that turns type-hinted functions into tools with generated schemas.

# server.py
import logging
import os
import sqlite3
import sys
from datetime import datetime, timezone

from mcp.server.fastmcp import FastMCP

logging.basicConfig(stream=sys.stderr, level=logging.INFO)
log = logging.getLogger("team-notes")

DB_PATH = os.environ.get("NOTES_DB", "notes.db")
MAX_BODY = 20_000

mcp = FastMCP("team-notes")

def db() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_PATH)
    conn.execute(
        "CREATE TABLE IF NOT EXISTS notes ("
        " title TEXT PRIMARY KEY, body TEXT NOT NULL, updated_at TEXT NOT NULL)"
    )
    return conn

@mcp.tool()
def add_note(title: str, body: str) -> str:
    """Create or replace a team note. Title is a short unique key."""
    title = title.strip()
    if not title or len(body) > MAX_BODY:
        raise ValueError("title required and body must be under 20000 characters")
    now = datetime.now(timezone.utc).isoformat()
    with db() as conn:
        conn.execute(
            "INSERT INTO notes(title, body, updated_at) VALUES (?, ?, ?) "
            "ON CONFLICT(title) DO UPDATE SET body = excluded.body, updated_at = excluded.updated_at",
            (title, body, now),
        )
    log.info("saved note %s", title)
    return f"Saved note '{title}'"

@mcp.tool()
def search_notes(query: str, limit: int = 5) -> str:
    """Search note titles and bodies. Returns matching titles with a short preview."""
    limit = max(1, min(limit, 20))
    with db() as conn:
        rows = conn.execute(
            "SELECT title, substr(body, 1, 160) FROM notes "
            "WHERE title LIKE ? OR body LIKE ? ORDER BY updated_at DESC LIMIT ?",
            (f"%{query}%", f"%{query}%", limit),
        ).fetchall()
    if not rows:
        return "No matching notes."
    return "\n".join(f"- {title}: {preview}" for title, preview in rows)

@mcp.resource("notes://{title}")
def read_note(title: str) -> str:
    """Full text of one note."""
    with db() as conn:
        row = conn.execute("SELECT body FROM notes WHERE title = ?", (title,)).fetchone()
    if row is None:
        raise ValueError(f"note not found: {title}")
    return row[0]

@mcp.prompt()
def incident_summary(note_title: str) -> str:
    """Draft an incident summary from a note."""
    return (
        f"Read the resource notes://{note_title} and write a five-line incident summary: "
        "impact, timeline, root cause, fix, follow-ups. Do not invent facts."
    )

if __name__ == "__main__":
    mcp.run()  # stdio transport by default

Things we did on purpose:

  • Docstrings are the tool descriptions. The model reads them to decide which tool to call, so they say what the tool does and what the arguments mean, nothing more.
  • Inputs are bounded. limit is clamped and note bodies have a size cap. A model will happily ask for ten thousand results.
  • SQL is parameterised. Arguments come from a model that may be steered by untrusted text; treat them like user input.
  • Errors are exceptions. FastMCP turns a raised exception into a tool result flagged as an error, which the model can read and recover from.

Test 1: the MCP Inspector

The Inspector is a browser UI that connects to a server and lets you list and call everything by hand. The SDK CLI starts both together:

uv run mcp dev server.py

It prints a local URL. Open it, connect, and work through a short checklist:

  1. Tools tab: add_note and search_notes appear with the generated schemas. Check limit shows a default of 5.
  2. Call add_note with an empty title and confirm you get an error result, not a crash.
  3. Resources tab: the notes://{title} template is listed; read one that exists and one that does not.
  4. Prompts tab: render incident_summary with a title.
  5. Watch the server's stderr panel; your log lines should appear there, never mixed into protocol traffic.

The Inspector also works with servers in other languages: npx @modelcontextprotocol/inspector uv run server.py launches the same UI for any command.

Test 2: a scripted client in pytest

Manual checks do not stop regressions. The SDK includes a client, so a test can launch the server over stdio exactly like a real host does and call it through the protocol.

# tests/test_server.py
import asyncio
import sys

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

def server_params(tmp_path) -> StdioServerParameters:
    return StdioServerParameters(
        command=sys.executable,
        args=["server.py"],
        env={"NOTES_DB": str(tmp_path / "test.db")},
    )

async def scenario(tmp_path) -> None:
    async with stdio_client(server_params(tmp_path)) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            tools = await session.list_tools()
            assert {t.name for t in tools.tools} == {"add_note", "search_notes"}

            saved = await session.call_tool(
                "add_note", {"title": "db-failover", "body": "Promoted replica at 02:14 UTC"}
            )
            assert not saved.isError

            found = await session.call_tool("search_notes", {"query": "replica"})
            assert "db-failover" in found.content[0].text

            bad = await session.call_tool("add_note", {"title": " ", "body": "x"})
            assert bad.isError

            res = await session.read_resource("notes://db-failover")
            assert "02:14" in res.contents[0].text

def test_end_to_end(tmp_path):
    asyncio.run(scenario(tmp_path))

Run it with:

uv run pytest -q

This test catches the regressions we actually had: a renamed tool that broke saved client configs, a print statement that corrupted stdout, and a schema change that made limit required. Depending on the SDK version, env may replace rather than extend the subprocess environment, so pass PATH or other variables explicitly if your server needs them.

Test 3: a real client

Finally, register the server with the client the team uses. For Claude Code, from the project directory:

claude mcp add team-notes -- uv --directory /abs/path/team-notes-mcp run server.py
claude mcp list

For Claude Desktop, add an entry to claude_desktop_config.json and restart the app:

{
  "mcpServers": {
    "team-notes": {
      "command": "uv",
      "args": ["--directory", "/abs/path/team-notes-mcp", "run", "server.py"],
      "env": { "NOTES_DB": "/abs/path/notes.db" }
    }
  }
}

Use absolute paths: the client does not start the process in your project directory, and a relative path is the most common reason a server shows as failed. Then ask a question that should trigger a tool ("what do our notes say about the replica failover?") and confirm in the client's tool-call view that search_notes ran with sensible arguments.

Common mistakes

Mistake Symptom Do this instead
print() for debugging Client reports parse errors or disconnects Log to stderr only
Relative paths in client config Server fails to start from the client Absolute paths for directory, command and data
Vague docstrings Model never calls the tool, or calls the wrong one One clear sentence per tool plus argument meaning
Unbounded arguments Huge responses fill the context window Clamp limits, cap sizes, paginate
Testing only in the chat client Regressions found by users Scripted client tests in CI

Key takeaways

  • A local MCP server is a subprocess speaking JSON-RPC over stdio; stdout is reserved for the protocol.
  • FastMCP turns type-hinted, documented Python functions into tools, resources and prompts with little code.
  • Treat tool arguments as untrusted input: parameterise queries, bound sizes, raise clear errors.
  • Test interactively with the Inspector, automatically with the SDK client in pytest, and last in a real client.
  • Absolute paths and explicit environment variables avoid most client startup failures.

References