How-to › Expose your API to agents

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.

Audience
Agent builder
Level
intermediate
Topic
Build an MCP server
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A read-only list toolEvery client calls tools, and the agent needs the data in the middle of a taskA definition in the model’s context on every turn, for data that changes twice a yearThe client surfaces resources and the tool list is already long
A static document fetched over HTTPSPublic data an agent with a fetch tool can read, with no server in the pathA fetch the agent may skip, no completion, and a document that versions apart from the serverThe data is per tenant, or the client has no fetch tool
MCP resources with URI templatesSlowly changing data a user or an application attaches as contextUneven client support, so the model may never see a resource the application does not surfaceThe 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.

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

  1. 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 ?query expression is correct by the letter and parsed by whichever clients took the trouble. ↩︎ Back to text

  2. The /llms.txt convention 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

  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 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.