# How to serve reference data as MCP resources instead of tools

> Serve plan limits, region codes, and a changelog as MCP resources behind URI templates, and keep one lookup tool for the clients that never read a resource.

Source: https://voxgig.com/howto/serve-reference-data-as-mcp-resources

- Audience: agent-builder
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## Short answer

Register the slowly changing data with `registerResource` in the official TypeScript SDK: a fixed URI for each set, and a `ResourceTemplate` such as `ref://plans/{plan}` for one record. Give the template a `list` callback so each record appears in `resources/list`. Resources are application-driven, so a client may show them to its user and never to the model. Keep one read-only lookup tool that returns the same bytes and names in its description the resources it duplicates.

---
## You will need

Node 22 or later, the official TypeScript SDK as `@modelcontextprotocol/server` and
`@modelcontextprotocol/client`, and `zod` for the tool schema. Verified 2026-10-21 against Node
22.22.0, `@modelcontextprotocol/server` 2.3.1, `@modelcontextprotocol/client` 2.3.1, and `zod`
4.6.7. The sample connects a client to the server inside one process over the SDK's in-memory
transport, so nothing listens on a port and nothing crosses the network. The
[resources page of the specification](https://modelcontextprotocol.io/specification/2026-07-28/server/resources)
defines the three requests involved, `resources/list`, `resources/templates/list`, and
`resources/read`, and borrows its template syntax from
[RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570).[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A read-only list tool](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#tool) | Every client calls tools, and the agent needs the data in the middle of a task | A definition in the model's context on every turn, for data that changes twice a year | The client surfaces resources and the tool list is already long |
| [A static document fetched over HTTPS](https://llmstxt.org/#format) | Public data an agent with a fetch tool can read, with no server in the path | A fetch the agent may skip, no completion, and a document that versions apart from the server | The data is per tenant, or the client has no fetch tool |
| [MCP resources with URI templates](https://modelcontextprotocol.io/specification/2026-07-28/server/resources#resource-templates) | Slowly changing data a user or an application attaches as context | Uneven client support, so the model may never see a resource the application does not surface | The agent needs the data mid task and the client only calls tools |

Resources keep the tool list short, and the price is who decides whether the model reads them:
the application, not the server. A lookup tool reaches every client and is paid for on every turn
whether that turn needs it or not. A document on the web needs a fetch tool, an allowed host, and
a release process of its own.[^2] The sample below serves resources and keeps one lookup tool,
which is the combination the pitfall section argues for.

## Register each record type as a template, not a tool

A static resource has one URI. A `ResourceTemplate` covers a family of them, with the variable in
braces, and its `list` callback is what turns the family into entries a client can show.

```js title="server.mjs"
server.registerResource('plan', new ResourceTemplate('ref://plans/{plan}', {
  // The list callback is what puts each plan into resources/list. The SDK
  // insists on the key; pass undefined only when the set cannot be enumerated.
  list: () => ({
    resources: keysOf('plans').map((plan) => ({ uri: `ref://plans/${plan}`, name: `plan ${plan}`, mimeType: JSON_MIME })),
  }),
  complete: { plan: (typed) => keysOf('plans').filter((plan) => plan.startsWith(typed)) },
}), {
  title: 'One plan',
  description: 'The limits of one plan, by name.',
  mimeType: JSON_MIME,
}, async (uri, { plan }) => {
  const text = lookup('plans', String(plan))
  // A missing resource is an error. The spec forbids answering with an empty
  // contents array, and the SDK would pass one through unchecked.
  if (text === null) throw new ResourceNotFoundError(uri.href)
  return contents(uri, text)
})
```

Three choices in that block carry the page. `list` enumerates the plans, so a picker shows
`ref://plans/team` rather than a pattern. `complete` answers the
[completion request](https://modelcontextprotocol.io/specification/2026-07-28/server/utilities/completion)
a client sends while its user types a variable. A name that matches nothing throws
`ResourceNotFoundError`, which the SDK turns into the `-32602` error the specification
requires.[^3]

```bash
node demo.mjs
```

```text output
server capabilities {"completions":{},"resources":{"listChanged":true},"tools":{"listChanged":true}}

resources/list
  ref://plans              plans            Plans and their limits
  ref://regions            regions          Regions
  ref://changelog          changelog        Metering API changelog
  ref://plans/free         plan free        One plan
  ref://plans/team         plan team        One plan
  ref://plans/enterprise   plan enterprise  One plan
resources/templates/list
  ref://plans/{plan}       plan             One plan
```

The list holds the three static resources and the three plans the template enumerated, and
`resources/templates/list` advertises the pattern itself. The client in every run is the SDK's
own, wired to the server in the same process the way
[the SDK's testing page](https://ts.sdk.modelcontextprotocol.io/v2/testing) shows.

## Keep one lookup tool and say what it duplicates

The lookup tool exists for the client that never sends `resources/read`. It calls the same
`lookup` function the resource callbacks call, so the two paths cannot drift, and its description
names every resource it repeats.

```js title="server.mjs"
function registerFallbackTool(server) {
  server.registerTool('reference_lookup', {
    title: 'Look up reference data',
    description: 'Returns the same data as the resources ref://plans, ref://plans/{plan}, ref://regions and ref://changelog, for a client that does not read resources. Read the resource instead when your client can.',
    inputSchema: z.object({
      set: z.enum(['plans', 'regions', 'changelog']).describe('Which reference set to return.'),
      key: z.string().optional().describe('One plan name or region code. Omit for the whole set.'),
    }),
    annotations: READ_ONLY,
  }, async ({ set, key }) => {
    const found = lookup(set, key)
    if (found === null) return refuse(`No ${set} entry named ${key}. Valid names: ${keysOf(set).join(', ')}.`)
    return text(found)
  })
}
```

`READ_ONLY` sets `readOnlyHint` and `idempotentHint` to true and `openWorldHint` to false, the
[annotations](https://modelcontextprotocol.io/specification/2026-07-28/server/tools#tool) a client
reads when deciding whether to ask before a call. The sentence naming the resources is for the
client that reads both: it can skip the tool, or tell its model that the two are one thing.

```bash
node demo.mjs
```

```text output
resources/read ref://plans/team
{
  "plan": "team",
  "requests_per_minute": 600,
  "meters": 500,
  "retention_days": 365,
  "price_eur_month": 49
}

tools/call reference_lookup {"set":"plans","key":"team"}
{
  "plan": "team",
  "requests_per_minute": 600,
  "meters": 500,
  "retention_days": 365,
  "price_eur_month": 49
}

same bytes from both paths: true
completion for ref://plans/{plan} after "e": enterprise
```

## Measure what the tool list costs

A host that loads its tools up front sends every definition with every model request that may call
a tool. The MCP project's
[guidance for client authors](https://modelcontextprotocol.io/docs/2026-07-28/develop/clients/client-best-practices)
warns that definitions can take most of the context window before the model has read the user's
message. The same guidance asks hosts to defer them past a threshold. A resource list is fetched
when the application chooses. The script builds the server three ways and measures the JSON each
one hands the client.

```bash
node measure.mjs
```

```text output
reference as   tools  tools/list bytes  resources/list bytes  templates/list bytes
tools              6              2076                     0                     0
resources          1               593                   958                   150
both               2              1275                   958                   150
```

Five lookup tools add 1483 bytes to the action tool's 593, on every turn. The fallback adds 682,
and the 958 bytes of resource list are read once, by the application, which then decides what the
model sees. Bytes are not tokens; read the ratio rather than the counts. The tool list with
resources and a fallback is 39 percent shorter than the one with five lookup tools.

## Check it worked

Run the tests and look for `# pass 10`. Four of the ten are the ones to keep when you adapt the
sample. The resource and the fallback return identical bytes, and an unknown plan is a `-32602`
error rather than an empty `contents` array. A name every object inherits, such as `constructor`,
is unknown too, and the description of the fallback names all four resources.

```bash
node --test reference.test.mjs
```

```text output
1..10
# tests 10
# suites 0
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 306.222017
```

## When it goes wrong

The agent never reads the resource. The client it runs in calls tools and stops there, and the
server has no way to detect that. The
[client capabilities](https://modelcontextprotocol.io/specification/2026-07-28/schema#clientcapabilities)
a request carries describe what a client offers the server: `experimental`, `roots`, `sampling`,
`elicitation`, and `extensions`. A server learns nothing from them about what the client does with
resources. The script below plays that agent against the server with resources only, then
against the server with the fallback.

```bash
node pitfall.mjs
```

```text output
reference as resources
  client capabilities the server received: {}
  tools the agent sees: meter_create
  meter_create with a guessed plan: Unknown plan platinum. Valid plans are free, team, enterprise, from the resource ref://plans.
  no tool returns the plans, so the agent can only guess again

reference as both
  client capabilities the server received: {}
  tools the agent sees: reference_lookup, meter_create
  meter_create with a guessed plan: Unknown plan platinum. Valid plans are free, team, enterprise, from the resource ref://plans or the reference_lookup tool.
  reference_lookup returns the plans: free, team, enterprise
  meter_create with a looked-up plan: Created meter mtr_1001 (SN-40199) on plan team in eu-west.

a server whose read callback returns contents: [] for an unknown plan
  resources/read ref://plans/platinum -> {"contents":[]}
  resources/list with list: undefined -> []
```

Both servers received the same empty capability object, so the fallback is the only lever the
server has. Which clients read resources is a matter of reading each one's documentation.
[Claude Code](https://code.claude.com/docs/en/mcp#use-mcp-resources) attaches a resource when its
user types an `@` mention, and it also gives the model tools to list and read them. A runtime that
wires `tools/list` into a model loop and stops there sees neither.

The last two lines show the second and third failures. The SDK accepts a read callback that answers
an unknown key with `contents: []`, although the specification forbids it. The array must never be
empty for a resource that does not exist, because a client cannot tell absence from emptiness.
Throw `ResourceNotFoundError` instead, as the sample does. A template constructed
with `list: undefined` is readable but invisible: `resources/list` has nothing to enumerate, so a
picker shows no plans at all. Pass a `list` callback whenever the set can be enumerated, as
[the SDK's resources guide](https://ts.sdk.modelcontextprotocol.io/v2/servers/resources) spells
out.

## When not to do this

Do not move data the agent needs in the middle of a task behind a resource alone. The pitfall
section is the whole argument: a tools-only client has no way to reach it, and the server cannot
tell such a client from one that reads resources. Keep the fallback, and keep its description
accurate when the resources change.

Do not treat a resource as a query. `resources/read` returns the whole content, and a template
variable is a key, not a filter. Data that changes by the minute or runs to megabytes wants a tool
with arguments and a page size, which is what tools are for.

Do not give a resource an `https://` URI unless the client can fetch that address itself. The
specification reserves the scheme for that case and asks for a custom scheme otherwise, even when
the server fetches the content from the web on the client's behalf. `ref://` is such a scheme, and
all a client needs to know about it is that the server answers `resources/read` for it.

Do not duplicate the actions. The fallback repeats the reference sets and nothing else.
`meter_create` stays a tool, because a write is not context, and a client that gates destructive
calls needs to see it where tools are listed.

## Related how-tos

- [Run an MCP server against a mock API while you build tools](/howto/run-an-mcp-server-against-a-mock-api)

- [Name tools so an agent picks the right one from fifty](/howto/name-tools-so-agents-pick-the-right-one)

- [Upgrade an MCP server to a newer protocol revision](/howto/upgrade-an-mcp-server-to-a-newer-revision)

- [Connect a remote MCP server to Claude Desktop](/howto/connect-a-remote-mcp-server-to-claude-desktop)

## Last verified

Verified 2026-10-21 against Node 22.22.0, `@modelcontextprotocol/server` 2.3.1,
`@modelcontextprotocol/client` 2.3.1, and `zod` 4.6.7. Every output block is what the command
preceding it printed. The client in every run is the SDK's own `Client` over its in-memory
transport. The statements about which clients surface resources come from those clients'
documentation rather than from running them.

[^1]: [RFC 6570](https://www.rfc-editor.org/rfc/rfc6570.html#section-1.2) sorts its templates
into four levels, and `{plan}` is Level 1: a single variable replaced by its value, with every
character outside the unreserved set percent-encoded. The RFC says most template processors
written before it existed implemented that form and nothing more, which is how the level got its
number. The MCP
specification links the RFC and names no level, so a template with a `?query` expression is
correct by the letter and parsed by whichever clients took the trouble.

[^2]: The `/llms.txt` convention was proposed by Jeremy Howard in September 2024, and
[the proposal](https://llmstxt.org/) styles itself version two, revised in August 2026. It
describes a Markdown file at a fixed path that tells an agent where the rest of a site's
documentation is. That is a resource list with no protocol around it, served by a web server that
cannot tell an agent from a browser, which is both the appeal and the limit.

[^3]: The code is JSON-RPC's `-32602`, `Invalid params`, because a resource is a parameter of
`resources/read` rather than a method of its own. Earlier revisions of the protocol used `-32002`,
which sits in the band from `-32000` to `-32099` that the
[JSON-RPC 2.0 specification](https://www.jsonrpc.org/specification) reserves for
implementation-defined server errors. The
[current resources page](https://modelcontextprotocol.io/specification/2026-07-28/server/resources#error-handling)
asks clients to go on accepting it. The error moved from the server's private band to the shared
one without changing what it means.