Tools, Resources, Prompts

Your Kitebase MCP server works. Claude can look up KITE-142 and search the help center. Now the support team wants three more things: Claude should assign tickets when asked, an agent should be able to drop the SSO help article into the chat without typing it out, and there should be a one-step “triage this ticket” routine everyone runs the same way.

You could build all three as tools, because tools are what you know. Then the SSO article only reaches the chat if the model decides to fetch it, and the triage routine only runs if the model guesses that’s what you wanted. Each of these needs a different party in charge. MCP gives you a primitive for each one, and picking the right one is mostly a question of who should decide.

What you’ll build: the Kitebase server from article 01, grown to three tools, four help articles and a ticket template as resources, and a triage prompt, plus a script that plays the host so you can see every message each primitive sends.

Who decides: the question behind all three

Article 01: What Is MCP? named the three things a server can offer. Here’s the idea that separates them. Every primitive answers the same question differently: who decides when this gets used?

  • Tools are model-controlled. The model reads the list of tools and decides, mid-conversation, to call one.
  • Resources are application-controlled (the spec says “application-driven”). The host, the app the user talks to, such as Claude Desktop or Claude Code, decides what to read. Usually that’s because the user picked something from a menu.
  • Prompts are user-controlled. The user picks one on purpose, usually as a slash command, and it expands into a message.
What starts it Who decides On the wire What lands in the chat Tool: the model decides USER ASKS "Give KITE-143 to Sam." THE MODEL reads the tool list and picks one TOOLS/CALL assign_ticket {"ticket_id": "KITE-143", "assignee": "Sam"} TOOL RESULT the updated ticket "assignee": "sam" Resource: the app decides USER ATTACHES the SSO article from a menu THE HOST APP reads it before the model runs RESOURCES/READ kitebase://help/sso-login just data, no side effects ATTACHMENT # Sign in with single sign-on + the article text Prompt: the user decides USER RUNS /triage_ticket KITE-142 from the slash menu THE USER already chose; the host expands it PROMPTS/GET triage_ticket {"ticket_id": "KITE-142"} USER MESSAGE "Triage this ticket: {KITE-142 ...} ... use search_help ..."
Same server, three different parties in charge.

Look at the second column. On the server, all three are decorated Python functions. What changes is who triggers them and where the result goes. A tool result goes back to the model mid-turn. A resource becomes an attachment before the model starts. A prompt becomes the user’s own message.

The spec calls this a design intent, not a rule: hosts can build any interface they like, and some blur the lines. Claude Code lets you @-mention resources and also gives the model its own tools to read them. So treat the split as who each primitive is designed for, and check what your target host does. All the output below comes from the companion example.

Tools: actions the model chooses

You built tools in article 03: Setting Up Your First MCP Server, so this section only adds the part that matters for choosing. A tool is the right primitive when the model should decide, on its own, to do something. Here’s the new one, which changes a ticket:

@mcp.tool()
def assign_ticket(
    ticket_id: TicketId,
    assignee: Annotated[str, Field(description="First name of a support teammate, e.g. sam")],
) -> dict:
    """Assign a Kitebase ticket to a support teammate. Only call this when the user asks."""
    if (ticket := find_ticket(ticket_id)) is None:
        raise ToolError(f"No ticket {ticket_id!r}. Ticket ids look like KITE-142.")
    if assignee.lower() not in TEAM:
        raise ToolError(f"{assignee!r} isn't on the support team. Teammates: {', '.join(TEAM)}.")
    ticket["assignee"] = assignee.lower()
    return ticket

TicketId is Annotated[str, Field(description="A Kitebase ticket id, like KITE-142")], shared with get_ticket. When a host connects, it sends tools/list and passes each definition to the model. Run python main.py --tools and you see exactly what the model gets for this one (trimmed):

{
  "name": "assign_ticket",
  "description": "Assign a Kitebase ticket to a support teammate. Only call this when the user asks.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "ticket_id": {"description": "A Kitebase ticket id, like KITE-142", "type": "string"},
      "assignee": {"description": "First name of a support teammate, e.g. sam", "type": "string"}
    },
    "required": ["ticket_id", "assignee"]
  }
}

