An MCP server, end to end
Write the list-tools and call-tool handlers, a resource and a transport for a small server, size what its client sees, and run it with least privilege.
On this page
A useful MCP server is small: a handler that lists tools, a handler that runs them, a resource or two, and a transport. The protocol is the easy part, and an SDK speaks it for you. The work is three decisions inside it: which tools exist and what their schemas allow, what each result costs the client's context, and what the server's process can touch when something goes wrong. The worked server here reads deploys, the system the agent in the loop that ran for 400 turns polled.
Tools from tasks, not from endpoints
The deploy API behind it has 40 endpoints: trigger, rollback, status, logs, config, secrets and the rest. The quick path is to generate one tool per endpoint from the API's OpenAPI document. That is 40 definitions at about 150 tokens, 6,000 tokens on every call of every client that connects, and the model can now call rollback and update_config because a generator found them. Neither the tokens nor the permissions came from a decision.
Start instead from what the agent is asked: "is my deploy done, and if it failed, why?" and "what shipped to checkout this week?". Two tools and one resource answer both:
| Primitive | Name | Returns | Bound |
|---|---|---|---|
| tool | get_deploy(deploy_id) | state, times, failing step, last 20 log lines | read-only; waits up to 30 s for a running deploy |
| tool | list_deploys(service, since) | up to 20 deploys, newest first | read-only; service is one of the team's 12 |
| resource | deploys://policy | freeze windows, who approves a rollback | read by the host, never by a tool |
About 300 tokens of definitions instead of 6,000. The 30-second wait inside get_deploy matters as much as the count: the server blocks until the deploy reaches a terminal state or 30 seconds pass, so the model asks at most twice a minute instead of on every turn, and most of the turns that polling burned never happen.
The list-tools handler: the schema is the contract
TOOLS = [
{
"name": "get_deploy",
"description": (
"Status of one deploy: state, start and end times, and for a failed "
"deploy the failing step with its last 20 log lines. Use when the user "
"gives a deploy id or asks whether a deploy finished. Waits up to 30 s "
"for a running deploy. To find an id, use list_deploys."
),
"inputSchema": {
"type": "object",
"properties": {"deploy_id": {"type": "string", "pattern": "^dep_[a-z0-9]{8}$"}},
"required": ["deploy_id"],
"additionalProperties": False,
},
},
# list_deploys: service is an enum of 12 names, since is an ISO 8601 date.
]
def list_tools(params):
return {"resultType": "complete", "tools": TOOLS, # same order on every call
"ttlMs": 300_000, "cacheScope": "public"} # how long a client may reuse it
The schema is the contract in both directions. The pattern on deploy_id means a string like ../admin never reaches the URL the server builds. The enum on service means the model cannot ask about a service the team does not own. additionalProperties: false means an invented force: true fails validation instead of being silently ignored. The description follows writing tool descriptions the model can use: when to use it, what comes back, and the neighbour for the case it does not cover.
The Model Context Protocol "Specification" (opens in a new tab) requires a server to validate every tool input, so the handler checks the schema again even when the client already did; the client is not the trust boundary. And the list comes back in the same order every time, which is what keeps the client's tool block byte-identical and its cache hitting. The ttlMs and cacheScope beside it are required by the current revision on every list and read result: how long the client may reuse the answer, and whether a shared cache may hold it.
The call-tool handler: errors are results
def call_tool(params):
name, args = params["name"], params.get("arguments", {})
if name not in HANDLERS:
raise ProtocolError(-32602, f"Unknown tool: {name}") # the client handles it
problem = check_schema(args, SCHEMAS[name])
if problem:
return result(f"invalid arguments: {problem}", is_error=True)
try:
record = HANDLERS[name](**args)
except DeployApiTimeout:
return result("deploy API timed out after 10 s; retry once", is_error=True)
return result(render(record)) # about 400 tokens
An unknown tool is a protocol error: the request itself is wrong, and the client deals with it. A bad argument or a timeout is a tool execution error, a normal result flagged isError with a sentence the model can act on. "deploy ids look like dep_ and 8 characters" gets a corrected call; "error" gets the same call again until a stop condition fires.
render is where the server earns its keep. The API's record for one deploy is about 6 KB of JSON, roughly 1,500 tokens at four characters a token. The rendered result keeps the state, the times, the failing step and the last 20 log lines: about 400 tokens. Every result is resent on every later turn, as memory and context management showed, so with 15 turns left in a task the trim saves 1,100 × 15 = 16,500 input tokens per call. A server that passes the raw record through is pushing that cost onto every client.
One resource, read by the host
def list_resources(params):
return {"resultType": "complete",
"resources": [{"uri": "deploys://policy", "name": "Deploy policy",
"mimeType": "text/markdown"}],
"ttlMs": 300_000, "cacheScope": "public"}
def read_resource(params):
if params["uri"] != "deploys://policy":
raise ProtocolError(-32602, "Resource not found")
return {"resultType": "complete",
"contents": [{"uri": "deploys://policy", "mimeType": "text/markdown",
"text": POLICY_TEXT}], # about 1,200 tokens
"ttlMs": 300_000, "cacheScope": "public"}
The policy is 1,200 tokens: the freeze windows and who may approve a rollback. As a resource, the host attaches it when the user asks about a rollback, and no other call pays for it. Folded into get_deploy's description "so the model knows the rules", it would cost 1,200 tokens on every call of every task, which is the choice between primitives from the protocol lesson made concrete.
The transport, and what the client sees
Over stdio the transport is a loop over standard input:
for line in sys.stdin: # one JSON-RPC message per line
msg = json.loads(line)
if "id" not in msg:
continue # a notification: no reply
try:
handler = METHODS[msg["method"]] # tools/list, tools/call, resources/...
reply = {"result": handler(msg.get("params", {}))}
except KeyError:
reply = {"error": {"code": -32601, "message": "Method not found"}}
except ProtocolError as e:
reply = {"error": {"code": e.code, "message": str(e)}}
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], **reply}), flush=True)
An SDK adds version handling, discovery and paging; nothing above changes. One rule bites everyone once: standard output carries protocol messages and nothing else, and the specification says so in those terms. A debugging print in a handler puts a line of prose in the stream, and the client reports invalid JSON on a call that had nothing wrong with it. Logs go to standard error.
Least privilege for the server's process
The tool surface bounds what the model can ask for. The process's credentials bound what a bug can do. A stdio server runs as whoever launched it and, unless told otherwise, inherits their environment: on a developer's laptop that is the admin token in the shell profile, the home directory and the whole network. Any path in the server's own code that a crafted argument reaches — a bug in the URL builder, an HTTP library that follows a redirect — runs with all of it, and no schema protects against bugs behind the schema.
Four controls, in the order they are usually added:
- A scoped credential, passed explicitly. A token that can read deploy status and logs and nothing else, handed to the subprocess in an environment that holds only what the server needs, not the inherited shell.
- A sandbox. A read-only filesystem and network egress to the deploy API host only. A server that can reach nothing else cannot be used to reach anything else.
- Writes in their own place. If
rollbackis ever added, it gets its own server or its own token, a pre-tool hook in the host that asks a person, and an idempotency key per call, so a retried rollback does not roll back twice. - Per-caller checks over HTTP. When the server becomes a shared service, every request carries the caller's credentials and the server checks their scopes on each call, as in authentication and authorisation; the specification lets a server list only the tools those scopes permit.
In an interview
The interviewer is testing whether you design the server from the tasks rather than the API, and whether you know where its limits come from. Say: "two tools and a resource from the questions the agent gets, about 300 tokens of definitions; schemas with patterns and enums as the contract; results trimmed from 1,500 tokens to 400 because they are resent every turn; execution errors as results the model can fix; and the process run with a read-only token in a sandbox."
The mistake that loses points is generating tools from the API's spec and running the server with the developer's own credentials. It pays 5,700 tokens a call for 38 tools nobody asked for, exposes rollback to the model, and gives every bug the developer's reach.
Check yourself
1. Product asks for rollback(deploy_id) in the same server. What changes?
The surface, the process and the host. Rollback is a write and irreversible in effect, so it goes in a second server with its own token that can roll back and nothing else, or at least a second token loaded only for that tool. The host gets a pre-tool hook that sends every rollback to a person, and the tool takes an idempotency key derived from the call id. The read server stays read-only.
2. The deploys server answers 300 get_deploy calls a day, with 15 turns left on average after each. What does trimming the 6 KB record to 400 tokens save?
The raw record is about 1,500 tokens, so the trim removes 1,100 tokens from every later send: 1,100 × 15 = 16,500 input tokens per call, about 5M a day at
P_in, or atC_r × P_inwhere the history is cached. It also keeps 1,100 tokens of log noise out of a window the model reasons over.
3. After a teammate adds a log line to call_tool, the client reports "invalid JSON" on every second call. What happened?
The log line went to standard output, which carries only protocol messages, so the client read a line of prose where it expected JSON-RPC. Send logs to standard error. The intermittency is the clue: only the calls that reach the new line fail.