Tools, Resources, Prompts & Elicitation

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against the 2026-07-28 revision of the specification, the Python mcp SDK 2.2.0, @modelcontextprotocol/server 2.2.0 with Zod 4.6, and the MCP Java SDK 2.0.1 with Spring AI 2.0.1 (annotations in org.springframework.ai.mcp.annotation), as checked on 2026-09-29 (see the Model Context Protocol baseline). Python examples target Python 3.12, Java examples Java 21 and Spring Boot 4.1, and TypeScript examples Node.js 24. The Java SDK and Spring AI speak protocol 2025-11-25 only, so the Java column of every example below has no elicitation through input_required and no caching hints.

An MCP server offers three kinds of content, and the protocol keeps them apart by who decides when they are used. Tools are chosen by the model, resources by the application, prompts by the user. A fourth mechanism, elicitation, runs in the opposite direction: the server asks the user for input while it is handling a request. This page covers all four, plus the freshness hints on list results and the client features the 2026-07-28 revision deprecated. Every primitive is shown as raw JSON-RPC first, so you see what any SDK is producing, and then as the same docs-assistant feature in the three stacks. The requests omit the _meta block described on MCP Architecture; every real request must carry it.

Primitive Controlled by Purpose docs-assistant example

Tool

The model, with the host asking the user to confirm sensitive calls

Do something or compute something: query, call an API, change state.

search_docs(query, component?, top_k) returns ranked hits as structuredContent.

Resource

The application (the host decides what to attach as context)

Expose data addressed by a URI: files, records, pages.

docs://{component}/{page} returns a page’s AsciiDoc source.

Prompt

The user (typically a slash command or menu entry)

A reusable, parameterised message template.

answer_with_citations(question, component) builds a grounded-answer instruction.

Elicitation

The server asks; the user answers through the client

Collect a missing value or a confirmation in the middle of a request.

"Which component should I search?" when component is omitted.

Tools

A tool is a named operation with a description and a JSON Schema for its arguments. The model reads the name, description and schema (see Structured Output & Tool Calling for how a host turns them into function calling), decides to call it, and the host sends tools/call. tools/list returns the definitions; a server that declares the tools capability must answer it, in a deterministic order so clients and LLM prompt caches can rely on it.

tools/list — a tool definition (abridged; the SDK-generated title keywords are removed)
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "resultType": "complete",
    "tools": [
      {
        "name": "search_docs",
        "description": "Search the documentation of one component and return the best matching pages.",
        "annotations": { "readOnlyHint": true, "openWorldHint": false },
        "inputSchema": {
          "type": "object",
          "properties": {
            "query": { "type": "string" },
            "component": { "anyOf": [{ "type": "string" }, { "type": "null" }], "default": null },
            "top_k": { "type": "integer", "default": 3 }
          },
          "required": ["query"]
        },
        "outputSchema": {
          "type": "object",
          "properties": {
            "query": { "type": "string" },
            "component": { "type": "string" },
            "hits": { "type": "array", "items": { "$ref": "#/$defs/Hit" } }
          },
          "required": ["query", "component", "hits"],
          "$defs": {
            "Hit": {
              "type": "object",
              "properties": {
                "component": { "type": "string" }, "page": { "type": "string" }, "title": { "type": "string" },
                "score": { "type": "integer" }, "snippet": { "type": "string" }
              },
              "required": ["component", "page", "title", "score", "snippet"]
            }
          }
        }
      }
    ],
    "ttlMs": 0,
    "cacheScope": "private"
  }
}

inputSchema and outputSchema are full JSON Schema, and 2026-07-28 makes the dialect explicit: when a schema has no $schema keyword it is JSON Schema 2020-12, so $defs, $ref, anyOf and the rest work as the example shows. inputSchema must be an object schema and must not be null; for a tool with no parameters the specification recommends { "type": "object", "additionalProperties": false }. Servers must validate every input against it before doing anything, because the model, not a compiler, produced the arguments. A property may also carry an x-mcp-header keyword that mirrors its value into an HTTP header, which is a transport feature covered on Transports.