That JSON is all the model knows. It never sees your Python, so the name, description and schema decide whether the model picks this tool, and when. “Only call this when the user asks” is there because this tool changes data: a model that searches too eagerly wastes a few tokens, one that reassigns tickets too eagerly annoys a support team.

Here’s the model in charge. The user says “Give KITE-143 to Sam, and find something to send them about spam.” and the model makes two calls, in an order it chose:

Model calls assign_ticket {"ticket_id": "KITE-143", "assignee": "Sam"}
   {"id": "KITE-143", "title": "Invite emails going to spam", "status": "open",
   "assignee": "sam", "customer": "Blue Fern Labs"}
Model calls search_help {"query": "invite emails spam"}
   invite-teammates: Invite teammates to a project
   account-recovery: Locked out of your account

Nobody told it to call search_help, or what to search for. That’s model-controlled, and it’s also the gotcha: the model might skip your tool, or call it when you didn’t want it to. The spec says hosts should keep a human in the loop who can deny a tool call, which is why hosts typically ask before running one.

server.py marks search_help and get_ticket with readOnlyHint. What does that do?

Tool annotations are optional hints about behaviour: readOnlyHint, destructiveHint, idempotentHint (calling twice does no more than calling once) and openWorldHint (touches systems outside your own). A host can use them, for example to skip the confirmation for a read-only tool.

Two catches. They’re hints, and the spec says clients must treat them as untrusted unless the server is trusted, so never rely on them for safety. And the defaults are cautious: a tool with no annotations counts as not read-only and destructive. That’s what assign_ticket gets.

Resources: data the app attaches

Now the second request. A support agent is looking at a customer locked out after an SSO change and wants the SSO help article in the conversation. With only tools, they’d type “look up the SSO article” and hope the model calls search_help, then reads the right result. That’s a model turn (one round of the model reading the conversation and replying, billed in tokens) spent on something the agent already knew.

A resource is data the server offers for reading, addressed by a URI: a string that names one thing, in the same scheme://path shape as a web address. You invent the scheme; the client treats it as an opaque name. Kitebase uses kitebase://help/sso-login. Reading a resource should never change anything. That’s what lets a host read one whenever it likes, without asking the model or the user.

A resource works in two steps. The host sends resources/list to see what exists, and shows the result in a menu. When the user picks one, the host sends resources/read with that URI and adds the contents to the conversation. In Claude Code you’d type @ and pick the article from the autocomplete list, the same way you’d mention a file.

Kitebase has four help articles, so the server lists each one:

def add_help_article(slug: str, title: str) -> None:
    # One listed resource per article, so hosts can offer each one in their attach menu.
    @mcp.resource(f"kitebase://help/{slug}", name=slug, title=title, mime_type="text/markdown",
                  description="A Kitebase help-center article.")
    def read() -> str:
        return article_markdown(slug)

for slug, (title, _) in HELP_ARTICLES.items():
    add_help_article(slug, title)

The title is what a person sees in the menu. The mime_type is the standard label for the content’s format (text/markdown, application/json, image/png), so the host knows how to show it. The helper function exists so each read remembers its own slug; a plain loop over a decorated function would give all four the last article. What the host gets:

kitebase://help/reset-password     Reset your password
kitebase://help/account-recovery   Locked out of your account
kitebase://help/sso-login          Sign in with single sign-on (SSO)
kitebase://help/invite-teammates   Invite teammates to a project

User picks "Sign in with single sign-on (SSO)" from the attach menu
Host reads kitebase://help/sso-login and adds it to the conversation:
   # Sign in with single sign-on (SSO)

   If your workspace uses SSO, sign in through your company's identity provider.
   After an SSO change, old passwords stop working and you are locked out until an
   admin re-invites you.

On the wire, a resources/read result is a contents list. Each entry has the uri, the mimeType, and either text or blob (base64-encoded bytes, for binary data like a PDF). One read can return several entries, for example every file in a folder.

The model never asked for this. By the time it starts its reply, the article is already in the conversation, as surely as if the agent had pasted it. That’s the payoff of a resource: the person who knows what’s relevant decides, and it costs no model turn.

