teachyou.ai academy
← All posts
MCP

MCP Resources vs Tools vs Prompts: The Three Primitives Explained

Ira Menon · May 27, 2026 · 15 min read

Why "just add a tool" stops working

Most people meet the Model Context Protocol through Tools. You write a function, decorate it, expose it, and suddenly your AI assistant can check the weather or query a database. That first win feels so complete that it's tempting to model everything as a tool — including things that were never actions in the first place.

Then the cracks show up. You build an MCP server for your company's internal wiki, and the "get_page" tool works fine until you have forty pages and the model starts guessing which one to fetch instead of just being shown the list. You build a support-ticket triage server, and every user ends up typing a slightly different version of the same instruction to get consistent behavior, because there's no shared, reusable prompt template baked into the server itself. Both of these are symptoms of the same root cause: MCP actually gives you three distinct primitives — Resources, Tools, and Prompts — and treating them as interchangeable wastes the protocol's actual design.

This article breaks down what each primitive is for, how the control flow differs between them, and how to decide which one a given piece of functionality belongs in. If you're building or integrating MCP servers for real, understanding this distinction early will save you from a rewrite later.

The core distinction: who is in control

Before touching syntax, it helps to anchor on one question for each primitive: who decides when it gets used?

  • Resources are controlled by the application (and often the human user). They're data the client fetches and attaches to context — think of them as read-only files or query results that get pulled in explicitly, similar to attaching a document to a chat.
  • Tools are controlled by the model. The LLM decides, based on the conversation, whether and when to call a tool, and MCP clients typically require explicit user approval before executing them because tools can have side effects.
  • Prompts are controlled by the user. They're pre-written templates — often surfaced as slash commands or menu items — that a human deliberately selects to kick off a specific, well-defined interaction.

That's the whole mental model in one paragraph, but it's worth sitting with, because almost every design mistake in an MCP server comes from picking the wrong control owner. If you find yourself building a "tool" that just returns static reference data with no side effects and no real reason for the model to decide when to call it, it's probably a Resource. If you find yourself writing a giant system-prompt-like instruction block that a tool is supposed to inject on every call, it's probably a Prompt.

Let's take each primitive in turn.

Resources: data the application attaches, not the model fetches

A Resource in MCP is any piece of data a server exposes — a file, a database record, an API response, a screenshot, a log excerpt — identified by a URI. Critically, the server describes what resources exist, and the client application decides when to read them, often because the user pointed at something explicitly ("summarize this file") or because the application has logic that decides what to attach.

A resource has three parts: a URI, a MIME type, and content that's either text or binary (base64-encoded). Here's a minimal example of a resource that exposes a project's changelog:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("docs-server")

@mcp.resource("docs://changelog")
def get_changelog() -> str:
    """Return the current CHANGELOG.md content."""
    with open("CHANGELOG.md") as f:
        return f.read()

@mcp.resource("docs://file/{path}")
def get_doc_file(path: str) -> str:
    """Return a specific documentation file by relative path."""
    with open(f"./docs/{path}") as f:
        return f.read()