Results, structuredContent and errors

A successful call returns content (text, image, audio, resource links or embedded resources) and, when the tool has structured output, structuredContent. That field can hold any JSON value — object, array, string, number, boolean or null — and if the tool declares an outputSchema the server must produce output that conforms to it and clients should validate it. For compatibility with clients that only read content, a tool that returns structured content should also return the serialised JSON in a text block. It is captured below from the Python server:

tools/call — request and result
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
  "params": { "name": "search_docs",
              "arguments": { "query": "matrix decomposition", "component": "algebra", "top_k": 2 },
              "_meta": { ... } } }
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "resultType": "complete",
    "isError": false,
    "content": [{ "type": "text", "text": "{ \"query\": \"matrix decomposition\", ... }" }],
    "structuredContent": {
      "query": "matrix decomposition",
      "component": "algebra",
      "hits": [
        { "component": "algebra", "page": "matrices", "title": "Matrices", "score": 2,
          "snippet": "Matrix decompositions such as LU, QR and SVD solve linear systems." },
        { "component": "algebra", "page": "wrappers", "title": "Wrappers", "score": 1,
          "snippet": "Wrapper classes hide the decomposition details behind a simple solver." }
      ]
    }
  }
}

Errors use two channels and the difference matters, because they are handled by different readers. A protocol error means the request itself was wrong in a way a model is unlikely to fix — an unknown tool, a request that does not match the schema, a server fault — and is a JSON-RPC error, for example -32602. A tool execution error means the tool ran and failed in a way the model can act on: an API failure, an invalid date, a business rule. It is a normal result with isError: true and a message the model can read and use to retry. Clients should pass tool execution errors to the model and may show protocol errors to it, though those rarely help. The Python server returns input-validation failures the way the specification describes, as tool execution errors:

A call with the wrong argument type (message abridged)
{
  "jsonrpc": "2.0",
  "id": 8,
  "result": {
    "resultType": "complete",
    "isError": true,
    "content": [{ "type": "text",
                  "text": "Error executing tool search_docs: 2 validation errors for search_docs..." }]
  }
}

SDKs differ on the edges. The specification classes an unknown tool as a protocol error (-32602), while the Python SDK 2.2.0 answered an unknown tool name with an isError: true result in the verification run; do not rely on either shape, and write tool descriptions so the model rarely gets there. Write actionable error messages: "unknown component nope; use algebra, units or statistics" lets the model correct itself, "internal error" does not. The design rules that follow from this are on Designing Good MCP Servers.

Annotations are hints, not guarantees

A tool may carry annotations that describe its behaviour. They are optional hints for clients deciding how to present and gate a tool:

Annotation Meaning Default

readOnlyHint

The tool does not modify its environment.

false

destructiveHint

The tool may make destructive updates; false means additive only. Meaningful only when readOnlyHint is false.

true

idempotentHint

Repeating the call with the same arguments has no further effect. Meaningful only when readOnlyHint is false.

false

openWorldHint

The tool may interact with an open world of external entities (a web search does, a memory tool does not).

true

The specification is blunt that all of these are hints that need not describe the tool faithfully, that clients must treat annotations as untrusted unless they come from a trusted server, and that clients "should never make tool use decisions" on annotations from an untrusted one. A malicious server can label a destructive tool readOnlyHint: true. Use annotations to improve the user experience — a host may skip a confirmation dialog for a read-only tool of a server it trusts — and never as the enforcement point. Enforcement belongs in the server’s own permission checks and, for remote servers, in Authorization; the threat model is on MCP Security. The specification also asks for a human in the loop with the ability to deny tool invocations (see Human-in-the-Loop).

The tool in three stacks

from typing import Annotated, Literal

from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver import Elicit, Resolve
from mcp.types import ToolAnnotations

import store

mcp = MCPServer("docs-assistant", version="1.0.0")


