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
安装命令
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
发现
标签

此技能尚未发布标签。

版本
最新版本元数据

版本

v2026.09.24

发布时间

2026年9月24日

分类

未分类

许可证

MIT

源路径

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

默认分支

main

最新提交

1668324

Tree SHA

b2d4cc3