Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Capstone: design, build, and deploy your own MCP server

Everything up to here has been mine.

My fixture corpus, my rubric, my domain. You typed it, ran it, and watched it break in the places I chose. This chapter is yours: you design and ship an MCP server that does something real, for a system you actually use.

That is a better capstone than another agent for three reasons. An MCP server is small enough to finish and open-ended enough to be genuinely yours. It is the artifact with the most leverage — write it once and every MCP client, now and in five years, can use it. And it forces the skills that separate people who have played with agents from people who can ship them: tool design, error contracts, authorization, idempotency, testing, deployment.

The chapter gives you a way to choose a scope, a design template to fill in, a complete worked reference to compare against, and a rubric for deciding whether what you built is portfolio-ready.


Choosing a scope

Most capstone projects fail at this step, before any code is written.

A good MCP server wraps a system you already understand, exposes a small number of operations at the granularity a caller thinks in, and has at least one operation that changes something.

Unpack that.

A system you already understand means you know its failure modes. You know what a “stale version” error means in your deploy tool and what a caller should do about it, and you can write a genuinely useful error message. A server over an API you read the docs for yesterday will have plausible-looking tools and useless errors, and errors are most of the value.

Operations at the granularity a caller thinks in is the tool-design rule from Part 2, Chapter 1, and it is the most common thing to get wrong. Do not expose your REST endpoints one-to-one. GET /changes?package=x&released=false is a database query; list_changes(package) returning “what is unreleased” is a thought a person has. Aim for the operation that would be one line in a colleague’s request, not one row in your API reference.

At least one operation that changes something is what makes the project non-trivial. Read-only servers are wrappers. The moment a tool mutates state you are forced to deal with authorization, idempotency, confirmation, and audit — which is where the engineering lives.

Ideas that work

  • Release notes and versioning for your own repositories — the reference example below.
  • Incident timeline builder: read your alerting and chat history, produce a timeline, let a human add annotations that persist.
  • Feature flag manager: read flag state, propose a change, apply it with a scope check and an audit record.
  • Query catalogue over your analytics warehouse: named, parameterized queries with typed results and a row cap, rather than a run_sql tool.
  • Local development environment: run the test suite for a package, summarize failures, and open a scratch branch.

Ideas that do not

  • “Wrap the whole GitHub API.” Not a scope, a career. Also already done, well.
  • A single run_shell(command) tool. This is not a design, it is a shell with extra steps, and it hands your machine to whatever prompt injection the model reads.
  • Anything read-only over public data with a good SDK. Correct, boring, teaches nothing you did not know.
  • A tool per endpoint of a large API. Forty tools, none of which map to an intention, is measurably worse than five that do — models degrade as tool count grows, and you will have proven it.

A design template

Fill this in before writing code. It takes twenty minutes and it saves a rewrite.

## <server name>

**One sentence:** what a caller can do with this that they could not do before.
**Who calls it:** which client, on whose behalf, from where.
**System of record:** what actually owns the data. What happens if two callers race.

### Tools
| name | intention it serves | read/write | error cases | idempotent? |
|------|--------------------|------------|-------------|-------------|

### Error contract
Every tool failure returns: CODE, human message, fix hint. List the codes.

### Auth
Which scopes exist. Which tools require which. What an unauthenticated caller may do.

### State and safety
What is mutable. How a retry is made safe. What needs a human.

### Non-goals
Three things this deliberately does not do.

The non-goals section is not filler. Writing “this does not create packages, only releases them” is what stops the project from growing a fourth tool every weekend until it is unfinishable.


The reference example

relnotes: a server that plans and publishes release notes for a set of packages.

Filled-in template, short form:

  • One sentence. An agent can ask what has been merged but not released, get the correct next semantic version, and publish the release — without ever inventing a version number.
  • System of record. A JSON file here; a database in real life. Two callers publishing at once is prevented by the version check plus the idempotency key.
  • Tools. list_packages (read), list_changes (read, paginated), plan_release (read, computes), publish_release (write, scoped, idempotent).
  • Error contract. UNKNOWN_PACKAGE, BAD_KIND, BAD_VERSION, NOTHING_TO_RELEASE, VERSION_MISMATCH, FORBIDDEN — each with a message and a fix hint.
  • Auth. release:read and release:write. Unauthenticated callers get nothing. Callers over stdio are local-dev and read-only.
  • Non-goals. Does not create packages. Does not edit changes. Does not talk to a package registry.

