Building and shipping a server
By the end: You have a working MCP server in a public repository that someone else can run.
- The shape of a minimal server
- Designing tools people can actually use
- Auth and secrets
- Publishing it so it counts
New to mcp & tool interfaces? Every tool an agent can use has to be described to it in some format, and for a while each product invented its own — so a connector to a company's wiki, written for one assistant, was useless in the next one. The Model Context Protocol is an agreed format for those descriptions. A server offers its tools, files and prompts in that format, and any client that speaks the protocol can use them without either side being rewritten. Why it matters
This is the practical half: writing a server, then putting it somewhere someone else can run it. The code is short, since the SDK handles transport and your type hints become the tool schemas, so nearly all of the work is design. Tools that are too many, too vague or too chatty make an agent worse rather than better, and a server with a hardcoded secret cannot be shared at all. A published server a stranger can install is also the most concrete evidence of this skill you can point at.
A minimal server#
A server declares which tools it has, and handles a call when one is invoked. The rest is transport, which the SDK handles.
# orders_server.py
from mcp.server.mcpserver import MCPServer # this class was called FastMCP before mcp 2.0
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("orders")
ORDERS = [{"id": "ord_1001", "email": "ana@example.com", "status": "shipped", "total": 19.99},
{"id": "ord_1002", "email": "ana@example.com", "status": "open", "total": 42.50}]
@mcp.tool()
def find_orders(email: str, status: str | None = None) -> list[dict]:
"""Find a customer's orders by email address.
Use when the user asks about order status, delivery or refunds.
Returns at most 10 of the most recent orders.
"""
rows = [o for o in ORDERS
if o["email"] == email and (status is None or o["status"] == status)]
if not rows:
raise ToolError("No orders for " + email + ". Check the address with the customer.")
return rows[:10]
if __name__ == "__main__":
mcp.run()Type hints become the argument schema automatically, which is one more reason the typing work earlier pays off. That claim is easy to check rather than believe, and checking it is the same as proving the server works, because the only honest test of a server is a client connecting to it.
Run it from a client#
A stdio server is started by its client as a child process, so a client is thirty lines and needs no deployment. This one performs the handshake, reads the tool list the SDK generated, calls the tool twice and prints what comes back.
# Save the block above as orders_server.py, then run this beside it.
import asyncio, os, sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main():
params = StdioServerParameters(command=sys.executable, args=["orders_server.py"])
log = open(os.devnull, "w") # the server logs to stderr; keep it out of the listing
async with stdio_client(params, errlog=log) as (read, write):
async with ClientSession(read, write) as session:
info = await session.initialize()
print("connected to", info.server_info.name, "on", info.protocol_version)
for t in (await session.list_tools()).tools:
print("tool:", t.name)
print(" required:", t.input_schema["required"])
print(" properties:", list(t.input_schema["properties"]))
good = await session.call_tool("find_orders", {"email": "ana@example.com"})
print("is_error", good.is_error, "->", good.content[0].text[:60].replace("\n", " "))
bad = await session.call_tool("find_orders", {"email": "nobody@example.com"})
print("is_error", bad.is_error, "->", bad.content[0].text)
asyncio.run(main())
# connected to orders on 2025-11-25
# tool: find_orders
# required: ['email']
# properties: ['email', 'status']
# is_error False -> { "id": "ord_1001", "email": "ana@example.com", "statu
# is_error True -> Error executing tool find_orders: No orders for
# nobody@example.com. Check the address with the customer.This is also your test suite. A server can be exercised completely without a model in the loop: connect, list the tools, call each one with good arguments, call each one with arguments a confused model would produce, and assert on what comes back. Those tests are fast, deterministic and free, and they catch the majority of what goes wrong, which is tools that raise on empty results, tools whose schema does not match the docstring, and tools that return half a megabyte.
Designing tools people can use#
Tool design is the part that decides whether a server is any good, and it is judgement rather than protocol knowledge.
- One clear job per tool. A tool that does four things gets called for the wrong one.
- Say when to use it, not only what it does. That sentence is how the model chooses between your tools.
- Cap what comes back. Unbounded results land in the context and stay there.
- Return errors as readable text the model can act on. Customer not found is useful; a stack trace is not.
- Name arguments the way a person would say them. email beats customer_identifier_string.
The second of those is the one that carries the most weight and is the least often done, so it is worth measuring rather than asserting. Below, the same six tools are described twice, once as a developer would name them and once with a sentence saying when to reach for each, and a crude nearest-description matcher picks one per request. The matcher is not a model. It is choosing between exactly the strings a model would be choosing between, which is enough to show what a vague description costs.
from sklearn.feature_extraction.text import TfidfVectorizer
VAGUE = {
"find_orders": "Get orders.",
"refund_order": "Handle refunds.",
"return_label": "Handle returns.",
"update_address": "Change account data.",
"reset_password": "Account help.",
"search_docs": "Search.",
}
SPECIFIC = {
"find_orders": "Find a customer's orders by email address. Use when the user asks "
"where an order is or whether it has shipped.",
"refund_order": "Start a refund for one delivered order. Use when the customer wants "
"money back for something they already have.",
"return_label": "Create a prepaid return label. Use when the customer wants to post "
"an item back to us.",
"update_address": "Change the delivery address on an order that has not been "
"dispatched. Use before dispatch only.",
"reset_password": "Send a password reset link to the address on the account. Use when "
"the customer cannot sign in.",
"search_docs": "Search the written policies for a rule or a timescale. Use for "
"questions about how things work rather than about one order.",
}
REQUESTS = [
("where has my parcel got to", "find_orders"),
("I want my money back for the coat that arrived yesterday", "refund_order"),
("I need to post this jacket back to you", "return_label"),
("I typed the wrong street and it has not been dispatched", "update_address"),
("I cannot sign in to my account", "reset_password"),
("what is your policy on refunds", "search_docs"),
]
def evaluate(catalogue, label):
"""Pick the nearest description. A model is not a TF-IDF matcher, but it is
choosing between these same strings."""
names = list(catalogue)
vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(4, 5), sublinear_tf=True)
M = vec.fit_transform([n.replace("_", " ") + ". " + catalogue[n] for n in names])
right = 0
for request, want in REQUESTS:
got = names[(M @ vec.transform([request]).T).toarray().ravel().argmax()]
right += got == want
if got != want:
print(" %-52s -> %s (wanted %s)" % (request, got, want))
print("%-22s %d of %d chosen correctly" % (label, right, len(REQUESTS)))
evaluate(VAGUE, "one-line names")
evaluate(SPECIFIC, "with a use-when line")
# I want my money back for the coat that arrived yesterday -> find_orders (wanted refund_order)
# I need to post this jacket back to you -> find_orders (wanted return_label)
# I typed the wrong street and it has not been dispatched -> find_orders (wanted update_address)
# what is your policy on refunds -> refund_order (wanted search_docs)
# one-line names 2 of 6 chosen correctly
# what is your policy on refunds -> refund_order (wanted search_docs)
# with a use-when line 5 of 6 chosen correctlyThat surviving failure is worth designing around rather than describing around, because it is the general case. An action and a question about that action look almost identical as strings, and a model that reaches for refund_order when the customer asked what the policy is will do something irreversible on the strength of a guess. Two defences work. Say the boundary explicitly in both descriptions, as in this tool refunds money and does not explain the rules. And make the dangerous one require an argument the model can only have if it did the lookup first, such as an order id, so a wrong choice fails loudly instead of succeeding.
Too many tools is its own failure
Past roughly twenty tools, selection accuracy degrades noticeably. If you need more, group them behind fewer tools with a mode argument, or split into several servers by domain.
Cap what comes back#
A tool result is not a return value, it is text appended to a conversation that will be re-sent on every following step. The natural implementation, returning the rows the underlying API gave you, is expensive in a way that does not show up until an agent has run for a while.
import json
def fake_orders(n):
return [{"id": "ord_%05d" % i, "placed": "2026-03-%02d" % (i % 28 + 1),
"status": ["open", "shipped", "delivered"][i % 3],
"total_pence": 1999 + i * 137, "currency": "GBP",
"items": [{"sku": "SKU-%04d" % (i * 7 % 900), "qty": 1 + i % 3,
"name": "Merino crew neck jumper, navy"}],
"address": {"line1": "%d Example Street" % (i + 1), "city": "Leeds",
"postcode": "LS1 4AB", "country": "GB"}}
for i in range(n)]
everything = json.dumps(fake_orders(50))
print("50 whole order objects %6d chars about %5d tokens"
% (len(everything), len(everything) / 4))
def summarise(orders, limit=5):
lines = ["%s %s %-9s GBP %.2f" % (o["id"], o["placed"], o["status"],
o["total_pence"] / 100)
for o in orders[:limit]]
if len(orders) > limit:
lines.append("%d more not shown. Narrow by status or date."
% (len(orders) - limit))
return "\n".join(lines)
capped = summarise(fake_orders(50))
print("5 rows, readable fields %6d chars about %5d tokens"
% (len(capped), len(capped) / 4))
print(capped)
# 50 whole order objects 14422 chars about 3605 tokens
# 5 rows, readable fields 264 chars about 66 tokens
# ord_00000 2026-03-01 open GBP 19.99
# ord_00001 2026-03-02 shipped GBP 21.36
# ord_00002 2026-03-03 delivered GBP 22.73
# ord_00003 2026-03-04 open GBP 24.10
# ord_00004 2026-03-05 shipped GBP 25.47
# 45 more not shown. Narrow by status or date.The same discipline applies to fields. Postcodes, currency codes and SKUs are in the response because the API returns them, not because anyone reading the answer needs them, and every one is charged on every subsequent step of the task. Decide what the model needs to answer with and return that, with an identifier it can use to fetch the rest if it turns out to matter.
Errors the model can act on#
A tool failure is not a server failure, and the protocol is explicit about the difference. A failure the tool anticipated comes back as an ordinary result carrying isError and your message, so the model reads it and can try something else. A protocol-level error is for the call itself being impossible: no such tool, malformed request. Using the second for the first hides the reason from the model, which is the one participant that could have recovered.
In the python SDK that distinction is a specific exception. Raising ToolError from a tool sends your text back to the model and logs the failure without a traceback, which is what the server above does when no orders match. Letting any other exception escape is treated as a crash: the model receives only the words error executing tool and the name, with your message deliberately withheld, and the server logs the traceback. That is a sensible default, because an arbitrary exception message can contain a connection string, but it means every failure you can foresee needs converting into a sentence you chose.
- No results is not an error. Return an empty list and say so in words; raising here makes the model retry the same call.
- Say what to do next. Not found for that email, ask the customer to confirm it, beats not found.
- Never put a secret, a connection string or a stack trace in the text, because everything you return becomes part of the conversation and may be logged with it.
- Rate limits and timeouts are anticipated failures. Say which one happened and whether waiting would help.
Secrets#
The server holds credentials, never the model. Read them from the environment, and never accept a key as a tool argument, because anything in an argument has passed through the model and into the conversation history.
import os
from mcp.server.mcpserver import MCPServer
server = MCPServer("search")
# Wrong: the key is now in the transcript.
@server.tool()
def search_with_key(query: str, api_key: str) -> list[dict]:
...
# Right: the server already knows who it is.
API_KEY = os.environ.get("SEARCH_API_KEY", "")
@server.tool()
def search(query: str) -> list[dict]:
...Which raises the question of whose credentials these are. A local stdio server runs as the person who started it and inherits their access, so the model can do anything that person can, and the only limit is what you gave the server. A remote HTTP server serves many people and has to know which one is asking, so it needs real authentication and per-user scoping, and the tool has to apply it rather than trusting an argument the model supplied. Deciding which of these you are building, before writing tools, saves rewriting all of them.
Ship it publicly#
This is asked as an ownership question
Reported interviews probe MCP by asking whether you have personally implemented a server, not by quizzing protocol internals. A public repository with a readable README, a real integration and a working example is the answer that lands. Having read the specification is not.
- Solve a real problem, even a small one. A wrapper around an API you actually use beats a toy.
- README with installation, configuration and one worked example.
- Handle the failures: bad credentials, rate limits, empty results.
- Run it from a client you did not write, and say so in the README.
Where people go wrong
Exposing your entire internal API surface as tools. A model with forty near-identical options picks badly, and every definition costs tokens on every call.
If this comes up in an interview
Have you built an MCP server?
Answer with the specific thing you built, what it wrapped, and one design decision you had to make, such as how you capped result size or handled auth. This is asked to find out whether you have shipped one, so a concrete link beats a description of the protocol.
What makes a good tool definition?
One clear job, a description that says when to use it rather than only what it does, bounded results, readable errors, and argument names a person would use. The description is what the model reads to choose between tools, so vague ones cause wrong selection.
How do you handle credentials in an MCP server?
The server reads them from the environment and the model never sees them. Never accept a key as a tool argument, because arguments are generated by the model and end up in conversation history.
What happens if a server exposes fifty tools?
Selection accuracy degrades, because the model is choosing between too many similar descriptions. Group related operations behind fewer tools with a mode argument, or split by domain into several servers.
Build this: Build a server wrapping an API you already use, with three well-described tools, real error handling and a README. Publish it, then use it from a client you did not write.
Watch it explained
This is one section of MCP & tool interfaces, 8 hours across 2 sections. Read it in full to get the whole topic in order.
Stop 17 of 50 on the AI Engineer route
0 of 50 topics done · 0h behind you, 603h to go
What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.