Setting Up Your First MCP Server

In What Is MCP? you ran the Kitebase server and watched Claude look up ticket KITE-142. Then the support lead asks something broader: “What SSO tickets do we have, and who’s on them?” The server can’t answer that. get_ticket needs an id, and nothing can search. Nor can it open a new ticket when a customer reports a bug.

So it’s time to write the server yourself: start from an empty folder, add the tools people actually ask for, check they work without a model, and plug the result into Claude.

What you’ll build: the Kitebase server from article 01, built from scratch and extended with search_tickets and create_ticket, with get_ticket now returning typed data. You’ll test it with a small client script and the MCP Inspector, then connect it to Claude Desktop and Claude Code. It runs offline against eight sample tickets in a JSON file.

A quick recap

A host is the AI app you talk to, like Claude Desktop or Claude Code. It starts your server, a separate program that offers tools: functions the model can ask to run, each with a name, a description and a schema for its arguments. They talk in JSON-RPC messages like {"method": "tools/call", ...}, which MCP Architecture took apart line by line. For a local server those messages go over stdio: the host launches your program, writes requests to its standard input and reads responses from its standard output. You write the tools. The SDK does the JSON-RPC.

Set up the project

You need Python 3.10 or newer. Make a folder and a virtual environment (a private copy of Python with its own packages), then install the official SDK, the mcp package:

mkdir kitebase-mcp && cd kitebase-mcp
python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "mcp[cli]>=2.2,<3"

The [cli] extra adds the mcp command-line tool. The version range matters: as article 01 warned, version 2 renamed FastMCP to MCPServer, and a lot of code online still uses the old name.

The finished project:

kitebase-mcp/
├── server.py          # the MCP server
├── client.py          # a test client that launches server.py
├── test_server.py     # offline tests
├── requirements.txt   # mcp[cli]>=2.2,<3
└── data/tickets.json  # 8 sample Kitebase tickets

A real Kitebase server would call Kitebase’s HTTP API. This one reads data/tickets.json into memory, so you can see every value and nothing needs a login. Wrapping a real API is article 05.

The smallest useful server

Here is the start of server.py, with one tool:

from typing import Annotated, Literal

from mcp.server import MCPServer
from pydantic import BaseModel, Field

class Ticket(BaseModel):
    id: str
    title: str
    status: Literal["open", "in_progress", "closed"]
    assignee: str | None
    customer: str

TICKETS = load_tickets()  # {"KITE-142": Ticket(...), ...} from data/tickets.json
mcp = MCPServer("kitebase")

@mcp.tool()
def search_tickets(
    query: Annotated[str, Field(description="Words to find in the title or customer name, e.g. 'sso'")],
    status: Literal["open", "in_progress", "closed", "any"] = "any",
) -> list[Ticket]:
    """Search Kitebase support tickets by words in their title or customer. Returns up to 10."""
    words = query.lower().split()
    hits = [t for t in TICKETS.values()
            if all(w in f"{t.title} {t.customer}".lower() for w in words)
            and status in ("any", t.status)]
    return hits[:10]

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

Three lines do the MCP work:

  • MCPServer("kitebase") creates the server. Hosts show the name in their UI and use it in log file names.
  • @mcp.tool() registers the function as a tool. Without it, search_tickets is a Python function no client can see.
  • mcp.run() starts the stdio loop: read a request from stdin, call your function, write the response to stdout, repeat until the host closes the pipe.

Everything else is ordinary Python. Ticket is a pydantic model (a class that validates its fields and converts to and from JSON), and the search is a list comprehension. Your tool stays a normal function you can unit test; the protocol lives in the decorator. The companion server.py also keeps article 01’s search_help tool, resource and prompt, unchanged. Tools, Resources, Prompts covers the last two.

Type hints become the input schema

The model never sees your Python. When the client asks for the server’s tools with tools/list, the SDK sends a JSON Schema for each one: a standard JSON format that says which arguments exist, their types, and which are required. The SDK builds it from your signature.

YOUR FUNCTION IN SERVER.PY def search_tickets( """Search Kitebase support tickets by words in their title or customer ...""" query: Annotated[str, Field( description="Words to find ...")] status: Literal["open", "in_progress", "closed", "any"] = "any" ) -> list[Ticket]: query has no default, status has one WHAT TOOLS/LIST SENDS "name": "search_tickets" "description": "Search Kitebase support tickets by words in their title ..." "query": {"type": "string", "description": "Words to find ..."} "status": {"enum": ["open", "in_progress", "closed", "any"], "default": "any"} "outputSchema": {"result": [Ticket]} "required": ["query"] The model only ever sees the right-hand side. Change the signature and the schema changes with it, so the two can't drift apart.
The SDK reads your signature and docstring. The model only sees the right-hand side.