class Hit(BaseModel):
    component: str
    page: str
    title: str
    score: int
    snippet: str


class SearchResult(BaseModel):
    query: str
    component: str
    hits: list[Hit]


class ComponentChoice(BaseModel):
    component: Literal["algebra", "units", "statistics"] = Field(description="Which component to search")


async def choose_component(component: str | None) -> ComponentChoice | Elicit[ComponentChoice]:
    if component:
        return ComponentChoice(component=component)  # type: ignore[arg-type]
    return Elicit("Which component should I search?", ComponentChoice)


@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
async def search_docs(
    query: str,
    choice: Annotated[ComponentChoice, Resolve(choose_component)],
    component: str | None = None,
    top_k: int = 3,
) -> SearchResult:
    """Search the documentation of one component and return the best matching pages."""
    hits = store.search(query, choice.component, top_k)
    return SearchResult(query=query, component=choice.component, hits=[Hit(**h) for h in hits])

The return annotation SearchResult becomes both the outputSchema and the structuredContent; the docstring becomes the description. store is the stub index from the sub-section’s example code (the real implementation queries pgvector, see Exposing RAG through MCP). The Resolve / Elicit pair is the elicitation mechanism described further down. See the Python SDK documentation.

@Component
public class DocsTools {

    private final DocStore store;

    public DocsTools(DocStore store) {
        this.store = store;
    }

    @McpTool(name = "search_docs",
            description = "Search the documentation of one component and return the best matching pages.",
            generateOutputSchema = true,
            annotations = @McpTool.McpAnnotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false))
    public DocStore.SearchResult searchDocs(
            @McpToolParam(description = "Free-text query", required = true) String query,
            @McpToolParam(description = "algebra, units or statistics", required = true) String component,
            @McpToolParam(description = "Maximum number of hits", required = false) Integer topK) {
        if (!DocStore.COMPONENTS.contains(component)) {
            throw new IllegalArgumentException("Unknown component '" + component + "'");
        }
        return store.search(query, component, topK == null ? 3 : topK);
    }
}

@McpTool from Spring AI’s org.springframework.ai.mcp.annotation package registers the method; with generateOutputSchema = true the return type produces the outputSchema and the result is returned as structuredContent. This server ran against Spring Boot 4.1.1 with Spring AI 2.0.1 and passed the same contract checks as the other two. See Spring Boot MCP Servers and the Spring AI MCP annotations reference.

import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
import { COMPONENTS, search } from './store.js';

const hitSchema = z.object({ component: z.string(), page: z.string(), title: z.string(), score: z.number(), snippet: z.string() });

export function createDocsAssistant(): McpServer {
    const server = new McpServer({ name: 'docs-assistant', version: '1.0.0' });

    server.registerTool(
        'search_docs',
        {
            description: 'Search the documentation of one component and return the best matching pages.',
            inputSchema: z.object({
                query: z.string(),
                component: z.string().optional(),
                top_k: z.number().int().min(1).max(10).default(3)
            }),
            outputSchema: z.object({ query: z.string(), component: z.string(), hits: z.array(hitSchema) }),
            annotations: { readOnlyHint: true, openWorldHint: false }
        },
        async ({ query, component, top_k }) => {
            if (component === undefined || !(COMPONENTS as readonly string[]).includes(component)) {
                return { isError: true, content: [{ type: 'text', text: `Unknown component '${component}'` }] };
            }
            const output = { query, component, hits: search(query, component, top_k) };
            return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
        }
    );
    return server;
}

This is the tool without the elicitation branch (that version is shown below). The Zod schemas are converted to JSON Schema for inputSchema and outputSchema, and the handler’s structuredContent is validated against the output schema. It type-checks with tsc --noEmit and passes the contract script. See the TypeScript SDK documentation.

Resources and URI templates

