Performance, Caching and Extensions

By the Syncfusion DevTutorials team · Last updated

Keeping MCP fast: pagination with cursors, deterministic tool order, ttlMs and cacheScope caching, list-changed invalidation, lazy tool groups and extension negotiation.

A good restaurant doesn't cook every dish from scratch when someone orders it. It prepares ingredients ahead of time, keeps popular sauces warm, and updates the menu when a dish changes. It also avoids handing guests a 40-page menu. Instead, it shows them today's specials and offers more when needed.

A fast MCP setup follows the same idea: show the model only the tools needed for the current task, use pages for long lists, keep results only while they are fresh, refresh them when the server announces a change, and keep messages small. Optional features outside the core protocol are called extensions. The client and server must both declare support before they use one.

The main idea: keep tool lists small and stable, paginate long lists, cache results using ttlMs and cacheScope, refresh cached data when it changes, keep payloads lean, and use extensions such as Tasks and MCP Apps only when both sides support them.

A Real-World Example

Brightgrid's assistant felt slow and expensive, so the team measured what each message required:

  • Every message sent 42 tool descriptions to the model, using 6,000 tokens before the user's question.
  • The host requested tools/list on every message, even though the tools changed only once a week.
  • export_report returned 2 MB of rows inside its result.

The team reduced that overhead in three ways:

  • The host shows only the tool groups relevant to the question.
  • It caches tools/list according to ttlMs and clears the cache when it receives notifications/tools/list_changed.
  • export_report returns a short summary and a resource_link to the full file, so the host can fetch the large data only when it needs it.

These changes reduced token usage, lowered costs, and helped replies arrive faster.

In simple words: send less, ask less often, and agree on optional features before using them.

What You Will Learn

In this lesson, you will learn:

  1. How to load tool groups only when a task needs them.
  2. How pagination and deterministic ordering make long lists easier to handle.
  3. How ttlMs and cacheScope guide caching, and how clients refresh changed data.
  4. How smaller payloads and reused connections reduce work.
  5. How extensions work, using Tasks and MCP Apps as examples.

In the MCP Lab Inspector, click a tools/list row and look at its result: it includes ttlMs and cacheScope. The lab server sends ttlMs: 0, which means the client should not reuse that result from cache. This is useful while you are changing the lab server and want each request to see the latest list.

Where time and tokens go, and the fix for each: smaller tool lists, cached lists cleared by change notices, links instead of big payloads, and pages for long lists.

Fewer Tools in the Context

Lazy and Grouped Tools

Each tool description included in the model's context uses tokens. A very large tool list also makes it harder for the model to choose the right tool. A host can reduce both costs by grouping related tools and showing only the groups that fit the current task:

  • Group tools by task: orders, returns, or reports.
  • Load a group only when the conversation needs it.
  • Keep a small set of common tools available all the time.

The example below uses simple keyword checks to illustrate choosing a group. Real hosts can use richer task classification and policy rules.

Load tool groups by need

// Lazy tool groups: show the model a small set, and load a group only when it is needed.
const groups = {
  orders: ["lookup_order", "get_order_status", "cancel_order"],
  returns: ["book_return"],
  reports: ["export_report", "export_csv", "schedule_report"],
};

function toolsFor(question) {
  const words = question.toLowerCase();
  const picked = Object.entries(groups).filter(
    ([g]) =>
      (g === "orders" && /order|deliver|arriv/.test(words)) ||
      (g === "returns" && /return|refund/.test(words)) ||
      (g === "reports" && /report|export/.test(words)),
  );
  return picked.flatMap(([, tools]) => tools);
}

for (const q of ["When will my order arrive?", "I want to return the lamp", "Hello!"]) {
  const t = toolsFor(q);
  console.log(q.padEnd(28), "->", t.length ? t.join(", ") : "no tools needed");
}

Expected output:

When will my order arrive?   -> lookup_order, get_order_status, cancel_order
I want to return the lamp    -> book_return
Hello!                       -> no tools needed

Measure before you optimize. Count how many tokens the tool list adds per turn, and how often each tool is used. Rarely used tools are good candidates for an on-demand group.

Lists That Scale

Grouping reduces the number of tools the host needs to show. A group can still contain a long list, though. Pagination lets the server return a list in manageable pieces.

Pagination

A long list can arrive in pages. A page may include a nextCursor, which points to the next page. The client sends that value back as cursor in another request. It repeats this until the server returns no cursor.

The cursor is opaque, which means the client treats it as an unreadable value: it passes the cursor back exactly as received and does not inspect or change it. The server decides what the cursor means.

Page through a long list

// Pagination: a client keeps asking with nextCursor until there is none.
const allTools = Array.from(
  { length: 23 },
  (_, i) => `tool_${String(i + 1).padStart(2, "0")}`,
);

