Your MCP server lists eighteen tools, and a third of them return data that changes twice a year:
plan limits, region codes, the changelog. An agent pays for all eighteen definitions on every
turn, then picks plans_list when it needed plan_get. The data is reference material, but the
tool list presents it as action.
What you get
You will end up with reference data served as MCP resources behind URI templates, a tool list cut to actions, and one lookup tool whose description names what it duplicates. This is for you if tools that only read reference data crowd your list.
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
defines the three requests involved, resources/list, resources/templates/list, and
resources/read, and borrows its template syntax from
RFC 6570.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A read-only list 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 | 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 | 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.
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
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
node demo.mjs
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 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.
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 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.
node demo.mjs
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 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.
node measure.mjs
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.
node --test reference.test.mjs
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
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.
node pitfall.mjs
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 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 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
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.
Footnotes
-
RFC 6570 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?queryexpression is correct by the letter and parsed by whichever clients took the trouble. ↩︎ Back to text -
The
/llms.txtconvention was proposed by Jeremy Howard in September 2024, and the proposal 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. ↩︎ Back to text -
The code is JSON-RPC’s
-32602,Invalid params, because a resource is a parameter ofresources/readrather than a method of its own. Earlier revisions of the protocol used-32002, which sits in the band from-32000to-32099that the JSON-RPC 2.0 specification reserves for implementation-defined server errors. The current resources page 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. ↩︎ Back to text