A resource is data identified by a URI. Where a tool is something the model does, a resource is something the application reads and attaches as context, for example a file the user selected in a picker. resources/list enumerates concrete resources and resources/read returns their contents. Servers with many addressable items expose a resource template instead: an RFC 6570 URI template that the client fills in. The docs-assistant publishes every documentation page through one template:

resources/templates/list (captured)
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "complete",
    "resourceTemplates": [
      { "name": "read_page", "uriTemplate": "docs://{component}/{page}",
        "description": "The AsciiDoc source of one documentation page.", "mimeType": "text/asciidoc" }
    ],
    "ttlMs": 0,
    "cacheScope": "private"
  }
}
resources/read (captured)
{ "jsonrpc": "2.0", "id": 2, "method": "resources/read",
  "params": { "uri": "docs://algebra/matrices", "_meta": { ... } } }
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "resultType": "complete",
    "contents": [
      { "uri": "docs://algebra/matrices", "mimeType": "text/asciidoc",
        "text": "= Matrices\n\nMatrix decompositions such as LU, QR and SVD solve linear systems.\n" }
    ],
    "ttlMs": 0,
    "cacheScope": "private"
  }
}

A read of a resource that does not exist must be a JSON-RPC error, with code -32602 (invalid params) in 2026-07-28, not an empty contents array, which would be ambiguous. Earlier revisions used -32002, so clients should accept both. In the verification run the Python SDK 2.2.0 answered a template whose handler raised with -32603; whatever your SDK does, check what it returns for a missing page. Servers must validate every URI they receive: a template parameter such as {page} is attacker-influenced input and must never be concatenated into a file path without normalisation (see MCP Security). A resource’s resources/read result is cacheable, and resources may declare subscribe so a client can watch specific URIs through subscriptions/listen (see Transports).

@mcp.resource("docs://{component}/{page}", mime_type="text/asciidoc")
def read_page(component: str, page: str) -> str:
    """The AsciiDoc source of one documentation page."""
    return store.read(component, page)

The URI template’s parameters must match the function’s parameter names, and the return value becomes the text of the contents entry. See the Python SDK documentation.

@McpResource(uri = "docs://{component}/{page}", name = "docs-page",
        description = "The AsciiDoc source of one documentation page", mimeType = "text/asciidoc")
public ReadResourceResult readPage(String component, String page) {
    return ReadResourceResult.builder(List.of(
            TextResourceContents.builder("docs://" + component + "/" + page, store.read(component, page))
                    .mimeType("text/asciidoc").build())).build();
}

ReadResourceResult, TextResourceContents and the other types come from io.modelcontextprotocol.spec.McpSchema in the MCP Java SDK; the template variables are bound to the method parameters by name. See the Spring AI MCP annotations reference.

import { ResourceTemplate } from '@modelcontextprotocol/server';

server.registerResource(
    'docs-page',
    new ResourceTemplate('docs://{component}/{page}', { list: undefined }),
    { title: 'Documentation page', description: 'The AsciiDoc source of one documentation page', mimeType: 'text/asciidoc' },
    async (uri, { component, page }) => ({
        contents: [{ uri: uri.href, mimeType: 'text/asciidoc', text: read(String(component), String(page)) }]
    })
);

{ list: undefined } declares that the template has no enumerable list. See the TypeScript SDK documentation.

Prompts

A prompt is a server-defined message template that the user picks, typically as a slash command or a menu entry. It is the least used primitive, but it suits reusable instructions that belong with the service, such as "answer from the documentation and cite page URIs". prompts/list describes the prompts and their arguments; prompts/get renders one:

prompts/list and prompts/get (captured)
{ "jsonrpc": "2.0", "id": 3, "result": {
    "resultType": "complete",
    "prompts": [ { "name": "answer_with_citations",
                   "description": "Instruct the model to answer from the docs and cite page URIs.",
                   "arguments": [ { "name": "question", "required": true },
                                  { "name": "component", "required": false } ] } ],
    "ttlMs": 0, "cacheScope": "private" } }
{ "jsonrpc": "2.0", "id": 4, "method": "prompts/get",
  "params": { "name": "answer_with_citations",
              "arguments": { "question": "How do I solve Ax=b?", "component": "algebra" },
              "_meta": { ... } } }
{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "resultType": "complete",
    "description": "Instruct the model to answer from the docs and cite page URIs.",
    "messages": [
      { "role": "user",
        "content": { "type": "text",
                     "text": "Answer the question using only search_docs results for component 'algebra'. Cite every claim as docs://algebra/<page>.\n\nQuestion: How do I solve Ax=b?" } }
    ]
  }
}

Prompt content is written by the server, but the user chooses when to use it, which is what "user-controlled" means in the table above. A prompt’s arguments end up in a message that goes to the model, so untrusted values placed in one are a prompt-injection path like any other (see Agent Guardrails & Safety). prompts/get may also return input_required, as tools and resources can.

@mcp.prompt()
def answer_with_citations(question: str, component: str = "algebra") -> str:
    """Instruct the model to answer from the docs and cite page URIs."""
    return (f"Answer the question using only search_docs results for component '{component}'. "
            f"Cite every claim as docs://{component}/<page>.\n\nQuestion: {question}")

A returned string becomes a single user message. See the Python SDK documentation.

@McpPrompt(name = "answer_with_citations",
        description = "Instruct the model to answer from the docs and cite page URIs.")
public GetPromptResult answerWithCitations(
        @McpArg(name = "question", required = true) String question,
        @McpArg(name = "component", required = false) String component) {
    String c = component == null ? "algebra" : component;
    String text = "Answer the question using only search_docs results for component '" + c + "'. "
            + "Cite every claim as docs://" + c + "/<page>.\n\nQuestion: " + question;
    return GetPromptResult.builder(List.of(new PromptMessage(Role.USER, TextContent.builder(text).build())))
            .build();
}
server.registerPrompt(
    'answer_with_citations',
    {
        description: 'Instruct the model to answer from the docs and cite page URIs.',
        argsSchema: z.object({ question: z.string(), component: z.string().default('algebra') })
    },
    ({ question, component }) => ({
        messages: [
            {
                role: 'user' as const,
                content: {
                    type: 'text' as const,
                    text: `Answer the question using only search_docs results for component '${component}'. Cite every claim as docs://${component}/<page>.\n\nQuestion: ${question}`
                }
            }
        ]
    })
);

Elicitation through multi-round-trip requests

Sometimes a server cannot finish a request without asking the user something: which component to search, whether to proceed with a destructive action, a missing parameter. In protocol versions up to 2025-11-25 the server sent its own elicitation/create request to the client down an open stream. That needs a live back-channel and connection state, so 2026-07-28 replaced it with multi-round-trip requests (MRTR), which need neither. A server may answer tools/call, prompts/get or resources/read — and only those — with an input-required result instead of the final one. The client gathers the answers, then retries the original request with them attached. Each attempt is a complete, independent request, so a different server instance can serve the retry.

Every result therefore carries a resultType: complete for a final answer, input_required for an interim one. An input-required result holds inputRequests, a map from server-chosen keys to request objects (an elicitation/create form or URL request, and, for the deprecated features, sampling or roots requests), and optionally an opaque requestState. Here is the round trip with the Python server; the client omits component, so the server needs it:

Attempt 1 — the server asks (captured; requestState shortened)
{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "resultType": "input_required",
    "inputRequests": {
      "__main__:choose_component": {
        "method": "elicitation/create",
        "params": {
          "mode": "form",
          "message": "Which component should I search?",
          "requestedSchema": {
            "type": "object",
            "properties": {
              "component": { "type": "string", "title": "Component", "description": "Which component to search",
                             "enum": ["algebra", "units", "statistics"] }
            },
            "required": ["component"]
          }
        }
      }
    },
    "requestState": "v1.XpCP-mS7hdlgo4cFd19Rv6uoB..."
  }
}
Attempt 2 — the client retries with a new id, the answers and the echoed state
{
  "jsonrpc": "2.0",
  "id": 5,
  "method": "tools/call",
  "params": {
    "name": "search_docs",
    "arguments": { "query": "matrix" },
    "inputResponses": {
      "__main__:choose_component": { "action": "accept", "content": { "component": "algebra" } }
    },
    "requestState": "v1.XpCP-mS7hdlgo4cFd19Rv6uoB...",
    "_meta": { ... }
  }
}

