MCP Tools, Resources and Prompts Explained
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.
Who triggers each one
| Tools | Resources | Prompts | |
|---|---|---|---|
| Who triggers it | The model, when it decides it needs the action | The app, which decides what context to load | The user, who picks it on purpose |
| What it's for | Doing something or calculating something, with arguments | Reading data the model or the user needs as context | Starting a conversation with a ready-made message |
| Identified by | A tool name | A URI, such as note://meeting-notes | A prompt name |
| Methods | tools/list, tools/call | resources/list, resources/templates/list, resources/read | prompts/list, prompts/get |
| Example in this guide | save_note, search_notes | notes://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/listreturns them, each with auri,name, and optionaltitle,description,mimeTypeandsize. - Resource templates have a URI with a placeholder, such as
note://{slug}.resources/templates/listreturns the pattern with itsuriTemplate. The client fills in the placeholder and callsresources/readwith 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:
| Feature | What the server gets | Status in 2026-07-28 |
|---|---|---|
| Elicitation | Input 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. |
| Sampling | A completion from the client's language model | Deprecated. New implementations SHOULD NOT adopt it. The spec's migration path is to call LLM provider APIs directly. |
| Roots | The directories the client wants the server to work in | Deprecated. 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.
- Python
- TypeScript
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.
[project]
name = "notes"
version = "1.0.0"
requires-python = ">=3.10"
dependencies = [
"mcp[cli]>=2.3.0",
]
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.
Create a folder with package.json, tsconfig.json and src/index.ts, then run npm install and npm run build. The packages are @modelcontextprotocol/server and zod, the same as the build guide.
{
"name": "notes",
"version": "1.0.0",
"type": "module",
"bin": {
"notes": "./build/index.js"
},
"scripts": {
"build": "tsc && chmod 755 build/index.js"
},
"files": [
"build"
],
"dependencies": {
"@modelcontextprotocol/server": "^2.3.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^7.0.2"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"types": ["node"],
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
#!/usr/bin/env node
import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import {
McpServer,
ResourceNotFoundError,
ResourceTemplate,
} from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
const NOTES_DIR = process.env.NOTES_DIR ?? join(homedir(), "notes-mcp");
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
mkdirSync(NOTES_DIR, { recursive: true });
function slugify(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function notePath(slug: string): string {
if (!NAME_PATTERN.test(slug)) {
throw new Error(
`Invalid note name '${slug}'. Use lowercase letters, digits and hyphens.`,
);
}
return join(NOTES_DIR, `${slug}.md`);
}
function readNote(slug: string): string {
const path = notePath(slug);
if (!existsSync(path)) {
throw new ResourceNotFoundError(`note://${slug}`, `No note named '${slug}'.`);
}
return readFileSync(path, "utf8");
}
function listSlugs(): string[] {
return readdirSync(NOTES_DIR)
.filter((file) => file.endsWith(".md"))
.map((file) => file.slice(0, -".md".length))
.sort();
}
function text(value: string) {
return { content: [{ type: "text" as const, text: value }] };
}
// serveStdio calls this whenever it needs a server instance,
// so it must return a new McpServer every time.
function createServer() {
const server = new McpServer({ name: "notes", version: "1.0.0" });
server.registerTool(
"save_note",
{
description:
"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.",
inputSchema: z.object({
title: z
.string()
.describe("Title of the note. Also sets its name."),
body: z.string().describe("Text of the note, in Markdown"),
}),
},
async ({ title, body }) => {
const slug = slugify(title);
if (!slug) {
throw new Error("The title must contain at least one letter or digit.");
}
writeFileSync(notePath(slug), `# ${title}\n\n${body}\n`);
console.error(`Saved note ${slug}`);
return text(`Saved note '${slug}'. Read it at note://${slug}`);
},
);
server.registerTool(
"search_notes",
{
description:
"Search the text of all notes and return the names of the notes that match.",
inputSchema: z.object({
query: z
.string()
.describe("One or more words to look for, case-insensitive"),
}),
},
async ({ query }) => {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const matches = listSlugs().filter((slug) => {
const body = readNote(slug).toLowerCase();
return words.every((w) => body.includes(w));
});
if (matches.length === 0) return text(`No notes match '${query}'.`);
return text(
matches.map((slug) => `- ${slug} (note://${slug})`).join("\n"),
);
},
);
server.registerResource(
"notes-index",
"notes://index",
{
title: "Notes index",
description: "The name of every saved note, one per line.",
mimeType: "text/markdown",
},
async (uri) => {
const slugs = listSlugs();
const body =
slugs.length === 0
? "No notes yet."
: slugs.map((slug) => `- ${slug}`).join("\n");
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: body }] };
},
);
server.registerResource(
"note",
new ResourceTemplate("note://{slug}", { list: undefined }),
{
title: "Note",
description: "The full text of one note, by name.",
mimeType: "text/markdown",
},
async (uri, variables) => ({
contents: [
{
uri: uri.href,
mimeType: "text/markdown",
text: readNote(String(variables.slug)),
},
],
}),
);
server.registerPrompt(
"summarize_note",
{
description: "Ask the model to summarize one note in three bullet points.",
argsSchema: z.object({
slug: z.string().describe("Name of the note to summarize"),
}),
},
({ slug }) => ({
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: `Summarize this note in three bullet points:\n\n${readNote(slug)}`,
},
},
],
}),
);
return server;
}
// Logs go to stderr. Anything written to stdout corrupts the stdio transport.
serveStdio(createServer, {
onerror: (error) => console.error("Notes server error:", error),
});
registerTool, registerResource and registerPrompt each take a name, a config object and a callback. A static resource gets a URI string. A template resource gets a ResourceTemplate, and its callback receives the filled-in variables. The list: undefined option means the template has no list callback, so it doesn't enumerate notes. The direct resource notes://index does that job.
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.
- Build an MCP serverWrite a working server with a tool and connect it to Claude Desktop.
- Test with MCP InspectorUse the Inspector web UI and CLI to check tools, resources and prompts.
- Tool names and descriptionsWrite names and descriptions that lead the model to the right tool.
- Tool outputsReturn text, structured content and output schemas from a tool.
- MCP clientsWhich AI apps connect to MCP servers and how to pick one.