Resource templates: one pattern for thousands of tickets

Listing works for four articles. It doesn’t work for tickets: Kitebase has thousands, and nobody wants a menu of all of them. What you want is to say “any ticket, by id”.

A resource template is a URI with a placeholder, like kitebase://tickets/{ticket_id}. The syntax is RFC 6570, the standard for URI templates. The host fills in the placeholder and reads the result like any other resource. In the SDK, a placeholder in the URI becomes a function argument:

@mcp.resource("kitebase://tickets/{ticket_id}", name="ticket", mime_type="text/markdown")
def read_ticket(ticket_id: str) -> str:
    """One Kitebase ticket: customer, status and assignee."""
    if (ticket := find_ticket(ticket_id)) is None:
        raise ResourceNotFoundError(f"No ticket {ticket_id}")  # a JSON-RPC error, code -32602
    return ticket_markdown(ticket)
RESOURCES/LIST: 4 FIXED URIS kitebase://help/reset-password kitebase://help/account-recovery kitebase://help/sso-login kitebase://help/invite-teammates THE HOST'S ATTACH MENU Reset your password Locked out of your account Sign in with single sign-on (SSO) ← user picks this Invite teammates to a project RESOURCES/TEMPLATES/LIST kitebase://tickets/{ticket_id} a pattern, not a resource: never in the list above HOST FILLS IT IN ticket_id = "KITE-142" kitebase://tickets/KITE-142 RESOURCES/READ RESULT mimeType: text/markdown text: "# KITE-142: Customer locked out after SSO change ... Assignee: priya" ticket_id = "KITE-999" NO SUCH TICKET JSON-RPC error -32602: "No ticket KITE-999" An error, as the spec requires. Never an empty contents list: that would look like a ticket with no text. read_ticket gets ticket_id="KITE-142" as a normal argument.
Listed resources are things. A template is a pattern for things.

Two details trip people up.

Templates don’t show up in resources/list. They have their own request, resources/templates/list. So a host menu built from the resource list won’t offer your tickets at all. Whether a host offers templates, and how the user fills them in, varies by host. Servers can help with the completion API (completion/complete), which suggests values as the user types, like ticket ids starting with “KITE-14”. Rule of thumb: list the things people pick often and the set is small (help articles, the current config, the open incident). Use a template for the large or open-ended set, and don’t assume every host will offer it in a menu.

A missing resource is an error, not an empty result. The spec requires JSON-RPC error -32602 when the resource doesn’t exist (the error shape from article 02). Earlier revisions used -32002, so clients should accept both. Compare what happens when the same missing ticket is asked for as a resource and as a tool:

Asking for KITE-999:
   resources/read -> JSON-RPC error -32602, for the host: No ticket KITE-999
   tools/call     -> is_error=True, for the model to read:
     Error executing tool get_ticket: No ticket 'KITE-999'. Ticket ids look like
     KITE-142.

The difference comes straight from who asked. A tool result goes to the model, so it’s a normal result with isError: true and a hint it can act on. A resource read comes from the host, so it’s a protocol error, and the host can tell the user the attach failed.

If get_ticket can return a ticket, why also have a ticket resource?

They serve different people. The tool is for the model: mid-task, it realises it needs KITE-142 and fetches it. The resource is for the user, who already knows which ticket matters and attaches it up front.

Both call the same find_ticket function: one piece of logic, two front doors. That also covers hosts that don’t let the model read resources, and hosts that don’t show them to the user.

Prompts: templates the user runs

The third request: a consistent triage routine. Every agent should summarise the ticket the same way, find a help article, and draft a reply. You could paste the same instructions into the chat each time. A prompt is that pasted message, stored on the server, with arguments. The user picks it, the host asks the server to fill it in, and the result becomes the user’s message.

Article 01’s triage_ticket told the model to look the ticket up with get_ticket. But the server builds the prompt, so it can put the ticket in directly:

