Skip to main content

MCP Tools, Resources and Prompts Explained

Short answer

An MCP server offers three kinds of features. Tools are actions or calculations that the model decides to call. Resources are read-only data that the app loads by URI. Prompts are message templates that the user picks on purpose. Each one has its own list method, so a client can ask the server what it offers with tools/list, resources/list or prompts/list. Choose by asking who should trigger the feature: the model, the app, or the user.

Tested withMCP spec 2026-07-28Python SDK (mcp) 2.3.0TypeScript SDK (@modelcontextprotocol/server) 2.3.1MCP Inspector 2.11.0Node.js 24.10Python 3.14Checked 20 Oct 2026

Who triggers each one​

ToolsResourcesPrompts
Who triggers itThe model, when it decides it needs the actionThe app, which decides what context to loadThe user, who picks it on purpose
What it's forDoing something or calculating something, with argumentsReading data the model or the user needs as contextStarting a conversation with a ready-made message
Identified byA tool nameA URI, such as note://meeting-notesA prompt name
Methodstools/list, tools/callresources/list, resources/templates/list, resources/readprompts/list, prompts/get
Example in this guidesave_note, search_notesnotes://index, note://{slug}summarize_note

The three features sit side by side on one server:

The spec describes the same split. Tools are "model-controlled", resources are "application-driven", and prompts are "user-controlled". The "who" describes who decides when the feature is used, not who wrote it. A prompt's text is still written by the server.

Tools: actions the model calls​

A tool is a named function with a JSON Schema for its input. The server lists its tools with tools/list. Each entry has a name, a description that the model reads, and an inputSchema. The model chooses a tool from that list and sends tools/call with the arguments it wants to use.

The spec asks for a person to stay in charge. For trust and safety, "there SHOULD always be a human in the loop with the ability to deny tool invocations." Clients can also let users pre-approve tools they trust, so a tool's name and description matter: they are what the user reads before deciding.

A tool result has a content array of text, image or other content blocks, plus an isError flag. The spec separates two kinds of failure:

  • Protocol errors are JSON-RPC errors, for example an unknown tool name or a request that doesn't match the call schema. The spec notes that the model is less likely to fix these on its own.
  • Tool execution errors come back as a normal result with "isError": true, and the text explains what went wrong. The model can read the message, fix its arguments and try again.

When you run save_note in the worked example below, the result looks like this (trimmed to the fields that matter here):

{
"content": [
{
"type": "text",
"text": "Saved note 'meeting-notes'. Read it at note://meeting-notes"
}
],
...
"isError": false
}

Tool names and descriptions decide whether a model picks the right tool, so they get their own guide: Tool names and descriptions. Structured results and outputSchema are covered in Tool outputs.

Resources: read-only data, addressed by URI​

A resource is a piece of data with a URI. The spec calls resources "passive data sources that provide read-only access to information for context", such as a file, a database schema or a page of documentation. The app decides when to read a resource and how much of it to pass to the model.

Resources come in two forms:

  • Direct resources have a fixed URI. resources/list returns them, each with a uri, name, and optional title, description, mimeType and size.
  • Resource templates have a URI with a placeholder, such as note://{slug}. resources/templates/list returns the pattern with its uriTemplate. The client fills in the placeholder and calls resources/read with the real URI.

resources/read returns a contents array. Each item holds the uri, a mimeType, and either text or a base64 blob. If the resource doesn't exist, the spec says the server MUST return a JSON-RPC error with code -32602 and MUST NOT return an empty contents array.

The URI scheme can be anything that follows RFC 3986. The worked example uses notes:// for the index and note:// for individual notes. Because a template placeholder comes from the client, the server has to check it. The spec says servers MUST validate resource URIs, and the example rejects any name that isn't lowercase letters, digits and hyphens before it touches the disk.

Change notifications work differently from the older spec. In the 2026-07-28 revision, subscriptions/listen replaces resources/subscribe. A client opens one stream, asks for resourcesListChanged or a list of resourceSubscriptions URIs, and receives notifications/resources/updated for the URIs it asked for.

Prompts: templates the user picks​

A prompt is a named template with optional arguments. Prompts are meant for the user to choose, usually from a menu or a slash command, so the model doesn't start one by itself. prompts/list returns each prompt's name, description and arguments, and each argument has a name, a description and a required flag.

Clients send prompts/get with the prompt's name and the argument values. The server returns messages, each with a role of user or assistant and a content block. The text the user sees in the conversation is the text the server builds here, so a prompt is the right place for a reusable instruction like "summarize this note in three bullet points".

If the name is wrong or a required argument is missing, the spec says the server SHOULD return -32602.

Client features: elicitation, sampling and roots​

Servers can also ask the client for things. The client-side features in the 2026-07-28 revision have different status:

FeatureWhat the server getsStatus in 2026-07-28
ElicitationInput from the user during a request. Form mode collects structured data. URL mode sends the user to a URL.Current. Form mode must not collect passwords, API keys, access tokens or payment credentials. Use URL mode for those.
SamplingA completion from the client's language modelDeprecated. New implementations SHOULD NOT adopt it. The spec's migration path is to call LLM provider APIs directly.
RootsThe directories the client wants the server to work inDeprecated. The migration path is to pass directories as tool parameters, resource URIs or server configuration.

Sampling and roots still work, and the spec keeps them for at least twelve months, but don't build new code on them. The deprecated features registry lists the earliest removal for both as the first revision released on or after 2027-07-28.

Elicitation uses the same request-and-retry pattern as the other features. While a server handles a tools/call, it can reply with an InputRequiredResult that contains an elicitation/create request. The client shows the form to the user, then repeats the original call with the answer attached. The server doesn't have to keep the request open while it waits.

Build the example​

The example is a notes server. Each note is a Markdown file in the folder named by the NOTES_DIR environment variable. The server offers:

  • A tool, save_note, that creates or replaces a note.
  • A tool, search_notes, that finds notes by words in their text.
  • A direct resource, notes://index, that lists every note name.
  • A resource template, note://{slug}, that returns one note.
  • A prompt, summarize_note, that asks the model for three bullet points about one note.

Each note's name comes from its title, so a note called "Meeting notes" becomes meeting-notes and its URI is note://meeting-notes.

Create a folder, copy in pyproject.toml and notes.py, then run uv sync. The dependency is mcp[cli], the same as the build guide.

pyproject.toml
[project]
name = "notes"
version = "1.0.0"
requires-python = ">=3.10"
dependencies = [
"mcp[cli]>=2.3.0",
]
notes.py
import logging
import os
import re
from pathlib import Path
from typing import Annotated

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError
from pydantic import Field

# Logs go to stderr. Anything written to stdout corrupts the stdio transport.
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("notes")

NOTES_DIR = Path(os.environ.get("NOTES_DIR", Path.home() / "notes-mcp"))
NOTES_DIR.mkdir(parents=True, exist_ok=True)

NAME_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*")

mcp = MCPServer("notes")


def slugify(title: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")


def note_path(slug: str) -> Path:
if not NAME_PATTERN.fullmatch(slug):
raise ValueError(
f"Invalid note name '{slug}'. Use lowercase letters, digits and hyphens."
)
return NOTES_DIR / f"{slug}.md"


def list_slugs() -> list[str]:
return sorted(path.stem for path in NOTES_DIR.glob("*.md"))


@mcp.tool()
def save_note(
title: Annotated[str, Field(description="Title of the note. Also sets its name.")],
body: Annotated[str, Field(description="Text of the note, in Markdown")],
) -> str:
"""Save a note, or replace an existing note with the same title.

Use this when the user asks to write down, save or remember something as a note.
"""
slug = slugify(title)
if not slug:
raise ToolError("The title must contain at least one letter or digit.")
note_path(slug).write_text(f"# {title}\n\n{body}\n")
logger.info("Saved note %s", slug)
return f"Saved note '{slug}'. Read it at note://{slug}"


@mcp.tool()
def search_notes(
query: Annotated[
str, Field(description="One or more words to look for, case-insensitive")
],
) -> str:
"""Search the text of all notes and return the names of the notes that match."""
words = query.lower().split()
matches = [
slug
for slug in list_slugs()
if all(w in note_path(slug).read_text().lower() for w in words)
]
if not matches:
return f"No notes match '{query}'."
return "\n".join(f"- {slug} (note://{slug})" for slug in matches)


@mcp.resource(
"notes://index",
name="notes-index",
title="Notes index",
description="The name of every saved note, one per line.",
mime_type="text/markdown",
)
def notes_index() -> str:
slugs = list_slugs()
if not slugs:
return "No notes yet."
return "\n".join(f"- {slug}" for slug in slugs)


@mcp.resource(
"note://{slug}",
name="note",
title="Note",
description="The full text of one note, by name.",
mime_type="text/markdown",
)
def read_note(slug: str) -> str:
path = note_path(slug)
if not path.exists():
raise ResourceNotFoundError(f"No note named '{slug}'.")
return path.read_text()


@mcp.prompt()
def summarize_note(
slug: Annotated[str, Field(description="Name of the note to summarize")],
) -> str:
"""Ask the model to summarize one note in three bullet points."""
return (
"Summarize this note in three bullet points:\n\n" + note_path(slug).read_text()
)


if __name__ == "__main__":
mcp.run(transport="stdio")

@mcp.tool() and @mcp.prompt() register a function by its name. @mcp.resource(uri) registers a resource. If the URI has a {placeholder}, the function becomes a resource template and its parameters are filled from the URI. The function's return value becomes the contents of the resource, or the text of the prompt.

Both versions use the same NOTES_DIR variable and the same tool, resource and prompt names, so the Inspector commands below work for either one.

Test every primitive​

MCP Inspector can call each method from the command line. Point NOTES_DIR at a scratch folder so your real notes stay out of the way. The Python server is shown here. For TypeScript, replace uv run --directory python notes.py with node typescript/build/index.js.

Save a note with a tool call:

npx @modelcontextprotocol/inspector@2.11.0 --cli -e NOTES_DIR=/tmp/notes-demo \
uv run --directory python notes.py --protocol-era modern \
--method tools/call --tool-name save_note \
--tool-arg title="Meeting notes" --tool-arg body="Ship the guide on Friday"

Read it back with a resource read:

npx @modelcontextprotocol/inspector@2.11.0 --cli -e NOTES_DIR=/tmp/notes-demo \
uv run --directory python notes.py --protocol-era modern \
--method resources/read --uri note://meeting-notes

The result comes back in the contents array:

{
"contents": [
{
"uri": "note://meeting-notes",
"mimeType": "text/markdown",
"text": "# Meeting notes\n\nShip the guide on Friday\n"
}
],
...
}

Get the prompt with its argument:

npx @modelcontextprotocol/inspector@2.11.0 --cli -e NOTES_DIR=/tmp/notes-demo \
uv run --directory python notes.py --protocol-era modern \
--method prompts/get --prompt-name summarize_note --prompt-args slug=meeting-notes

The prompt returns one user message with the note text built in:

{
...
"description": "Ask the model to summarize one note in three bullet points.",
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "Summarize this note in three bullet points:\n\n# Meeting notes\n\nShip the guide on Friday\n"
}
}
]
}

