Skip to content

Commit a5afb98

Browse files
committed
docs: add resources guide covering templates, security, and low-level usage
Adds docs/server/resources.md as the first page under the planned docs/server/ directory. Covers static resources, RFC 6570 template patterns, the built-in security checks and how to relax them, the safe_join pattern for filesystem handlers, and equivalent patterns for low-level Server implementations. Creates the docs/server/ nav section in mkdocs.yml.
1 parent 00a1336 commit a5afb98

File tree

2 files changed

+366
-0
lines changed

2 files changed

+366
-0
lines changed

docs/server/resources.md

Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
1+
# Resources
2+
3+
Resources give clients read-only access to your data. Think of them as
4+
the files, records, and reference material an LLM might need as context:
5+
a config file, a database schema, the contents of a document, yesterday's
6+
log output.
7+
8+
Resources are different from tools. A tool is something the model
9+
*calls* to make something happen: send an email, run a query, write a
10+
file. A resource is something the application *reads* to understand the
11+
world. Reading a resource should not change state or kick off expensive
12+
work. If it does either, you probably want a tool.
13+
14+
## A static resource
15+
16+
The simplest case is a fixed URI that returns the same kind of content
17+
every time.
18+
19+
```python
20+
from mcp.server.mcpserver import MCPServer
21+
22+
mcp = MCPServer("docs-server")
23+
24+
25+
@mcp.resource("config://features")
26+
def feature_flags() -> str:
27+
return '{"beta_search": true, "new_editor": false}'
28+
```
29+
30+
When a client reads `config://features`, your function runs and the
31+
return value is sent back. Return `str` for text, `bytes` for binary
32+
data, or anything JSON-serializable.
33+
34+
The URI scheme (`config://` here) is up to you. The protocol reserves
35+
`file://` and `https://` for their usual meanings, but custom schemes
36+
like `config://`, `db://`, or `notes://` are encouraged. They make the
37+
URI self-describing.
38+
39+
## Resource templates
40+
41+
Most interesting data is parameterized. You don't want to register a
42+
separate resource for every user, every file, every database row.
43+
Instead, register a template with placeholders:
44+
45+
```python
46+
@mcp.resource("tickets://{ticket_id}")
47+
def get_ticket(ticket_id: str) -> dict:
48+
ticket = helpdesk.find(ticket_id)
49+
return {"id": ticket_id, "subject": ticket.subject, "status": ticket.status}
50+
```
51+
52+
The `{ticket_id}` in the URI maps to the `ticket_id` parameter in your
53+
function. A client reading `tickets://TKT-1042` calls
54+
`get_ticket("TKT-1042")`. Reading `tickets://TKT-2001` calls
55+
`get_ticket("TKT-2001")`. One template, unlimited resources.
56+
57+
### Parameter types
58+
59+
Extracted values arrive as strings, but you can declare a more specific
60+
type and the SDK will convert:
61+
62+
```python
63+
@mcp.resource("orders://{order_id}")
64+
def get_order(order_id: int) -> dict:
65+
# "12345" from the URI becomes the int 12345
66+
return db.orders.get(order_id)
67+
```
68+
69+
### Multi-segment paths
70+
71+
A plain `{name}` matches a single URI segment. It stops at the first
72+
slash. To match across slashes, use `{+name}`:
73+
74+
```python
75+
@mcp.resource("files://{+path}")
76+
def read_file(path: str) -> str:
77+
# Matches files://readme.txt
78+
# Also matches files://guides/quickstart/intro.md
79+
...
80+
```
81+
82+
This is the pattern you want for filesystem paths, nested object keys,
83+
or anything hierarchical.
84+
85+
### Query parameters
86+
87+
Optional configuration goes in query parameters. Use `{?name}` or list
88+
several with `{?a,b,c}`:
89+
90+
```python
91+
@mcp.resource("logs://{service}{?since,level}")
92+
def tail_logs(service: str, since: str = "1h", level: str = "info") -> str:
93+
return log_store.query(service, since=since, min_level=level)
94+
```
95+
96+
Reading `logs://api` uses the defaults. Reading
97+
`logs://api?since=15m&level=error` narrows it down. The path identifies
98+
*which* resource; the query tunes *how* you read it.
99+
100+
### Path segments as a list
101+
102+
If you want each path segment as a separate list item rather than one
103+
string with slashes, use `{/name*}`:
104+
105+
```python
106+
@mcp.resource("tree://nodes{/path*}")
107+
def walk_tree(path: list[str]) -> dict:
108+
# tree://nodes/a/b/c gives path = ["a", "b", "c"]
109+
node = root
110+
for segment in path:
111+
node = node.children[segment]
112+
return node.to_dict()
113+
```
114+
115+
### Template reference
116+
117+
The template syntax follows [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570).
118+
Here's what the SDK supports:
119+
120+
| Pattern | Example input | You get |
121+
|--------------|-----------------------|-------------------------|
122+
| `{name}` | `alice` | `"alice"` |
123+
| `{name}` | `docs/intro.md` | *no match* (stops at `/`) |
124+
| `{+path}` | `docs/intro.md` | `"docs/intro.md"` |
125+
| `{.ext}` | `.json` | `"json"` |
126+
| `{/segment}` | `/v2` | `"v2"` |
127+
| `{?key}` | `?key=value` | `"value"` |
128+
| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` |
129+
| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` |
130+
131+
## Security
132+
133+
Template parameters come from the client. If they flow into filesystem
134+
or database operations, a hostile client can try path traversal
135+
(`../../etc/passwd`) or injection attacks.
136+
137+
### What the SDK checks by default
138+
139+
Before your handler runs, the SDK rejects any parameter that:
140+
141+
- contains `..` as a path component
142+
- looks like an absolute path (`/etc/passwd`, `C:\Windows`)
143+
- smuggles a delimiter through URL encoding (for example, `%2F` in a
144+
plain `{name}` where `/` isn't allowed)
145+
146+
A request that trips these checks is treated as a non-match: the SDK
147+
raises `ResourceError("Unknown resource: {uri}")`, which the client
148+
receives as a JSON-RPC error. Your handler never sees the bad input.
149+
150+
### Filesystem handlers: use safe_join
151+
152+
The built-in checks stop obvious attacks but can't know your sandbox
153+
boundary. For filesystem access, use `safe_join` to resolve the path
154+
and verify it stays inside your base directory:
155+
156+
```python
157+
from mcp.shared.path_security import safe_join
158+
159+
DOCS_ROOT = "/srv/app/docs"
160+
161+
162+
@mcp.resource("files://{+path}")
163+
def read_file(path: str) -> str:
164+
full_path = safe_join(DOCS_ROOT, path)
165+
return full_path.read_text()
166+
```
167+
168+
`safe_join` catches symlink escapes, `..` sequences, and absolute-path
169+
tricks that a simple string check would miss. If the resolved path
170+
escapes the base, it raises `PathEscapeError`, which surfaces to the
171+
client as a `ResourceError`.
172+
173+
### When the defaults get in the way
174+
175+
Sometimes `..` in a parameter is legitimate. A git commit range like
176+
`HEAD~3..HEAD` contains `..` but it's not a path. Exempt that parameter:
177+
178+
```python
179+
from mcp.server.mcpserver import ResourceSecurity
180+
181+
182+
@mcp.resource(
183+
"git://diff/{+range}",
184+
security=ResourceSecurity(exempt_params={"range"}),
185+
)
186+
def git_diff(range: str) -> str:
187+
return run_git("diff", range)
188+
```
189+
190+
Or relax the policy for the whole server:
191+
192+
```python
193+
mcp = MCPServer(
194+
resource_security=ResourceSecurity(reject_path_traversal=False),
195+
)
196+
```
197+
198+
The configurable checks:
199+
200+
| Setting | Default | What it does |
201+
|-------------------------|---------|-------------------------------------|
202+
| `reject_path_traversal` | `True` | Rejects `..` as a path component |
203+
| `reject_absolute_paths` | `True` | Rejects `/foo`, `C:\foo`, UNC paths |
204+
| `exempt_params` | empty | Parameter names to skip checks for |
205+
206+
## Errors
207+
208+
If your handler can't fulfil the request, raise an exception. The SDK
209+
turns it into an error response:
210+
211+
```python
212+
@mcp.resource("articles://{article_id}")
213+
def get_article(article_id: str) -> str:
214+
article = db.articles.find(article_id)
215+
if article is None:
216+
raise ValueError(f"No article with id {article_id}")
217+
return article.content
218+
```
219+
220+
## Resources on the low-level server
221+
222+
If you're building on the low-level `Server`, you register handlers for
223+
the `resources/list` and `resources/read` protocol methods directly.
224+
There's no decorator; you return the protocol types yourself.
225+
226+
### Static resources
227+
228+
For fixed URIs, keep a registry and dispatch on exact match:
229+
230+
```python
231+
from mcp.server.lowlevel import Server
232+
from mcp.types import (
233+
ListResourcesResult,
234+
ReadResourceRequestParams,
235+
ReadResourceResult,
236+
Resource,
237+
TextResourceContents,
238+
)
239+
240+
RESOURCES = {
241+
"config://features": lambda: '{"beta_search": true}',
242+
"status://health": lambda: check_health(),
243+
}
244+
245+
246+
async def on_list_resources(ctx, params) -> ListResourcesResult:
247+
return ListResourcesResult(
248+
resources=[Resource(name=uri, uri=uri) for uri in RESOURCES]
249+
)
250+
251+
252+
async def on_read_resource(ctx, params: ReadResourceRequestParams) -> ReadResourceResult:
253+
if (producer := RESOURCES.get(params.uri)) is not None:
254+
return ReadResourceResult(
255+
contents=[TextResourceContents(uri=params.uri, text=producer())]
256+
)
257+
raise ValueError(f"Unknown resource: {params.uri}")
258+
259+
260+
server = Server(
261+
"my-server",
262+
on_list_resources=on_list_resources,
263+
on_read_resource=on_read_resource,
264+
)
265+
```
266+
267+
The list handler tells clients what's available; the read handler
268+
serves the content. Check your registry first, fall through to
269+
templates (below) if you have any, then raise for anything else.
270+
271+
### Templates
272+
273+
The template engine `MCPServer` uses lives in `mcp.shared.uri_template`
274+
and works on its own. You get the same parsing, matching, and
275+
structural checks; you wire up the routing and policy yourself.
276+
277+
#### Matching requests
278+
279+
Parse your templates once, then match incoming URIs against them in
280+
your read handler:
281+
282+
```python
283+
from mcp.server.lowlevel import Server
284+
from mcp.shared.uri_template import UriTemplate
285+
from mcp.types import ReadResourceRequestParams, ReadResourceResult, TextResourceContents
286+
287+
TEMPLATES = {
288+
"files": UriTemplate.parse("files://{+path}"),
289+
"row": UriTemplate.parse("db://{table}/{id}"),
290+
}
291+
292+
293+
async def on_read_resource(ctx, params: ReadResourceRequestParams) -> ReadResourceResult:
294+
if (vars := TEMPLATES["files"].match(params.uri)) is not None:
295+
content = read_file_safely(vars["path"])
296+
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=content)])
297+
298+
if (vars := TEMPLATES["row"].match(params.uri)) is not None:
299+
row = db.get(vars["table"], int(vars["id"]))
300+
return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=row.to_json())])
301+
302+
raise ValueError(f"Unknown resource: {params.uri}")
303+
304+
305+
server = Server("my-server", on_read_resource=on_read_resource)
306+
```
307+
308+
`UriTemplate.match()` returns the extracted variables or `None`. URL
309+
decoding and the structural checks (rejecting `%2F` in simple `{name}`
310+
and so on) happen inside `match()`, the same as in `MCPServer`.
311+
312+
Values come out as strings. Convert them yourself: `int(vars["id"])`,
313+
`Path(vars["path"])`, whatever your handler needs.
314+
315+
#### Applying security checks
316+
317+
The path traversal and absolute-path checks that `MCPServer` runs by
318+
default are in `mcp.shared.path_security`. Call them before using an
319+
extracted value:
320+
321+
```python
322+
from mcp.shared.path_security import contains_path_traversal, is_absolute_path, safe_join
323+
324+
DOCS_ROOT = "/srv/app/docs"
325+
326+
327+
def read_file_safely(path: str) -> str:
328+
if contains_path_traversal(path) or is_absolute_path(path):
329+
raise ValueError("rejected")
330+
return safe_join(DOCS_ROOT, path).read_text()
331+
```
332+
333+
If a parameter isn't a filesystem path (say, a git ref or a search
334+
query), skip the checks for that value. You control the policy per
335+
handler rather than through a config object.
336+
337+
#### Listing templates
338+
339+
Clients discover templates through `resources/templates/list`. Return
340+
the protocol `ResourceTemplate` type, using the same template strings
341+
you parsed above:
342+
343+
```python
344+
from mcp.types import ListResourceTemplatesResult, ResourceTemplate
345+
346+
347+
async def on_list_resource_templates(ctx, params) -> ListResourceTemplatesResult:
348+
return ListResourceTemplatesResult(
349+
resource_templates=[
350+
ResourceTemplate(name="files", uri_template=str(TEMPLATES["files"])),
351+
ResourceTemplate(name="row", uri_template=str(TEMPLATES["row"])),
352+
]
353+
)
354+
355+
356+
server = Server(
357+
"my-server",
358+
on_read_resource=on_read_resource,
359+
on_list_resource_templates=on_list_resource_templates,
360+
)
361+
```
362+
363+
`str(template)` gives back the original template string, so your list
364+
handler and your matching logic can share one source of truth.

mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ nav:
1616
- Migration Guide: migration.md
1717
- Documentation:
1818
- Concepts: concepts.md
19+
- Server:
20+
- Resources: server/resources.md
1921
- Low-Level Server: low-level-server.md
2022
- Authorization: authorization.md
2123
- Testing: testing.md

0 commit comments

Comments
 (0)