
MCP Has Three Primitives, Not One: Add Resources and Prompts to Make Your Server Actually Useful
Chris Harper
3 min read
Jul 5, 2026 · 20:03 UTC
TL;DR: MCP has three primitives — tools (model-controlled), prompts (user-triggered), and resources (app-injected) — and using all three lifts multi-step workflow reliability from ~60% to 90%+.
What you'll be able to do after this:
- Know when to use a tool, prompt, or resource — and why the control-plane split changes reliability
- Define
@mcp.resource()URI templates and@mcp.prompt()workflows in FastMCP Python - Structure deterministic steps server-side and let the LLM handle only language tasks
The three control planes
Most MCP servers only implement tools. But the spec defines two more:
| Primitive | Controlled by | Best for |
|---|---|---|
| Tools | The model | Single actions: search, API calls, file writes |
| Prompts | The user | Repeating workflows: weekly reports, incident runbooks |
| Resources | The host app | Static context: schemas, docs, config, templates |
The reliability difference is measurable. When the LLM orchestrates a multi-step report using only tools, it gets the sequence right ~60–70% of the time — it picks the wrong step order, recalculates things the server should have computed, or skips a step entirely. When you encode the data-fetch steps as a prompt (the server runs them deterministically, the LLM handles only the summary), compliance rises to 85–95%.
Adding resources in FastMCP
A resource is a URI-addressable, read-only data source. The host app decides when to inject it; the model never has to ask.
from fastmcp import FastMCP
mcp = FastMCP(name="MyServer")
# Static resource — same content every time
@mcp.resource("docs://schema/sales")
def sales_schema() -> str:
"""Sales database schema for context injection."""
return "# Sales Schema\n## tables: orders, customers, products..."
# Dynamic resource — URI template maps path params to function args
@mcp.resource("config://{env}/limits")
def rate_limits(env: str) -> dict:
return {"env": env, "requests_per_min": 1000 if env == "prod" else 100}
Use URI scheme prefixes (docs://, config://, data://) to organize by type. Resources can be text or binary (base64) and support RFC 6570 URI templates for dynamic lookup.
Adding prompts in FastMCP
A prompt is a reusable workflow template the host surfaces as a slash command. When a user triggers it, the server runs deterministic steps first and passes the results to the LLM.
@mcp.prompt
def weekly_sales_report(week: str) -> str:
"""Weekly sales report — server fetches data, LLM summarizes."""
# In production: fetch + aggregate data here before returning
return (
f"Summarize sales data for week {week}. "
"Focus on top SKUs and week-over-week percentage changes."
)
# Multi-turn prompt with explicit messages
from fastmcp.prompts import Message
@mcp.prompt
def incident_runbook(service: str, severity: str) -> list[Message]:
return [
Message(f"Service: {service} | Severity: {severity}"),
Message(
"Logs and deployment history are pre-fetched above. "
"Draft a mitigation summary and list the three highest-priority next steps."
),
]
Steps 1–3 (fetch service status, pull logs, get deployment history) execute server-side. Step 4 (write the summary) goes to the LLM. Data flows explicitly — the model never has to track workflow state.
The bridge pattern for today
Most MCP clients support resources/list and resources/read but lack a native resource-picker UI. Pragmatic workaround: also expose critical resources as tools so the model can request them directly:
@mcp.tool
def get_sales_schema() -> str:
"""Returns the sales database schema."""
return sales_schema() # same function, two surfaces
Once clients add native resource pickers, the tool wrapper disappears. Build the resource now; the bridge costs nothing to remove later.
Sources: MCP Prompts and Resources: The Primitives You're Not Using — DEV Community, Prompts — FastMCP docs, Resources — MCP official docs