mcpstepbystep

Tutorials Builder

MCP tools vs resources vs prompts: when to use each (with code)

Tools, resources, and prompts explained with Python and TypeScript code: who controls each, structured output, annotations, and token-efficient design.

updated 2026-07-19 ⏱ 25–35 min

Most MCP servers in the wild expose everything as tools, because tools are the primitive every client supports and every quickstart teaches. That works — until your server dumps forty tools into the model’s context, or you realise the “tool” that returns your style guide never needed the model’s permission to exist at all.

MCP gives you three primitives, and the cleanest way to keep them straight is by who controls each one: tools are model-controlled, resources are application-controlled, prompts are user-controlled. Get that mapping right and most design decisions make themselves.

Step 1 — Tools: actions the model decides to take

A tool is a function the model chooses to call — with a name, a description, and a JSON Schema for its arguments. Use tools for anything with a verb in it: search, create, update, send, compute.

Python (FastMCP, from the official mcp SDK — schemas come from type hints and docstrings):

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("library")

@mcp.tool()
def search_books(query: str, max_results: int = 5) -> str:
    """Search the catalogue by title or author. Returns matching titles with IDs."""
    results = catalogue.search(query)[:max_results]
    return "\n".join(f"{b.id}: {b.title}{b.author}" for b in results)

TypeScript:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({ name: "library", version: "1.0.0" });

server.registerTool(
  "search_books",
  {
    description: "Search the catalogue by title or author. Returns matching titles with IDs.",
    inputSchema: { query: z.string(), maxResults: z.number().default(5) },
  },
  async ({ query, maxResults }) => {
    const results = await catalogue.search(query, maxResults);
    return { content: [{ type: "text", text: results.map(formatBook).join("\n") }] };
  }
);

The description is not decoration — it’s the model’s only manual for the tool. Write it for the model: what the tool does, when to use it, and what comes back.

Step 2 — Resources: context the application attaches

A resource is read-only content addressed by a URI — a document, a config, a schema. Crucially, the model doesn’t fetch resources on its own: the client application (or the user through it) decides what to attach to context. In Claude Desktop that’s an explicit attach action; some clients read resources automatically for context; others ignore them entirely.

Use resources for reference material that shouldn’t cost a tool call: documentation, style guides, database schemas, file contents.

Python — static resources and URI templates:

@mcp.resource("catalogue://genres")
def list_genres() -> str:
    """All genres in the catalogue."""
    return ", ".join(catalogue.genres())

@mcp.resource("catalogue://books/{book_id}")
def book_details(book_id: str) -> str:
    """Full details for one book."""
    return catalogue.get(book_id).to_text()

TypeScript:

server.registerResource(
  "genres",
  "catalogue://genres",
  { description: "All genres in the catalogue." },
  async (uri) => ({
    contents: [{ uri: uri.href, text: catalogue.genres().join(", ") }],
  })
);

Rule of thumb: if the model needs to decide when to fetch it, make it a tool. If a human or the host app should decide what background material is in play, make it a resource. If client support matters more than purity — many clients still handle resources patchily — it’s legitimate to expose the same data as a read-only tool as well.

Step 3 — Prompts: workflows the user invokes

A prompt is a parameterised message template the user triggers deliberately — surfaced as slash commands in Claude Code and Cursor. Use prompts for repeatable workflows where a human wants a well-engineered starting message: “review this PR”, “summarise this incident”, “draft a release note”.

Python:

@mcp.prompt()
def review_book(title: str) -> str:
    """Draft a structured book review."""
    return (
        f"Write a review of '{title}'. Cover plot, prose, and pacing, "
        "and end with a one-line verdict. Use search_books to check the catalogue entry first."
    )

TypeScript:

server.registerPrompt(
  "review_book",
  {
    description: "Draft a structured book review.",
    argsSchema: { title: z.string() },
  },
  ({ title }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `Write a review of '${title}'. Cover plot, prose, and pacing, and end with a one-line verdict.`,
        },
      },
    ],
  })
);

Prompts cost nothing until invoked, and they’re the most underused primitive: a good prompt encodes your team’s best way of asking, so nobody has to remember it.

Step 4 — The comparison, side by side

ToolsResourcesPrompts
Who decides to use itThe modelThe application / userThe user
ShapeFunction + JSON SchemaURI + contentMessage template + arguments
Best forActions, lookups, anything with a verbReference docs, schemas, filesReusable workflows, slash commands
Context cost when idleDefinition always in contextNone until attachedNone until invoked
Client support (as of spec 2025-11-25)UniversalPatchyPatchy (good in Claude Code, Cursor)

Verdict: default to tools for actions, but move reference material to resources and repeatable requests to prompts — every definition you keep out of the tool list is context you get back.

Step 5 — Structured tool output

Since spec 2025-06-18, tools can return machine-readable results alongside text: declare an outputSchema, return structuredContent that conforms to it. Clients that understand structured output can validate and post-process results instead of parsing prose.

Python — the SDK derives the output schema from your return type annotation and populates structuredContent for you:

from pydantic import BaseModel

class StockLevel(BaseModel):
    book_id: str
    in_stock: int
    reserved: int

@mcp.tool()
def get_stock_level(book_id: str) -> StockLevel:
    """Current stock for a book."""
    return StockLevel(book_id=book_id, in_stock=12, reserved=3)

TypeScript — declare outputSchema and return both fields (text as a fallback for clients that don’t read structured output):