This is the real inputSchema for search_tickets, trimmed of pydantic’s title fields:

{
  "type": "object",
  "properties": {
    "query": {"type": "string", "description": "Words to find in the title or customer name, e.g. 'sso'"},
    "status": {"type": "string", "enum": ["open", "in_progress", "closed", "any"], "default": "any"}
  },
  "required": ["query"]
}

Each type hint turns into a rule:

  • str, int, bool become "type": "string", "integer", "boolean".
  • Literal[...] becomes an enum, so the model can only pick one of those values.
  • A default value makes the argument optional. No default puts it in required.
  • Annotated[str, Field(description=...)] describes one argument. The docstring describes the whole tool.

The docstring deserves the most care: it becomes the tool’s description, which is how the model decides whether to call your tool at all. Say what it does, what the input looks like and what comes back.

The gotcha: a parameter with no type hint accepts any value, so the model can send a number or a list and your code finds out at runtime. Type every argument.

Returning results

Return a value and the SDK turns it into a tool result. In article 01, get_ticket returned a plain dict. Return a pydantic type instead, like list[Ticket], and the SDK also publishes an output schema and sends the data twice: as JSON text in content, which every host can pass to the model, and as structuredContent, typed data that matches the output schema. A call with {"query": "sso"} returns this structuredContent:

{
  "result": [
    {"id": "KITE-139", "title": "SSO login loops back to the sign-in page",
     "status": "closed", "assignee": "priya", "customer": "Northwind Studio"},
    {"id": "KITE-142", "title": "Customer locked out after SSO change",
     "status": "in_progress", "assignee": "priya", "customer": "Northwind Studio"},
    {"id": "KITE-144", "title": "SSO users can't reset their password",
     "status": "open", "assignee": null, "customer": "Harbor Pine"}
  ]
}

The SDK wraps a list in {"result": ...} and says so in the output schema; a single Ticket comes back as is. A plain str return still works. Default to a pydantic model for anything with fields.

The gotcha is size. Every result goes into the model’s context, and you pay for it in tokens (the unit models read and bill in, roughly 4 characters of English). Three tickets at about 50 tokens each is nothing. All 4,000 tickets in a real workspace is roughly 200,000 tokens in one call: slow, expensive, and the tickets that matter get buried. That’s why search_tickets stops at 10 and says so.

Returning errors the model can use

Article 01 showed the rule: raise ToolError for failures the model should see, because any other exception reaches it as a bare “Error executing tool”. Here it is in get_ticket, now with a message that says how to recover:

from mcp.server.mcpserver.exceptions import ToolError

@mcp.tool()
def get_ticket(
    ticket_id: Annotated[str, Field(description="A ticket id such as KITE-142")],
) -> Ticket:
    """Look up one Kitebase ticket by id. Returns its title, status, assignee and customer."""
    ticket = TICKETS.get(ticket_id.strip().upper())
    if ticket is None:
        raise ToolError(f"No ticket {ticket_id!r}. Ids look like KITE-142; use search_tickets to find one.")
    return ticket
1. RETURN A VALUE called with "KITE-142" return ticket THE MODEL GETS isError: false {"id": "KITE-142", "title": "Customer locked out after SSO change", "assignee": "priya", ...} Data it can use in the answer. 2. RAISE TOOLERROR called with "KITE-999" raise ToolError("No ticket 'KITE-999'. Ids look like KITE-142; use search_tickets") THE MODEL GETS isError: true "Error executing tool get_ticket: No ticket 'KITE-999'. Ids look like KITE-142; use search_tickets" It knows what went wrong and what to try next. 3. ANY OTHER EXCEPTION called with "KITE-999" return TICKETS[ticket_id] KeyError: 'KITE-999' THE MODEL GETS isError: true "Error executing tool get_ticket" and nothing else The real error is only in your log, as a traceback. Arguments that break the schema (a 3-letter title) never reach your function: the SDK sends back isError: true with pydantic's message, like case 2.
Raise ToolError for failures you expect. Anything else hides the reason from the model.

The SDK hides a crash’s text on purpose, since it might contain internals like a SQL query. That’s safe, but it gives the model nothing to act on. So catch the failures you can predict (“not found”, “not allowed”, “Kitebase is down”) and raise ToolError with a message written for the model: what went wrong and what to try instead. “Use search_tickets to find one” turns a dead end into a next step.

