Tools vs Resources vs Prompts: the MCP primitive you're misusing

Table of contents
Three origami paper figures showing who pulls the trigger on each MCP primitive: a blue paper human firing a Prompt, a green paper hand pulling out a document as a Resource, and a purple paper robot holding a Tool.

The whole post in one picture: who pulls the trigger. The user fires a Prompt (blue), the app pulls a Resource (green), the model reaches for a Tool (purple).

There are three MCP primitives, mainly, and almost everyone ships just one of them. Tools, and then more Tools, until every read and every write is just a button the model can choose to press. Then you hit the wall that everyone hits and blame the protocol for it: context eaten before any real work starts, the model reaching for things it shouldn’t, or skipping a read it plainly needed.

The other two primitives are the fix, and I would rather show you that from a server you can open and read for yourself than from a story you have to take on faith.

So a word on what Pustak is, since it is going to be the running example. I build a lot of interactive HTML explainers these days, an idea I cribbed straight from Thariq Shihipar’s unreasonable effectiveness of HTML: when you want to explain something, a self-contained HTML page with a diagram or a widget the reader can poke will usually beat a wall of markdown.

Pustak is the tool I built to live inside that loop, and it uploads, stores, reads, and shares those explainers for me. To do all that it wears two faces. One is a web app, where a human logs in to see and manage their pages. The other is an MCP server, so the agent that generates and updates the explainers can work with them directly.

It is also one of the very few servers I can talk about in public that has a legitimate use for all three MCP primitives at once: a Resource, a Tool, and a Prompt I really did call explainer. The source is open now, and the interactive explainers scattered through this post came out of that same workflow.

That is also what makes it such a clean example to argue from. Pustak exposes the very same page read two ways on purpose, as a read_page Tool and as a pustak://page/{path} Resource, while the writes stay Tools. Set those two reads side by side and the whole argument falls out of them, because one hands the model a decision and the other never asks it to make one.

MCP primitives sorted by who pulls the trigger

Forget “read versus write,” and forget “what does the call actually do.” The question that sorts MCP’s three primitives is narrower, and a little meaner, than either of those: who pulls the trigger on this call?

Tools are model-controlled. The model decides, mid-reasoning, to call one, and that is the entire point of a Tool. You hand the model an action and you trust it to pick the moment. In Pustak, write_page and delete_page are Tools, and rightly so, because whether to write or delete a page is a real judgement call and the model is the thing that should be making it.

Resources are app-controlled. The client pulls them by URI, the way a browser issues a GET, and the model no more “decides” to read a Resource than a browser decides to fetch a stylesheet. Pustak registers pustak://pages for the catalogue and pustak://page/{path} for a single page’s content, and the host wires those in before the model ever starts to reason.

Prompts are user-controlled. They are slash-command shaped, and a human is the one who fires them. Pustak exposes an explainer prompt for exactly that shape: a workflow a person kicks off deliberately, not one the agent stumbles into on its own.1

The MCP spec says all of this in plain language: Tools are model-controlled, Resources are application-controlled, Prompts are user-controlled. None of it is my reinterpretation; it is written down. The catch is that the SDKs let you ignore it completely, and registering everything as a Tool compiles fine, ships fine, and demos well enough that you never notice what you gave away.

One honest nuance before we go on. The protocol does have a resource-read request path, so it is not literally true that a model can never trigger a read. But the design intent, and the default flow, is the client pulling by URI, so when I say the model doesn’t decide, I mean that a Resource takes the decision out of its path by construction. That is the lens I want you holding for the part that actually costs you something.

Play it: pick a call, then pick its primitive. Box it wrong and watch the failure play out. That is the whole argument, in the small.

The misuse that bites: read-only data as a Tool

Here is the misuse in its smallest form. You hand the agent a get_customer(id) tool: no side effect, just a read. It looks completely harmless, and that is exactly the problem. By making it a Tool, you have quietly turned whether to read the customer at all into a decision the model has to make for itself, mid-task, every single time it comes up.

And it is a decision with only one right answer. If the agent is reasoning about customer 4012, there is no version of the story where skipping the record is the clever move. So you have taken something that was certain, that the agent needs this data, and handed it back to the model as a question it can get wrong. Usually it reads. Sometimes it doesn’t, and then it carries on reasoning, confidently, about a customer it never actually loaded. The read was never really a decision. It was a precondition you dressed up as a choice.

