mcp-server-authoring

v2026.09.24

FastMCP authoring for Python MCP servers — tools, resources, prompts, TDD, release-please. Use when building or extending an MCP server, adding a tool/resource, or scaffolding a new server.

GitHub
Install command
npx skhub add laurigates/mcp-server-authoring
Markdown
SKILL.md

MCP Server Authoring

Producer-side patterns for building a Python Model Context Protocol server with the official SDK's FastMCP (or the standalone fastmcp package). Applies to any Python MCP server you own — the guidance is portfolio-independent.

For consuming / installing servers into a project, this is the wrong skill — see the table below.

When to Use This Skill

Use this skill when...Use a different skill when...
Building or scaffolding a new MCP serverInstalling an existing server into .mcp.json → agent-patterns-plugin:mcp-management
Adding a tool / resource / prompt to a serverRunning MCP compliance checks on a project → configure-plugin:configure-mcp
Wiring a server's tests, lint, release-pleaseAn agent calls 50+ MCP tools and needs the code-execution pattern → agent-patterns-plugin:mcp-code-execution
Choosing transport (stdio vs HTTP) for a server you ownManaging OAuth for a remote server you consume → mcp-management

The Build Path

Build a server in this order. Each step's why is the load-bearing part — it is what lets you generalize past the example.

Step 1 — Scaffold the project

uv init --package my-server && cd my-server
uv add "mcp[cli]"          # bundled SDK; or `uv add fastmcp` for the standalone package
uv add --group dev pytest ruff ty

Why uv init --package: it lays down a src/ package + pyproject.toml with a console-script entrypoint, so the server installs as a real command (uvx my-server) rather than a loose script. That is what a consumer's .mcp.json will invoke.

Why one dependency choice up front: FastMCP ships two ways — bundled in the official mcp SDK (from mcp.server.fastmcp import FastMCP) and as the standalone fastmcp package (from fastmcp import FastMCP). They expose the same high-level decorator API; pick one and pin it in pyproject.toml so the import path is stable. Prefer the bundled SDK unless you need a standalone-only feature.

Step 2 — Write the entrypoint as a thin shim

# src/my_server/__init__.py
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("my-server")  # the name surfaced to the client


def main() -> None:
    mcp.run()  # defaults to stdio transport

Why a thin shim: the FastMCP instance + mcp.run() is the MCP boundary, not where logic lives. Keep business logic in plain, separately-tested modules the tools call. Decorators that accrete logic become untestable — you end up needing a live MCP client to exercise a pure computation.

Step 3 — Add a tool

A tool is an action or computation the model invokes. Write it as a typed, docstringed plain function, then decorate it:

from my_server.catalog import list_items  # plain, tested module

@mcp.tool()
def list_catalog(active_only: bool = True) -> list[dict]:
    """List catalog items. The first docstring line becomes the tool description."""
    return list_items(active_only=active_only)

Why type hints are the contract: the SDK derives the tool's JSON input schema from the annotations. Precise types (Literal, enums, pydantic models) give the model a schema it can fill correctly; a bare dict/Any gives it nothing to validate against.

Why the docstring is user-facing: the first line becomes the description the model reads when selecting the tool. Write it as intent ("List catalog items"), not implementation.

Why you fail loudly: raise on bad input and let the SDK surface the error. Returning a sentinel (None, {"error": ...}) forces the model to interpret an ad-hoc convention instead of seeing a clean error.

Step 4 — Add resources and prompts (when they fit)

PrimitiveDecoratorUse for
Resource@mcp.resource("scheme://{id}")Read-only data the client fetches by URI; path params map to function args
Prompt@mcp.prompt()Reusable prompt templates the client surfaces; returns a string or message list
@mcp.resource("item://{item_id}")
def get_item(item_id: str) -> dict:
    """Fetch one catalog item by id."""
    return load_item(item_id)

Why the split matters: a tool is a verb the model calls to act; a resource is a noun the client reads by URI without model action. Modeling read-only data as a resource keeps it out of the tool-selection surface, so the model isn't offered a "tool" whose only job is to fetch. See REFERENCE.md for the full primitive table.

Step 5 — Test the function, not the decorator (TDD)

RED → GREEN → REFACTOR against the underlying function:

# tests/test_catalog.py
def test_list_catalog_filters_inactive(fake_store):
    fake_store.seed([{"id": 1, "active": True}, {"id": 2, "active": False}])
    assert [i["id"] for i in list_items(active_only=True)] == [1]
  1. Write the failing test against the plain function.
  2. Implement the minimum logic; decorate it with @mcp.tool().
  3. Refactor while green.

Why test the plain function: the decorator is a thin registration wrapper — exercising it needs a live MCP session and tests the SDK, not your logic. Testing the underlying function is fast and deterministic. Reserve a single integration-marked end-to-end test for the live-backend path (a real API, Ollama, a database), and exclude it from the fast quality gate.

Step 6 — Wire lint, types, and release

