---
title: "Model Context Protocol in Practice"
chapter: "Serve data as resources and prompts"
chapter_id: resources-and-prompts
book: model-context-protocol-in-practice
edition: 2026.11.5
verified: 2026-11-04
edition_code: MA29-KPNM
canonical: https://rollingedition.com/model-context-protocol-in-practice/resources-and-prompts
license: CC BY 4.0
attribution: "“Serve data as resources and prompts,” chapter 4 of Model Context Protocol in Practice, Edition 2026.11.5, examples passed Nov 4, 2026. By Rolling Edition, https://rollingedition.com/model-context-protocol-in-practice/resources-and-prompts. Text CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Code MIT, copyright Rolling Edition and contributors. Written with AI. Verified by people."
---
# Serve data as resources and prompts

By the end of this chapter you can publish the shop's policies and records as Model Context Protocol resources, address many records with one resource template, complete an argument while a user types it, tell a client when a resource changes, and package a refund review as a prompt the user can pick. Everything runs against the specification at revision 2026-07-28 and the Python SDK, `mcp` 2.3.0.

This book follows one case study throughout: a small online shop for kitchen and garden goods, which we call **the shop**, and whose support team answers customer tickets. [Meet the shop and its API](#the-shop) introduces it. [Separate the host, the client and the server](#host-client-server) gave the shop's server, `shop-support`, one capability of each kind: the tool `get_order_status`, the refund policy as a resource, and a short `review_refund` prompt. Much of what the support team's host needs isn't an action, and one document and one prompt don't cover it. Every refund answer must follow the refund policy, a ticket is about one order, and a stock question is about one product's count. If each of those is a tool, the model has to decide to fetch it, and it may not.

The Model Context Protocol (MCP) gives a server two other primitives for this, which differ from tools in who decides when they're used ([Put each capability in the right primitive](#choose-a-primitive)). A **resource** is data the server publishes under a URI, and the host decides whether to put it in front of a model. A **prompt** is a message template the server offers, and the user picks it. Chapter 2 showed one of each; this chapter builds both out for the shop.

This chapter keeps the server's name, `shop-support`. Each example defines only the part of it that its section teaches, and runs itself as chapter 2's server examples do: with no arguments, the file starts a client that launches the same file with `--serve` over stdio.

## Expose the policies as resources {#policies-as-resources}

`shop-support` already serves one of the rules an answer must follow, the refund policy, as [Resources are data the application chooses](#resources-application-chooses) showed, and the shop has five more. Each is a job for a resource: a piece of data that a server publishes under a URI, with a `name`, an optional `title` and `description`, a `mimeType`, and contents that are either text or a base64 `blob` ([resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources)). A client lists a server's resources with `resources/list` and reads one with `resources/read`.

Resources are application-driven. The same page says the host decides how to use them: it might show a picker, search them, or include some automatically, "based on heuristics or the AI model's selection". The protocol gives the model no way to read a resource. The host decides, though a host may let the model choose which resources to include. A resource's URI follows RFC 3986, and a server may use a scheme of its own. The specification keeps `https://` for resources the client can fetch and load "directly from the web on its own", without asking the server.

The shop has six such documents: **the shop's policies**, short rules written for this book for refunds, returns, shipping, stock, data handling and escalation. They live as Markdown in `examples/data/shop/policies/`, licensed CC BY 4.0 with the book's text, and the data card in `examples/data/shop/README.md` lists them. A small shared module, `lib.policies`, reads each file's title, its `Updated:` date and its one-sentence summary, so the server can describe every policy without opening the files on each request.

The server publishes each policy as a static resource at `shop://policies/{slug}`. The refund policy keeps the URI, `shop://policies/refund`, and the name, `refund-policy`, that chapter 2 gave it. The SDK's `FileResource` serves a file and reads it again on every `resources/read`, so an edited policy reaches the next read without a restart (`mcp/server/mcpserver/resources/types.py`). Each resource also carries three **annotations**, hints the host may use or ignore ([resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources)):

- `audience` says who the content is for. The policies are for `assistant`, the model.
- `priority`, from 0.0 to 1.0, says how important the content is, and 1 means "effectively required". We give the refund policy 1.0 and the others 0.5. That is a judgement: every refund answer must follow the refund policy, while the other five matter only for some tickets.
- `lastModified` is an ISO 8601 time, here each policy's `Updated:` date.

At the pinned revision, results of `resources/list` and `resources/read` carry two cache hints: `ttlMs`, how long the result stays fresh, and `cacheScope`, whether a cache may share it across callers ([caching](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching)). The SDK's default is a `ttlMs` of 0 and the `private` scope (`mcp/server/caching.py`). The policies are the same for every caller and change rarely, so this server marks both methods `public` for an hour.

Example `list-policy-resources` (Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0), passing in Edition 2026.11.5:

```python
"""Serve the shop's six policies as static resources, then list them and read the refund policy from a client."""
import asyncio
import sys

from mcp import Client, StdioServerParameters
from mcp.server.caching import CacheHint
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.resources import FileResource
from mcp.types import Annotations

from lib.policies import load_policies

HOUR_MS = 3_600_000
server = MCPServer("shop-support", version="0.4.0", cache_hints={   # policies are the same for every caller
    "resources/list": CacheHint(ttl_ms=HOUR_MS, scope="public"),
    "resources/read": CacheHint(ttl_ms=HOUR_MS, scope="public")})

for policy in load_policies():
    server.add_resource(FileResource(
        uri=f"shop://policies/{policy.slug}", name=f"{policy.slug}-policy", title=policy.title,
        description=policy.summary, mime_type="text/markdown", path=policy.path,
        annotations=Annotations(audience=["assistant"], priority=1.0 if policy.slug == "refund" else 0.5,
                                last_modified=f"{policy.updated}T00:00:00Z")))


async def main() -> None:
    params = StdioServerParameters(command=sys.executable, args=[__file__, "--serve"], env={})
    async with Client(params) as client:
        print("protocol revision:", client.protocol_version)
        listed = await client.list_resources()
        print("resources:", len(listed.resources))
        for resource in listed.resources:
            print(f"  {resource.uri}  {resource.title!r}  priority {resource.annotations.priority}")
        print("list cache scope:", listed.cache_scope)
        print("list ttl ms:", listed.ttl_ms)
        read = await client.read_resource("shop://policies/refund")
        contents = read.contents[0]
        print("mime type:", contents.mime_type)
        print("first line:", contents.text.splitlines()[0])
        print("read cache scope:", read.cache_scope)
        print("read ttl ms:", read.ttl_ms)


if __name__ == "__main__":
    server.run() if "--serve" in sys.argv else asyncio.run(main())
```

Output:

```text
protocol revision: 2026-07-28
resources: 6
  shop://policies/data-handling  'Data-handling policy'  priority 0.5
  shop://policies/escalation  'Escalation policy'  priority 0.5
  shop://policies/refund  'Refund policy'  priority 1.0
  shop://policies/returns  'Returns policy'  priority 0.5
  shop://policies/shipping  'Shipping policy'  priority 0.5
  shop://policies/stock  'Stock policy'  priority 0.5
list cache scope: public
list ttl ms: 3600000
mime type: text/markdown
first line: # Refund policy
read cache scope: public
read ttl ms: 3600000
```

The client sees 6 resources, sorted by slug, and only the refund policy has priority 1.0. The list came back with scope `public` and a lifetime of 3600000 milliseconds, so a client may reuse it for an hour and share it between users. Reading `shop://policies/refund` returned the file as `text/markdown` text, starting with the line `# Refund policy`, under the same scope, `public`. [Cache list results](#cache-list-results) shows what a client does with the hints.

Nothing in that exchange put the refund policy in front of a model. The server only published it, and the host decides when to include it. The book's host loop does that for every refund ticket in [Put resources in the model's context](#resources-in-context).

**Working rule:** Publish the documents a host should be able to put in front of a model as static resources, under a scheme that names your system, with a MIME type and a description the host can show. Use `https://` only for resources the client can fetch without your server. Mark results `public` only when they are the same for every caller, and give a high priority only to the one or two documents every answer depends on, not to all of them.

## Address records with resource templates {#resource-templates}

The six policies in [the previous section](#policies-as-resources) fit in a list, but the shop's data holds far more orders, 501 of them with IDs that start with `O-2` alone, and listing each as a resource would be useless to a host and expensive to serve. A **resource template** names many resources with one URI template, in the syntax of RFC 6570 ([RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570)). A client lists templates with `resources/templates/list`, fills in the variables, and reads the result with `resources/read` like any other resource ([resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources)).

In the Python SDK, the URI given to `@server.resource()` decides which kind you get. A URI with a variable in braces registers a template, and the function's parameters must match the variables; a URI without one registers a static resource (`mcp/server/mcpserver/server.py`, `resource`). A function that returns a `str` gives text, `bytes` give a blob, and anything else is converted to JSON.

A template also changes what a miss means. At the pinned revision, a server answers a read of a resource that doesn't exist with the JSON-RPC error -32602, Invalid Params, with the URI in the error's `data`. It "MUST NOT return an empty `contents` array" instead ([resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources)). Revision 2025-11-25 used -32002 for the same case, and clients should still accept that code from older servers ([changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog)). In `MCPServer`, a function raises `ResourceNotFoundError` to get -32602. Any other `ResourceError` becomes -32603, and unlike a tool, a resource has no `isError` result to fail with ([handling errors](https://py.sdk.modelcontextprotocol.io/v2/servers/handling-errors/)).

The shop's order template is `shop://orders/{order_id}`. Its function wraps `GET /orders/{order_id}` on the shop's API, the same endpoint that `get_order_status` wraps, and raises `ResourceNotFoundError` when the API answers 404. An order belongs to one customer, so the read keeps the SDK's default cache hint, `private` with a lifetime of 0. The caching page names `private` as the scope for `resources/read` results "that depend on the authenticated user" ([caching](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching)). The function first checks that the variable looks like an order ID, `O-` and four digits, and raises `ResourceNotFoundError` if it doesn't, so a malformed ID never reaches the API. The client reads one real order, one that doesn't exist, one ID in the wrong format, and one URI whose variable tries to climb out of the order's path.

Example `order-resource-template` (Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0), passing in Edition 2026.11.5:

```python
"""Address any order as shop://orders/{order_id}: a resource template over the shop's API, and what a miss returns."""
import asyncio
import json
import re
import sys

from mcp import Client, MCPError, StdioServerParameters
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ResourceNotFoundError

from lib import loopback, shop_api, shop_reads

server = MCPServer("shop-support", version="0.4.0")   # default cache hint: ttl 0, scope private


@server.resource("shop://orders/{order_id}", title="One of the shop's orders", mime_type="application/json")
async def order(order_id: str) -> dict:
    """One order: its status, dates, lines and total, as the shop's API returns it."""
    if not re.fullmatch(r"O-\d{4}", order_id):                      # check the format before the API sees it
        raise ResourceNotFoundError(f"Order IDs look like O-1873, not {order_id!r}.")
    if (found := await shop_reads.get_json(f"/orders/{order_id}")) is None:    # the API answered 404
        raise ResourceNotFoundError(f"The shop has no order {order_id}.")
    return found


async def main() -> None:
    with loopback.serve(shop_api.create_app()) as base_url:
        params = StdioServerParameters(command=sys.executable, args=[__file__, "--serve"],
                                       env=shop_api.server_env(base_url))
        async with Client(params) as client:
            template = (await client.list_resource_templates()).resource_templates[0]
            print("template:", template.uri_template)
            read = await client.read_resource("shop://orders/O-2476")
            print("O-2476 status:", json.loads(read.contents[0].text)["status"])
            print("read cache scope:", read.cache_scope)
            for case, uri in (("unknown order", "shop://orders/O-7144"),
                              ("malformed", "shop://orders/2476"),
                              ("traversal", "shop://orders/..%2F..%2Frefunds")):
                try:
                    await client.read_resource(uri)
                except MCPError as err:
                    print(f"{case} code:", err.error.code)
                    print(f"{case} says:", err.error.message)
                    print(f"{case} data uri:", err.error.data["uri"])


if __name__ == "__main__":
    server.run() if "--serve" in sys.argv else asyncio.run(main())
```

Output:

```text
template: shop://orders/{order_id}
O-2476 status: shipped
read cache scope: private
unknown order code: -32602
unknown order says: The shop has no order O-7144.
unknown order data uri: shop://orders/O-7144
malformed code: -32602
malformed says: Order IDs look like O-1873, not '2476'.
malformed data uri: shop://orders/2476
traversal code: -32602
traversal says: Unknown resource: shop://orders/..%2F..%2Frefunds
traversal data uri: shop://orders/..%2F..%2Frefunds
```

The template comes back as `shop://orders/{order_id}`, and order O-2476 reads as shipped, with cache scope `private`. The unknown order, O-7144, fails with code -32602, the handler's own message, and the URI in the error's data, `shop://orders/O-7144`. The ID `2476` fails with the same code, -32602, and the format check's message, `Order IDs look like O-1873, not '2476'.`.

The last read fails with the same code, -32602, but its message is the SDK's, `Unknown resource: shop://orders/..%2F..%2Frefunds`, not the handler's. The SDK decoded the variable to `../../refunds`, found `..` path segments in it, and refused the URI before the function ran. That check is `ResourceSecurity`, which by default rejects `..` segments, absolute paths and NUL bytes in template variables, "regardless of how the value was encoded in the URI" (`mcp/server/mcpserver/resources/templates.py`). Without it, the handler would have put `../../refunds` into the path of its request to the shop's API. The specification requires servers to validate every resource URI ([resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources)), and the SDK's check is a backstop for that, not a replacement. `2476` passes `ResourceSecurity`, and only the handler's format check stops it.

This template reads any order for whoever holds the server's API key, which suits the support team's host. [The next section](#argument-completion) scopes reads to one customer.

**Working rule:** Use a resource template for records that share one shape and an ID, return the API's own JSON, and raise `ResourceNotFoundError` for a miss so the client gets -32602 with the URI. Keep the scope `private` for anything that belongs to a user. Leave the SDK's `ResourceSecurity` on, and validate the variable's format yourself too. Exempt a variable from the check only when it must carry a path, and then check the path yourself.

## Complete arguments for the user {#argument-completion}

A host that shows the order template from [the previous section](#resource-templates) to a person needs them to fill in `order_id`, and few people remember order IDs. **Argument completion** lets the host ask the server for suggestions while the user types. The host sends `completion/complete` with a reference, either `ref/resource` with a template's URI or `ref/prompt` with a prompt's name, the argument's name and its partial value, and optionally `context.arguments` with the values the user has already chosen for other arguments. The result holds `values`, at most 100 of them, with an optional `total` and `hasMore`. A server that offers completion declares the `completions` capability ([completion](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion)).

In the Python SDK, one `@server.completion()` handler serves every template and prompt on the server. It receives the reference, the argument and the context, and returns a `Completion` or `None`. Registering it declares the capability, and the SDK doesn't filter for you: "whatever you put in values is what the UI shows" ([completions](https://py.sdk.modelcontextprotocol.io/v2/servers/completions/)). It doesn't enforce the 100-item cap either. The `Completion` type's description says "Must not exceed 100 items", and nothing checks it (`mcp_types/_types.py`), so the handler has to cap the list itself.

Completion is also a lookup, and a lookup can leak. The specification requires servers to control access to sensitive suggestions and to "prevent completion-based information disclosure" ([completion](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion)). A handler that completes order IDs over the whole shop would tell anyone who types `O-2` which of the shop's orders exist.

So in the shop's case, the handler completes only the orders of the customer the server acts for. Over stdio, the host says who that is in the server's environment, as it passes the shop's API key. Here, `SHOP_CUSTOMER_ID` is C-0127, the customer who placed order O-2476. Over HTTP, [Authorize clients with OAuth](#authorization) replaces the variable with what an access token says: a support host's token names a member of staff, so the customer comes from the ticket the server looks up, and only where customers sign in themselves is the token's subject the customer ([Keep the server from acting as a confused deputy](#confused-deputy)). The handler asks the shop's API for that customer's orders and keeps those that start with what the user typed. The API returns at most 100 of a customer's orders, newest first (`examples/lib/shop_api.py`), so the answer can never pass the protocol's cap, and the handler keeps the API's order: for a support ticket, the newest orders are the likeliest, and the specification asks for suggestions "sorted by relevance" ([completion](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion)). The API's response also carries the customer's total number of orders. When that total is more than the API returned, older orders might match too, so the handler leaves `total` out and sets `has_more`. Otherwise it reports the number of matches as `total`. The order template applies the same scope to reads: another customer's order gets the same not-found answer as an order that doesn't exist. A shared module, `lib.shop_reads`, holds the one async helper both handlers use to call the shop's API.

Example `complete-order-ids` (Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0), passing in Edition 2026.11.5:

```python
"""Complete order IDs for shop://orders/{order_id}, offering only the orders of the customer the server acts for."""
import asyncio
import os
import sys

from mcp import Client, MCPError
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
from mcp.types import Completion, ResourceTemplateReference

from lib import loopback, shop_api, shop_reads
server = MCPServer("shop-support", version="0.4.0")


@server.resource("shop://orders/{order_id}", title="One of your orders", mime_type="application/json")
async def order(order_id: str) -> dict:
    found = await shop_reads.get_order(order_id)                     # checks the ID's format before the API sees it
    if found is None or found["customer_id"] != os.environ["SHOP_CUSTOMER_ID"]:
        raise ResourceNotFoundError(f"You have no order {order_id}.")   # the same answer either way
    return found


@server.completion()
async def complete(ref, argument, context) -> Completion | None:
    if not isinstance(ref, ResourceTemplateReference) or argument.name != "order_id":
        return None
    page = await shop_reads.get_json("/orders", customer_id=os.environ["SHOP_CUSTOMER_ID"], limit=100)  # newest first
    matches = [o["order_id"] for o in page["orders"] if o["order_id"].startswith(argument.value.upper())]
    cut_short = page["total"] > len(page["orders"])          # older orders the API didn't return may match too
    return Completion(values=matches, total=None if cut_short else len(matches), has_more=cut_short)


async def main() -> None:
    print("shop orders starting O-2:", shop_reads.orders_starting("O-2"))
    print("most orders for one customer:", shop_reads.most_orders_per_customer())
    with loopback.serve(shop_api.create_app()) as base_url:
        async with Client(shop_reads.server_params(__file__, base_url, SHOP_CUSTOMER_ID="C-0127")) as client:
            for typed in ("O-2", "o-24"):
                ref = ResourceTemplateReference(uri="shop://orders/{order_id}")
                result = (await client.complete(ref, {"name": "order_id", "value": typed})).completion
                print(f"{typed} offers:", ", ".join(result.values), f"({result.total} in all)")
            for order_id in ("O-1767", "O-7144"):                 # another customer's order, and nobody's
                try:
                    await client.read_resource(f"shop://orders/{order_id}")
                except MCPError as err:
                    print(f"{order_id} answer:", err.error.code, err.error.message)


if __name__ == "__main__":
    server.run() if "--serve" in sys.argv else asyncio.run(main())
```

Output:

```text
shop orders starting O-2: 501
most orders for one customer: 100
O-2 offers: O-2476, O-2230 (2 in all)
o-24 offers: O-2476 (1 in all)
O-1767 answer: -32602 You have no order O-1767.
O-7144 answer: -32602 You have no order O-7144.
```

Over the whole shop, `O-2` would match 501 orders, more than the cap allows in one answer. Customer C-0127 typing `O-2` is offered O-2476, O-2230 (2 in all), and nothing else. Typing `o-24` in lower case is still offered O-2476 (1 in all), because the handler upper-cases what the user typed before it matches. Reading another customer's order, O-1767, returns `-32602 You have no order O-1767.`, and reading O-7144, which doesn't exist, returns `-32602 You have no order O-7144.`. The two answers differ only in the ID, so the caller can't tell an order that exists from one that doesn't.

The bound matters only for a customer with more than 100 orders, and in the shop's data the most orders any one customer has is 100. So for every customer the API returns all their orders, and the handler reports a `total`. A customer with a 101st order would be offered matches from the newest 100 only, with no `total` and `has_more` true. The example can't show that case, because no customer in the shop's data has that many.

`context.arguments` is useful for a prompt with more than one argument, such as narrowing order IDs to a customer the user picked first. Treat it as the user's input, never as authorization, because whoever calls the server chooses what it says. The specification also asks servers to rate-limit completion requests and clients to debounce them, since a host may send one per keystroke ([completion](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion)).

**Working rule:** Complete arguments only from what the caller may read, decided by who the server acts for, not by anything in the request. Filter by the typed prefix yourself, and keep the list within 100 values, because the SDK won't. Set `has_more` whenever the source you filtered was itself cut short, and report a `total` only when you counted every match. Give an inaccessible record the same not-found answer as a missing one, on reads and in completions alike.

## Tell clients when a resource changes {#resource-change-notifications}

A host that has put a resource in front of a model, such as an order from [the order template](#resource-templates), needs to know when the resource changes, or it keeps showing a stale copy. At the pinned revision, a client asks for change notifications with one request, `subscriptions/listen`. Its filter can name resource URIs to watch (`resourceSubscriptions`) and ask for list changes (`resourcesListChanged`, `toolsListChanged`, `promptsListChanged`), and the response is a long-lived stream of notifications ([subscriptions](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions)).

`subscriptions/listen` replaces `resources/subscribe`, `resources/unsubscribe` and the HTTP GET stream of the 2025 revisions ([changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog)). The SDK's `Client.subscribe_resource` is deprecated and talks only to 2025-era servers (`mcp/client/client.py`); [Inventory what the old server depends on](#inventory-2025-features) covers moving off it.

The stream has a fixed shape ([subscriptions](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/subscriptions)):

- The server first sends `notifications/subscriptions/acknowledged`, naming the subset of the filter it will honour.
- It then tags every notification with `io.modelcontextprotocol/subscriptionId`, the listen request's JSON-RPC ID, and "MUST NOT send notification types the client has not explicitly requested."
- A `notifications/resources/updated` message carries the URI and nothing else. It says that the resource changed, not what it holds, and the client reads it again.
- On stdio, "the server holds no subscription state across reconnections." After a dropped stream, the client sends `subscriptions/listen` again and refetches what it depends on.

In the Python SDK, server code publishes a change with `await ctx.notify_resource_updated(uri)`, which hands it to a subscription bus that every open stream reads from (`mcp/server/mcpserver/context.py`). On the client, `async with client.listen(resource_subscriptions=[...]) as changes` sends the request and waits for the acknowledgment, `changes.honored` holds what the server agreed to, and iterating over `changes` yields events. A graceful close by the server ends the loop, and an abrupt drop raises `SubscriptionLost` (`mcp/client/client.py`, `listen`). Nothing is replayed: in the SDK's words, "a dropped stream is not resumable" (`mcp/server/subscriptions.py`), and the example below shows it.

The shop's stock policy says that a negative count means a recount is due, and product KG-1018 has a negative count in the `south` warehouse. To show a change within one process, the example keeps the stock counts in the server's memory, standing in for the shop's stock system. It publishes each product's counts as `shop://stock/{sku}` and adds a tool, `set_on_hand`, that a warehouse's host would call after a recount: the tool replaces one count and publishes an update for that product's URI. The client watches KG-1018 and KG-9999, which doesn't exist. It records a recount of another product first, then one of KG-1018, and later closes the stream and opens it again.

Example `stock-resource-changes` (Python 3.12.15 · anyio 4.16.0 · cryptography 50.0.2 · httpx2 2.16.0 · jsonschema 4.26.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0), passing in Edition 2026.11.5:

```python
"""Watch shop://stock/{sku} with subscriptions/listen: an update arrives, the client re-reads, nothing is replayed."""
import asyncio
import sys

import anyio
from mcp import Client, StdioServerParameters
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.exceptions import ResourceNotFoundError

from lib.shop_data import WAREHOUSES, load_shop
from lib.shop_reads import stock_summary

server = MCPServer("shop-support", version="0.4.0")
counts = {(s.sku, s.warehouse): s.on_hand for s in load_shop().stock}   # stands in for the shop's stock system


@server.resource("shop://stock/{sku}", title="On-hand stock for one product", mime_type="application/json")
def stock(sku: str) -> dict:
    if (sku, WAREHOUSES[0]) not in counts:
        raise ResourceNotFoundError(f"The shop sells no product {sku}.")
    return {warehouse: counts[sku, warehouse] for warehouse in WAREHOUSES}


@server.tool()
async def set_on_hand(sku: str, warehouse: str, counted: int, ctx: Context) -> str:
    """Record a warehouse's recount of one product, replacing its on-hand count."""
    counts[sku, warehouse] = counted
    await ctx.notify_resource_updated(f"shop://stock/{sku}")
    return f"{sku} in {warehouse}: {counted}"


async def main() -> None:
    watched = ["shop://stock/KG-1018", "shop://stock/KG-9999"]           # KG-9999 doesn't exist
    async with Client(StdioServerParameters(command=sys.executable, args=[__file__, "--serve"], env={})) as client:
        async with client.listen(resource_subscriptions=watched) as changes:
            print("honoured:", " and ".join(changes.honored.resource_subscriptions))
            print("before:", await stock_summary(client, watched[0]))
            await client.call_tool("set_on_hand", {"sku": "KG-1026", "warehouse": "north", "counted": 0})
            await client.call_tool("set_on_hand", {"sku": "KG-1018", "warehouse": "south", "counted": 4})
            print("first event:", (await anext(changes)).uri)
            print("after:", await stock_summary(client, watched[0]))
        await client.call_tool("set_on_hand", {"sku": "KG-1018", "warehouse": "south", "counted": 3})
        async with client.listen(resource_subscriptions=watched) as changes:
            with anyio.move_on_after(1.0) as waited:                      # wait a second for anything replayed
                await anext(changes)
            print("events after re-listening:", 0 if waited.cancelled_caught else "at least 1")
            print("re-read:", await stock_summary(client, watched[0]))


if __name__ == "__main__":
    server.run() if "--serve" in sys.argv else asyncio.run(main())
```

Output:

```text
honoured: shop://stock/KG-1018 and shop://stock/KG-9999
before: north 2, south -6
first event: shop://stock/KG-1018
after: north 2, south 4
events after re-listening: 0
re-read: north 2, south 3
```

The acknowledgment honours both URIs, shop://stock/KG-1018 and shop://stock/KG-9999, including the product the shop doesn't sell. The SDK honours every URI a client asks for, whether or not it exists and whether or not the caller may read it (`mcp/server/subscriptions.py`), and [What goes wrong in production](#resources-and-prompts-in-production) comes back to that.

Before the recount, KG-1018 reads north 2, south -6. The recount of KG-1026 sent nothing on this stream, because nobody asked to watch it, and the first event names shop://stock/KG-1018. Reading it again gives north 2, south 4. The client then closed the stream, and a second recount set the `south` count to 3 while nobody was listening. The client waited a second on the reopened stream, and 0 events arrived. The notification for that change was gone for good, and only the re-read, north 2, south 3, shows it.

The in-memory bus reaches only the streams in its own process. With two replicas of the server, a change on one never reaches a stream on the other, so replicas need a shared bus ([subscriptions](https://py.sdk.modelcontextprotocol.io/v2/handlers/subscriptions/)). [Run replicas without sticky sessions](#scale-without-sessions) runs the shop's server that way.

**Working rule:** Watch only the resources the host has put in front of a model or a user, and treat each event as "read this again", never as the new value. Re-read everything you depend on whenever you open a stream, the first time and after every drop, because nothing is replayed. Publish an update for the exact URI clients read, from the code path that makes the change.

## Package a workflow as a prompt {#workflows-as-prompts}

The resources in [the previous sections](#policies-as-resources) give a host the shop's data, but some of the support team's work is a fixed procedure that a person starts, such as reviewing a refund before anyone issues it. A **prompt** packages such a procedure: a named message template that the server offers and the user picks, for example as a slash command. The user decides when it's used and the server writes it, as [Prompts are templates the user chooses](#prompts-user-chooses) quoted.

A prompt has a `name`, an optional `title` and `description`, and `arguments`, each with a name, a description and whether it is required. A client lists prompts with `prompts/list` and fills one in with `prompts/get`, which returns `messages`. Each message has the role `user` or `assistant` and one content block: text, an image, audio, a resource link, or an embedded resource, which carries a resource's contents inside the message. There is no `system` role, so a prompt is not a system prompt, and the host keeps its own. The same page says implementations "MUST carefully validate all prompt inputs and outputs to prevent injection attacks or unauthorized access to resources."

In the Python SDK, `@server.prompt()` takes the prompt's name from the function, its arguments from the parameters, and its description from the docstring. The function returns the messages, and a bare string or content block becomes one `user` message (`mcp/server/mcpserver/prompts/base.py`). An `MCPError` the function raises reaches the client unchanged. For an unknown prompt or a missing required argument, though, the SDK raises a plain `ValueError` (`mcp/server/mcpserver/server.py`, `get_prompt`), where the specification assigns -32602 to both ([schema](https://modelcontextprotocol.io/specification/2026-07-28/schema)).

In the shop's case, the support team reviews each refund request against the refund policy, which says that a refund above €150.00 needs a support lead to confirm it. Chapter 2's `review_refund` took only an `order_id`, and its one message told the model which tool and resource to fetch. Here it gains a second argument, `amount`, and carries what the review needs itself. It checks that both look like the shop's formats before it calls anything, looks the order up through the shop's API, and returns two `user` messages. The first embeds the refund policy under its URI, `shop://policies/refund`, so the host needn't read it separately. The second quotes the order and asks the review's three questions. The client fills the prompt in for order O-1145, whose customer asks for €343.49, then tries a path where an order ID belongs, a missing argument and a misspelled prompt name.

Example `refund-review-prompt` (Python 3.12.15 · cryptography 50.0.2 · httpx2 2.16.0 · mcp 2.3.0 · mcp-types 2.3.0 · pydantic 2.14.2 · starlette 1.10.0 · uvicorn 0.54.0), passing in Edition 2026.11.5:

```python
"""Package a refund review as a prompt: the refund policy embedded, the order quoted, the arguments checked first."""
import asyncio
import json
import re
import sys

from mcp import Client, MCPError, StdioServerParameters
from mcp.server.mcpserver import MCPServer
from mcp.types import INVALID_PARAMS, EmbeddedResource, TextResourceContents

from lib import loopback, shop_api, shop_reads
from lib.policies import policy

server = MCPServer("shop-support", version="0.4.0")


@server.prompt(title="Review a refund request")
async def review_refund(order_id: str, amount: str) -> list[EmbeddedResource | str]:
    """Check a customer's refund request against the refund policy before a person issues it."""
    if not re.fullmatch(r"\d{1,5}\.\d{2}", amount):
        raise MCPError(INVALID_PARAMS, f"Amounts look like 24.99, not {amount!r}.")
    if not re.fullmatch(r"O-\d{4}", order_id) or (order := await shop_reads.get_json(f"/orders/{order_id}")) is None:
        raise MCPError(INVALID_PARAMS, f"No order {order_id!r}. Order IDs look like O-1873.")
    return [EmbeddedResource(type="resource", resource=TextResourceContents(       # each item: one user message
                uri="shop://policies/refund", mime_type="text/markdown", text=policy("refund").text)),
            (f"Order {order_id}: {json.dumps(order)}\n\nThe customer asks for a refund of {amount}. "
             "From the policy and the order only: does it qualify, how much may the shop refund, "
             "and must a support lead confirm it?")]


async def main() -> None:
    good = {"order_id": "O-1145", "amount": "343.49"}
    tries = {"path as order_id": ("review_refund", {**good, "order_id": "../refunds"}),
             "missing amount": ("review_refund", {"order_id": "O-1145"}),
             "unknown prompt": ("refund_review", good)}
    with loopback.serve(shop_api.create_app()) as base_url:
        env = shop_api.server_env(base_url)
        async with Client(StdioServerParameters(command=sys.executable, args=[__file__, "--serve"], env=env)) as client:
            for n, message in enumerate((await client.get_prompt("review_refund", good)).messages, 1):
                print(f"message {n}:", message.role, message.content.type)
            for case, (name, arguments) in tries.items():
                try:
                    await client.get_prompt(name, arguments)
                except MCPError as err:
                    print(f"{case}:", err.error.code, err.error.message)


if __name__ == "__main__":
    server.run() if "--serve" in sys.argv else asyncio.run(main())
```

Output:

```text
message 1: user resource
message 2: user text
path as order_id: -32602 No order '../refunds'. Order IDs look like O-1873.
missing amount: -32603 Internal server error
unknown prompt: -32603 Internal server error
```

The filled-in prompt is two messages. The first is `user resource`: a user message whose content is the embedded refund policy. The second is `user text`, the order and the questions. The example makes no model call. The messages are what a host sends to a model when a person on the support team picks the prompt, and [Run the host loop](#run-the-host-loop) makes that turn.

The failures show where the SDK and the specification part. The path, `../refunds`, never reached the shop's API: the format check raised `MCPError` with `INVALID_PARAMS`, and the client got `-32602 No order '../refunds'. Order IDs look like O-1873.`. The two failures the SDK handles itself came back differently. A missing amount gave `-32603 Internal server error`, and the misspelled name gave `-32603 Internal server error`. That is the internal-error code, not the -32602 the specification assigns, and the SDK's own message is hidden behind a generic one, so a host can't tell either mistake from a crash in the server.

Prompt arguments also become text a model reads. A customer's words pasted into a free-text argument would reach the model inside a `user` message, which is one reason the prompt accepts only an order ID and a decimal amount. [Treat ticket text as untrusted data](#untrusted-tool-output) covers that path in general.

**Working rule:** Offer a prompt for a procedure a person starts by name, and keep its arguments to values with a format you can check. Embed the resources the procedure depends on, so the host needn't fetch them. Validate every argument before you use it, and raise `MCPError` with `INVALID_PARAMS` for a bad one, because the SDK's own checks answer with an internal error.

## Choose between a resource and a tool {#resource-or-tool}

[Put each capability in the right primitive](#choose-a-primitive) settled most of this choice by who decides when the data is used. It left one case open: data that is both. [The previous sections](#policies-as-resources) published the same kinds of shop data that the tools in [Shape tools around tasks, not endpoints](#tasks-not-endpoints) return: an order from `get_order_status`, a product's stock from `check_stock`. What follows is judgement, not measurement.

The order template lets a host attach the order a ticket is about before the model sees the ticket, and `get_order_status` lets the model look up an order it finds in the ticket's text. Keep both reading through the same API call, so they can't disagree.

When a tool finds records the host may want to keep, it can return resource links to them instead of their full contents, and the host reads the ones it needs. Resource links returned by tools "are not guaranteed to appear in the results of a `resources/list` request" ([tools](https://modelcontextprotocol.io/specification/2026-07-28/server/tools)), so a server can link to records that only a template addresses.

**Working rule:** Offer a record as both a resource and a tool when hosts attach it and models look it up, and serve both from one code path so they can't disagree. When a tool finds records the host may keep, return resource links to them rather than their contents.

## What goes wrong in production {#resources-and-prompts-in-production}

The shop's resources and prompts work on one machine with one caller. In production, with many callers, caches, replicas and hosts the shop didn't write, they fail in a handful of ways, each with a sign you can watch for.

**A per-user read gets a public cache scope.** A cache "MUST NOT" be shared across authorization contexts, and `public` tells it that the result may be ([caching](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/caching)). In `MCPServer`, whose resource functions can't set the hint, `cache_hints` sets one hint per method, so a server that serves public policies and private orders gets the same hint on every `resources/read` (`mcp/server/mcpserver/server.py`, `_handle_read_resource`). Keep `resources/read` private on a server that serves anything per user, or serve the policies from a server of their own. The sign is a user who sees an order that isn't theirs, which no test with one user finds. And `ttlMs` is not a polling interval: clients "SHOULD NOT" poll on it.

**Lists differ between connections.** `resources/list` and `prompts/list` must not vary per connection, as [Cache list results](#cache-list-results) said of the tool list. Each list may vary by the authorization on the request, and may change over time if the server sends a list-changed notification ([resources](https://modelcontextprotocol.io/specification/2026-07-28/server/resources), [prompts](https://modelcontextprotocol.io/specification/2026-07-28/server/prompts)). A server that adds a resource after a tool call breaks every client's cache. The sign is a list that changes after a tool call, or with no list-changed notification.

**A client watches what it can't read.** The SDK honours every URI a client asks to watch, as [the stock example](#resource-change-notifications) showed with a product the shop doesn't sell. A client watching another customer's order would then learn that the order exists, and when it changed. Gate the filter with middleware on `subscriptions/listen` that rejects URIs the caller may not read ([subscriptions](https://py.sdk.modelcontextprotocol.io/v2/handlers/subscriptions/)). The sign is an acknowledgment that echoes URIs the caller could never read.

**An update never matches.** `MCPServer` compares URIs as exact strings ([subscriptions](https://py.sdk.modelcontextprotocol.io/v2/handlers/subscriptions/)), so a client watching `shop://stock/kg-1018` never hears about `shop://stock/KG-1018`. The specification also allows an update to name a sub-resource of the watched URI ([schema](https://modelcontextprotocol.io/specification/2026-07-28/schema)), and `MCPServer` never matches one. The sign is a client that refreshes only when it reconnects. Publish the exact URI your templates produce, and normalize IDs before you build a URI.

**A dropped stream goes unnoticed.** Nothing is replayed, so a host that loses its stream and never re-listens shows stale data indefinitely, and one that re-listens without re-reading misses every change made while it was away, as [the stock example](#resource-change-notifications) showed (`mcp/server/subscriptions.py`). Catch `SubscriptionLost`, re-listen, then re-read.

**Completion becomes an enumeration endpoint.** A handler that completes from every record, or trusts `context.arguments` for access, lets anyone list what exists, one keystroke at a time. A host that sends a request per keystroke also multiplies the load ([completion](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion)). Scope suggestions to the caller, cap them, rate-limit per caller, and watch completion request rates as you would login attempts.

**Prompt errors look like crashes.** The SDK answers an unknown prompt or a missing argument with -32603, as [the prompt example](#workflows-as-prompts) showed, so a host's logs mix users' mistakes with server faults. Raise `MCPError` with `INVALID_PARAMS` for every argument you reject, and alert on the internal errors that remain.

**Working rule:** Before resources and prompts go to production, test them with two callers. Neither may see, complete or watch the other's records, and neither should see a list change without a list-changed notification. Check that every `resources/read` of a user's data carries the `private` scope, and that a host recovers from a dropped stream by re-listening and re-reading.

## Decisions this chapter made {#resources-and-prompts-decisions}

- **The shop's policies are six static resources** at `shop://policies/{slug}`, served from Markdown files written for the book, with the refund policy at priority 1.0 as a judgement.
- **The policies' list and reads are `public` for an hour,** because they are the same for every caller. Orders keep the `private` default.
- **Orders are a resource template,** `shop://orders/{order_id}`, over the same API endpoint as `get_order_status`, and a miss raises `ResourceNotFoundError` for -32602.
- **The SDK's `ResourceSecurity` stays on,** and handlers still check the format of what they're given.
- **Completion offers only the caller's own orders,** newest first and bounded by the API's limit of 100, with `has_more` when the API held more back. Another customer's order reads as not found.
- **Who the server acts for comes from the environment** over stdio. Over HTTP, the token's subject says who is calling: for the support host, a member of staff, with the customer taken from the ticket record ([Authorize clients with OAuth](#authorization), [Keep the server from acting as a confused deputy](#confused-deputy)).
- **The stock example keeps counts in the server's memory,** standing in for the shop's stock system, and the `set_on_hand` tool publishes the update from the code path that changes the count.
- **Change notifications use `subscriptions/listen`,** never `resources/subscribe`, and the client re-reads on every event and every new stream.
- **No model call in this chapter.** The prompt's messages are the input to a model turn that the book's host loop makes later.
- **The `review_refund` prompt embeds the refund policy,** checks its arguments' formats first, and raises `MCPError` with `INVALID_PARAMS` itself.
- **Data the host attaches is a resource, and data the model looks up is a tool.** A record can be both, served from one code path.
