|
| 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. |
0 commit comments