<img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 https://www.facebook.com/tr?id=1063935717132479&amp;ev=PageView&amp;noscript=1 "> Adding a Resource

Adding a Resource

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 URI demo://tasks.
  • Titled Task list, serving text/plain.
  • Registered inside createServer, so it closes over the same tasks array 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

Question 1 of 2

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?