Session 12. MCP architecture and primitives — Tue 29 Sep
The four primitives, and what each one is for
Three the server offers, one it asks for
Week 0 named three: tools, resources, prompts. There is a fourth, and it runs the other way.
| Primitive | Offered by | Chosen by | It is for |
|---|---|---|---|
| Tool | the server | the model | doing something, or computing something |
| Resource | the server | the application, or the person | read-only context, addressed by URI |
| Prompt | the server | the person | a task's instructions, written once |
| Sampling | the client | the server asks, the host decides | the server needs a completion and has no model |
The first three you have served. The fourth is the one that changes how you think about the boundary, so it gets its own section below.
Those four are not the whole protocol. A server can also announce completions
and logging; a client can also announce roots and elicitation. They are
announced the same way and they matter, but none of them is a thing you
discover a list of, which is what this session is about.
Which one to reach for
The mistake is almost always the same: everything becomes a tool. A tool is the one primitive the model can invoke on its own, so making everything a tool feels like making everything available. What it actually does is put every decision in the least predictable place.
| You want | Use | Because |
|---|---|---|
| the model to act, or compute | a tool | the model chooses, and the choice is the point |
| the model to have some text in front of it | a resource | the application chooses, so it is not a coin flip |
| a task's instructions reused across runs | a prompt | the person chooses, and the wording stops drifting |
A resource is not a weaker tool. It is a different control point. get_locations
as a tool means the model decides whether to look; file://locations.txt as a
resource means your application decided, and the text is simply there. When a
run must not depend on a model remembering to look something up, that is a
resource.
Sampling: the arrow reverses
Every other primitive points one way — you ask, the server answers. Sampling points back. The server says "I need a completion for this", and the request arrives at your client.
normal: host -> client -> server "call this tool"
sampling: host <- client <- server "run this through your model"
Three things are true at once about that, and they are worth saying plainly:
- It is genuinely useful. A server can be intelligent without shipping a model, a key, or a bill.
- It spends your tokens, through your model, under your system prompt.
- The text it asks you to complete was written by the server.
Which is why a host that supports sampling asks a human, and why the spec is explicit that the human is in the loop. It is the same asymmetry as concepts-1, made concrete: the server cannot reach your model, so it asks you to do it.
Nothing in this session implements sampling. It is here because "the server can only be read" stops being obvious the moment you meet the one message that goes the other way, and because a client that supports it has widened its boundary a long way.
Capabilities are not collections
Two different answers to two different questions, and the exercise turns on keeping them apart.
# the handshake: what this server CLAIMS it supports
{"tools": {"listChanged": True}, "resources": {}, "prompts": {}}
# the listings: what it actually HAS
list_tools() -> [search_docs, read_page]
list_resources() -> [docs://index]
list_prompts() -> []
prompts is announced and empty. Three readings of that, only one of them
right:
| Reading | What it produces |
|---|---|
| "prompts: 0" | true, useless — indistinguishable from a server that never claimed any |
| "no prompts capability" | false — it announced one |
| "prompts: advertised, and empty" | a claim with nothing behind it, which somebody can act on |
The third is what describe_surface returns, and the reason is not tidiness. A
server that advertises what it cannot serve is a real state with real causes: a
catalogue row copied from a template, a deployment half rolled out, a permission
your credential does not carry. All three are somebody's bug, and all three are
invisible the moment you count len(collection) and move on.
The inverse case is not a defect and must not be reported as one. A server that
lists two resources and never announced a resources capability has an untidy
handshake, and the resources are there. Nothing was claimed, so nothing is
broken. ch12-e2's third listing is exactly that, and a describer that walks the
collections instead of the capabilities fails on it.
Read the field a client can use
Each primitive carries more than one label, and only one of them is the one you ask for.
| Primitive | Read | Not |
|---|---|---|
| tool | name |
its description |
| resource | uri |
its name, which is a display name |
| prompt | name |
its title, which is for a menu |
Unit 10 had this as a trap: the prompt whose name equals its title fails, because
get_prompt() takes the name and the title is what a person reads. It is the
same distinction here, three times over. A map of a surface is for calling
things, so it records what a call needs.
The count is a claim too
A server can print a total. It can say sixteen and list two. When it does, the listing wins — not because servers lie on purpose, but because a total is maintained by hand and a listing is generated.
That is the general rule this session keeps returning to, and ch12-e2's fourth
listing exists to enforce it: count what is there, not what it says.