function toolsList(cursor) {
  const start = cursor ? Number(atob(cursor)) : 0; // the server interprets its own cursor
  const page = allTools.slice(start, start + 10);
  const next = start + 10 < allTools.length ? btoa(String(start + 10)) : undefined;
  return { tools: page, ...(next ? { nextCursor: next } : {}), resultType: "complete" };
}

let cursor;
let pages = 0;
const collected = [];
do {
  const r = toolsList(cursor);
  pages++;
  collected.push(...r.tools);
  console.log(
    `page ${pages}: ${r.tools[0]}..${r.tools.at(-1)} nextCursor=${r.nextCursor ?? "none"}`,
  );
  cursor = r.nextCursor;
} while (cursor);
console.log("total tools:", collected.length);

Expected output:

page 1: tool_01..tool_10 nextCursor=MTA=
page 2: tool_11..tool_20 nextCursor=MjA=
page 3: tool_21..tool_23 nextCursor=none
total tools: 23

In this small example, the server uses Base64 to encode an offset, but that is only an implementation detail. A real client should still treat the cursor as opaque.

Deterministic Ordering

Pagination controls the size of each response. Stable ordering helps the client compare responses and helps a host reuse cached model context. For that reason, the spec says servers should return tools/list in a deterministic order: the same list should appear in the same order when it has not changed.

  • Clients can compare and cache lists more easily.
  • Many model APIs cache the start of a prompt. A tool list in the same order helps that prompt cache keep working, which can save time and money.

Stable order

// Deterministic order: the same tool list in the same order makes caches work.
const fromDatabase = [
  { name: "lookup_order" },
  { name: "book_return" },
  { name: "cancel_order" },
];
const shuffled = [fromDatabase[2], fromDatabase[0], fromDatabase[1]];

const stable = (list) => [...list].sort((a, b) => a.name.localeCompare(b.name));
const key = (list) => list.map((t) => t.name).join(",");

console.log("unsorted equal:", key(fromDatabase) === key(shuffled));
console.log("sorted equal:  ", key(stable(fromDatabase)) === key(stable(shuffled)));
console.log("order:", key(stable(shuffled)));

Expected output:

unsorted equal: false
sorted equal:   true
order: book_return,cancel_order,lookup_order

Caching

Lists are often unchanged between requests, so a client can avoid asking for the same data repeatedly. The server's caching hints tell the client how long a result can be reused and whether a shared cache may store it.

ttlMs and cacheScope

In the 2026-07-28 version of the course protocol, results from tools/list, prompts/list, resources/list, resources/templates/list, and resources/read can include two caching hints:

  • ttlMs: how long, in milliseconds, the client may treat the result as fresh. A value of 0 means the client should not reuse the result from cache.
  • cacheScope: "private" means only the requesting client may keep the copy. "public" means shared middle layers, such as a company gateway, may cache and share it.

These fields guide caching; they do not make an unsafe cache safe. A cache must also distinguish requests that can return different data. For example, key a filtered list by its relevant request parameters, and never share a per-user result.

Invalidation

A time limit alone cannot tell the client that data changed early. Change notices solve that problem: when the client receives a notice, it removes the affected cached value and fetches the latest result.

The client asks to hear about changes through the subscriptions/listen stream. For example, notifications/tools/list_changed, notifications/prompts/list_changed, and notifications/resources/list_changed mean that a corresponding list changed; the client should request that list again. For an individual resource, notifications/resources/updated identifies the changed URI, which the client can read again.

A cache with ttlMs and invalidation

// A client cache that respects ttlMs and cacheScope, and is cleared by list_changed.
const cache = new Map();
let serverCalls = 0;

function serverToolsList() {
  serverCalls++;
  return { tools: ["lookup_order"], ttlMs: 300000, cacheScope: "public" };
}
function getTools(now) {
  const hit = cache.get("tools/list");
  if (hit && now < hit.expires) {
    return "cache";
  }
  const r = serverToolsList();
  cache.set("tools/list", {
    value: r,
    expires: now + r.ttlMs,
    shareable: r.cacheScope === "public",
  });
  return "server";
}
function onNotification(method) {
  if (method === "notifications/tools/list_changed") {
    cache.delete("tools/list");
  }
}

console.log("t=0      ", getTools(0));
console.log("t=60s    ", getTools(60000));
onNotification("notifications/tools/list_changed");
console.log("changed  ", getTools(61000));
console.log("t=200s   ", getTools(200000));
console.log(
  "server calls:",
  serverCalls,
  "| shareable by gateways:",
  cache.get("tools/list").shareable,
);

Expected output:

t=0       server
t=60s     cache
changed   server
t=200s    cache
server calls: 2 | shareable by gateways: true

The example uses a small in-memory cache to show the decision points. A real shared cache must enforce cacheScope and include the right request and user context in its cache key.

Never mark per-user data as "public". A gateway could serve one user's order list to another. Use "private" for anything that depends on who is asking.

Payload and Connection Efficiency

