You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: docs/advanced/low-level-server.md
+12-6Lines changed: 12 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -31,18 +31,22 @@ Three things changed, and they are the whole low-level API:
31
31
32
32
### Try it
33
33
34
-
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`:
34
+
`mcp dev` and `mcp run` only accept an `MCPServer`, so you serve this one yourself. The last line of `server.py` builds an ordinary ASGI app from it, and uvicorn runs that:
35
35
36
-
```python title="main.py"
36
+
```console
37
+
uvicorn server:app --port 8000
38
+
```
39
+
40
+
Point the Inspector, or any client, at `http://localhost:8000/mcp`:
41
+
42
+
```python title="client.py"
37
43
import asyncio
38
44
39
45
from mcp import Client
40
46
41
-
from server import server
42
-
43
47
44
48
asyncdefmain() -> None:
45
-
asyncwith Client(server) as client:
49
+
asyncwith Client("http://localhost:8000/mcp") as client:
46
50
result =await client.call_tool("search_books", {"query": "dune", "limit": 5})
47
51
print(result.content)
48
52
@@ -59,6 +63,8 @@ The same text the `@mcp.tool()` version produced. Two honest differences:
59
63
*`result.structured_content` is `None`. The high-level server wraps a `-> str` into `{"result": ...}` for you; here nobody builds what you didn't build.
60
64
*`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.
61
65
66
+
In a test you skip uvicorn and the port: `Client(server)` takes a low-level `Server` in-process exactly like it takes an `MCPServer`, and **[Testing](../get-started/testing.md)** is that pattern.
67
+
62
68
## Nothing is checked for you
63
69
64
70
`MCPServer` rejects a bad argument before your function ever runs, validating the call against the schema it generated (**[Tools](../servers/tools.md)**).
@@ -210,4 +216,4 @@ Each of these is one idea you now have the vocabulary for; each has its own page
210
216
*`add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved.
211
217
* The capabilities a `Server` advertises are derived from which handlers you registered.
212
218
213
-
`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)**.
219
+
The client 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)**.
Copy file name to clipboardExpand all lines: docs/advanced/pagination.md
+8-4Lines changed: 8 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -26,9 +26,13 @@ Pagination is for the server whose resource list is really a database: thousands
26
26
27
27
### Try it
28
28
29
-
`Client(server)` connects to a low-level `Server` in memory exactly as it connects to an `MCPServer`.
29
+
`mcp run` only accepts an `MCPServer`, so you serve this one yourself. The last line of `server.py` builds an ordinary ASGI app from the `Server`, and uvicorn runs that:
30
30
31
-
Call `list_resources()` with no arguments. You get ten resources, `book-1` through `book-10`, and `next_cursor` is the string `"10"`.
31
+
```console
32
+
uvicorn server:app --port 8000
33
+
```
34
+
35
+
Point any client (**[The Client](../client/index.md)**, or the Inspector) at `http://localhost:8000/mcp` and call `list_resources()` with no arguments. You get ten resources, `book-1` through `book-10`, and `next_cursor` is the string `"10"`.
32
36
33
37
Hand it back with `list_resources(cursor="10")` and the first resource is `book-11`, the new `next_cursor` is `"20"`.
34
38
@@ -38,15 +42,15 @@ The tenth page comes back with `next_cursor` set to `None`. Done.
38
42
39
43
Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) takes a `cursor=` keyword. Draining a paged list is one `while True`:
40
44
41
-
```python title="client.py" hl_lines="26-32"
45
+
```python title="client.py" hl_lines="9-15"
42
46
--8<--"docs_src/pagination/tutorial002.py"
43
47
```
44
48
45
49
*`cursor` starts as `None`, so the first request carries no cursor.
46
50
* Extend **before** you look at `next_cursor`: the last page has resources too.
47
51
*`next_cursor is None` is the exit. Anything else goes straight back into `cursor=`, untouched.
48
52
49
-
Run its `main()` and it prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages.
53
+
With uvicorn still serving `server.py`, run `python client.py` in a second terminal. It prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages.
50
54
51
55
This is the same loop **[The Client](../client/index.md)** shows for every `list_*` verb, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once.
Copy file name to clipboardExpand all lines: docs/client/caching.md
+17-3Lines changed: 17 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -25,7 +25,7 @@ Out of the box every result says `ttlMs: 0, cacheScope: "private"`: immediately
25
25
26
26
On the low-level `Server`, handlers build their results by hand, and `ttl_ms` / `cache_scope` are just fields on the result models. A handler that sets them explicitly always wins over the constructor map, field by field:
27
27
28
-
```python title="server.py" hl_lines="10 16"
28
+
```python title="server.py" hl_lines="11 17"
29
29
--8<--"docs_src/caching/tutorial002.py"
30
30
```
31
31
@@ -39,10 +39,24 @@ One caveat on paginated lists: the protocol requires the **same `cacheScope` on
39
39
40
40
On a 2026-07-28 session, `Client` honors the hints for you: it has a built-in response cache, on by default. A result that arrives carrying a `ttlMs` is stored, and an identical call within that TTL is served from the cache with no round trip. A result that carries *no* hint is not cached: hint-less results get `CacheConfig.default_ttl_ms`, which defaults to `0` (immediately stale), so a server that declares nothing sees exactly the call-for-call traffic it always did.
41
41
42
-
```python title="client.py" hl_lines="33 35 38"
42
+
To watch that happen, serve the `server.py` from the previous section with uvicorn (its last line builds the ASGI app). The handler prints a line every time it actually runs:
43
+
44
+
```console
45
+
uvicorn server:app --port 8000
46
+
```
47
+
48
+
```python title="client.py" hl_lines="20 23 28"
43
49
--8<--"docs_src/caching/tutorial003.py"
44
50
```
45
51
52
+
Run `python client.py` from a second terminal. It prints the hints the first result carried, the handler's `ttlMs` next to the map's `cacheScope`:
53
+
54
+
```text
55
+
1000 public
56
+
```
57
+
58
+
The server's terminal tells the rest of the story: between uvicorn's request logs, `tools/list served` appears three times.
59
+
46
60
Four calls, three fetches. The second call found a fresh entry and never reached the server; advancing the (injected) clock past the TTL made the third fetch again; the fourth said `cache_mode="refresh"`. That kwarg exists on the five caching verbs (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`):
47
61
48
62
*`"use"` (the default) serves a fresh entry if there is one, and stores the fetch if not.
@@ -51,7 +65,7 @@ Four calls, three fetches. The second call found a fresh entry and never reached
51
65
52
66
One rule sits above `"use"`: **calls carrying `meta` always reach the server.** A request with `meta` set (a progress token, tracing fields) expects a wire request, so under `cache_mode="use"` it is treated as `"refresh"`: the cache read is skipped, and the fetched result still replaces the cached entry. `"bypass"` and an explicit `"refresh"` behave as they always do.
53
67
54
-
To turn caching off entirely, construct with `Client(server, cache=None)`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing.
68
+
To turn caching off entirely, pass `cache=None` when constructing the `Client`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing.
55
69
56
70
Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were. On a 2026-07-28 connection those notifications arrive on a `subscriptions/listen` stream you open with `client.listen(...)`, and eviction completes before your watcher sees the event; **[Subscriptions](subscriptions.md)** is that page.
0 commit comments