server.registerTool(
  "get_stock_level",
  {
    description: "Current stock for a book.",
    inputSchema: { bookId: z.string() },
    outputSchema: { bookId: z.string(), inStock: z.number(), reserved: z.number() },
  },
  async ({ bookId }) => {
    const output = { bookId, inStock: 12, reserved: 3 };
    return {
      content: [{ type: "text", text: JSON.stringify(output) }],
      structuredContent: output,
    };
  }
);

One rule with teeth: if a tool declares an outputSchema, its results must include structuredContent — the SDK enforces it (see Troubleshooting).

Step 6 — Tool annotations: hints about behaviour

Annotations (spec 2025-03-26+) let a tool describe its own behaviour so clients can make UI decisions — like skipping a confirmation prompt for read-only tools:

from mcp.types import ToolAnnotations

@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
def search_books(query: str, max_results: int = 5) -> str:
    """Search the catalogue by title or author."""
    ...
server.registerTool(
  "delete_book",
  {
    description: "Permanently remove a book from the catalogue.",
    inputSchema: { bookId: z.string() },
    annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
  },
  async ({ bookId }) => { /* … */ }
);

The four hints: readOnlyHint (no state changes), destructiveHint (may delete or overwrite), idempotentHint (repeat calls are safe), openWorldHint (touches external systems). They are exactly what the name says — hints. Clients may use them for UX; nothing enforces them.

Step 7 — Elicitation and sampling, briefly

Two server-initiated features round out the picture. Elicitation (spec 2025-06-18+) lets a tool pause and ask the user for structured input mid-call — “this will cancel 3 orders, confirm?” — via ctx.elicit(...) in Python or the corresponding client callback in TypeScript. It’s the right way to do confirmations, rather than hoping the model asks.

Sampling lets the server request an LLM completion through the client, using the user’s model and billing. It never saw wide client support, and the 2026-07-28 spec release (landing soon, with a 12-month deprecation window) deprecates sampling along with roots and logging. Practical advice: don’t build new designs on sampling; if your server needs LLM calls, make them directly server-side.

Step 8 — Design for the token budget

Every tool definition — name, description, full input schema — is injected into the model’s context at session start, whether the tool is ever used or not. This is the standing criticism of MCP at scale, and it’s a fair one: a real-world 106-tool server has been measured consuming roughly 54K tokens at initialisation, before the user typed a word.

The fix is editorial, not technical — fewer, better tools:

  • Design task-shaped tools, not API-shaped ones. One search_books beats list_books, get_book_by_title, and get_book_by_author. (This is the core lesson of wrapping a REST API in MCP.)
  • Name for disambiguation. Consistent verb_noun names, prefixed by domain when tools from several servers coexist (library_search_books), so the model never confuses your search with another server’s.
  • Spend description tokens on when, not just what. “Search the catalogue. Use this before get_stock_level to find a valid book ID” prevents a wasted round trip.
  • Trim schemas. Every optional parameter you add costs context in every conversation, forever. Cut parameters nobody uses.
  • Move reference data to resources and workflows to prompts. They cost nothing until used.

If a debugging session ever shows the model picking the wrong tool, the fix is almost always in the descriptions, not the model — and you already know how to test that quickly with the Inspector.

Troubleshooting

keyValidator._parse is not a function A zod version clash in a TypeScript server. The SDK accepts zod ^3.25 or ^4, but a mismatched or duplicated zod install (for example, your project pulls v4 while a dependency bundles v3 — or two copies land in node_modules) produces schema objects the SDK can’t consume. Fix: align on one zod version across the project — the official docs pin zod@3 — then delete node_modules and the lockfile entry and reinstall.

Tool declares an outputSchema but the call fails with a missing structuredContent error The spec requires that a tool with an outputSchema return structuredContent conforming to it, and the SDKs enforce this on every result. In TypeScript, return both structuredContent and a content text fallback. In Python, make sure your function actually returns an instance of the annotated return type — returning a bare string from a tool annotated -> StockLevel will fail validation.

MCP error -32602 (Invalid params) when a tool is called The arguments didn’t validate against your input schema — the model sent a string where you demanded a number, or omitted a required field. Reproduce the exact call in the Inspector to confirm. Then make the schema more forgiving where it’s safe (defaults, coercion) and sharpen the description and parameter names so the model can guess right: max_results: int = 5 self-corrects in a way a required, undocumented n never will.

Resources registered but nothing shows up in the client Usually not a bug. Resources are application-controlled: Claude Desktop lists them for the user to attach manually, and several clients don’t surface resources at all. Verify the server side with the Inspector’s Resources tab — if they list there, your code is correct and the behaviour is the client’s. If a resource genuinely must be model-fetchable in a client with weak resource support, mirror it as a read-only tool.

“No such tool available” after renaming a tool The client is holding the tool list from when the session started. Fully restart the client (Cmd+Q for Claude Desktop, not just the window) and start a new conversation. If you rename tools regularly during development, the Inspector’s CLI (--method tools/list) is the fastest way to confirm what the server is actually advertising right now.

Next step

You now know which primitive carries which job. Time to apply it to the most common real-world task there is: wrapping an existing REST API in an MCP server — without mirroring every endpoint into a token-devouring tool list.

newsletter

One practical MCP guide in your inbox. No news, no hype.

Tutorials and decision frameworks as they ship. Unsubscribe anytime.