49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
200 lines
12 KiB
Markdown
200 lines
12 KiB
Markdown
# The low-level Server
|
|
|
|
`@mcp.tool()` is a layer. Underneath it is a second server class, `Server`, that speaks raw MCP: you hand it the protocol objects and it puts them on the wire, unchanged.
|
|
|
|
`MCPServer` is built on top of it. You drop down when the convenience layer is in the way:
|
|
|
|
* You need to emit an **exact** schema (loaded from a file, generated from a database), not one derived from a Python signature.
|
|
* You need full control of the result: `_meta`, `is_error`, every key of `structured_content`.
|
|
* You need to handle a method MCP doesn't define.
|
|
|
|
For everything else, stay on `MCPServer`.
|
|
|
|
## The same tool, by hand
|
|
|
|
This is the `search_books` tool that **[Tools](../servers/tools.md)** writes in nine lines of `@mcp.tool()`, with the sugar removed:
|
|
|
|
```python title="server.py" hl_lines="23 27 33"
|
|
--8<-- "docs_src/lowlevel/tutorial001.py"
|
|
```
|
|
|
|
Three things changed, and they are the whole low-level API:
|
|
|
|
* **Handlers are constructor parameters.** `on_list_tools=` and `on_call_tool=` go into `Server(...)`. There are no decorators down here, and every handler has the same shape: `async (ctx, params) -> result`.
|
|
* **You write the input schema.** `Tool.input_schema` is a plain JSON Schema `dict`. Nobody derives it from type hints, because there are no type hints to derive it from.
|
|
* **You build the result.** `CallToolResult(content=[TextContent(...)])`, by hand. Nothing is wrapped, converted, or inferred from a return annotation.
|
|
|
|
`params` is the parsed request: `CallToolRequestParams` gives you `.name` and `.arguments`. `ctx` is a `ServerRequestContext`: `ctx.session` for talking back to the client, `ctx.lifespan_context`, `ctx.request_id`, and `ctx.meta`, the request's inbound `_meta`.
|
|
|
|
!!! info
|
|
If you've used FastAPI, you already know this relationship. `MCPServer` is the decorators-and-type-hints layer; `Server` is the Starlette underneath. They are not rivals: `MCPServer` constructs a `Server` and registers handlers exactly like these on it.
|
|
|
|
### Try it
|
|
|
|
There is no Inspector for this one: `mcp dev` and `mcp run` only accept an `MCPServer`. The in-memory `Client` doesn't care; it takes a low-level `Server` exactly like it takes an `MCPServer`:
|
|
|
|
```python title="main.py"
|
|
import asyncio
|
|
|
|
from mcp import Client
|
|
|
|
from server import server
|
|
|
|
|
|
async def main() -> None:
|
|
async with Client(server) as client:
|
|
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
|
|
print(result.content)
|
|
|
|
|
|
asyncio.run(main())
|
|
```
|
|
|
|
```text
|
|
[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)]
|
|
```
|
|
|
|
The same text the `@mcp.tool()` version produced. Two honest differences:
|
|
|
|
* `result.structured_content` is `None`. The high-level server wraps a `-> str` into `{"result": ...}` for you; here nobody builds what you didn't build.
|
|
* `list_tools` returns the schema **you** typed, character for character. The high-level version had `"title": "Query"` on every property and a `"title": "search_booksArguments"` at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there.
|
|
|
|
## Nothing is checked for you
|
|
|
|
`MCPServer` rejects a bad argument before your function ever runs, validating the call against the schema it generated (**[Tools](../servers/tools.md)**).
|
|
|
|
`Server` does not do that. Your `input_schema` is *advertised* to the client; it is never *applied* to `params.arguments`.
|
|
|
|
!!! check
|
|
Call `search_books` without `limit` and your `args["limit"]` raises `KeyError`. The client sees:
|
|
|
|
```text
|
|
MCPError: Internal server error
|
|
```
|
|
|
|
A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **[Testing](../get-started/testing.md)**.)
|
|
|
|
That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **[Handling errors](../servers/handling-errors.md)**.
|
|
|
|
## Two tools, one handler
|
|
|
|
`on_call_tool` is the single entry point for every tool on the server. You route on `params.name`:
|
|
|
|
```python title="server.py" hl_lines="39-44"
|
|
--8<-- "docs_src/lowlevel/tutorial002.py"
|
|
```
|
|
|
|
* `list_tools` advertises both. `call_tool` dispatches on the name.
|
|
* The `else` branch matters: `Server` will happily forward a `tools/call` for a name you never listed straight into your handler. Raising there turns the call into the same `-32603` as above.
|
|
|
|
## Structured output, by hand
|
|
|
|
Declare `output_schema` on the `Tool` and put `structured_content` on the result. Both are yours:
|
|
|
|
```python title="server.py" hl_lines="20-24 37"
|
|
--8<-- "docs_src/lowlevel/tutorial003.py"
|
|
```
|
|
|
|
Call it and the result carries both representations:
|
|
|
|
```json
|
|
{
|
|
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
|
|
"structuredContent": {"matches": 3, "query": "dune"},
|
|
"isError": false,
|
|
"resultType": "complete"
|
|
}
|
|
```
|
|
|
|
The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../servers/structured-output.md)**.
|
|
|
|
## `_meta`: for the application, not the model
|
|
|
|
`content` is the part of the answer the model reads. `structured_content` is the same answer as typed data. `_meta` is the third channel: data that rides along with the result for the **client application**, without being part of the answer at all.
|
|
|
|
Use it for record IDs, trace IDs, anything your UI needs and your prompt doesn't:
|
|
|
|
```python title="server.py" hl_lines="38"
|
|
--8<-- "docs_src/lowlevel/tutorial004.py"
|
|
```
|
|
|
|
* You construct it as `_meta=`, the wire name. The client reads it back as `result.meta`.
|
|
* Namespace your keys (`bookshop/record_ids`). The `io.modelcontextprotocol/*` keys are reserved by the protocol.
|
|
|
|
!!! warning
|
|
`_meta` is a convention between you and the client application, not a guarantee about what reaches
|
|
the model. The host decides what it renders. Never put a secret in any part of a tool result.
|
|
|
|
## Capabilities follow your handlers
|
|
|
|
A `Server` advertises exactly the method families you gave it handlers for. The `Bookshop` above passes `on_list_tools` and `on_call_tool` and nothing else, so a client connecting to it sees:
|
|
|
|
```json
|
|
{"tools": {"listChanged": false}}
|
|
```
|
|
|
|
No `resources`, no `prompts`: there is nothing to back them. Pass `on_list_prompts` and `prompts` appears; pass `on_completion` and `completions` appears.
|
|
|
|
`MCPServer` always advertises tools, resources and prompts, whether you registered any or not, because its managers always exist. Down here the declaration *is* the constructor call.
|
|
|
|
## The lifespan generic
|
|
|
|
`Server` is generic in the type its lifespan yields. Annotate it once and the object is typed everywhere it surfaces:
|
|
|
|
```python title="server.py" hl_lines="25-27 45-46 51"
|
|
--8<-- "docs_src/lowlevel/tutorial005.py"
|
|
```
|
|
|
|
* The lifespan is a `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` on an `async` generator gives you exactly that.
|
|
* Whatever it `yield`s becomes `ctx.lifespan_context`, and because the handlers are annotated `ServerRequestContext[Catalog]`, `.search(...)` autocompletes and type-checks.
|
|
* It is entered once when the server starts and exited once when it stops. Startup, teardown, and `MCPServer`'s version of the same idea are in **[Lifespan](../handlers/lifespan.md)**.
|
|
|
|
Without a `lifespan=`, `ctx.lifespan_context` is an empty `dict`.
|
|
|
|
## A method of your own
|
|
|
|
The constructor covers the methods MCP defines. `add_request_handler` covers everything else:
|
|
|
|
```python title="server.py" hl_lines="35-36 39-40 43-44 48"
|
|
--8<-- "docs_src/lowlevel/tutorial006.py"
|
|
```
|
|
|
|
* The first argument is the method string. Notifications have a twin, `add_notification_handler`.
|
|
* `params_type` is the model the incoming `params` are validated against **before** your handler runs, so custom methods *do* get the validation tools don't. Subclass `RequestParams` so the `_meta` field parses like every other method's.
|
|
* The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result.
|
|
|
|
One honest caveat: the high-level `Client` only has verbs for the methods MCP defines, so there is no `client.reindex()`. A vendor method is for a peer that already knows it exists: a client you also ship, or another service of yours speaking JSON-RPC.
|
|
|
|
One method you cannot claim:
|
|
|
|
```text
|
|
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
|
|
use Server.middleware to observe or wrap initialization
|
|
```
|
|
|
|
The handshake belongs to the runner. `server/discover`, `ping`, and every other built-in are yours to replace.
|
|
|
|
!!! tip
|
|
`Server.middleware`, mentioned in that error, wraps **every** inbound message, including `initialize`. If what you want is to observe or rewrite traffic rather than answer a new method, start at **[Middleware](middleware.md)**.
|
|
|
|
## The other handlers
|
|
|
|
Each of these is one idea you now have the vocabulary for; each has its own page.
|
|
|
|
* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**).
|
|
* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives.
|
|
* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](../handlers/subscriptions.md)** for the full composition.
|
|
* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story.
|
|
|
|
## Recap
|
|
|
|
* The low-level `Server` takes its handlers as `on_*` **constructor parameters**; every handler is `async (ctx, params) -> result`.
|
|
* You write the `input_schema` dict and you build the `CallToolResult`. Nothing is derived, wrapped, or validated for you.
|
|
* An exception in a handler is a `-32603` protocol error. A tool error the model can read is a `CallToolResult` with `is_error=True` that **you** return.
|
|
* `_meta` on the result is addressed to the client application, not the model.
|
|
* `Server[T]` is generic in what its lifespan yields; `ctx.lifespan_context` is a typed `T`.
|
|
* `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved.
|
|
* The capabilities a `Server` advertises are derived from which handlers you registered.
|
|
|
|
`Client(server)` treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **[Middleware](middleware.md)**.
|