# Performance, Caching and Extensions

Course: [Model Context Protocol](https://www.devtutorials.ai/learn/mcp) · Section: Enterprise MCP
Source: https://www.devtutorials.ai/learn/mcp/enterprise/performance-caching-and-extensions

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

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

```js runnable title="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:

```text
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.

```js runnable title="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:

```text
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.

```js runnable title="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:

```text
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.

```js runnable title="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:

```text
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 <code>"public"</code>. A gateway could serve one user's order list to another. Use <code>"private"</code> 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.

```js runnable title="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:

```text
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.