Setup:

mkdir -p relnotes && cd relnotes
python3 -m venv .venv && source .venv/bin/activate
pip install "mcp==1.27.0" uvicorn pytest pytest-asyncio

Step 1: the domain, with no MCP in it

"""Domain logic. No MCP anywhere in this file — that is the point.

The server is a thin protocol adapter over this module. Everything here is a plain
function you can unit test, reuse from a CLI, or call from a web handler.
"""

This is the single most important structural decision in the project, and it is the one most capstone servers get wrong by putting business logic inside @mcp.tool functions.

Keep the protocol at the edge. Then your tests are fast, your logic is reusable, and the day MCP’s next revision changes something you rewrite one thin file.

The interesting parts:

class DomainError(Exception):
    """An expected, actionable failure. Carries a code and a fix hint."""

    def __init__(self, code: str, message: str, hint: str = "") -> None:
        super().__init__(message)
        self.code = code
        self.hint = hint

    def as_text(self) -> str:
        return f"{self.code}: {self}" + (f" | fix: {self.hint}" if self.hint else "")


def level_for(changes: list[Change]) -> str:
    kinds = {c.kind for c in changes}
    if "breaking" in kinds:
        return "major"
    if "feat" in kinds:
        return "minor"
    return "patch"

DomainError versus every other exception is the whole error contract. A DomainError is something the caller can act on: wrong package, wrong version, nothing to release. Anything else — a KeyError, a disk failure — is a bug in the server, and those must not be dressed up as friendly tool errors, because a model that receives “fix hint: try again” for a genuine crash will loop.

And the mutating operation, which carries all the safety machinery:

    def publish(self, package: str, version: str, *, actor: str,
                idempotency_key: str) -> Release:
        for r in self.releases:
            if r.idempotency_key == idempotency_key:
                return r                      # replay: same key, same answer, no new work
        expected, _level, pending = self.plan(package)
        if version != expected:
            raise DomainError(
                "VERSION_MISMATCH",
                f"{version} is not the next version for {package}; expected {expected}",
                "call plan_release and publish the version it returns")
        release = Release(...)
        self.changes = [...]                  # stamp each change with its release
        self.packages[package] = version
        self.releases.append(release)
        self.save()
        return release

Three defences, and each one exists because of a specific way agents fail.

