How MCP WorksBeginner7 min04 / 10

Tools, Resources & Prompts

The three primitives an MCP server can expose — Tools, Resources, and Prompts — and who controls each one.

An MCP server is a connector: it advertises capabilities to a host, validates inputs, and executes the real work. But it can only expose three kinds of things. Learn these three primitives and you understand almost everything a server can do.

Think of it like

The three things a server can expose

The most important distinction isn't what each returns — it's who controls when it fires:

  • Tools are model-controlled — the LLM decides when to call them.
  • Resources are app-controlled — the host app pulls them in as context.
  • Prompts are user-controlled — a person triggers them (think slash-commands).
Quick check

Which of these are MCP server 'primitives'?

#Tools — model-controlled actions

Tools are actions the model can invoke — run a query, send an email, create a file. Like POST endpoints with a schema. The agent decides when to call a tool; your code decides what it does. Every tool declares an input schema so the model knows how to call it correctly. Common examples: "query_sales", "send_slack_message".

server.ts — registering a tool
// 2. Expose a TOOL. The agent decides *when* to call it;
//    your code decides *what* it does. The agent never sees the DB directly.
server.registerTool(
  "query_sales",
  {
    title: "Query sales",
    description: "Aggregate revenue by customer or by month.",
    inputSchema: {
      metric: z.enum(["revenue", "recency"]),
      groupBy: z.enum(["customer", "month"]).optional(),
      limit: z.number().int().max(50).default(5),
    },
  },
  async ({ groupBy, limit }) => {
    const sql =
      groupBy === "month"
        ? `SELECT strftime('%Y-%m', created_at) AS month,
             SUM(amount) AS revenue
           FROM orders GROUP BY month ORDER BY month LIMIT ?`
        : `SELECT c.name AS customer, SUM(o.amount) AS total
           FROM orders o JOIN customers c ON c.id = o.customer_id
           GROUP BY c.name ORDER BY total DESC LIMIT ?`;

    const rows = db.prepare(sql).all(limit);
    return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
  }
);
Note

The schema is the contract

The inputSchema (here written with Zod) is what the model reads to figure out how to call the tool. You declare the shape; the SDK handles discovery, validation, and transport. The function body is ordinary code — that's where your logic lives.

#Resources — app-controlled context

Resources are read-only data the model can pull in as context — a schema, a document, a config file. Like GET endpoints. They're app-controlled: the host application decides what context to attach, rather than the model triggering a side effect. Each resource is addressed by a URI. Common examples: "schema://shop", "file:///report.pdf".

server.ts — registering a resource
// 3. Expose a RESOURCE — read-only context the agent can pull in.
server.registerResource(
  "schema",
  "schema://shop",
  { title: "Database schema", mimeType: "text/plain" },
  async (uri) => ({
    contents: [{ uri: uri.href, text: "orders(id, customer_id, amount, created_at)" }],
  })
);
Tip

Tools do, Resources describe

A quick gut-check: if it changes something or runs work, it's a Tool. If it's just read-only data you'd want as context, it's a Resource. The sales connector exposes both — a query_sales tool and a schema://shop resource that tells the model what columns exist to reason over.

#Prompts — user-controlled templates

Prompts are reusable, parameterized templates a user can trigger — a slash-command that pre-fills a rich interaction. They're user-controlled: a person invokes them intentionally, and they can take arguments to fill in the blanks. Common examples: "/summarize-sales", "/review-pr".

Prompts show up to the user as invokable templates
{
  "prompts": [
    {
      "name": "summarize-sales",
      "description": "Summarize sales for a time window",
      "arguments": [
        { "name": "month", "description": "e.g. 2026-06", "required": true }
      ]
    },
    {
      "name": "review-pr",
      "description": "Review an open pull request",
      "arguments": [
        { "name": "pr", "description": "PR number or URL", "required": true }
      ]
    }
  ]
}
Quick check

A user types `/review-pr 482` in their chat app to kick off a code review. Which primitive is that?

Common mistake

"Prompt" here doesn't mean your chat message

In MCP, a Prompt is a named, reusable template the server exposes for the user to trigger — not the free-text message you type into the model. Different concept, same word. When in doubt, ask who controls it: model (Tool), app (Resource), or user (Prompt).

Key takeaways

  • An MCP server exposes exactly three primitives: Tools, Resources, and Prompts.
  • Tools are model-controlled actions (like POST) — the model decides when to call; your code decides what happens.
  • Resources are app-controlled, read-only context (like GET), addressed by a URI such as `schema://shop`.
  • Prompts are user-controlled, parameterized templates — slash-commands like `/review-pr` that a person triggers.
  • The clearest way to tell them apart is to ask who controls when it fires: model, app, or user.
Try it yourself · The three primitives
See who controls Tools, Resources, and Prompts — model, app, or user.
Model-controlled
App-controlled
User-controlled
Toolslike POST endpoints

Actions the model can invoke — it decides when to call them.

query_sales · send_slack_message
Tools · 1/3
Practice challenges
Test yourself · earn XP
0/4
Predict the output#1

The model wants to run work: aggregate revenue by customer. Which primitive does it reach for, and how does that call travel on the wire?

predict-output
// The model decides to aggregate revenue by customer.
// It has these three primitives available from the server:
//   - Tool:     query_sales
//   - Resource: schema://shop
//   - Prompt:   /summarize-sales
Fill in the blank#2

Complete the Node SDK call that exposes a read-only database schema as context the model can pull in.

server.(
  "schema",
  "schema://shop",
  { title: "Database schema", mimeType: "text/plain" },
  async (uri) => ({
    contents: [{ uri: uri.href, text: "orders(id, customer_id, amount, created_at)" }],
  })
);
Fix the bug#3

This connector is supposed to let the model SEND a Slack message. Something about how it's exposed is wrong. What's the fix?

fix-bug
server.registerResource(
  "send_slack_message",
  "slack://send",
  { title: "Send a Slack message" },
  async () => {
    await slack.postMessage(channel, text);
    return { contents: [{ uri: "slack://send", text: "sent" }] };
  }
);
Reorder the lines#4

Put the JSON-RPC exchange in the order it happens when a model uses the query_sales tool.

1
← Server runs the query and returns { "result": { "content": [...] } }
2
→ Client asks what tools exist: { "method": "tools/list" }
3
← Server advertises query_sales with its input schema
4
→ Model calls the tool: { "method": "tools/call", "params": { "name": "query_sales", "arguments": { ... } } }
Your turn
Practice exercise

You're designing an MCP connector for a project-management tool (think Jira or Linear). Sketch one of each primitive for it:

  1. A Tool — name it and give it a one-line description plus 1-2 input fields.
  2. A Resource — give it a URI and say what read-only context it returns.
  3. A Prompt — name the slash-command and its argument(s).

For each, state who controls it (model / app / user) and one sentence justifying why it belongs in that category rather than the other two.

Try it yourself — a starting point to build on:

starter.py
# Write your solution here