Model it as a Resource and it stops being a decision at all. customer://4012 is simply there in the context, the way an open file sits in your editor without the model ever deciding to open it, so the data the task obviously needs arrives before the reasoning instead of as a gamble taken inside it.

That is the exact fork Pustak puts side by side. A page is read-only reference content, so if I hand it over as the read_page Tool, then on a task whose whole point is working with that page, the agent has to first decide to call read_page, then call it, then finally reason about what came back.

Three problems fall out of that one framing choice. The agent can burn a whole turn deliberating over a question with exactly one right answer; it can skip the read altogether and reason about a page shape it merely guessed at; and on every task that never touches the page, its schema still rides along in the tool list, lugged around for nothing.

Model the page as pustak://page/{path} instead, URI-addressed and pulled in by the client before the agent starts reasoning, and the deliberation turn has nothing left to deliberate. The real content is already in context, and the schema stops appearing on tasks that have nothing to do with it. The agent still re-reads the page constantly as it works, of course, but it never re-decides whether to look.

Once you frame it that way, Tool is obviously the wrong box and Resource is obviously the right one, and the cleanest wins I have ever gotten out of an MCP server, Pustak or mcplex running Code Mode, never came from writing better tool descriptions. They came from moving things out of Tools.

The cost nobody counts

Read-only-as-a-Tool has an obvious cost, and a quieter, more serious one sitting underneath it.

The obvious cost is tokens. Every registered Tool drops its full definition, name, description, and entire input JSON schema, into context on every request, whether the task touches that tool or not. Ten read-only “tools” is ten schemas hauled along every single turn for nothing.

This is a genuine, self-inflicted slice of the “MCP eats your context” complaint, and I am scoping it carefully to the naive default: the deferral and code-execution escapes that knock those numbers down are a different story. I worked the quantified version in No, MCP servers aren’t dead and will not re-derive the figures here.

The cost I actually care about does not show up on a token counter at all.

Every Tool is a decision you have handed to the model, and some of those decisions are exactly the ones you want it making. Which action moves the goal forward is the thing models are genuinely good at, and that judgement is the whole reason you reached for an agent in the first place. But “should I fetch the reference data this task obviously requires” is not that kind of question. It has one right answer every single time, so casting the model to rule on it is just casting it in the wrong role.

That is the part that bothers me. Every read-only-as-a-Tool takes something that was a structural certainty, that the agent will need this, and turns it into a probabilistic coin flip, then spends your tokens flipping the coin. You end up paying tokens for the privilege of inventing a failure mode that never had to exist.

And the dangerous part is the asymmetry between the two costs: the wasted tokens you can see, and the bad decisions you cannot. A token counter will show you the schema bloat all day long, but it will never show you the run where the agent confidently designed against a page it never actually pulled.

Flip each read-only tool to a Resource. The token meter is the cost you can see. The ‘decisions handed to the model’ meter is the one no token counter will ever show you.

That asymmetry, far more than the token bill, is why “what did I just hand the model a decision about” has become a design-review question of its own for me.

Why everyone does it anyway

I don’t think this misuse is laziness, and I want to be fair about why it keeps happening, because the excuse behind it is real.

Resources have the worst adoption-to-value ratio of the three primitives. Client support for them is genuinely uneven as of mid-2026, and I would not bet on a given agent surfacing Resources cleanly the way I would bet on it running Tools. Tools work everywhere. So the ecosystem quietly learned a habit, “when in doubt, make it a Tool,” and dressed it up as pragmatism.

If your users are on a client that barely surfaces Resources, shipping a page as a Tool really is the thing that works today. It is also, not by coincidence, why Pustak still registers read_page and list_pages as Tools right alongside the Resources: the redundant read tools are a ramp for clients that have not caught up yet, not the design I would point at as correct.

Sourced as of July 2026: which agentic surfaces surface Resources and Prompts, not just Tools. Watch the split. Headless and enterprise agents converge on Tools-only (GitHub’s own cloud agent documents dropping the other two), while single-developer desktop apps moved fastest.

