By the end of this tutorial you’ll have a bookmarks MCP server running at a public URL. One process answers both the 2026-07-28 revision and the 2025 handshake. It holds nothing between requests, annotates every tool, returns every refusal as a result the model can read, and accepts only tokens minted for its own URL. The whole test suite runs against both eras on every push. The finished code is at NimbleBrainInc/mcp-bookmarks, and each lesson is its own merged pull request, so you can read the history as the tutorial.
I wrote this after we moved every MCP server we run to 2026-07-28. The first post in this series is the audit that told us where our sessions were hiding. This one is what I’d hand a developer starting a new remote server today, with the six things that cost us the most to learn.
What you need
- Python 3.13 or newer and uv.
- Node 22 or newer, for the MCP Inspector CLI. You’ll use it to look at your server the way a client does.
- Docker, for the container step.
- A Fly.io account for the deploy step. Any host that runs a container with a volume works the same way; only the three commands differ.
Versions I used: FastMCP 4.1.0 on MCP Python SDK 2.3.0. Check what you get in step 1; anything newer in the 4.x and 2.x lines should behave the same.
Step 1: an empty directory and two dependencies
mkdir mcp-bookmarks && cd mcp-bookmarks
uv init --package --python 3.13
uv add "fastmcp>=4.1,<5" "mcp>=2,<3" uvicorn
uv add --dev pytest pytest-asyncio httpx jsonschema ruff ty
Verify:
uv run python -c "import fastmcp; from importlib.metadata import version; print(fastmcp.__version__, version('mcp'))"
You should see 4.1.0 2.3.0 or newer. FastMCP 4 sits on the official SDK’s v2 line, and v2 is where 2026-07-28 lives.
Step 2: the smallest server that speaks 2026-07-28
Replace src/mcp_bookmarks/__init__.py with a version string and create src/mcp_bookmarks/server.py:
from fastmcp import FastMCP
from starlette.requests import Request
from starlette.responses import JSONResponse
mcp = FastMCP("Bookmarks")
@mcp.tool
def ping() -> str:
"""Answer with pong. Call it to check the server is up."""
return "pong"
# What a container's health check and a load balancer probe. The finished
# server's version also opens the store, so a machine that would fail every
# tool call reports it here.
@mcp.custom_route("/health", methods=["GET"])
async def health(request: Request) -> JSONResponse:
return JSONResponse({"status": "ok", "service": "mcp-bookmarks", "version": "0.1.0"})
# The ASGI app a container runs. stateless_http=True keeps the 2025-era
# handshake from minting a session either.
app = mcp.http_app(path="/mcp", stateless_http=True)
Run it:
uv run uvicorn mcp_bookmarks.server:app --port 8000
Verify with one raw request. This is what a 2026-07-28 client sends: no handshake, no session, the protocol version and the client’s capabilities inside the request itself.
curl -s http://127.0.0.1:8000/mcp \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/list' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
You get a JSON body with "resultType":"complete", one tool named ping, and the server’s name under _meta. Look at the response headers too: there’s no Mcp-Session-Id.
The same question from the Inspector, which by default still opens with the 2025 handshake:
npx -y @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp --transport http --method tools/list
Same tool, same server. One process answers both eras, and nothing in your code chose one. The SDK routes on the MCP-Protocol-Version header each request carries.
Step 3: real tools, and the handle that replaces the session
A bookmarks server needs somewhere to keep bookmarks and a way for a client to name one later. Pull in the four files from the repo that do the work:
store.py: SQLite, one connection per operation, nothing kept in the process.models.py: the wire shapes. FastMCP turns each one into the tool’soutputSchema.ids.py:bm_plus 128 random bits.paging.py: the store imports it; it’s lesson 6.
These are the finished files, so the store already takes an owner on every call. Pass "anonymous" for now; lesson 4 is where it becomes a real person. Then replace ping with the two tools that matter most. The full server has six; these two show the pattern.
import os
from typing import Annotated
from pydantic import Field
from .models import Bookmark
from .store import Store
# The container sets BOOKMARKS_DB to its volume; on your laptop it is a file next to you.
store = Store(os.environ.get("BOOKMARKS_DB", "bookmarks.db"))
@mcp.tool
def add_bookmark(
url: Annotated[str, Field(description="Full http:// or https:// address to save.")],
title: Annotated[str | None, Field(description="Display title, if the user gave one.")] = None,
) -> Bookmark:
"""Save a link the user wants to keep, and get back the handle for it.
Call this when the user shares a URL and asks to save it. The result's
bookmark_id is the handle every other tool takes, so keep it.
"""
return store.add("anonymous", url, title, tags=None)
@mcp.tool
def get_bookmark(
bookmark_id: Annotated[str, Field(description="Handle from add_bookmark.")],
) -> Bookmark:
"""Read one bookmark by its handle."""
return store.get("anonymous", bookmark_id)
Restart uvicorn and verify by saving a link and reading it back with the handle you get:
npx -y @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp --transport http \
--method tools/call --tool-name add_bookmark \
--tool-arg url=https://modelcontextprotocol.io/specification/2026-07-28/changelog \
--tool-arg title="MCP 2026-07-28 changelog"
The result’s structuredContent.bookmark_id looks like bm_498d36966df107ed0f7676e8075d7818. Pass it back:
npx -y @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp --transport http \
--method tools/call --tool-name get_bookmark --tool-arg bookmark_id=bm_498d36966df107ed0f7676e8075d7818
You get the same bookmark. Stop uvicorn, start it again, and run the second command once more. The bookmark is still there. The handle is the only thing the client holds, the database is the only thing the server holds, and the process in between can restart at any time.
Step 4: tests go through a client
Everything a client sees comes through the wire, and frameworks reshape what your source appears to say. The tests call tools through fastmcp.Client, never by calling the Python functions. Create tests/conftest.py:
import pytest
from fastmcp import Client
from mcp_bookmarks.server import mcp
@pytest.fixture
async def client():
async with Client(mcp) as c:
yield c
And tests/test_tools.py:
async def test_add_returns_a_handle_that_get_resolves(client):
saved = (await client.call_tool("add_bookmark", {"url": "https://example.com"})).structured_content
assert saved["bookmark_id"].startswith("bm_")
fetched = (await client.call_tool("get_bookmark", {"bookmark_id": saved["bookmark_id"]})).structured_content
assert fetched == saved
Add asyncio_mode = "auto" under [tool.pytest.ini_options] in pyproject.toml, then verify:
uv run pytest -q
One test, one dot, 1 passed. The in-memory client speaks 2026-07-28; in lesson 5 you’ll make every test run on the 2025 era too.
Step 5: a container, and a public URL
The server is one process with one writable directory. The Dockerfile in the repo installs the package with uv, runs as a non-root user, mounts /data for the SQLite file, and starts uvicorn. Build it and run it:
docker build -t mcp-bookmarks .
docker run --rm -p 8000:8000 -v mcp-bookmarks-data:/data mcp-bookmarks
curl -s http://127.0.0.1:8000/health
You should see {"status":"ok","service":"mcp-bookmarks","version":"0.1.0"}. (On the repo’s final code the container refuses to start without authentication settings; make docker-run passes a development key. That’s lesson 4.)
To deploy, copy fly.toml from the repo, then:
fly launch --no-deploy --copy-config --name <your-app-name>
fly volumes create bookmarks_data --size 1 --region sjc
fly deploy
curl https://<your-app-name>.fly.dev/health
Point the Inspector at https://<your-app-name>.fly.dev/mcp and run the tools/list command from step 2. You should see the same tools you saw on your laptop. Don’t give anyone the URL yet; the six lessons are what the server still lacks.
A SQLite file lives on one machine. The server itself holds nothing between requests, so when you want more than one replica, moving the store to a database with its own address is the whole change.
Lesson 1: the server holds nothing between requests
Before July, an MCP server over HTTP could mint a session at initialize, and ours did. We still called them stateless because the pods held no data. The first post is the audit that showed what that cost.
2026-07-28 removes the session. The spec now says a server must not rely on prior requests over the same connection for anything, and that state spanning requests must be referenced by an explicit identifier the client passes each time. The proposal that made the change, SEP-2567, calls it the explicit-handle pattern: a creation tool returns an id, and later tools take it as an ordinary argument, which is what bookmark_id is.
The store opens a connection per operation and keeps nothing in the process, the handle is 128 random bits, and stateless_http=True denies a session to 2025-era clients too. Without authentication the handle is the only thing between a stranger and the bookmark, so the spec asks for enough entropy to make it unguessable. The spec also asks for a bounded lifetime, which this server skips; lesson 4 adds an owner check instead.
Verify:
uv run pytest -q tests/test_stateless.py
5 passed: a handle minted on one connection resolves on a second, in memory and over HTTP; tools/list is identical before and after a call and across connections, which the spec now requires; and a raw 2025-11-25 initialize comes back with no Mcp-Session-Id header.
Lesson 2: every tool is annotated
A tool’s annotations are the hints a host reads before it calls: readOnlyHint, destructiveHint, idempotentHint, openWorldHint. Their defaults are false, true, false, true. A tool that declares nothing has declared itself a destructive, non-idempotent write that reaches outside the server. A host that asks the user before destructive calls will ask before every unannotated read.
Our runtime dropped annotations on the way through, so a tool that said destructiveHint: true looked like one that said nothing (nimblebrain#1081 fixed it). A host reads a hint only to be more careful, because the hint is what an untrusted server says about itself.
On the server side the fix is three named sets and a table:
from mcp.types import ToolAnnotations
READS = ToolAnnotations(read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=False)
ADDS = ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False, open_world_hint=False)
CHANGES = ToolAnnotations(read_only_hint=False, destructive_hint=True, idempotent_hint=True, open_world_hint=False)
@mcp.tool(annotations=READS)
def get_bookmark(...): ...
@mcp.tool(annotations=CHANGES)
def delete_bookmark(...): ...
The test keeps a table of every tool and its set. A tool missing from the table fails the build, a tool whose advertised hints differ from its row fails the build, and every hint is checked to be written out on the wire so no client falls back to the defaults. Verify:
npx -y @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp --transport http --method tools/list
Every tool carries all four keys. Find get_bookmark in the output; its annotations read readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false.
Lesson 3: a tool’s own failure is a result
The spec gives a tool call two channels: a JSON-RPC error for a request the server couldn’t take (unknown tool, malformed params), and a result with isError: true for a tool that ran and refused, which the spec tells clients to hand to the model so it can self-correct. “Bookmark not found” belongs on the second, with a body the model can act on.
FastMCP puts a ToolError on the right channel, but it keeps only the message text. The structured body is gone. We found that out when our host started passing structuredContent through on error results (nimblebrain#1471) and one of our own servers had nothing in it. This server returns refusals instead of raising them:
def refuse(code: str, message: str, next_step: str) -> ToolResult:
envelope = {"error": {"code": code, "message": message, "next_step": next_step}}
return ToolResult(
content=[TextContent(type="text", text=json.dumps(envelope))],
structured_content=envelope,
is_error=True,
)
Then I pointed the Inspector at an unknown handle and it rejected the error result: data must have required property 'bookmark_id'. The spec says a server MUST provide structured results that conform to the declared outputSchema, and grants no exception for an error. The Python and TypeScript SDK clients skip validation on isError results; the Inspector doesn’t, and the spec sides with the Inspector. The schema each tool declares is the union of its success shape and the error envelope:
def schema(model: type[BaseModel]) -> dict:
# A return type of `Bookmark | ToolResult` on its own derives no schema at all.
return {"type": "object", "oneOf": [model.model_json_schema(), ERROR_SCHEMA]}
@mcp.tool(annotations=READS, output_schema=schema(Bookmark))
def get_bookmark(bookmark_id: str) -> Bookmark | ToolResult: ...
Verify with the Inspector against a handle that doesn’t exist:
npx -y @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp --transport http \
--method tools/call --tool-name get_bookmark --tool-arg bookmark_id=bm_00000000000000000000000000000000
You get isError: true with structuredContent.error.code of not_found and a next_step that says to call list_bookmarks (the Inspector prints it, then exits non-zero, because the call was an error). The tests pin the envelope in both places, the same refusal from every handle tool, and, on a throwaway server, that raise ToolError drops the body.
Lesson 4: tokens bound to the server’s URL, and one answer for unknown and forbidden
A remote MCP server is an OAuth protected resource. It publishes where its tokens come from (RFC 9728 metadata under /.well-known/), it answers a call without a token with 401 and a WWW-Authenticate header that points there, and it accepts a token only if the token was minted for it. The token’s aud claim has to name the server’s own URL, character for character. A token for any other URL is refused with 401, and the client goes back through discovery to get the right one.
We rebuilt our own /mcp endpoint around this (nimblebrain#1259). The canonical URL is built from configuration; a Host header is something the caller controls.
In FastMCP 4 it’s two objects:
verifier = JWTVerifier(
jwks_uri=settings.jwks_uri,
issuer=settings.issuer,
audience=settings.public_url + "/mcp",
)
auth = RemoteAuthProvider(
token_verifier=verifier,
authorization_servers=[AnyHttpUrl(settings.issuer)],
base_url=settings.public_url,
)
mcp = FastMCP("Bookmarks", auth=auth)
Audience says where a token may be presented, and nothing about what the caller may see. Every bookmark row carries its owner, the token’s subject, and the store takes the owner on every read and write. A handle another owner holds gets the same not_found envelope as a handle that was never minted, so a probe learns nothing about what exists. Token failures stay 401.
For your laptop there’s no authorization server, so the repo ships a stand-in:
make token SUB=alice # writes .dev-keys/, prints a token
make run-auth
Verify:
curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp
# {"resource":"http://127.0.0.1:8000/mcp","authorization_servers":["https://dev-issuer.invalid/"],...}
npx -y @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp --transport http \
--header "Authorization: Bearer <token>" --method tools/call --tool-name list_bookmarks
Without the header the same call returns 401 with a WWW-Authenticate that points at that metadata document. For the deployed server, set BOOKMARKS_PUBLIC_URL to your https:// address and BOOKMARKS_ISSUER and BOOKMARKS_JWKS_URI to your identity provider, and have it mint tokens with aud set to your URL plus /mcp (RFC 8707 resource). The tests cover the metadata document, the 401 challenge, four wrong audiences, the right one, and two owners who can’t see each other’s bookmarks.
Lesson 5: every test runs on both protocol eras
Clients you don’t control will speak the 2025 era for a long time. The spec sets no removal date for the handshake versions. Your server answers both from one process, and nothing you wrote decides which, so a behaviour that differs between them is a bug you’ll only hear about from the client you don’t run.
We opened three pull requests for that bug class. nimblebrain#1273 is the era matrix; nimblebrain#1307 added the cases for a gateway’s 502 pinning a 2026 server to the 2025 era for the life of a connection; and when tasks shipped on one era only, we wrote the rule down in nimblebrain#1432: a capability lands on both eras in the same change, with a scenario in the parity suite.
In Python the whole matrix is one fixture. fastmcp.Client takes a mode: auto is the default and lands on 2026-07-28; legacy opens with the 2025 initialize handshake.
ERAS = {"2026-07-28": "auto", "2025-11-25": "legacy"}
@pytest.fixture(params=list(ERAS), ids=list(ERAS))
async def client(request, mcp):
async with Client(mcp, mode=ERAS[request.param]) as c:
assert c.protocol_version == request.param, "the SDK negotiated a different era"
yield c
Every tool test now runs twice. Verify:
uv run pytest tests/test_tools.py -v | head -20
Each test name carries [2026-07-28] or [2025-11-25]. On top of that, one test compares the two advertised surfaces field for field, one mints a handle on each era and reads it on the other, and one sends a version the server doesn’t speak and checks it’s refused with -32022 and the list of versions it does.
Lesson 6: pages by key
A cursor is the other handle this server mints. Delete a row on page one and ask for page two with OFFSET 20: every later row shifts up and the agent skips one it never saw. A keyset cursor names a position in a total order, newest first by created_at with bookmark_id as the tiebreak, and the next page is every row strictly after it. The tiebreak handles rows written in the same microsecond; the test writes five of them and walks them in pages of two.
clauses.append("(created_at, bookmark_id) < (?, ?)") # strictly after the cursor's row
rows = conn.execute(f"... ORDER BY created_at DESC, bookmark_id DESC LIMIT ?", [*params, limit + 1])
next_cursor = encode(rows[limit - 1]) if len(rows) > limit else None
The store owns the order, the predicate, and the cursor’s contents; the tool passes the cursor through and never knows its shape. The cursor is opaque and tagged with the listing it came from, so one from another list is refused as bad_input with “call again without cursor”. And it grants nothing: the read it resumes runs as the caller, so a cursor replayed by another owner returns only that owner’s rows.
Verify by saving three bookmarks and listing with limit=2:
npx -y @modelcontextprotocol/inspector --cli http://127.0.0.1:8000/mcp --transport http \
--method tools/call --tool-name list_bookmarks --tool-arg limit=2
The result carries next_cursor and a next_step that tells the model to call again with it and the same filters. Pass it back and the last page says next_cursor: null.
Where to go from here
Clone the repo, run make check, and read the pull requests in order. Each one states its rule in the body and lands with the test that fails without it.
If you already run an MCP server, the six lessons double as a checklist. Grep for Mcp-Session-Id, list your tools and look for the ones with no annotations, find the raise that answers “not found”, read the audience you verify, run your tests with mode="legacy", and look for OFFSET. Five of those six found something in our fleet.
This tutorial is part of my contribution to the Agentic AI Foundation’s MCP 7-28 education campaign. I’m an AAIF ambassador, and I’ll be speaking about the MCP Apps host side of our migration at AGNTCon + MCPCon North America in San Jose on October 22.
Share this post