@mcp.prompt(title="Triage a ticket")
def triage_ticket(ticket_id: TicketId) -> str:
    """Summarise a ticket and find a help article to send the customer."""
    ticket = find_ticket(ticket_id)
    details = json.dumps(ticket) if ticket else f"(no ticket {ticket_id} found; say so and stop)"
    return (f"Triage this Kitebase ticket: {details}\n\n"
            "Summarise it in two sentences. Then use search_help to find the article "
            "we should send the customer, and draft a three-line reply that links it.")

Two requests carry it. prompts/list tells the host what to show: the name, the title, the description, and the arguments, each with a name, a description and whether it’s required. prompts/get sends the name and argument values and gets back a list of messages. In Claude Code the prompt shows up in the / menu as /kitebase:triage_ticket (MCP), and you run it with /mcp__kitebase__triage_ticket KITE-142. The companion example shows what comes back:

/triage_ticket <ticket_id>   "Triage a ticket"

User runs /triage_ticket KITE-142
Host adds a user message to the chat, then the model takes over:
   Triage this Kitebase ticket: {"id": "KITE-142", "title": "Customer locked out
   after SSO change", "status": "in_progress", "assignee": "priya", "customer":
   "Northwind Studio"}

   Summarise it in two sentences. Then use search_help to find the article we
   should send the customer, and draft a three-line reply that links it.

Notice how the primitives meet. The user chose the prompt, the prompt carries the ticket, and the instructions steer the model toward a tool call. A prompt is how a user starts a workflow on purpose; the tools still do the work.

Prompt messages can hold more than text: images, audio, a link to a resource, or a whole embedded resource (its contents inline). That’s how a prompt can pull in files or records without the model asking.

The gotchas are practical:

  • A prompt only exists if the host shows it. Tools work in every MCP host. Prompt support is less even, and a host with no prompt menu makes yours invisible. Check your target host before you invest in prompts.
  • Arguments are strings. prompts/get sends a map of string to string, so there are no numbers, lists or enums. Validate inside the function. Claude Code also splits arguments on whitespace, so an argument value can’t contain spaces. That’s one reason ticket_id works better than a customer name like “Northwind Studio”.

How to choose

Most of the design work is choosing. Here’s the order to ask in, with the Kitebase answer for each:

Ask these in order Kitebase examples 1. Does it change anything? writes, sends, assigns, deletes yes TOOL assign_ticket(ticket_id, assignee) no 2. Should the model fetch it itself, in the middle of a task? yes TOOL get_ticket("KITE-142"), search_help("sso") no, or both 3. Will a person point at it? "this article", "this ticket" yes RESOURCE kitebase://help/sso-login kitebase://tickets/{ticket_id} no 4. Is it a workflow the user starts on purpose, again and again? yes PROMPT /triage_ticket KITE-142 no Not sure? Make it a tool. Yes to 2 and 3? Expose both, with one function behind them.
Anything that changes state is a tool. After that, ask who needs to start it.
  • Changes something? Tool, always. If reading a resource assigned a ticket, a host could trigger it just by showing a preview.
  • The model needs it on its own? Tool. Only tools run mid-task without a person stepping in.
  • A person points at it? Resource. Help articles, a ticket, a config file, a database schema.
  • A repeated workflow a person starts? Prompt. Triage, a weekly report, “review this pull request”.

Default to tools. Every host supports them, and a server that’s only well-described tools is a good server. Add resources when people keep telling the model which thing to look at. Add prompts when people keep pasting the same instructions. Both are responses to what you see users do, not boxes to tick on day one.

Try it yourself

The companion example is the whole Kitebase server plus a script that plays the host: it lists and calls each primitive in-process, with a scripted user and model. It runs offline and needs no API key.

Download the runnable example (zip)

cd 04-tools-resources-prompts
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python main.py

Then try these:

  1. Delete the docstring from assign_ticket and run python main.py --tools. The tool still works, but its description is now empty. The model has to guess from the name alone when to call it, and it no longer knows to wait until the user asks.
  2. Replace add_help_article with the template article 01 used, @mcp.resource("kitebase://help/{slug}"). Reading kitebase://help/sso-login still works, but the four articles vanish from the list main.py prints, so a host has nothing to put in its menu.
  3. Swap the resource decorator on read_ticket for @mcp.tool(). Run main.py and the attach step stops with Unknown resource: kitebase://tickets/KITE-142. The model can still fetch tickets, but the user can no longer attach one.

