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_ticketsis 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.
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,boolbecome"type": "string","integer","boolean".Literal[...]becomes anenum, 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
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.
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.pyon its own points nowhere. For the same reason,server.pybuilds its data path fromPath(__file__). - Point
commandat the venv’s Python, not plainpython, which may be a system Python withoutmcp. On Windows it’sC:\\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
commandandargsout of the config and run them in a terminal.No module named 'mcp'meanscommandpoints 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:
- In
get_ticket, replace theif ticket is Nonecheck withreturn TICKETS[ticket_id]. Runclient.pyagain: the KITE-999 call now returns onlyError executing tool get_ticket, and theKeyErrortraceback is inserver.log. - Add
("search_tickets", {"query": "northwind", "status": "in_progress"})toCALLSinclient.py. The customer name matches too, and the filter leaves only KITE-142. - Add a parameter
assignee: str | None = Nonetosearch_ticketsand filter on it. Open the Inspector and look at the new schema:assigneeappears, but not inrequired.
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 itspythonmay not havemcp. Use absolute paths and the venv’s Python. - Letting exceptions escape. The model gets
Error executing tool ...and nothing else. RaiseToolErrorwith 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.pyor 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
commandplusargsand talks JSON-RPC over stdin and stdout. MCPServerplus@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
ToolErrorwith 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
- MCP Python SDK. The
mcppackage, includingMCPServer, theClientclass and the v1 to v2 migration guide. - MCP spec: Tools. The exact shape of
tools/list,tools/call,isErrorandstructuredContent. - Connect to local MCP servers. The official Claude Desktop setup, including config and log locations.
- Claude Code: Connect to tools via MCP.
claude mcp add, scopes and.mcp.json. - MCP Inspector. The debugging UI used above.
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.