Free lesson · GenAI Agent Engineering
Implement MCP resources
You can expose MCP resources with stable URIs, implement resource templates, attach mimeType metadata, manage resource subscriptions for changing data, and decide between exposing data as a tool vs a resource.
Course: GenAI Agent Engineering · Chapter 24 · The MCP Server
Free to read — no subscription required.
Introduction
When building MCP servers, exposing data to an LLM requires more than just returning strings — clients need to discover what resources exist, fetch them by a stable URI, and receive updates when content changes. Without a consistent resource abstraction, each server invents its own ad-hoc approach, making integrations brittle and discovery impossible. In this lesson you will implement a complete ResourceProvider that registers ResourceDefinition objects, serves content through async handlers, manages subscriber callbacks for live updates, and constructs well-formed URIs for both file-based and custom-scheme resources.
Key Terminology
- ResourceDefinition — A Python
dataclassthat bundles all metadata for a single MCP resource: itsuri, human-readablename,description,mime_type, and anasynchandlercallable that fetches content on demand. - ResourceProvider — The central registry class that stores
ResourceDefinitionobjects keyed by URI, dispatchesread()calls to the appropriate handler, and manages per-URI subscriber callback lists via_resourcesand_subscriptionsdictionaries. - Resource URI — A stable, unique string identifier for an MCP resource, constructed either with
build_file_uri()for filesystem paths orbuild_custom_uri()for application-defined schemes;urllib.parsepercent-encoding ensures the URI is safe for transport. - Async handler — An
Optional[Callable[[], Awaitable[str]]]field onResourceDefinitionthatResourceProvider.read()awaits to retrieve current resource content without blocking the event loop. - Observer pattern — The subscription mechanism implemented by
subscribe(),unsubscribe(), andnotify_update()that lets clients registerasynccallbacks to receive pushed content whenever a resource changes, without the server needing to know which clients are listening. - MIME type — A metadata field on
ResourceDefinition(defaulting to"text/plain") that is forwarded in the MCPResourceprotocol object so clients know how to interpret the content they receive from aread()call.
Concepts
Resources as a Discoverable Contract
MCP resources give an LLM structured, URI-addressed access to data — configuration files, live metrics, database snapshots, or any content that enriches a prompt. The key design principle is that discovery and fetching are separated: get_resources() returns a list of lightweight Resource protocol objects (URI, name, description, MIME type) that a client can inspect without triggering any I/O. Only when a client explicitly calls read() does the provider invoke the async handler and fetch real content.
This separation lets a server advertise many resources at negotiation time while clients selectively pull only what their current task needs. The to_mcp_resource() method on ResourceDefinition handles the translation from the internal dataclass to the wire-level Resource type expected by the MCP SDK, keeping protocol concerns out of the domain model (see Code Walkthrough).
On-Demand Fetching and Explicit Error Boundaries
Fetching is deferred to an async handler callable rather than storing content directly on the ResourceDefinition. This matters because resources often represent live data — a file that changes, an API response, a queue depth — not a value known at registration time. Storing a callable means ResourceProvider invokes it at read time and always delivers fresh content.
read() enforces two distinct error boundaries: it raises ValueError for an unrecognized URI (the resource was never registered) and a separate ValueError when a registered resource has no handler (a definition-only placeholder). Both are explicit failures — the caller always knows why a read did not succeed rather than receiving None or a silent empty string.
Push Notifications via the Observer Pattern
Static reads cover most use cases, but some clients need to react to resource changes without polling. ResourceProvider implements the observer pattern through three methods: subscribe() appends a per-URI async callback to _subscriptions; unsubscribe() removes it by identity comparison; and notify_update() calls read() once to get current content, then fans it out to every registered callback in order.
This design keeps notification logic centralized — the server calls notify_update(uri) whenever content changes, and all interested clients receive the new value without knowing about each other. Crucially, the same async handler that serves explicit reads also serves the push path, so there is no duplication of fetch logic (see Code Walkthrough).
URI Construction Conventions
Every resource needs a URI that is stable, unique, and safe to embed in protocol messages. build_file_uri() wraps urllib.parse.quote() around a filesystem path to produce a valid file:// URI, handling spaces and special characters through percent-encoding. build_custom_uri() extends this to arbitrary application-defined schemes — db://, metrics://, or any custom prefix — and optionally appends query parameters via urllib.parse.urlencode(). Using these helpers instead of manual string concatenation ensures that edge-case characters in paths or parameter values never produce a malformed URI that clients cannot parse or dereference.
Code Walkthrough
Now that you've seen Resources as a Discoverable Contract, On-Demand Fetching and Explicit Error Boundaries, Push Notifications via the Observer Pattern, and URI Construction Conventions, this walkthrough turns them into working code.
Resource Structure and URIs
Resources provide read-only access to data that can enrich LLM context. Each resource is identified by a URI and has associated metadata including name, description, and MIME type. The following code defines a ResourceDefinition dataclass that captures all resource metadata alongside an async handler for fetching content. It also introduces the ResourceProvider class that manages resource registrations, handles read requests by delegating to the appropriate handler, and supports a subscription mechanism for notifying clients when resource content changes.
Code snippet python
1from dataclasses import dataclass, field 2from typing import Dict, Any, List, Optional, Callable, Awaitable 3from mcp.types import Resource, TextResourceContents, BlobResourceContents 4import urllib.parse 5 6@dataclass 7class ResourceDefinition: 8 """ 9 Definition of an MCP resource. 10 11 Attributes: 12 uri: Unique resource identifier (URI format) 13 name: Human-readable resource name 14 description: Description of the resource content 15 mime_type: MIME type of the content 16 handler: Async function that fetches the resource 17 """ 18 uri: str 19 name: str 20 description: str 21 mime_type: str = "text/plain" 22 handler: Optional[Callable[[], Awaitable[str]]] = None 23 24 def to_mcp_resource(self) -> Resource: 25 """Convert to MCP Resource type.""" 26 return Resource( 27 uri=self.uri, 28 name=self.name, 29 description=self.description, 30 mimeType=self.mime_type 31 ) 32 33class ResourceProvider: 34 """ 35 Provides access to MCP resources. 36 37 Manages resource definitions, handles read requests, 38 and supports resource subscriptions. 39 """
- Lines 1–4: Import dataclass utilities, typing helpers, MCP resource types, and
urllib.parsefor URI handling - Lines 7–23: Define
ResourceDefinitionwithuri,name,description,mime_type, and anasynchandlerfield - Lines 25–32: Implement
to_mcp_resource()converting the definition to the MCPResourceprotocol type - Lines 35–41: Define
ResourceProviderclass with a docstring describing its role in managing resources and subscriptions
The continuation below implements the ResourceProvider methods in full. The constructor initializes dictionaries for resource storage and subscription tracking. Registration, listing, and reading methods provide the core interface, while subscribe, unsubscribe, and notify_update implement the observer pattern for resource change notifications. Utility functions at the end construct properly formatted file URIs and custom-scheme URIs with optional query parameters.
Code snippet python
1 def __init__(self): 2 self._resources: Dict[str, ResourceDefinition] = {} 3 self._subscriptions: Dict[str, List[Callable]] = {} 4 5 def register(self, resource: ResourceDefinition) -> None: 6 """Register a resource definition.""" 7 self._resources[resource.uri] = resource 8 9 def get_resources(self) -> List[Resource]: 10 """Get all registered resources in MCP format.""" 11 return [r.to_mcp_resource() for r in self._resources.values()] 12 13 async def read(self, uri: str) -> str: 14 resource = self._resources.get(uri) 15 if not resource: 16 raise ValueError(f"Unknown resource: {uri}") 17 if resource.handler: 18 return await resource.handler() 19 raise ValueError(f"Resource has no handler: {uri}") 20 21 def subscribe(self, uri: str, callback: Callable[[str], Awaitable[None]]) -> None: 22 if uri not in self._subscriptions: 23 self._subscriptions[uri] = [] 24 self._subscriptions[uri].append(callback) 25 26 def unsubscribe(self, uri: str, callback: Callable[[str], Awaitable[None]]) -> None: 27 if uri in self._subscriptions: 28 self._subscriptions[uri] = [cb for cb in self._subscriptions[uri] if cb != callback] 29 30 async def notify_update(self, uri: str) -> None: 31 if uri in self._subscriptions: 32 content = await self.read(uri) 33 for callback in self._subscriptions[uri]: 34 await callback(content) 35 36def build_file_uri(path: str) -> str: 37 return f"file://{urllib.parse.quote(path)}" 38 39def build_custom_uri(scheme: str, path: str, params: Dict[str, str] = None) -> str: 40 uri = f"{scheme}://{path}" 41 if params: 42 uri = f"{uri}?{urllib.parse.urlencode(params)}" 43 return uri
- Lines 1–2: Constructor initializes
_resourcesand_subscriptionsdictionaries - Lines 4–10:
register()andget_resources()provide the registration and listing interface - Lines 12–19:
read()resolves a URI to its handler and awaits the content fetch, raisingValueErrorfor unknown or handler-less resources - Lines 21–27:
subscribe()andunsubscribe()manage per-URI callback lists using the observer pattern - Lines 29–32:
notify_update()reads the current content and fans out to all registered subscribers - Lines 35–41:
build_file_uri()andbuild_custom_uri()produce correctly percent-encoded URIs for file paths and custom schemes respectively
To verify the implementation is wired correctly, instantiate ResourceProvider, call register() with a ResourceDefinition whose handler returns a known string, then assert that get_resources() returns a list containing that URI and that await provider.read(uri) returns the expected string without raising.
Do's and Don'ts
Having walked through implementing resources above, the following Do's and Don'ts distill it into practice.
Do's
- ✓Do provide an
asynchandler on everyResourceDefinitionyou intend to serve —ResourceProvider.read()awaitsresource.handler()to fetch content; a definition registered without a handler raisesValueError("Resource has no handler")at read time even thoughget_resources()still advertises the URI as discoverable. - ✓Do use
build_file_uri()andbuild_custom_uri()instead of raw f-string concatenation when constructing resource URIs — both helpers callurllib.parse.quote()orurllib.parse.urlencode(), which percent-encodes spaces and special characters that would otherwise produce malformed URIs clients cannot dereference. - ✓Do call
subscribe()with a stable, named callback object rather than an inlinelambdawhen you will later need tounsubscribe()—unsubscribe()removes callbacks by object identity (cb != callback); a freshlambdacreated at each call site is a distinct object and will never match, leaving the callback permanently registered and receiving notifications after the caller has moved on.
Don'ts
- ✗Don't return
ResourceDefinitionobjects directly fromget_resources()— the MCP client expectsResourceprotocol objects (frommcp.types), whose field ismimeTypenotmime_type; theto_mcp_resource()conversion method exists specifically to produce the correct wire shape, and skipping it causes schema validation failures on the client side. - ✗Don't call
notify_update()before subscribers have been registered viasubscribe()—notify_update()fans out only to callbacks already present in_subscriptions[uri]at call time; any client that subscribes after the update fires receives no notification and sees stale content until the next change. - ✗Don't suppress or swallow the
ValueErrorthatread()raises for an unknown URI — that exception is the only signal that a URI was never passed toregister(); silently returning an empty string orNonein a caller-side except block hides registration bugs and causes the LLM context to be silently enriched with empty or wrong data.
This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the GenAI Agent Engineering subscription.
From · cancel anytime