Caching and stable lists reduce repeated work. The next opportunity is to make each response and connection cheaper to handle.

Smaller Messages

Large results take more time to transfer and more tokens to process. Return only what the current task needs:

  • Return resource_link blocks for large data instead of embedding the full content. The host can fetch the linked resource only when needed.
  • Filter fields on the server, as you learned in Chapter 4.
  • When a tool declares an outputSchema, return matching structuredContent for programs and keep a concise text copy for clients that use text.

Connections

  • Over HTTP, reuse connections (keep-alive) and use HTTP/2 where possible. Each MCP request is its own POST, so reusing a connection avoids some setup work.
  • MCP does not support JSON-RPC batch requests (several requests in one array). Send separate requests, in parallel when they do not depend on each other.
  • Long jobs belong in the Tasks extension, not in a request that keeps a connection open for minutes.

Extensions

Smaller lists, pages, caching, and lean responses improve the core protocol workflow. Extensions address a different need: they let clients and servers agree on optional features that the core protocol does not require every implementation to support.

How They Work

An extension is an optional feature beyond the core protocol. Each extension has an identifier, such as io.modelcontextprotocol/tasks. The course's official extensions use the io.modelcontextprotocol prefix. An organization can use its reversed domain for its own extension, such as com.brightgrid/approvals.

  • The client declares supported extensions in the extensions field of io.modelcontextprotocol/clientCapabilities inside _meta on each request.
  • The server declares its supported extensions in the extensions field of its server/discover capabilities.
  • Use an extension only when both sides declare it. If they do not, use the core behavior when possible. If the task depends on that extension, explain that it is unsupported instead of assuming it will work.

The example below compares the extensions each client declares in clientCapabilities with the extensions the server declares in server/discover. It then chooses interactive UI only for the client that supports it.

Use an extension only if both sides have it

// Extension negotiation: use an extension only if BOTH sides declared it.
const server = {
  capabilities: {
    tools: {},
    extensions: { "io.modelcontextprotocol/ui": {}, "io.modelcontextprotocol/tasks": {} },
  },
};
const clients = {
  "chat app": {
    clientCapabilities: {
      extensions: {
        "io.modelcontextprotocol/ui": { mimeTypes: ["text/html;profile=mcp-app"] },
      },
    },
  },
  CLI: { clientCapabilities: { extensions: {} } },
};

for (const [name, caps] of Object.entries(clients)) {
  const clientExtensions = caps.clientCapabilities.extensions;
  const both = Object.keys(server.capabilities.extensions).filter(
    (id) => id in clientExtensions,
  );
  const ui = both.includes("io.modelcontextprotocol/ui");
  console.log(
    `${name.padEnd(9)} shared: ${both.join(", ") || "none"} -> ${ui ? "send an interactive chart" : "send the chart as text"}`,
  );
}

Expected output:

chat app  shared: io.modelcontextprotocol/ui -> send an interactive chart
CLI       shared: none -> send the chart as text

Tasks

You met the Tasks extension (io.modelcontextprotocol/tasks) in Chapter 5. It is for work that may take longer than one request can reasonably wait, such as a large import or report. The server returns a task handle, and the client checks progress with tasks/get instead of holding the original request open.

MCP Apps

MCP Apps (io.modelcontextprotocol/ui) let a server provide interactive UI, such as a chart, form, or map, inside a conversation. The host displays that UI in a sandboxed frame and applies its own security controls. The server should still return useful text for clients that do not support MCP Apps.

Other Official Extensions

Other extensions include authorization options such as OAuth client credentials for machine-to-machine access, and Skills over MCP, which lets servers offer reusable workflow instructions. These are optional too. Check that the client and server support an extension before relying on it.

Common mistakes

  • Reading or building cursors. Cursors are opaque. Pass back exactly what the server sent.
  • "public" caching of private data. Shared caches can leak one user's data to another.
  • Huge results. Megabytes in a tool result cost tokens and time. Link instead.
  • Expecting JSON-RPC batches. MCP does not support them. Send separate requests.
  • No fallback for extensions. A server that only works with an extension may not work with a client that does not support it. Fall back to core behavior where possible.

Exercises

Each exercise ends with a value on the last line. Press Check to compare it with the expected answer.

What to Remember

  • Show the model small, task-specific tool groups, loaded on demand.
  • Paginate with opaque nextCursor, and return lists in a deterministic order.
  • Cache list and read results for ttlMs. cacheScope is "private" (this client only) or "public" (shared caches allowed).
  • Invalidate caches on list_changed and resources/updated notices.
  • Keep payloads lean: links, field filtering, and structuredContent that matches outputSchema when one is declared. MCP does not support JSON-RPC batching.
  • Extensions (Tasks, MCP Apps io.modelcontextprotocol/ui, and authorization extensions) are used only when both sides declare them, with a core-behavior fallback when possible.

Next: Chapter 8, Security, the attacks to defend against and how.