
A server for an internal ticketing system shipped with 31 tools. Eighteen of them were reads: get_ticket, list_tickets, get_customer, get_sla_policy, list_tags, and so on. Every one of those reads was model-controlled, which meant every agent session carried 31 descriptions in context, every read was a decision the model could make on its own, and every read needed a scope, a credential and an audit entry to be handled correctly, because the protocol gave the host no way to tell a read apart from a write.
The team did not pick tools because tools were right. They picked tools because tools were the primitive they knew. That choice has a cost, and the cost is mostly security, not ergonomics.
The MCP specification defines what a server can expose: tools via tools/list and tools/call, resources via resources/list and resources/read, prompts via prompts/list and prompts/get. The differences that matter are not in the message shapes. They are in who decides to use each one.
A tool is a function the model may call when it concludes calling it is useful. A resource is a document the host may attach to context, addressed by URI, that the model reads but never fetches on its own initiative. A prompt is a pre-written interaction the user picks from a list and fills in.
The ticketing server's eighteen reads were tools because the author wanted the model to be able to fetch a ticket when it needed one. That is a reasonable requirement. But it made every fetch a model decision, and a model decision has to be authorized at the moment it is made, because the host did not approve it in advance.
This is the point that gets lost when the primitives are explained as "actions, data, templates." The question is not what kind of thing it is. The question is who has to be trusted for it to be used safely.
When a capability is model-controlled, the trust boundary is at invocation. The host has to answer, for each call, whether this user is allowed to do this thing with these arguments right now. That is why tool calling needs scoped credentials, per-call audit records, and in multi-user systems a per-user identity behind every execution. The model is not a principal; it is acting for one, and the host has to resolve which one on every call.
When a capability is application-controlled, the trust boundary moves to selection. The host decides which resources to include in context, and it can make that decision with full knowledge of who the user is and what they can see. A resource the host never attaches is a resource the model never reads. The authorization question is answered once, at attach time, by code you wrote, not by a model mid-reasoning.
When a capability is user-controlled, the trust boundary is a human. A prompt the user did not select is not in play.
So the primitive you pick is also the place you are choosing to put your authorization check. Tools put it at the point of least visibility and highest frequency. That is sometimes necessary. It should never be the default for things that are not actions.
Recommended Reading: Tool Calling Authentication for AI Agents on what per-invocation authorization has to look like for model-controlled tools.
Ask three questions, in order.
Does it change anything? If invoking it creates, updates, deletes, sends or transitions, it is a tool. There is no other primitive for side effects, and there should not be: side effects need per-invocation authorization and that is what tools provide.
Does the model need to decide when to fetch it, or can the host decide? A read the model has to request based on what it learns mid-task (look up the customer the ticket mentions) is a tool, and it should be marked read-only. A read the host can determine in advance (the ticket the user opened, the SLA policy for this tenant, the team's style guide) is a resource. The host attaches it before the model starts and the model never has to ask.
Is it a workflow the user kicks off, with the same shape every time? "Triage this ticket," "draft a release note from these PRs," "summarize the escalation thread." Those are prompts. The user picks one, the host fills in the arguments, and the model receives a structured starting point instead of inferring the workflow from a chat message.
Applied to the ticketing server:
Thirty-one tools become roughly thirteen: the writes, plus the reads where the model has to decide. The rest moves to resources the host attaches by policy, and the workflows move to prompts the user selects.
Two details carry weight. The annotations on each tool (readOnlyHint, destructiveHint) are the only in-band signal a host has about what a tool does to the world, and a host can use them to start a session with reads only and require confirmation before anything else. And the prompt references the resources by name rather than telling the model to go fetch them, because the host, not the model, is responsible for attaching them.
Tools work everywhere because the LLM providers handle them: a tool definition becomes a function in the model's tool list regardless of host. Resources and prompts are host features. Claude Desktop surfaces both; VS Code exposes resources as attachable context and prompts as slash commands; many custom harnesses and API-level MCP connectors support tools only.
The tempting fix is to expose everything as tools as well, "for compatibility." That recreates the original problem: the surface doubles, the model now has get_ticket as a tool and tickets://{ticket_id} as a resource, and it will call the tool because tools are what it knows how to call. You have added the resource without removing the decision.
The better fix is to accept tools as the floor and treat resources and prompts as enhancement that replaces tools where the host supports them. A server can check client capabilities at connection time and serve a tool-only variant to hosts without resource support, with the read tools still annotated read-only so the host can at least gate them. Where the host does support resources, the read tools for host-knowable context are simply not served.
The tradeoff is two variants of the surface to maintain. That is a real cost. The alternative is a single bloated surface everywhere, which is the thing the first question in this article was about.
Thirty-one tools at a couple of hundred tokens each is around six thousand tokens of definitions before the model has read the user's message. More importantly, it is thirty-one candidates for every decision. A model choosing between thirty-one options misfires more often than one choosing between thirteen; the wrong read gets called, the right one gets called twice, arguments get hallucinated to fit a schema the model half-remembers.
Moving reads to resources shrinks the decision space without losing any capability: the data is still in context, it just arrived by policy rather than by inference. The thirteen tools that remain are the ones that have to be tools, and they are the ones worth spending authorization effort on.
In a multi-user platform the same shrinking has to happen per user. The tools a given user may call depend on what they have authorized, not on what the server could in principle do. Scalekit's list_scoped_tools returns exactly that slice, the tools the current user's connected accounts are authorized to call, and each definition carries read_only_hint and destructive_hint in its annotations so the agent can be started on the read-only subset and the rest gated behind confirmation.
That is the same decision rule applied one layer down: what the model is allowed to decide, for this user, is the smallest set that does the job, and the host knows which of those decisions have consequences.
The primitives are not three flavours of the same thing. They are three places to put a trust decision. Choose a tool when the model has to be the one deciding and you are prepared to authorize each decision. Choose a resource when your code can decide in advance and the model only needs to read. Choose a prompt when a person should be the one who starts it.
Make that choice first and the authorization design follows from it. Make it last and you end up with thirty-one tools and an audit log that cannot tell a read from a write.
Recommended Reading: Why OAuth for Tool Calling Needs More Than Traditional Flows on why the credential behind a tool call cannot be the user's session.
Each connector's tools, with their read-only and destructive annotations, are listed at docs.scalekit.com/agentkit/connectors.
Who decides to use them. The model invokes tools during reasoning, the host application attaches resources by policy, and the user selects prompts explicitly. The message shapes matter less than the controller.
A model-controlled capability must be authorized at each invocation, with scoped credentials and audit records. An application-controlled one is authorized once at attach time by code you wrote. A user-controlled one is authorized by a person choosing it.
When the model has to decide mid-task that it needs the data, such as looking up the customer a ticket mentions. Reads the host can determine in advance, like the open ticket or a tenant policy, belong in resources.
No. Tools work everywhere because model providers handle them. Resources and prompts depend on host support, which is uneven, so tools are the floor and the other two are enhancement that replaces tools where supported.
That doubles the surface and keeps every read a model decision. The model calls the tool because tools are what it knows, so you have added the resource without removing the decision or the authorization cost.
Fewer candidates mean fewer misfires and less context spent on definitions. Per user, the surface should be only the tools that user's connected accounts authorize, with read-only and destructive hints so writes can be gated behind confirmation.