The reply is an ordinary complete result with structuredContent. The rules that make this safe are short:

  • The retry gets a different JSON-RPC id; the keys in inputResponses are the server’s keys from inputRequests.

  • The client must echo requestState exactly, and must not parse or change it. A server must treat it as attacker-controlled input: if it influences authorization, resource access or business logic, the server must protect its integrity (an HMAC or authenticated encryption), and should bind it to the authenticated user, a short expiry and the originating request. State that fails verification is rejected. Replay protection is not single use; if a state may be consumed only once, the server has to enforce that itself.

  • A server must not send an inputRequests entry of a kind the client did not declare in its capabilities, so the Python server only elicits because the request’s _meta declared "elicitation": { "form": {} }.

  • Elicitation has two modes. Form mode collects structured data through a restricted, flat JSON Schema and exposes that data to the client; it must never be used for passwords, API keys, tokens or payment details. URL mode sends the user to an external page for such sensitive interactions, and the data never passes through the client.

Table 1. Retrying where the state protection matters
Stack What the SDK does with requestState

Python mcp 2.2

MCPServer seals outgoing state and verifies every echo with authenticated encryption, bound to a time window (default 600 seconds), the authenticated principal and the server name. The default key is generated at process start, so a retry that reaches another worker, another instance behind a load balancer, or the same server after a restart is rejected. For any multi-process deployment pass shared keys, for example MCPServer("docs-assistant", request_state_security=RequestStateSecurity(keys=[key])) with RequestStateSecurity imported from mcp.server.mcpserver and the key read from a secret store.

TypeScript 2.2

Applies no protection of its own. createRequestStateCodec returns an HMAC-SHA256 mint / verify pair (key of at least 32 bytes, shared across instances, with a TTL) that you pass as ServerOptions.requestState.verify; tampered or expired state is then rejected with -32602 before the handler runs.

Java (SDK 2.0.1, Spring AI 2.0.1)

Not applicable: protocol 2025-11-25 has no input_required results. Elicitation uses the earlier server-initiated request through the server exchange (createElicitation), which the STATELESS protocol mode does not offer.

async def choose_component(component: str | None) -> ComponentChoice | Elicit[ComponentChoice]:
    if component:
        return ComponentChoice(component=component)
    return Elicit("Which component should I search?", ComponentChoice)


@mcp.tool()
async def search_docs(
    query: str,
    choice: Annotated[ComponentChoice, Resolve(choose_component)],
    component: str | None = None,
    top_k: int = 3,
) -> SearchResult: ...

A resolver is a dependency the SDK runs before the tool: it either returns the value or returns Elicit(…​). On a 2026-07-28 connection the SDK turns Elicit into the input_required result above and re-runs the tool on the retry; on a legacy connection it asks the client directly, so one implementation serves both eras. The resolved parameter (choice) must have a different name from the tool argument (component), and the inputRequests key is derived from the module and resolver name, which is why it reads main:choose_component when the server runs as a script. Calling ctx.elicit() from inside a tool body works only for legacy clients and fails on a 2026-07-28 connection. See the Python SDK documentation.

import { acceptedContent, inputRequired, type CallToolResult, type InputRequiredResult } from '@modelcontextprotocol/server';

const componentChoice = z.object({ component: z.enum(COMPONENTS).meta({ title: 'Which component should I search?' }) });