The idempotency key. Models retry. Networks retry. A tool call that times out after succeeding will be re-issued, and without a key you publish twice. With one, the second call returns the first call’s answer and does no work. This is the same pattern payment APIs use, for the same reason (https://stripe.com/docs/api/idempotent_requests).

The version check. The caller must pass the exact version plan_release returned. A model that guesses “3.0.0” because it looks right gets an error naming the correct value. This converts a whole class of hallucination into a caught, recoverable mistake.

The actor. Every release records who published it. An action with no attributable actor is an incident waiting to be un-investigable.

Step 2: the protocol adapter

def build_server(store: Store, *, auth: AuthSettings | None = None,
                 verifier: TokenVerifier | None = None) -> FastMCP:
    mcp = FastMCP(
        "relnotes",
        instructions=(
            "Plan and publish release notes. Read the changes, call plan_release to get "
            "the next version, then publish_release with that exact version and a fresh "
            "idempotency key. Never invent a version number."
        ),
        stateless_http=True,
        token_verifier=verifier,
        auth=auth,
    )

    def _fail(exc: DomainError):
        """One error contract for the whole server: code, message, fix hint."""
        raise ValueError(exc.as_text()) from exc

The instructions string is server-level guidance the client can surface to the model, and it is where the workflow between your tools belongs. Individual tool descriptions say what one tool does; instructions say what order to use them in. “Never invent a version number” belongs here, not repeated in three docstrings.

stateless_http=True is not optional in 2026. The 2026-07-28 revision made requests self-contained — no initialize handshake, no session id, continuity passed explicitly — so a stateless server runs behind an ordinary load balancer with no shared session store (https://modelcontextprotocol.io/specification/2026-07-28). Part 2, Chapter 4’s instruction was to design stateless from the first line, and this is what that looks like: a store passed in, no per-connection memory anywhere.

A read tool, with pagination:

    @mcp.tool(title="List unreleased changes",
              annotations=ToolAnnotations(readOnlyHint=True, idempotentHint=True))
    def list_changes(
        package: Annotated[str, Field(description="Package name from list_packages.")],
        kind: Annotated[str | None, Field(description=f"Filter by kind: {', '.join(KINDS)}.")] = None,
        limit: Annotated[int, Field(ge=1, le=50, description="Page size.")] = 20,
        cursor: Annotated[str | None, Field(description="next_cursor from a previous page.")] = None,
    ) -> ChangePage:
        """Merged changes that are not in a release yet, newest PR last.

        Paginated: when `next_cursor` is not null there are more changes, and you
        should call again with that value rather than raising `limit`.
        """
        try:
            rows = store.unreleased(package, kind)
        except DomainError as exc:
            _fail(exc)
        start = int(cursor) if cursor else 0
        page = rows[start:start + limit]
        nxt = str(start + limit) if start + limit < len(rows) else None
        return ChangePage(package=package, changes=[_to_info(c) for c in page],
                          total=len(rows), next_cursor=nxt)

Four things to copy into your own server.

Every parameter has a description, and the descriptions point at other tools: “Package name from list_packages.” That is how a model learns your call order without being told.

ge=1, le=50 on limit puts the bound in the schema, so an out-of-range value is rejected by validation before your code runs.

The docstring says what to do when there is more data. Without that sentence, models raise limit to 50 and truncate. With it, they page.

The return type is a pydantic model, so the tool gets an outputSchema and results arrive as structuredContent. As Chapter 1’s autopsy showed, a bare dict annotation silently produces neither.

The mutating tool:

    @mcp.tool(
        title="Publish a release",
        annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False,
                                    idempotentHint=True, openWorldHint=False),
    )
    def publish_release(
        package: Annotated[str, Field(description="Package name from list_packages.")],
        version: Annotated[str, Field(description="Must equal plan_release().next_version.")],
        idempotency_key: Annotated[str, Field(
            min_length=8,
            description="Unique per intended publish. Replaying the same key returns the "
                        "original result instead of publishing twice.")],
    ) -> PublishResult:
        """Publish the pending changes as a release. Requires the release:write scope.

        This mutates state: it stamps the changes as released and advances the
        package version. It is safe to retry with the same idempotency_key.
        """
        try:
            who = _require("release:write")
            before = len(store.releases)
            release = store.publish(package, version, actor=who,
                                    idempotency_key=idempotency_key)
        except DomainError as exc:
            _fail(exc)
        return PublishResult(..., replayed=len(store.releases) == before)

ToolAnnotations are hints a client may use to decide whether to prompt the user (https://modelcontextprotocol.io/specification/2026-07-28/server/tools). They are hints, not enforcement — the enforcement is _require("release:write"), in code, before anything happens. Setting readOnlyHint=True on a tool that writes is not a mistake a client can protect you from.

replayed in the result is a small kindness: the caller can tell “I published” from “someone already published this and you got their answer.”

Step 3: authorization

class StaticTokenVerifier(TokenVerifier):
    """Dev-grade verifier: a map of token -> principal and scopes.

    Swap for a JWT or introspection verifier in production; the tools below do not
    change, because they only ever ask for the scopes on the current token.
    """

    async def verify_token(self, token: str) -> AccessToken | None:
        entry = self._tokens.get(token)
        if entry is None:
            return None
        client_id, scopes = entry
        return AccessToken(token=token, client_id=client_id, scopes=scopes)


def _principal(default: str = "local-dev") -> tuple[str, set[str]]:
    """Who is calling and what may they do.

    Over stdio there is no token: the caller is whoever launched the process, so
    the server grants the local principal read scope only. Anything mutating must
    come over an authenticated transport.
    """
    token = get_access_token()
    if token is None:
        return default, {"release:read"}
    return token.client_id, set(token.scopes)


def _require(scope: str) -> str:
    who, scopes = _principal()
    if scope not in scopes:
        raise DomainError("FORBIDDEN", f"{who} lacks the {scope!r} scope",
                          "ask an administrator for a token with that scope")
    return who

The transport question — “what does authorization mean over stdio?” — is one every MCP server author hits and most answer by ignoring. Over stdio there is no token because there is no request: the caller is whoever started the process. The honest answer is the one above: the local principal gets read scope and nothing else, so a developer running the server from a terminal can explore it and cannot accidentally publish.

AccessToken and TokenVerifier are the SDK’s shapes, and get_access_token() reads the authenticated principal out of the request context. Replacing the static map with JWT validation or an introspection call changes one class and nothing else, because no tool ever touches the token.


Running it

In-process, for tests and exploration

['list_packages', 'list_changes', 'plan_release', 'publish_release']
{"result": [{"name": "orbital-agent", "current_version": "2.4.1", "unreleased_changes": 4}, {"name": "stepbudget", "current_version": "0.3.0", "unreleased_changes": 1}]}
{
 "package": "orbital-agent",
 "current_version": "2.4.1",
 "next_version": "3.0.0",
 "level": "major",
 "reason": "at least one breaking change",
 "change_ids": ["c1", "c2", "c3", "c4"],
 "notes_preview": "## orbital-agent 3.0.0\n\n### Breaking changes\n\n- Rename Agent.run(mission=) to Agent.run(task=) (#820, @ade)\n\n### Features\n\n- Add per-tenant concurrency caps to the tool work
publish: True Error executing tool publish_release: FORBIDDEN: local-dev lacks the 'release:write' scope | fix: ask an administrator for a token with that scope

One breaking change among four, so the plan is a major bump with the reason spelled out — and the publish attempt over stdio is refused, exactly as designed.

Over Streamable HTTP, with auth

The HTTP entrypoint is five lines:

os.environ.setdefault("RELNOTES_AUTH", "on")
app = default_server().streamable_http_app()

if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("PORT", "8080")),
                log_level="warning")

streamable_http_app() returns a Starlette app, which means it mounts inside an existing FastAPI service if you have one — your MCP server and your REST API can be the same deployment.

Start it and talk to it with three different tokens:

$ python3 serve.py &
$ python3 http_demo.py
no token          -> DENIED HTTPStatusError: Client error '401 Unauthorized' for url 'http://127.0.0.1:8080/mcp'
reader token      -> OK     {"result": [{"name": "orbital-agent", "current_version": "2.4.1", "unreleased_changes": 4}, {"name": "stepbudget", "current_version": "0.3.0", "unreleased_changes": 1}]}
reader publishing -> ERROR  Error executing tool publish_release: FORBIDDEN: ci-reader lacks the 'release:write' scope | fix: ask an administrator for a token with that scope
release token     -> OK     {"package": "orbital-agent", "version": "3.0.0", "published_at": "2026-08-06T21:03:55+00:00", "published_by": "release-bot", "change_count": 4, "replayed": false}
same key replay   -> OK     {"package": "orbital-agent", "version": "3.0.0", "published_at": "2026-08-06T21:03:55+00:00", "published_by": "release-bot", "change_count": 4, "replayed": true}
stale version     -> ERROR  Error executing tool publish_release: NOTHING_TO_RELEASE: orbital-agent has no unreleased changes | fix: merge something first, or release a different package

Every line of that is a design decision paying off.

The unauthenticated call fails at the transport, with a 401, before any tool code runs — that is AuthSettings(required_scopes=["release:read"]) doing its job. The reader’s publish fails at the tool, with a message naming the principal and the missing scope. The replay returns the original published_at and replayed: true. And the second, differently-keyed publish attempt gets NOTHING_TO_RELEASE rather than a confusing success, because after a release there is genuinely nothing pending.

Two notes on that output. The 401 surfaces in the client as an ExceptionGroupanyio wraps task failures — which is why the demo unwraps it before printing; the raw exception is much less legible than the HTTP status. And the Error executing tool ... prefix is the SDK’s wrapper around a raised ValueError, delivered as an MCP result with isError: true. Protocol errors and tool errors are different things (https://modelcontextprotocol.io/specification/2026-07-28/server/tools), and a tool that raises produces the second, which is what you want: the model sees it and can react.


Tests

$ python3 -m pytest test_relnotes.py -q
...........                                                              [100%]
11 passed in 0.53s

Three layers, and the shape is the point.

Domain tests need no protocol at all — they are why the logic lives outside the tool functions:

def test_publish_is_idempotent(store):
    a = store.publish("orbital-agent", "3.0.0", actor="bot", idempotency_key="k-1234567")
    b = store.publish("orbital-agent", "3.0.0", actor="bot", idempotency_key="k-1234567")
    assert a == b and len(store.releases) == 1
    assert store.packages["orbital-agent"] == "3.0.0"
    assert store.unreleased("orbital-agent") == []

Protocol tests run the real MCP session over in-memory streams:

async def test_every_tool_documents_itself(store):
    async with connect(build_server(store)._mcp_server) as s:
        tools = (await s.list_tools()).tools
        assert {t.name for t in tools} == {"list_packages", "list_changes",
                                           "plan_release", "publish_release"}
        for t in tools:
            assert t.description and len(t.description) > 40, t.name
            assert t.outputSchema, t.name
            for prop in t.inputSchema.get("properties", {}).values():
                assert prop.get("description") or prop.get("anyOf"), t.name

That test is worth stealing wholesale. It fails the day someone adds a tool with a one-line docstring, an undocumented parameter, or a bare dict return. Documentation quality is a testable property, and on an MCP server it is a functional property — the description is the interface.

Contract tests prove the behaviours you promised:

async def test_pagination_walks_every_change(store):
    async with connect(build_server(store)._mcp_server) as s:
        seen, cursor = [], None
        while True:
            res = await s.call_tool("list_changes",
                                    {"package": "orbital-agent", "limit": 2, "cursor": cursor})
            page = res.structuredContent
            seen += [c["id"] for c in page["changes"]]
            cursor = page["next_cursor"]
            if cursor is None:
                break
        assert seen == ["c1", "c2", "c3", "c4"]


async def test_mutating_tool_denies_an_unscoped_caller(store):
    """Over stdio there is no token, so the local principal is read-only."""
    async with connect(build_server(store)._mcp_server) as s:
        res = await s.call_tool("publish_release", {"package": "orbital-agent",
                                                    "version": "3.0.0",
                                                    "idempotency_key": "k-1234567"})
        assert res.isError and "FORBIDDEN" in res.content[0].text
        assert store.packages["orbital-agent"] == "2.4.1"      # nothing changed

That last assertion — nothing changed — is the one people forget. A denied call that returns an error and mutates anyway passes a naive test and fails an audit.


Containerizing and deploying

The Dockerfile is Part 6, Chapter 7’s pattern applied to a protocol server:

FROM python:3.12-slim AS build
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.12-slim
RUN useradd --create-home --uid 10001 app
COPY --from=build /install /usr/local
WORKDIR /app
COPY --chown=app:app relnotes/ ./relnotes/
COPY --chown=app:app serve.py ./
COPY --chown=app:app data/ ./data/
USER app
ENV PORT=8080 RELNOTES_AUTH=on RELNOTES_DB=/app/data/changes.json PYTHONUNBUFFERED=1
EXPOSE 8080
# The MCP endpoint requires a token, so an HTTP 200 is not the right liveness signal.
# "Is the socket accepting connections" is.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD python -c "import socket; socket.create_connection(('127.0.0.1',8080),2).close()" \
      || exit 1
CMD ["python", "serve.py"]

This sandbox has no Docker daemon, so the image was not built heredocker build fails with failed to connect to the docker API, and inventing a build log would be worse than saying so. The two things the Dockerfile asserts were verified against the running server:

$ python3 -c "import socket; socket.create_connection(('127.0.0.1',8080),2).close(); print('healthcheck: ok')"
healthcheck: ok
$ curl -s -o /dev/null -w "GET /mcp without a token -> %{http_code}\n" http://127.0.0.1:8080/mcp
GET /mcp without a token -> 401

That 401 is the reason the healthcheck is a socket connect rather than an HTTP probe. A liveness check that requires a credential is a liveness check that will page you at 3 a.m. when the credential rotates.

For deployment, three things specific to MCP servers on top of the usual container advice.

Statelessness is what makes horizontal scaling free. With stateless_http=True and no per-connection memory, any replica can serve any request, and a rolling deploy does not drop sessions because there are none.

Your data layer is now the shared state. The reference stores a JSON file, which is correct for a single replica and wrong for two — the second replica will happily publish the same release. In production that store is a database with a unique constraint on the idempotency key, and the constraint, not the Python, is what makes the guarantee.

Publish a manifest. Servers can be listed in the MCP registry (https://github.com/modelcontextprotocol/registry) so clients can find them. If yours is internal, at minimum ship a README with the URL, the scopes, an example token exchange, and the tool list.


Is it portfolio-ready?

Score your own server honestly. Anything under 18 is a project you should finish before showing it.

#Criterion012
1ScopeWraps an API endpoint-for-endpointSensible but read-onlyTools map to intentions; at least one mutates
2Tool descriptionsTerse or absentPresentSay what the tool is for, when to use it, and what to call first
3SchemasUntyped argsTypedEvery field described, bounded, and returning a typed model
4Error contractExceptions escapeMessages presentOne contract: code, message, fix hint — and a test for each code
5AuthNoneA shared secretScopes enforced in code, with a documented stdio story
6Safety of writesNoneConfirmation flagIdempotency key, precondition check, recorded actor
7TestsNoneDomain onlyDomain, protocol, and contract layers; runs offline in seconds
8ArchitectureLogic inside tool functionsPartially separatedDomain module with no protocol imports
9DeploymentRuns on your laptopDockerfileNon-root container, healthcheck, config from env, documented deploy
10DocumentationREADME stubSetup and tool listDesign rationale, error codes, scopes, non-goals, and known limitations

Two rows do most of the work in an interview.

Row 4, because a candidate who has thought about what an error message does — that it is read by a model which will act on it — has thought about the thing that separates a working demo from a working system.

Row 10, because the non-goals and known-limitations sections are what a senior engineer looks for. “I did not implement multi-replica safety; here is exactly where the race is and what the fix is” reads better than any feature you could have added instead.

Extensions worth doing

Each is small and each teaches something the reference deliberately leaves out.

Elicitation. Have publish_release ask the caller’s human to confirm through the client rather than requiring a scope, using MCP’s elicitation flow. The Part 6 gate, moved into the protocol.

A resource, not just tools. Expose relnotes://packages/{name}/notes so a client can read notes as context without spending a tool call.

Real persistence. Move the store to SQLite with a unique index on idempotency_key, then run two replicas and try to double-publish. Watch the constraint save you.

Structured content plus rendered text. Return both a notes_markdown string and structured change data, and see which one the model uses.

Contract tests against the spec. Assert every tool’s outputSchema validates its own sample response. Cheap, and it catches a whole class of drift.

What you should be able to do now

  • Choose an MCP server scope by the three-part test — a system you understand, operations at the caller’s granularity, at least one mutation — and reject the ideas that fail it.
  • Fill in a design template, including non-goals, before writing code.
  • Keep all domain logic in a module with no protocol imports, and treat the MCP layer as a thin adapter.
  • Define one error contract — code, message, fix hint — and distinguish expected, actionable failures from bugs that must not be dressed up as friendly errors.
  • Design a mutating tool that is safe to retry: idempotency key, precondition check against a value the caller had to fetch, and a recorded actor.
  • Enforce scopes in code at the top of the tool, answer the “what does auth mean over stdio” question deliberately, and explain why tool annotations are hints rather than enforcement.
  • Serve the same server over stdio and Streamable HTTP, know why stateless_http=True is what makes replicas cheap, and mount it inside an existing web app.
  • Write tests at three layers, including a test that asserts every tool documents itself.
  • Containerize a protocol server as a non-root image with a liveness check that does not require a credential, and name the piece of the system that actually enforces idempotency once you have more than one replica.
  • Score your own work against a rubric and know which two rows a reviewer will read first.

Further reading