Tools, Resources, and Prompts in Practice
Dynamic tool discovery, letting a server ask the model something mid-task, resource URI design, and writing prompts that actually guide a workflow.
Discovery beats hardcoding
The Notebook server from the last chapter advertises its tools at connection time rather than the host having a fixed list baked in. That sounds like a small detail, but it's what makes a few real things possible: an assistant that picks up new capabilities the moment a server adds them, no client update required; tool selection based on what's actually available right now, not what was available when the assistant was built; and graceful degradation when a server goes offline, since the assistant can simply see it's gone rather than crash trying to call a hardcoded function.
Sampling: letting the server ask the model something
Most of what we've covered so far is the model calling the server. Sampling runs it the other way: a tool, mid-execution, can ask the AI model to generate something and use that result before finishing. Extend the Notebook server's cousin, a small pantry tracker, with a tool that asks the model to turn a list of ingredients into a suggestion:
@mcp.tool()
async def suggest_recipe(context: Context, available_items: list[str]) -> str:
"""Suggest a simple recipe using only the given pantry items"""
suggestion = await context.sample(
prompt=f"Suggest one simple recipe using only these ingredients: {', '.join(available_items)}. "
f"Keep it to 5 steps or fewer.",
max_tokens=300,
)
return suggestion
The tool itself doesn't know how to cook; it hands the reasoning back to the model and returns whatever comes back. This is the pattern behind self-improving tools and multi-stage workflows: a server can lean on the model's reasoning for the part that's genuinely a language task, while handling the parts that are genuinely deterministic (looking up what's actually in the pantry) itself.
Designing resource URIs that make sense
Resources are addressed by URI, and a little care here pays off the same way a well-designed REST API does:
pantry://items/{item_id}
pantry://shelf/{shelf_name}/items
inventory://expiring?within_days={n}
Prompts that guide a real workflow
A prompt in MCP isn't a single instruction; it's a reusable starting point for a multi-step task the server knows how to support. A weekly meal-planning prompt for the same pantry server might look like this:
@mcp.prompt()
async def weekly_meal_plan(context: Context) -> str:
"""Guided workflow for planning a week of meals from current pantry stock"""
return """I'll help you plan meals for the week using what's already in your pantry.
To get started, tell me:
1. How many meals you're planning for
2. Any dietary restrictions to work around
3. Whether you're open to a short grocery run for a few extra items
Once I know that, I'll check what's on hand, flag anything expiring soon so it gets used first, and suggest a plan that minimizes waste."""
The value isn't the text itself, it's that the prompt encodes the sequence a good outcome actually requires: gather constraints, check real inventory, prioritize what's expiring, then suggest. A user typing a one-line request would get a much shallower result than one walked through this sequence.
All three primitives showed up together in that last example: the prompt structures the conversation, a resource (expiring items) supplies real data partway through, and a tool eventually acts on the plan. Well-built MCP servers rarely use just one primitive in isolation.