Nothing yet reads the list back. You could write a list-tasks tool for that and it
would work, but then the list only reaches the model when the model thinks to ask for it. A
resource is the other option: read-only content the host can pull in on its own, without the model
deciding anything.
The four arguments to registerResource
server.registerResource(
"resource-name", // 1. name
"scheme://path", // 2. the URI clients read
{ // 3. config
title: "Display Name",
mimeType: "text/plain"
},
async (uri) => ({ // 4. read handler
contents: [{
uri: uri.href,
text: "whatever this resource currently says"
}]
})
); The name is an internal label, the way add-task was for a tool.
The URI is how clients actually ask for it, and it is the real identifier here. Any scheme works: it is a made-up address, not a location that resolves to anything on the network.
The config carries a display title and the mimeType of
what you return, so a client knows whether it is holding plain text, JSON, or something else.
The read handler receives the parsed uri and returns
contents, a list because one read can return several parts, each carrying the
uri it came from. Note what is missing: there is no inputSchema, because a
resource read takes no arguments.
How a resource differs from a tool
- It's addressed by a URI, not a name. Clients read the URI rather than calling the resource by its label.
- There's no input schema. If you find yourself wanting arguments, you either want a tool, or a resource template, which parameterizes the URI itself.
- It's safe to read. A resource reports on state without changing it, which is what lets a host pull one into context without asking anyone's permission.
✏️ Exercise: expose the task list
Add a resource to src/stdio-server.ts, beside the tool you already wrote:
- Named
tasks, read at the URIdemo://tasks. - Titled
Task list, servingtext/plain. -
Registered inside
createServer, so it closes over the sametasksarray the tool pushes to. This is the part that matters: put it outside and it will read a different array than the one being written. -
Returning the tasks one per line, numbered from 1, or the text
No tasks yet.while the array is empty.
Verify it
First confirm the server advertises it:
npx @modelcontextprotocol/inspector --cli npx tsx src/stdio-server.ts --method resources/list {
"resources": [
{
"name": "tasks",
"title": "Task list",
"uri": "demo://tasks",
"mimeType": "text/plain"
}
]
} Then read it:
npx @modelcontextprotocol/inspector --cli npx tsx src/stdio-server.ts \
--method resources/read --uri "demo://tasks" {
"contents": [
{
"uri": "demo://tasks",
"text": "No tasks yet."
}
]
} Expect the empty answer. That is "No tasks yet." even though you added
"Buy milk" in the last lesson, and it is correct. Every --cli invocation
starts your server as a brand new process, and tasks is an array in that process's
memory, so it begins empty each time and nothing from an earlier command survives. The tool and the
read only see the same list when they happen against one running process, which is what the
assistant gives you below.
If resources/list comes back empty, the registration is not running. If the read fails
on the URI, it does not match what you registered.
Solution
server.registerResource(
"tasks",
"demo://tasks",
{ title: "Task list", mimeType: "text/plain" },
async (uri) => ({
contents: [{
uri: uri.href,
text: tasks.length
? tasks.map((t, i) => `${i + 1}. ${t}`).join("\n")
: "No tasks yet."
}]
})
); Attach it in your assistant
Restart demo-server the way you did in lesson 3 so your assistant picks up the
resource, then add a task or two through it.
Now bring the list into context, which is the part the --cli read can't show you. In
Copilot, use the chat's "Add Context" control and pick demo://tasks.
In Claude Code, write @demo-server:demo://tasks in your message.
The tasks are there, because that one connection stayed open across both steps. That's the difference between a resource read against a fresh process and one against a connection your assistant is holding open, and it's also the difference between the host pulling content in and the model deciding to go get it.
Your server now exposes both kinds of capability, one the model reaches for and one the host does.
Check your understanding
What changes if you swap the resource for a tool?
Someone suggests replacing the `demo://tasks` resource with a `list-tasks` tool instead. What actually changes?