Skip to content

Tools, resources and prompts

Spec
2026-07-28
Evidence
E2Reproducedarm64
TypeScript SDK (server)
@modelcontextprotocol/server@2.4.0
TypeScript SDK v2 (Node HTTP)
@modelcontextprotocol/node@2.1.2
Python SDK
mcp@2.3.0
Verified

A server can offer three kinds of things. The difference between them is who decides to use them:

Primitive Controlled by Typical use
Tool The model Do something or compute something: add, search_orders, create_issue
Resource The application Data the host reads and puts into context: a file, a config, a database row
Prompt The user A template the person picks from a menu, such as “review this code”

Pick by that question. If the model should decide when to call it, it is a tool. If the host should load it as context, it is a resource. If a person should choose it, it is a prompt.

All examples below come from one TypeScript server (SDK @modelcontextprotocol/server@2.4.0) that we ran over Streamable HTTP and queried with curl. The responses are trimmed but otherwise unchanged.

A tool has a name, a description, an inputSchema and, optionally, an outputSchema. Both schemas are JSON Schema; since 2026-07-28 they may use any JSON Schema 2020-12 keyword, as long as inputSchema has type: "object" at the root.

server.registerTool('add', {
description: 'Add two numbers.',
inputSchema: z.object({ a: z.number(), b: z.number() }),
outputSchema: z.object({ sum: z.number() }),
}, async ({ a, b }) => ({
content: [{ type: 'text', text: String(a + b) }],
structuredContent: { sum: a + b },
}));

A call returns two things:

  • content: text, images or other blocks for the model and for clients that only display text.
  • structuredContent: the machine-readable result. If the tool declares an outputSchema, the result must match it. Since 2026-07-28 it can be any JSON value, not only an object.

In tools/list, the TypeScript SDK turned the Zod output schema into JSON Schema with "additionalProperties": false, so returning an extra field fails validation.

A resource is data addressed by a URI. The server lists fixed resources with resources/list and parameterized ones with resources/templates/list:

server.registerResource('config', 'config://app',
{ title: 'App config', mimeType: 'application/json' },
async (uri) => ({ contents: [{ uri: uri.href, mimeType: 'application/json', text: '{"theme":"dark"}' }] }));
server.registerResource('note', new ResourceTemplate('notes://{id}', { list: undefined }),
{ title: 'A note by id', mimeType: 'text/plain' },
async (uri, { id }) => ({ contents: [{ uri: uri.href, text: `note ${id}` }] }));

resources/list returned only config://app. The template appeared in resources/templates/list as "uriTemplate":"notes://{id}", and reading notes://42 returned:

{"contents":[{"uri":"notes://42","text":"note 42"}],"resultType":"complete","ttlMs":0,"cacheScope":"private"}

The URI scheme is yours to choose. What matters is that the host can read the resource without the model deciding to call anything.

A prompt is a named template with arguments. prompts/list shows the arguments and whether each is required; prompts/get fills them in and returns messages:

server.registerPrompt('review-code', {
title: 'Code review',
argsSchema: z.object({ code: z.string(), language: z.string().optional() }),
}, ({ code, language }) => ({
messages: [{ role: 'user', content: { type: 'text', text: `Review this ${language ?? ''} code:\n${code}` } }],
}));
"arguments":[{"name":"code","required":true},{"name":"language","required":false}]

Clients usually show prompts as slash commands or menu items. The server never runs them on its own.

In 2026-07-28, results of tools/list, prompts/list, resources/list, resources/templates/list, resources/read and server/discover must carry two caching hints:

  • ttlMs: how long, in milliseconds, the client may treat the result as fresh. 0 means re-fetch whenever needed.
  • cacheScope: "public" if the result has no user-specific data and a shared gateway may cache it; "private" if it may only be reused for the same authorization context.

The TypeScript SDK sends ttlMs: 0 and cacheScope: "private" unless you set hints. We set one for tools/list:

new McpServer({ name: 'primitives-demo', version: '1.0.0' },
{ cacheHints: { 'tools/list': { ttlMs: 60000, cacheScope: 'public' } } });

tools/list then returned "ttlMs":60000,"cacheScope":"public", while the other lists kept the defaults. prompts/get and tools/call carry no caching fields at all. In the Python SDK 2.3.0 the same option is cache_hints on MCPServer.

Only mark a list public if every user sees the same list. If tools depend on who is calling, keep it private.

When the set of tools, prompts or resources changes, the server can notify clients that opted in. In 2026-07-28 that happens on a subscriptions/listen stream, not through a separate GET connection. Caching and notifications work together: the TTL says how long a list stays fresh, and a notification says it changed early.

Sources

MCPDev is community documentation. It is not affiliated with the Model Context Protocol project or with the vendors of the clients it covers.