ConcernToolCommand
Deps / venvuv + pyproject.toml + uv.lockuv sync --group dev
Lint + formatruffuv run ruff check . && uv run ruff format .
Type checkty (or mypy)uv run ty check .
Testspytestuv run pytest -m "not integration"
Releaserelease-please (conventional commits)automated on push to main

Why conventional commits: they drive release-please's version bumps — feat: minor, fix: patch, feat!: major. Hand-editing CHANGELOG.md or the version field fights the automation; let the commit history be the source of truth.

Step 7 — Report progress from any tool that can run long

A tool that takes more than a few seconds — a model call, a subprocess, a crawl — is a black box to the client. Take a Context and report:

from mcp.server.fastmcp import Context

@mcp.tool()
async def analyze(path: str, ctx: Context) -> str:
    """Analyze a repository."""
    files = discover(path)
    for i, f in enumerate(files, 1):
        await ctx.report_progress(i, len(files), f"analyzing {f.name}")   # message shows in the client
        await inspect(f)
    return summarize(files)

Why it is not cosmetic: progress notifications reset the client's idle timeout. A tool that emits nothing can be aborted for idleness while it is still working (Claude Code: 30 min stdio, 5 min HTTP/SSE). For a single long await with no natural increments, spawn a task that re-reports elapsed time on an interval — a heartbeat is both a status line and a keepalive.

What the user actually sees (verified against Claude Code 2.1.207 — behavior, not a documented contract): the message is rendered on the in-flight tool row, whitespace-collapsed and truncated at 200 chars.

You sendClient shows
message + progress/totalanalyzing auth.py (42%)
message onlyanalyzing auth.py
progress onlyProcessing… 7
nothingCalling <server>… ← the black box

Report cost back in the result, not the progress line. Progress reaches only the user's terminal; nothing can reach the calling model mid-call. Token counts, elapsed time, and anything the agent should reason about must ride back in the tool result (or its _meta), or the agent stays blind to what its own delegation cost.

Don't reach for the other channels: notifications/message (the logging capability) is silently dropped by Claude Code — no handler is registered. For stdio servers, plain stderr is the debug path (claude --debug mcp).

ctx.report_progress no-ops when the client sends no progressToken, so it is always safe to call. Keep it best-effort: a failed notification must never fail the tool call it describes.

Step 8 — Choose transport before you ship

TransportWhenRun
stdioLocal servers the client launches as a subprocessmcp.run() or mcp.run(transport="stdio")
HTTPRemote / shared servers reachable over the networkmcp.run(transport="streamable-http")

Why stdio is the default: a locally-launched server needs no network, no auth, and no port — the client owns its lifecycle over stdin/stdout. Reach for HTTP only when the server must be shared (multiple clients, a remote host), which then pulls in auth and deployment concerns stdio avoids. Consumers register a stdio server in .mcp.json with command + args and ${VAR} env references (never hardcoded secrets) — see mcp-management for the consumer side.

Shipping Checklist

Before a server is done, confirm each — the failure mode after each is what it guards against:

  • Entrypoint is a console script (uvx <server> runs it) — else a consumer's .mcp.json can't launch it.
  • Every tool parameter and return is type-annotated — else the model gets an empty input schema.
  • Every tool's first docstring line reads as intent — else tool selection degrades.
  • Tools raise on bad input (no sentinel returns) — else errors reach the model as ambiguous data.
  • Unit tests cover the plain functions; the live-backend path is a single integration-marked test — else the fast gate is slow and flaky.
  • uv run ruff check . && uv run pytest -m "not integration" is green — the fast quality gate.
  • Any tool that can run long calls ctx.report_progress — else the client shows a silent spinner and may abort the call on its idle timeout.
  • Cost/telemetry the agent should see (tokens, duration) is in the tool result, not only the progress message — progress never reaches the model.
  • Transport chosen deliberately (stdio unless the server must be shared).

For the full primitive/transport reference, packaging, and the SDK inspector, see REFERENCE.md.

Agentic Optimizations

ContextCommand
Fast quality gateuv run ruff check . && uv run pytest -m "not integration" -q
Single tool's testsuv run pytest tests/test_<tool>.py -q
Fail fastuv run pytest -x -q -m "not integration"
Inspect a server's toolsuv run mcp dev server.py (SDK inspector)

Quick Reference

NeedPattern
New action tool@mcp.tool() on a typed, docstringed function
Read-only data by URI@mcp.resource("scheme://{id}")
Reusable prompt@mcp.prompt() returning a string/message list
Local launchmcp.run() (stdio default)
Remote launchmcp.run(transport="streamable-http")
Input schemaType hints on every parameter
Tool descriptionFirst line of the docstring

Related

  • agent-patterns-plugin:mcp-management — installing/configuring servers you consume (the inverse of this skill)
  • agent-patterns-plugin:mcp-code-execution — the code-execution pattern when an agent drives many MCP tools
  • configure-plugin:configure-mcp — project-level MCP compliance and .mcp.json setup
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

MIT

Source path

agent-patterns-plugin/skills/mcp-server-authoring

Default branch

main

Latest commit

1668324

Tree SHA

b2d4cc3