A tool that changes things

create_ticket opens a ticket. Its schema carries constraints of its own:

@mcp.tool()
def create_ticket(
    title: Annotated[str, Field(min_length=5, max_length=120)],
    customer: str,
) -> Ticket:
    """Open a new, unassigned Kitebase ticket. Only call this when the user asks for one."""
    new_id = f"KITE-{138 + len(TICKETS)}"
    ticket = Ticket(id=new_id, title=title, status="open", assignee=None, customer=customer)
    TICKETS[new_id] = ticket
    print(f"created {new_id}: {title}", file=sys.stderr)
    return ticket

min_length and max_length show up in the schema as "minLength": 5 and "maxLength": 120. Call it with {"title": "SSO"} and the SDK rejects the arguments before your code runs:

is_error: true
Error executing tool create_ticket: 2 validation errors for create_ticketArguments
title
  String should have at least 5 characters [type=string_too_short, input_value='SSO', input_type=str]
customer
  Field required [type=missing, input_value={'title': 'SSO'}, input_type=dict]

The model reads that, fixes the call and tries again. You wrote no validation code.

“Only call this when the user asks” is there because writes are riskier than reads. A model that searches too eagerly wastes a few tokens; one that opens tickets too eagerly fills the queue with junk. Hosts ask the user before running a tool by default, but the description sets the expectation. The tickets live in memory here, so new ones vanish when the host restarts the server.

Running it: a process with three pipes

Run python server.py in a terminal and nothing happens. That’s correct: the server is waiting for JSON on stdin, the way a host talks to it. Press Ctrl+C to stop it.

HOST: CLAUDE DESKTOP from claude_desktop_config.json: command: .venv/bin/python args: [server.py] (absolute paths in the real file) Starts the server when the app launches. YOUR SERVER python server.py a normal process with three pipes No output when you run it by hand is correct: it's waiting for JSON on stdin. stdin: requests {"method": "tools/call", ...} stdout: responses, nothing else {"result": {"content": ...}} stderr: your logs HOST LOG FILE mcp-server-kitebase.log A PRINT() THAT FORGETS FILE=SYS.STDERR print(f"created {new_id}") lands on stdout, between two JSON-RPC messages. The client reads "created KITE-146" where it expects JSON and logs: Failed to parse JSONRPC message from server Some hosts drop the connection instead.
stdout is the protocol channel. Your logs go to stderr.

Every process has three streams: stdin, stdout and stderr, a second output stream meant for errors and logs. On stdio, stdout belongs to the protocol, which is why create_ticket logs with file=sys.stderr. Hosts save stderr to a log file, so it’s where your debugging output belongs. Forget the file= argument and you get the Failed to parse JSONRPC message from server error from article 01. The companion tests catch exactly that.

Test it without a model

Don’t test with Claude first. When Claude doesn’t call your tool, you can’t tell whether the server is broken or the model chose not to. Test the server alone.

A small client script. client.py does what a host does: it launches server.py, lists its tools and calls them. The SDK’s Client class handles the connection:

from mcp import Client, StdioServerParameters, stdio_client

SERVER = StdioServerParameters(
    command=sys.executable,
    args=[str(Path(__file__).parent / "server.py")],
)

async def main() -> None:
    with LOG_FILE.open("w") as log:  # the server's stderr goes here, like a host's log file
        async with Client(stdio_client(SERVER, errlog=log)) as client:
            tools = await client.list_tools()
            for tool in tools.tools:
                print(f"  {tool.name}({', '.join(tool.input_schema['properties'])})")
            for name, arguments in CALLS:  # search "sso", get KITE-999, a bad create
                print(f"\n{name} {json.dumps(arguments)}")
                print(show(await client.call_tool(name, arguments)))

StdioServerParameters holds the same two things a host config holds: a command and its arguments. sys.executable is the Python running the script, so the server gets the one with mcp installed. show prints is_error, then the data or the error text. Running python client.py prints (trimmed):

Connected with protocol 2026-07-28. 4 tools:
  search_tickets(query, status)
  get_ticket(ticket_id)
  create_ticket(title, customer)
  search_help(query, limit)

search_tickets {"query": "sso"}
  is_error: false
  {"result": [{"id": "KITE-139", ...}, {"id": "KITE-142", ...}, {"id": "KITE-144", ...}]}

