MCP server code conventions
The Python inside a stdio-transport MCP server. Split out of python-conventions on 2026-08-31,
which keeps the general design defaults — this skill owns only what is different because the code
is an MCP server, and those differences are not stylistic: two of the three are protocol
correctness.
Not about shipping. Entry points, uv tool install, claude mcp add and scope selection belong to
mcp-server-shipping; this is what the code does once it runs.
MCP-stdio logging discipline
Scope: stdio-transport MCP servers only (the *-polite-mcp family) — not applicable to a repo that
has no MCP server of its own.
- Snippet:
references/snippets/mcp-tool-boundary.py
- Default: never a bare
print() in server package code. Route all logging through the stdlib
logging module, explicitly configured to write to stderr at startup (or defer to FastMCP's own
configure_logging(), which already defaults there).
- Why: the MCP stdio spec is unambiguous — a server MUST NOT write anything to stdout that isn't
a valid MCP message; a single stray
print() or a dependency's stdout-bound log handler corrupts
the JSON-RPC stream. stderr is the sanctioned outlet for everything, logs included.
- Don't: trust that "no
print() in my code" is sufficient — a dependency that logs to stdout
on its own initiative isn't caught by explicit stderr configuration of your own logging. The
robust check is exercising the real stdio transport end-to-end, not code review alone.
- Model default: overrides — sharply, not a style nudge. This is protocol-specific knowledge a
model has no general-Python reason to know; without it, a model reaches for
print() debugging
exactly as it would in any other script, silently breaking the server.
Error handling at the MCP tool boundary
Scope: stdio-transport MCP servers only, same as above.
- Snippet:
references/snippets/mcp-tool-boundary.py
- Default: at the point an internal exception is about to cross back to the client, decide
deliberately what's safe to expose — either re-raise as
fastmcp.exceptions.ToolError with an
explicit, hand-written message, or let a plain exception raise (FastMCP's default already surfaces
it, unmasked, to the client). Never assume str(exc) of an arbitrarily caught exception is safe
by default, especially inside a broad per-item except Exception (a legitimate pattern for
isolating one bad item in a batch call — just don't let its caught exception's message reach the
client unreviewed).
- Why: FastMCP's default (
mask_error_details=False) already includes full exception detail in the
client-facing response — this isn't an opt-in risk, it's the out-of-the-box behavior. The same
shape as this skill's SecretStr caveat: a broad safety switch doesn't sanitize text you
deliberately choose to expose.
- Don't: flip
mask_error_details=True project-wide as a blanket fix — it suppresses
legitimately useful validation detail (a bad argument, a batch-size violation) that plain
raise ValueError(...) call sites rely on being visible to the calling agent. Prefer the
finer-grained per-message ToolError choice.
- Model default: overrides. A model doesn't know
ToolError exists or that FastMCP unmasks
exceptions by default unless told — without this, it either leaves exceptions to propagate as-is
(today's actual state in this family) or reaches for a blanket masking flag, both worse than the
deliberate per-boundary choice.
MCP tool docstrings
Scope: any function decorated as an MCP tool (@mcp.tool() in this family). A genuinely distinct
concern from general docstring style — no general docstring-content rule exists elsewhere in this
skill to extend.
- Default: write the docstring to Anthropic's tool-definition bar, not PEP 257's — what the tool
does and how it differs from its nearest sibling by name, when to use/not use it, where each
non-obvious parameter's value comes from, response shape/size caveats, at least 3–4 sentences,
more for a complex tool. See
references/rationale.md §11 for the concrete template and this
family's own strongest real examples.
- Why: the docstring becomes the wire-level
Tool.description an LLM client reads at inference time
to decide whether/how to call the tool — a weak one causes wrong-tool-picked, wrong-arguments, or
never-invoked failures, a correctness cost paid by every future call, not a comprehension-speed
one the way a weak human-facing docstring is.
- Escalate to:
Annotated[x, Field(description=...)] for per-parameter descriptions once free-form
prose isn't enough (so the JSON-Schema inputSchema itself carries them); annotations=
(readOnlyHint/destructiveHint/etc.) on any tool with real side effects, since clients use it
to decide when to skip or require confirmation prompts.
- Model default: overrides. A model's default docstring instinct is PEP 257's — a concise
one-line summary — which is actively worse here; the entire point of this section is that the bar
for an LLM-facing description is different from, not a stricter version of, ordinary docstring
style.
Full rationale
See references/rationale.md — the sources for the stdout constraint,
the error-taxonomy reasoning, and the docstring-as-contract argument.
Starter snippet
references/snippets/mcp-tool-boundary.py is a runnable
sketch of the tool-boundary error handling described here.
Editing this skill
This file is copied into ~/.agents/skills/mcp-python-conventions at install time, never
symlinked, so editing the deployed copy is local drift — the exact thing this skill exists to
prevent, and it reaches no other machine. Edit the source in the repo this was installed from, push,
and re-run skills add <that source> --global --skill mcp-python-conventions to refresh every
project's copy. If you installed it from someone else's repo rather than your own fork, the source
is theirs: open an issue or a pull request there instead.
1---2name: mcp-python-conventions3description: Use when writing the Python inside an MCP server and deciding how it should behave — where log output may go when stdout carries JSON-RPC framing and why a stray print breaks the protocol, what an exception at a tool boundary should turn into for the calling model, how much internal detail an error may safely expose, and how to write a tool docstring that the model reads as its instructions rather than as documentation for a human. Scoped to stdio-transport servers. For a server's packaging, installation and registration rather than its code, see the MCP server shipping skill; for general Python design questions, see the Python conventions skill.4---56# MCP server code conventions78The Python inside a stdio-transport MCP server. Split out of `python-conventions` on 2026-08-31,9which keeps the general design defaults — this skill owns only what is different _because_ the code10is an MCP server, and those differences are not stylistic: two of the three are protocol11correctness.1213Not about shipping. Entry points, `uv tool install`, `claude mcp add` and scope selection belong to14`mcp-server-shipping`; this is what the code does once it runs.1516## MCP-stdio logging discipline1718_Scope: stdio-transport MCP servers only (the `*-polite-mcp` family) — not applicable to a repo that19has no MCP server of its own._2021- Snippet: [`references/snippets/mcp-tool-boundary.py`](references/snippets/mcp-tool-boundary.py)22- Default: never a bare `print()` in server package code. Route all logging through the stdlib23 `logging` module, explicitly configured to write to stderr at startup (or defer to FastMCP's own24 `configure_logging()`, which already defaults there).25- Why: the MCP stdio spec is unambiguous — a server **MUST NOT** write anything to stdout that isn't26 a valid MCP message; a single stray `print()` or a dependency's stdout-bound log handler corrupts27 the JSON-RPC stream. stderr is the sanctioned outlet for everything, logs included.28- **Don't**: trust that "no `print()` in my code" is sufficient — a dependency that logs to stdout29 on its own initiative isn't caught by explicit stderr configuration of your own logging. The30 robust check is exercising the real stdio transport end-to-end, not code review alone.31- Model default: **overrides — sharply, not a style nudge.** This is protocol-specific knowledge a32 model has no general-Python reason to know; without it, a model reaches for `print()` debugging33 exactly as it would in any other script, silently breaking the server.3435## Error handling at the MCP tool boundary3637_Scope: stdio-transport MCP servers only, same as above._3839- Snippet: [`references/snippets/mcp-tool-boundary.py`](references/snippets/mcp-tool-boundary.py)40- Default: at the point an internal exception is about to cross back to the client, decide41 deliberately what's safe to expose — either re-raise as `fastmcp.exceptions.ToolError` with an42 explicit, hand-written message, or let a plain exception raise (FastMCP's default already surfaces43 it, unmasked, to the client). Never assume `str(exc)` of an arbitrarily caught exception is safe44 by default, especially inside a broad per-item `except Exception` (a legitimate pattern for45 isolating one bad item in a batch call — just don't let its caught exception's message reach the46 client unreviewed).47- Why: FastMCP's default (`mask_error_details=False`) already includes full exception detail in the48 client-facing response — this isn't an opt-in risk, it's the out-of-the-box behavior. The same49 shape as this skill's `SecretStr` caveat: a broad safety switch doesn't sanitize text you50 deliberately choose to expose.51- **Don't**: flip `mask_error_details=True` project-wide as a blanket fix — it suppresses52 legitimately useful validation detail (a bad argument, a batch-size violation) that plain53 `raise ValueError(...)` call sites rely on being visible to the calling agent. Prefer the54 finer-grained per-message `ToolError` choice.55- Model default: **overrides.** A model doesn't know `ToolError` exists or that FastMCP unmasks56 exceptions by default unless told — without this, it either leaves exceptions to propagate as-is57 (today's actual state in this family) or reaches for a blanket masking flag, both worse than the58 deliberate per-boundary choice.5960## MCP tool docstrings6162_Scope: any function decorated as an MCP tool (`@mcp.tool()` in this family). A genuinely distinct63concern from general docstring style — no general docstring-content rule exists elsewhere in this64skill to extend._6566- Default: write the docstring to Anthropic's tool-definition bar, not PEP 257's — what the tool67 does and how it differs from its nearest sibling by name, when to use/not use it, where each68 non-obvious parameter's value comes from, response shape/size caveats, **at least 3–4 sentences,69 more for a complex tool**. See `references/rationale.md` §11 for the concrete template and this70 family's own strongest real examples.71- Why: the docstring becomes the wire-level `Tool.description` an LLM client reads at inference time72 to decide whether/how to call the tool — a weak one causes wrong-tool-picked, wrong-arguments, or73 never-invoked failures, a correctness cost paid by every future call, not a comprehension-speed74 one the way a weak human-facing docstring is.75- Escalate to: `Annotated[x, Field(description=...)]` for per-parameter descriptions once free-form76 prose isn't enough (so the JSON-Schema `inputSchema` itself carries them); `annotations=`77 (`readOnlyHint`/`destructiveHint`/etc.) on any tool with real side effects, since clients use it78 to decide when to skip or require confirmation prompts.79- Model default: **overrides.** A model's default docstring instinct is PEP 257's — a concise80 one-line summary — which is actively worse here; the entire point of this section is that the bar81 for an LLM-facing description is different from, not a stricter version of, ordinary docstring82 style.8384## Full rationale8586See [`references/rationale.md`](references/rationale.md) — the sources for the stdout constraint,87the error-taxonomy reasoning, and the docstring-as-contract argument.8889## Starter snippet9091[`references/snippets/mcp-tool-boundary.py`](references/snippets/mcp-tool-boundary.py) is a runnable92sketch of the tool-boundary error handling described here.9394## Editing this skill9596This file is _copied_ into `~/.agents/skills/mcp-python-conventions` at install time, never97symlinked, so **editing the deployed copy is local drift** — the exact thing this skill exists to98prevent, and it reaches no other machine. Edit the source in the repo this was installed from, push,99and re-run `skills add <that source> --global --skill mcp-python-conventions` to refresh every100project's copy. If you installed it from someone else's repo rather than your own fork, the source101is theirs: open an issue or a pull request there instead.