async ({ query, component, top_k }, ctx): Promise<CallToolResult | InputRequiredResult> => {
    let chosen = component;
    if (chosen === undefined) {
        const answer = acceptedContent(ctx.mcpReq.inputResponses, 'component', componentChoice);
        if (answer === undefined) {
            return inputRequired({
                inputRequests: {
                    component: inputRequired.elicit({ message: 'Which component should I search?', requestedSchema: componentChoice })
                }
            });
        }
        chosen = answer.component;
    }
    // ... validate `chosen`, search, and return structuredContent
}

The handler returns inputRequired(…​) until the answer arrives, then reads it with acceptedContent, which validates the client’s answer against the same Zod schema — inputResponses come from the client and are untrusted. The ctx.mcpReq.elicitInput push channel of earlier SDK versions throws on a 2026-07-28 request. See the TypeScript SDK documentation. Clients that must drive this loop themselves are covered in Building MCP Clients; the Python and TypeScript clients run it for you, as the example on MCP Architecture shows.

The MCP Inspector CLI declares no elicitation capability, so a tool that elicits fails there with "Client did not declare the form elicitation capability"; the sub-section’s contract script exercises this branch with curl instead (see Testing & Debugging).

Cacheable list results

Clients used to re-fetch tools/list on every notification or every conversation. 2026-07-28 lets a server say how long a result stays fresh. The results of server/discover, tools/list, prompts/list, resources/list, resources/templates/list and resources/read carry two fields, and every complete result of those methods must include them:

Field Meaning

ttlMs

Milliseconds the client may treat the result as fresh, analogous to Cache-Control: max-age. 0 means immediately stale; the value must be at least 0. It is a freshness hint, not a guarantee, and not a polling interval.

cacheScope

"public" for results that contain no user-specific data, which any client, gateway or proxy may store and share; "private" for results that depend on the caller, which may be reused only within the same authorization context.

A public tool list looks like this, taken from the specification’s example:

{ "jsonrpc": "2.0", "id": 1,
  "result": { "resultType": "complete", "tools": [ ... ], "ttlMs": 300000, "cacheScope": "public" } }

A client computes freshness as now < t_received + ttlMs. A listChanged notification received while a cached result is still fresh invalidates it, so TTL and notifications complement each other. Results produced by a retry with inputResponses or requestState must never be cached, and input_required results carry no hints at all. Note the consequence for docs-assistant: the captured results above say "ttlMs": 0, "cacheScope": "private", which is the Python SDK’s default and the safe one — correct, but it makes every client re-fetch. A server whose tool list is the same for every user should say so; the Python low-level Server accepts cache_hints={"tools/list": CacheHint(ttl_ms=300000, scope="public")} (CacheHint is in mcp.server.caching) for handlers that do not set the fields themselves. Choose private whenever the list varies with the caller’s granted scopes, which the specification explicitly allows for tools/list.

Deprecated client features and what replaces them

Three client-side features were deprecated in 2026-07-28. They remain in the specification, but new implementations should not adopt them, and the earliest they can be removed is the first revision released on or after 2027-07-28.

Feature What it was Use instead

Roots

The client told the server which directories or files it could work in.

Pass directories and files as tool parameters or resource URIs, or configure them on the server.

Sampling

The server asked the client’s model for a completion.

Integrate directly with an LLM provider API from the server (see LLM Foundations & Prompting), or restructure so the host’s agent does the reasoning and the server returns data.

Logging (logging/setLevel, notifications/message)

The server streamed log messages to the client.

Log to stderr on stdio servers and use OpenTelemetry for observability, with the trace context in _meta described on MCP Architecture.

Sampling and roots requests are also the request kinds that can still appear inside an input_required result during the deprecation period; they use the same retry mechanism as elicitation. The SDKs keep supporting them (the Python client still accepts sampling_callback and list_roots_callback, and a log_level that opts in to per-request logging), so existing servers keep working, but a new docs-assistant-style server has no reason to use them. The features that were removed outright — ping, the logging/setLevel request and notifications/roots/list_changed — are covered as history on MCP Architecture.