The list methods are tools/list, resources/list, resources/templates/list and prompts/list. Run each one with --protocol-era modern, then again with --protocol-era legacy. The second run checks that clients still using the 2025-era handshake see the same features.

Common problems​

These messages come from running the example server with the Inspector.

No note named 'does-not-exist'. (error -32602) The URI names a note that has no file. The name in a note:// URI is the note's file name without .md, so a note titled "Meeting notes" is note://meeting-notes. Read notes://index to see the names that exist. The spec uses -32602 for a missing resource, and the Python and TypeScript SDKs send it when the handler raises ResourceNotFoundError.

Error executing tool save_note: The title must contain at least one letter or digit. (Python) or The title must contain at least one letter or digit. (TypeScript) The title has no letters or digits, so there is no file name to make from it. The result has "isError": true, which is the tool execution error the spec describes. The model can read the message and retry with a better title. The prefix before the message depends on the SDK.

Invalid arguments for prompt summarize_note: slug: Invalid input: expected string, received undefined (TypeScript) The prompts/get call left out the required slug argument. Send every argument marked required in the prompts/list result.

Internal server error (Python) The Python SDK 2.3.0 returns this generic message when a required prompt argument is missing, not the -32602 the spec recommends for that case. The traceback is in the server's stderr log. Send every required argument, and check the log first if the message doesn't explain the problem.

Common misconceptions​

"A resource is just a tool that returns text." A tool is called by the model with arguments. A resource is read by the app from a URI. If the data is fixed context, such as a schema or a document, a resource fits. If the work changes something or depends on input the model invents, use a tool.

"Prompts run on their own." The spec says prompts are user-controlled. The model doesn't start a prompt. The user picks it, and the server returns the messages to start the conversation.

"resources/templates/list lists every note." It returns the pattern, such as note://{slug}. It doesn't list the notes that match it. In this example, notes://index is the direct resource that lists them, and the template's list callback is left as undefined.

"Sampling and roots are how servers get AI or files in 2026." Both are deprecated as of 2026-07-28. Use the replacements in the table above.

"A tool runs as soon as the model asks." The spec says there should be a human in the loop who can deny the call. Write names and descriptions that make the purpose of each call clear to that person.

FAQ​

What is the difference between an MCP tool and an MCP resource?

A tool is an action or calculation the model decides to call, with arguments. A resource is read-only data that the app loads by URI, such as a file, a note or a database schema. Use a tool when the work changes something or depends on what the model asks for, and a resource when the data is context to read.

Do I need to build tools, resources and prompts all together?

No. A server can offer only tools. Add resources when you have data the app should load as context, and prompts when the user needs a reusable starting message. The server declares only the features it has.

What is a resource template?

A resource template is a URI with a placeholder, such as note://{slug}. The client fills in the placeholder and reads the resource at the resulting URI. resources/templates/list returns the pattern, not every item that matches it.

Are sampling and roots still part of MCP?

Yes, but both are deprecated as of the 2026-07-28 spec revision. They remain in the spec for at least twelve months, and new implementations should not adopt them. Use direct LLM provider API calls for sampling, and tool parameters, resource URIs or server configuration for roots.