pip install pytest && pytest -q runs the offline tests. One starts server.py over stdio the way a host does, and one connects with the older initialize handshake from article 02, to check that hosts on earlier protocol revisions get the same resources and prompts.

Common beginner mistakes

  • Everything is a tool. It works, but the user can’t attach the thing they already know is relevant, and every lookup costs a model turn.
  • Side effects in a resource read. A host may read a resource just to show a preview. If that marks a ticket as seen or sends an email, it happens without anyone asking.
  • Returning an empty contents list for something that doesn’t exist. The host can’t tell “empty” from “missing”. Raise a not-found error.
  • Prompts for a host that doesn’t show them. Check host support first, or the prompt is dead code.
  • A write tool with no “when” in its description. The model can’t see your intent. Say when to call it, and when not to.

Questions you will face in production

“Can the model read resources on its own?” Depends on the host. The protocol leaves it open: the spec lists “automatic context inclusion, based on heuristics or the AI model’s selection” as one option. Claude Code gives the model tools to list and read resources; other hosts only let the user attach them. If the model must be able to fetch something, also expose it as a tool, with the same function behind both.

“What happens when the list of resources changes, like a new help article?” Servers can declare listChanged for tools, resources and prompts and send a list_changed notification, and the host fetches the list again. Servers can also let clients watch individual resources and notify them when one changes. The Python SDK declares these capabilities for you. Hosts vary in what they do with them; Claude Code refreshes on list_changed.

Check your understanding

Kitebase wants a "mark ticket as resolved" feature. A teammate suggests a resource, kitebase://tickets/{id}/resolve, so users can attach it from the menu. What's wrong with that?

Resolving changes state, so it has to be a tool. A resource read is meant to be safe to do at any time: a host may read it to show a preview, or read it twice. Make it a resolve_ticket tool, with no readOnlyHint and a description that says to call it only when the user asks.

You add a template, kitebase://customers/{customer_id}, but it never shows up in Claude Code's @ menu. Is the server broken?

Probably not. Templates come from resources/templates/list, not resources/list, and a host decides whether and how to offer them. Check with a client script or the MCP Inspector that resources/templates/list returns it. If users need to pick customers from a menu, list the ones they use most as concrete resources, and consider a completion handler for the template.

A user attaches kitebase://tickets/KITE-999, which doesn't exist. What should read_ticket do, and why not return an empty string?

Raise ResourceNotFoundError, which the SDK sends as JSON-RPC error -32602, as the spec requires. An empty string is a valid resource with no text, so the host would attach it and the model would reason about an empty ticket. The error lets the host tell the user the attach failed.

Your triage prompt takes a customer name as its argument. It works in the Inspector but breaks in Claude Code for "Northwind Studio". Why?

Prompt arguments are strings, and Claude Code splits what you type after the command on whitespace, so it sees two arguments. Take an id or key without spaces, like the ticket id KITE-142, and look the name up inside the prompt function.

What to remember

  • The three primitives differ in who decides: the model calls tools, the host app reads resources, the user picks prompts.
  • Anything that changes state is a tool. Resources are reads with no side effects.
  • List resources people pick often. Use a template like kitebase://tickets/{ticket_id} for large sets, and remember templates aren’t in resources/list.
  • A missing resource is JSON-RPC error -32602. A failed tool is a result with isError: true the model can read.
  • A prompt becomes the user’s own message. Its arguments are strings, and it only helps in hosts that show prompts.
  • Default to tools. Add resources and prompts when you see users asking for them.

What to study next

Now that you can pick the right primitive, the most common real job is turning an existing REST API into a server like this one: article 05: Wrapping an API as an MCP Server covers designing tools around tasks instead of endpoints, keeping credentials away from the model, and trimming API responses so they don’t flood the context.

Further reading

Where this article comes from. This is a synthesis of the MCP specification and common practice as of 2026, not a citation of any single paper. The sources above are where the mechanics come from. If you find an error or have a better source for a claim, the article gets fixed within a day, send me a note.


Auto-marks when you reach the end. Click to toggle.