get_ticket {"ticket_id": "KITE-999"}
  is_error: true
  Error executing tool get_ticket: No ticket 'KITE-999'. Ids look like KITE-142; use search_tickets to find one.

The server's stderr is in server.log.

2026-07-28 is the current spec revision. It has no handshake: every request carries the protocol version in its _meta, and a client can call server/discover to see what the server offers (article 02 shows the messages). A host that only speaks an older revision sends the initialize handshake instead, and the SDK answers both. The companion tests connect both ways.

The MCP Inspector. The Inspector is the official debugging UI for MCP servers. It needs Node.js 22.19 or newer. Point it at the same command a host would run:

npx @modelcontextprotocol/inspector .venv/bin/python server.py

It starts a local web page and prints its address. From there you connect, list the tools, read each schema, and call a tool from a form built from its input schema. It’s the fastest loop while you’re editing: change server.py, reconnect, call again. If you have uv installed, mcp dev server.py starts the Inspector for you.

Connect it to Claude Desktop

Claude Desktop (macOS and Windows) reads its servers from claude_desktop_config.json. Open it from the Claude menu: Settings, then the Developer tab, then Edit Config. The file lives at:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json

Add your server under mcpServers:

{
  "mcpServers": {
    "kitebase": {
      "command": "/Users/you/kitebase-mcp/.venv/bin/python",
      "args": ["/Users/you/kitebase-mcp/server.py"]
    }
  }
}

The host runs exactly command plus args, so:

  • Use absolute paths for both. The host doesn’t start in your project folder, so server.py on its own points nowhere. For the same reason, server.py builds its data path from Path(__file__).
  • Point command at the venv’s Python, not plain python, which may be a system Python without mcp. On Windows it’s C:\\Users\\you\\kitebase-mcp\\.venv\\Scripts\\python.exe, with doubled backslashes because it’s JSON.

Then quit Claude Desktop completely and start it again; closing the window isn’t enough. The host reads the config only at launch and keeps running the server process it started, so code changes need a restart too. To check it loaded, click the “Add files, connectors, and more” button at the bottom left of the message box, then Connectors, then Manage connectors: kitebase should be listed with its tools. Menus move between app versions, so if yours differs, the logs below are the reliable check.

Now ask the support lead’s question. The wording of the reply changes between runs; it looks something like this:

You:     What SSO tickets do we have, and who's on them?
         [Claude asks to run search_tickets with {"query": "SSO"}. You allow it.]
Claude:  Three tickets mention SSO:
         - KITE-142, in progress with priya: Northwind Studio locked out after an SSO change.
         - KITE-144, open and unassigned: Harbor Pine's SSO users can't reset their password.
         - KITE-139, closed (priya): an SSO login loop for Northwind Studio.

The facts come from your server, not the model’s memory. When something goes wrong, read the logs: ~/Library/Logs/Claude/ on macOS or %APPDATA%\Claude\logs on Windows. mcp-server-kitebase.log holds your server’s stderr, and mcp.log holds the host’s side of the connection.

Connect it to Claude Code

Claude Code, the terminal coding agent, adds servers with one command. Everything after -- is the command to run:

claude mcp add kitebase -- /Users/you/kitebase-mcp/.venv/bin/python /Users/you/kitebase-mcp/server.py

By default that’s saved in ~/.claude.json, for you and the current project only. --scope user makes it available in every project. --scope project writes a .mcp.json in the project root that you can commit for your team, in the same shape as Claude Desktop’s config:

{
  "mcpServers": {
    "kitebase": {
      "type": "stdio",
      "command": "/Users/you/kitebase-mcp/.venv/bin/python",
      "args": ["/Users/you/kitebase-mcp/server.py"]
    }
  }
}

Claude Code asks you to approve servers from a project’s .mcp.json before starting them, since anyone with commit access could have added one. Check the connection with claude mcp list, or /mcp inside a session. The tools show up with the server name in front, like mcp__kitebase__search_tickets. More hosts, and remote servers, are in Connecting to Claude Desktop, Cursor, Etc..

When the first run fails

  • The server doesn’t appear. Copy command and args out of the config and run them in a terminal. No module named 'mcp' means command points at the wrong Python; “No such file or directory” means a typo in a path. If it starts and waits silently, the command is fine, so check the JSON itself: one missing comma breaks the whole file.
  • It appears, then fails. Read mcp-server-kitebase.log. A traceback at startup, like a data file opened by relative path, kills the server before it can answer.
  • It connects, but Claude never calls the tool. Check the tool works in the Inspector, then improve its description: what it does and when to use it, in your users’ words.