Let me say the harsh part the matrix earns. If you build a coding agent and skip Resources and Prompts, you are not behind, you are telling on yourself. This is a small, fully-specified slice of a protocol you already did the hard ninety percent of, and supporting it hands your own model less rope: fewer reads left to a coin flip, fewer workflows it can fire by accident.

Choosing not to is not feature velocity spent somewhere better, and it is not pragmatism. It is a quiet statement that whether the thing built on top of you actually works is not really your problem.

OpenAI, this one is aimed squarely at you.2 You make the model and the agent, ChatGPT’s connectors are Tools-only on both counts, and you above anyone understand what a single stray read does to a reasoning trace. When the party best placed to get this right treats it as beneath them, I do not walk away doubting the protocol. I walk away knowing to bring every last one of my doubts to the table before I build anything on top of you.

I feel the pull of that too. I still think you should refuse to let it set the shape of your server, and this is where I plant my feet.

When you model read-only data as a Tool because the weakest client in your user base does not handle Resources well, you are not being pragmatic. You are baking 2026’s tooling gap into your architecture permanently. The client limitation is temporary; clients ship fixes, Resource support is improving, and the agent you are scared of supporting today gets better next quarter.

The design decision, on the other hand, is not temporary. It sits in your server’s shape long after the limitation that justified it is gone, and by then it is simply wrong, with no live excuse still attached to it. So model the data as what it is, keep the read tool around as a compatibility shim if you have to, and let the clients catch up underneath you.

And the day Resources and Prompts just work across the clients you care about, you drop the shim Tools in a single server-side deploy. Nobody downstream has to lift a finger, because a remote HTTP server is yours to change. You are not rewriting your server when that day comes. You are deleting the crutch.

This is the same shape as the config-debt problem I wrote about earlier: the format moves faster than the plumbing under it, and if you design to the plumbing’s current limits you inherit them forever.

There is a boundary here I will not pretend away, because Resources are not a free lunch. When you pull one in, it costs context, and now you always pay that cost, even on the run that did not need the data. For data the task obviously needs, that always-pull is the entire point of the exercise, so you are happy to pay it.

For genuinely optional reference data, though, where fetching really is a judgement call, a large or expensive lookup the model should weigh, or an ambiguous “which of these N records” choice, a Tool can legitimately win. That is the honest line: read-only data is almost never a Tool, and the “almost” is exactly the case where fetching it is a real decision rather than a foregone one.

The same mistake exists in mirror image on the Prompts side, and it is worth a sentence. A /weekly-report workflow that a human should kick off, buried inside a Tool, lets the model decide at 2am, mid-unrelated-task, to fire it off, which is the read-only mistake pointed the other way. I just hit the read-only version far more often, so that is the one I have spent the post on.

The rule, earned

Before I touch the SDK now, I ask one thing about every read and every write: who should be pulling this trigger?

Side effect, plus a real judgement call the model should make, that is a Tool. Read-only reference the agent will obviously need, that is a Resource, addressed by URI, pulled in before the model reasons. A workflow a human kicks off, that is a Prompt, slash-shaped.

“Make everything a Tool” only looks like the simple choice. It is really the one mistake the SDKs will happily let you ship, because everything-as-a-Tool compiles, runs, and demos just fine, right up until the model burns a turn deciding whether to read the one page it was always going to need. This taxonomy is not trivia; it is the difference between an agent that acts on its certainties and one that gambles on them.

If you want to see the whole thing wired up rather than described, Pustak is live to poke at and its source is on GitHub, reads split across Tools and Resources, writes kept as Tools, and the compatibility shims sitting right where the argument says they should.

May the force, and the right primitive, be with you…


  1. Purely my own observation from building these, not something the spec blesses, so treat it as a workaround rather than a pattern. If a client will not surface Prompts, you can fake one with a Tool: have it take a string argument and return a prompt string, and the model will mostly treat that returned text as its own instructions and follow it. That word “mostly” is load-bearing. It is best-effort, it drifts between models, and I would not hang anything critical on it. ↩︎

  2. In case this reads as reflexive OpenAI-bashing, fine, let that happen. It has a reason. I currently rank them the fourth-best model provider going, behind Claude, Gemini, and GLM, and this exact habit is the reason why: they ship half-baked decisions and still want your full attention and trust anyway. The missing Resources and Prompts support is one more line on that list, not the whole case. ↩︎

See Also


Tags