Notice the second example uses a URI template (docs://file/{path}). This is how MCP handles parameterized resources — the client can list available resources (a resources/list call), see the template, and construct a concrete URI, or the server can expose a fixed catalog if the resource set is small and known ahead of time.

The key operational detail is resources/list versus resources/read. The client asks "what do you have?" once, gets back a lightweight index of URIs and descriptions, and only pulls full content for the specific resource it needs via resources/read. This two-step handshake is what makes Resources scale — you're not stuffing every wiki page into the model's context on every turn, you're letting the client (or the user, through a picker UI) choose what actually gets attached.

Resources also support subscriptions. A client can subscribe to a resource URI and get notified when the underlying data changes, which matters for things like a live log tail or a document that's being edited concurrently. This is a feature Tools don't have — Tools are one-shot request/response, but Resources can represent live, evolving state.

A good rule of thumb: if your users would say "attach this" or "look at this file" rather than "do this," it's a Resource. Config files, documentation, database schemas, recent error logs, and reference tables are classic Resource material.

Tools: actions the model decides to invoke

Tools are what most developers build first, and for good reason — they're the primitive that lets an LLM actually *do* something instead of just reading. A tool is a named function with a JSON Schema describing its inputs, and the model calls it autonomously as part of reasoning through a task.

from mcp.server.fastmcp import FastMCP
import httpx

mcp = FastMCP("weather-server")

@mcp.tool()
async def get_forecast(city: str, days: int = 3) -> str:
    """Get a weather forecast for a city.

    Args:
        city: Name of the city, e.g. "Bengaluru"
        days: Number of days to forecast (1-7)
    """
    async with httpx.AsyncClient() as client:
        resp = await client.get(
            "https://api.example-weather.com/forecast",
            params={"city": city, "days": days},
        )
        resp.raise_for_status()
        data = resp.json()

    lines = [f"{d['date']}: {d['summary']}, {d['high']}/{d['low']}°C"
             for d in data["days"]]
    return "\n".join(lines)

Two things matter more than the code here. First, the docstring is not documentation for humans — it's the interface the model reasons over. If your description is vague ("gets weather data"), the model will misuse the tool, pass wrong argument types, or skip it entirely when it should have been called. Treat tool descriptions with the same care you'd give a public API's docs, because that's exactly what they are, just consumed by a different reader.

Second, tools carry the highest trust burden of the three primitives, because they can have side effects — sending an email, writing to a database, deleting a file. This is why the MCP spec expects clients to gate tool execution behind human approval by default, and why servers should annotate tools with hints about their behavior. MCP defines annotation fields for exactly this purpose:

@mcp.tool(
    annotations={
        "readOnlyHint": False,
        "destructiveHint": True,
        "idempotentHint": False,
    }
)
def delete_ticket(ticket_id: str) -> str:
    """Permanently delete a support ticket. This cannot be undone."""
    ...

readOnlyHint, destructiveHint, and idempotentHint aren't enforced by the protocol — they're advisory metadata a client can use to decide how much friction to add before executing. A well-behaved client might auto-approve a read-only tool call but always prompt for a destructive one. If you're building a server that other people will plug into their own agents, setting these annotations honestly is part of the job, not an optional nicety.

One practical trap: don't build a tool that just returns static, unchanging data with no computation and no side effect. If get_pricing_table() always returns the same JSON regardless of arguments and has no reason to be gated behind model judgment, expose it as a Resource. Reserve Tools for things that genuinely require the model to decide "should I do this, and with what arguments, right now."

Prompts: user-triggered templates for repeatable workflows

Prompts are the least understood of the three primitives, mostly because they solve a problem people don't realize MCP already solved for them: reusable, parameterized instructions that a human explicitly invokes, rather than the model deciding to invoke them.

Think about how many times you've typed some version of "review this code for security issues, check for SQL injection, check for hardcoded secrets, and format the output as a checklist" into a chat. That's not a tool call — there's no external action, no side effect — and it's not raw data — it's an instruction. It's a Prompt.

from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.prompts import base

mcp = FastMCP("code-review-server")

@mcp.prompt()
def security_review(language: str, code: str) -> list[base.Message]:
    """Generate a structured security review prompt for a code snippet."""
    return [
        base.UserMessage(
            f"You are performing a security review of the following "
            f"{language} code. Check specifically for injection "
            f"vulnerabilities, hardcoded credentials, unsafe "
            f"deserialization, and missing input validation. "
            f"Return findings as a checklist with severity ratings.\n\n"
            f"```{language}\n{code}\n```"
        )
    ]

The client surfaces this to the user — often as a slash command like /security_review in a chat UI, or as a menu item — and the user picks it deliberately, fills in the arguments, and the server returns a fully formed message (or sequence of messages) that gets injected into the conversation. The model never "decides" to run this; a human chose it.

This matters for consistency. If your MCP server backs a team's incident-response workflow, you don't want every engineer typing a slightly different version of "summarize this incident for the postmortem." You want one canonical Prompt, versioned and maintained in the server, so the instruction quality doesn't degrade based on who's typing it that day. Prompts are also how you package multi-step reasoning scaffolds — a debugging prompt that asks the model to state a hypothesis, list evidence, then conclude — as a first-class, discoverable, reusable object rather than a copy-pasted block of text living in someone's notes app.

Prompts can also embed Resources directly. A prompt template can return an EmbeddedResource block that pulls in a file's content as part of the constructed message, letting you combine "here's a canned instruction" with "and here's the data to run it on" in a single invocable unit.

Putting it together: a support-ticket MCP server

Let's walk through a single realistic server that uses all three primitives correctly, so the boundaries are concrete rather than abstract.

Say you're building an MCP server for a support-ticket system. Here's how the functionality splits:

  • Resource: tickets://open — a live list of currently open tickets. The application (say, a triage dashboard) fetches this to show the user what's outstanding, and can subscribe to it for real-time updates as tickets come in.
  • Resource: tickets://{id} — a specific ticket's full detail, fetched when a user clicks into one.
  • Tool: assign_ticket(ticket_id, agent_email) — an action with a side effect. The model decides to call this when reasoning through "this ticket about billing should go to the billing team," and a client should treat it as non-destructive but state-changing.
  • Tool: close_ticket(ticket_id, resolution_note) — destructive-ish (changes ticket state permanently in most workflows), so it gets destructiveHint: true and should be gated behind explicit approval.
  • Prompt: triage_summary — a human-invoked template that says "summarize all open tickets by severity and suggest an assignment for each," producing a consistent, well-structured request every time a team lead runs it at standup.
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("support-tickets")

# Resource: application-controlled, read-only, subscribable
@mcp.resource("tickets://open")
def list_open_tickets() -> str:
    tickets = fetch_open_tickets_from_db()
    return "\n".join(f"[{t.id}] {t.title} (sev={t.severity})" for t in tickets)

# Tool: model-controlled action with a real side effect
@mcp.tool(annotations={"destructiveHint": False, "idempotentHint": True})
def assign_ticket(ticket_id: str, agent_email: str) -> str:
    """Assign an open ticket to a support agent by email."""
    update_ticket_assignment(ticket_id, agent_email)
    return f"Ticket {ticket_id} assigned to {agent_email}"

# Prompt: user-controlled, reusable workflow template
@mcp.prompt()
def triage_summary() -> str:
    """Summarize open tickets by severity with assignment suggestions."""
    return (
        "Review the open tickets resource. Group them by severity "
        "(critical, high, medium, low), and for each ticket suggest "
        "which team should own it based on the title and description. "
        "Present the result as a severity-ordered list."
    )

Notice that the Prompt doesn't fetch the ticket data itself — it references the workflow, and the client is expected to have the tickets://open Resource available in context (either already attached, or fetched as a follow-up). This separation is deliberate: Prompts orchestrate intent, Resources supply data, Tools perform actions. Mixing these responsibilities into one giant tool — do_triage() that internally fetches, summarizes, and reassigns everything — collapses three primitives with different trust models and different control owners into one opaque function, and you lose the ability to let a human approve the risky step (reassignment) while auto-running the safe one (listing).

Common mistakes when choosing a primitive

A few patterns show up repeatedly in servers that get the split wrong:

  1. Turning static reference data into a tool. If a "tool" takes no meaningful arguments and always returns the same thing until an admin updates a config file somewhere, it's a Resource wearing a tool's clothing. This wastes a model turn deciding to call something that didn't need deciding.
  2. Turning a canned instruction into a tool that just returns text. If you catch yourself writing a tool whose entire body constructs a string and returns it without touching any external system, ask whether a human should be choosing to invoke that string deliberately — if so, it's a Prompt.
  3. Skipping annotations on destructive tools. Leaving destructiveHint unset on a tool that deletes data means well-behaved clients have no signal to add a confirmation step. This isn't a protocol violation, but it's a missed opportunity to make your server safe to plug into agents you don't control.
  4. Overloading one tool to do everything. A single manage_tickets(action, ...) tool with a dozen possible action values is harder for a model to reason about than several precisely named tools (assign_ticket, close_ticket, reopen_ticket). Smaller, well-described tools consistently perform better because the model's tool-selection reasoning has less ambiguity to resolve.
  5. Forgetting that Resources can be dynamic. Some developers assume Resources only cover static files and route everything live-updating through Tools instead. Subscriptions exist precisely so dashboards, logs, and other changing data can live as Resources without becoming polling tools.

How clients actually surface all three

It's worth knowing what happens on the other side of the protocol, because it explains why getting the primitive right matters practically, not just philosophically. During connection setup, an MCP client calls initialize and the server declares its capabilities — whether it supports resources, tools, prompts, and features like subscriptions or list-change notifications. From there:

  • The client calls tools/list to get every tool's name, description, and input schema, and typically feeds that entire list to the model as part of its system context, so the model can choose to call one during generation.
  • The client calls resources/list to build a picker UI (a file browser, an "@mention" menu, an attachment button) — the user or the application logic decides what to actually fetch with resources/read.
  • The client calls prompts/list to populate something like a slash-command menu, and only constructs the actual message via prompts/get when a user explicitly selects one and supplies arguments.

If you mis-classify your functionality, you get the wrong UI surface for it. A destructive action modeled as a Resource never gets model-driven invocation at all, since resources aren't meant to be "called" by the model in the tool sense. A large reference dataset modeled as a Tool means the model has to decide, every relevant turn, whether to spend a call fetching something that should have just been sitting in context already. And a workflow template modeled as a Tool instead of a Prompt means users can't easily discover or trigger it without knowing to ask the model for it by name — you lose the slash-command-style, deliberate invocation Prompts are built for.

A simple decision checklist

Next time you're about to add something to an MCP server, run it through these questions in order:

  1. Does invoking it change state or cause a side effect in the world? If yes, it's almost certainly a Tool, and you should think about its destructive/idempotent annotations.
  2. Is it read-only data that either the user or the application should choose to attach to context? If yes, it's a Resource — and if the underlying data changes over time, consider whether it should support subscriptions.
  3. Is it a canned instruction or workflow that a human deliberately triggers, rather than something the model stumbles into mid-reasoning? If yes, it's a Prompt.
  4. Does it combine data and instruction in a single reusable package? That's a Prompt that embeds a Resource — a perfectly valid pattern, and often the cleanest way to package a recurring analysis workflow.

Most real functionality sorts cleanly once you ask "who's deciding, and is there a side effect." The cases that feel ambiguous are usually doing two jobs at once and should be split into two primitives rather than forced into one.

Where this fits into building real MCP servers

Resources, Tools, and Prompts aren't three ways to do the same thing — they're three different contracts about who's in control and what kind of trust is required. Tools need the heaviest guardrails because the model decides and side effects follow. Resources need good indexing and URI design because they're meant to scale to large, browsable datasets. Prompts need to be genuinely reusable and well-parameterized because their whole value is consistency across repeated human invocations.

Getting this right early saves you from the exact rewrite pain described at the start of this article: tools that should have been lookups, prompts that got smuggled in as tool return values, and destructive actions with no safety annotations. If you're ready to go from understanding the primitives to actually shipping a server — handling capability negotiation, transport choices, authentication, and testing your server against real MCP clients — that's exactly the ground covered in our course on Building & Integrating MCP Servers, where we take you from a single-file FastMCP prototype to a production-ready server other teams can safely plug into their own agents.