Try it yourself

The companion example is the whole project: the server, the test client and offline tests.

Download the runnable example (zip)

cd 03-first-mcp-server
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python client.py

Then try these:

  1. In get_ticket, replace the if ticket is None check with return TICKETS[ticket_id]. Run client.py again: the KITE-999 call now returns only Error executing tool get_ticket, and the KeyError traceback is in server.log.
  2. Add ("search_tickets", {"query": "northwind", "status": "in_progress"}) to CALLS in client.py. The customer name matches too, and the filter leaves only KITE-142.
  3. Add a parameter assignee: str | None = None to search_tickets and filter on it. Open the Inspector and look at the new schema: assignee appears, but not in required.

pip install pytest && pytest -q runs the tests, offline. One launches server.py over stdio with both the current server/discover flow and the older initialize handshake, and fails if anything but protocol messages reaches stdout.

Common beginner mistakes

  • Relative paths, or plain python, in the host config. The host doesn’t run from your project folder, and its python may not have mcp. Use absolute paths and the venv’s Python.
  • Letting exceptions escape. The model gets Error executing tool ... and nothing else. Raise ToolError with a message that says what to do next.
  • Untyped or undocumented parameters. The schema is all the model knows. Type every argument and describe the ones whose meaning isn’t obvious from the name.
  • Returning everything. A tool that returns every ticket burns the model’s context. Cap results and say so in the description.
  • Testing in Claude first. You can’t tell a broken server from a model that chose another tool. Run client.py or the Inspector first.

Questions you will face in production

“Should one tool do everything, or should I make many small ones?” Start with a few tools that match what users ask for, like search_tickets and get_ticket, not one per API endpoint. Every tool’s name, description and schema is sent to the model, so 40 tools cost context on every request and make picking the right one harder. MCP Server Best Practices goes further.

“Where do API keys go?” Not in the code and not in args. Add an env block next to command in the host config ("env": {"KITEBASE_API_TOKEN": "..."}) and read it with os.environ in the server. Don’t count on variables exported in your shell profile: Claude Desktop isn’t started from your terminal, so it never sees them.

“Can I write this in TypeScript?” Yes. The official @modelcontextprotocol/sdk package works the same way, with Zod schemas instead of type hints and node as the command. Everything else here still applies.

Check your understanding

You change status: Literal["open", "in_progress", "closed", "any"] = "any" to status: str. What changes for the model?

The schema loses the enum and the default, so status becomes a required, free-form string. The model has to send it every time and guess the allowed values, so you’ll see calls with "status": "Open" or "in progress" that match nothing.

Your server works in the Inspector, but Claude Desktop shows nothing for it. What do you check, in order?

That you fully quit and restarted it, and that the config is valid JSON with absolute paths. Then run the exact command and args in a terminal, and read mcp-server-kitebase.log and mcp.log. Most often it’s the wrong Python or a relative path.

A tool calls Kitebase's API, which sometimes times out. What should the tool do when that happens?

Catch the timeout and raise ToolError("Kitebase didn't respond in time. Try again in a minute."). If the exception escapes, the model gets only “Error executing tool …” and can’t tell a timeout from a bug, so it can’t decide to retry or tell the user.

Claude calls create_ticket with only a title. Does your function run?

No. customer has no default, so it’s in required, and the SDK rejects the call against the schema with Field required. The model reads that, asks the user or works out the customer, and calls again.

What to remember

  • An MCP server is a normal program. On stdio, the host runs your command plus args and talks JSON-RPC over stdin and stdout.
  • MCPServer plus @mcp.tool() turns a typed function into a tool. The type hints become the input schema and the docstring becomes the description the model reads.
  • Return pydantic models for structured results, and keep them small, because everything you return goes into the model’s context.
  • Raise ToolError with a message that says what to try next. Bad arguments are rejected against the schema before your code runs.
  • Test with a client script or the Inspector before a host. In host configs, use absolute paths and the venv’s Python, and restart the host after every change.

What to study next

Your server is mostly tools. It also carries a resource and a prompt from article 01 that this article skipped. Tools, Resources, Prompts covers all three and when to reach for each.

Further reading

Where this article comes from. This is a synthesis of the MCP specification, the official SDK, and common practice as of 2026, not a citation of any single paper. The sources above are where the mechanics come from. If you find an error or have a better source for a claim, the article gets fixed within a day, send me a note.


Auto-marks when you reach the end. Click to toggle.