mcp-server-starter
by anddali
README.md
# mcp-server-starter
A production starter template for building **MCP servers** — the kind you fork once per server
and ship. Single container, Streamable HTTP, stateless, OAuth 2.1 resource server, Postgres
audit trail.
Sibling to [`agent-starter`](https://github.com/anddali/agent-starter), and deliberately shares
its conventions: `uv`, folder-based autodiscovery, scopes as the one authorisation model, a
`Makefile` with a `make.ps1` shim, and `AGENTS.md` as the contract.
## What this is not
An MCP server owns neither a model nor a conversation — the client's model decides what to
call. So there is no agent runtime, no turn loop and no conversation store here. What gets
persisted is a **tool-call audit trail**: who called what, with which arguments, how long it
took, what was denied and what failed.
## Protocol revision
Built for MCP revision **`2026-07-28`**, which is the largest change since launch:
- **Stateless core.** No `initialize` handshake, no `Mcp-Session-Id`. Client info, capabilities
and protocol version travel in `_meta` on every request. Any request can land on any replica:
no sticky routing, no shared session store.
- **`server/discover`** replaces the handshake for version and capability negotiation.
- **`CacheableResult`** — every list result carries `ttlMs` and `cacheScope`.
- **`subscriptions/listen`** replaces the HTTP GET endpoint and `resources/subscribe`.
- **Roots, Sampling and Logging are deprecated.** Nothing here builds on them.
- Resource-not-found moved from `-32002` to `-32602`; several new codes were renumbered into
the `-32020`+ range.
## Quick start
```bash
make up
```
Everything in Docker: Postgres, migrations, a canned stub for the `catalog` upstream, then the
server. Point a client at `http://localhost:8080/mcp`.
The stub (`devtools/fake_upstream.py`) exists so the example tools actually return data on the
first run. Without it they answer "The catalog service could not be reached" — correct
behaviour against an upstream you have not configured, and impossible to tell apart from a
broken checkout. Delete it and its compose service when you point the fork at a real API.
If port 8080 or 5432 is taken — on Windows a system service often owns 8080 — set
`APP_HOST_PORT` / `POSTGRES_HOST_PORT` in `.env` rather than fighting it.
For the fast loop — Postgres in Docker, server on your machine with a real debugger:
```bash
make dev
```
On Windows, where `make` is usually not on PATH:
```bash
.\make.ps1 up
```
## Trying it
The local test client is the fastest way to see scope filtering work. It can change the
presented identity and scopes mid-session, which is the thing that is otherwise hard to test:
```bash
make client
```
```
mcp> scopes catalog.read
mcp> tools
catalog_get_item catalog.read
catalog_search catalog.read
whoami -
mcp> scopes catalog.read catalog.write
mcp> tools
catalog_archive_item catalog.write
catalog_get_item catalog.read
catalog_search catalog.read
whoami -
mcp> call whoami
mcp> call catalog_search query=widget
```
## Adding a capability
Create `src/app/capabilities/<name>/` with an `__init__.py` exporting `CAPABILITY`. Nothing
needs registering — discovery picks it up, so two people adding a capability in the same sprint
do not collide in a shared list.
```python
# src/app/capabilities/orders/tools.py
class LookupParams(BaseModel):
order_id: str = Field(description="The order identifier.")
@tool(description="Look up an order by id.", scopes={"orders.read"}, upstream="orders")
async def lookup_order(ctx: ToolContext, params: LookupParams) -> str:
data = await ctx.http("orders").get_json(f"/orders/{params.order_id}")
return format_order(data)
```
```python
# src/app/capabilities/orders/__init__.py
CAPABILITY = capability("orders", "Read and amend customer orders.", tools=(lookup_order,))
```
The scope you declared is now enforced centrally, filtered out of `tools/list` for callers who
lack it, and advertised in the server's Protected Resource Metadata. Copy
`src/app/capabilities/catalog/` as the reference shape.
## Authorization
This server is an OAuth 2.1 **Resource Server**. It validates tokens; it does not issue them.
- **Protected Resource Metadata** at `/.well-known/oauth-protected-resource<path>` advertising
the authorization server(s) and every scope any capability declares.
- **`WWW-Authenticate`** on 401 and 403, carrying both `resource_metadata` (where to
authenticate) and `scope` (what to ask for). External clients need no out-of-band config.
- **Audience-bound validation.** A token not issued *for this resource* is rejected. This is
confused-deputy prevention and it is the whole point.
- **Step-up authorization.** A call to a tool the caller's scopes do not cover answers `403
insufficient_scope` with the complete missing set, so a client can re-authorize once rather
than once per scope.
Configure against any OIDC provider by setting `AUTH__JWKS_URL`, `AUTH__ISSUER` and
`AUTH__RESOURCE_URL` — Entra ID, Auth0, Okta and Keycloak all work without code changes.
**Client registration is not implemented here and cannot be.** Dynamic Client Registration and
Client ID Metadata Documents are authorization-server endpoints; a resource server never sees a
registration request. See [`docs/authorization.md`](docs/authorization.md) for how the pieces fit.
## Deployment
**There is none in this repository, deliberately.** The container is the deliverable; how it
reaches an environment depends on patterns this template cannot guess. What it does specify is
what any deployment must satisfy — migration ordering, statelessness, probe semantics, the
resource URL rule — under "Deployment contract" in [`AGENTS.md`](AGENTS.md). Hand that section to
whoever owns your platform.
## Documentation
- [`AGENTS.md`](AGENTS.md) — the contract: commands, layout, rules, deployment requirements,
known gaps. Read this first if you are changing the repository.
- [`docs/authorization.md`](docs/authorization.md) — pointing the server at an authorization
server, and what each provider does differently.
## Verification status
What was checked by running it, and what was not, is recorded honestly in `AGENTS.md` under
"Verification status". The image builds and runs locally; it has **never been deployed**, and
integration with a real identity provider is **unverified**.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues