Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,62 @@ var client = McpClient.sync(transport)
.build();
```

### Result Caching

When a server marks a response cacheable, the client stores it and answers later identical calls
from that store instead of going back to the server. This covers `listTools`, `listPrompts`,
`listResources`, `listResourceTemplates`, and `readResource`.

Caching is on by default and does nothing until a server opts in, because only a response that
carries a time to live (TTL) is ever stored. An entry is dropped when its TTL lapses, when the
matching `*_changed` or `resources/updated` notification arrives, and when the client reconnects
or closes.

To read current server state without waiting for either the TTL or a notification, drop every
entry:

```java
client.invalidateCache();
ListToolsResult fresh = client.listTools();
```

To ignore server TTLs altogether, turn caching off:

```java
var client = McpClient.sync(transport)
.enableResultCaching(false)
.build();
```

**Choosing where entries are kept**

Entries live in a bounded in-memory store that holds 512 of them and evicts the oldest first. To
use a cache library or share one store across several clients, implement `McpClientCacheStore` and
pass it to the builder:

```java
// MyCacheStore is your own implementation, for example over Caffeine
var client = McpClient.sync(transport)
.cacheStore(new MyCacheStore())
.build();
```

A store receives an `McpClientCacheKey` identifying the request, the value, and a TTL in
milliseconds. It must be safe for concurrent use, and it must not return an entry whose TTL has
lapsed. The client decides what may be cached and when an entry has to go, so a store only has to
honor those decisions.

**What the client won't cache**

- A listing that spans several pages. Pairing a cached first page with a later page fetched fresh
would mix two different views of the server's catalog.
- A response whose TTL is missing, zero, or negative.
- A response that arrives after the notification that invalidates it, which would otherwise pin a
stale listing for the whole TTL.

A TTL longer than 24 hours is capped at 24 hours, so a client can't be left serving a response the
server has no way to invalidate.

### Pagination

`listTools`, `listResources`, `listResourceTemplates`, and `listPrompts` all accept an optional opaque `cursor` string, and their results carry a `nextCursor` that is non-null while more pages remain. Loop until `nextCursor` is `null` to collect every page:
Expand Down
62 changes: 62 additions & 0 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,68 @@ The same `addToolFilter(...)` method is available on the stateless builders.
- With STDIO there is no per-request metadata, so the filter receives `McpTransportContext.EMPTY` and has nothing to key
on.

### Caching Hints for Listings

A server can tell clients how long they may reuse a listing before asking for it again, so that a
client polling `tools/list` on a stable catalog stops paying for a round trip each time. The hint
travels on the response as a time to live (TTL) in milliseconds and a cache scope; a client that
honors it serves the cached listing until the TTL lapses or a `*_changed` notification arrives.

Listings carry no TTL by default. Set one with `listCache(...)`, which applies to `tools/list`,
`prompts/list`, `resources/list`, and `resources/templates/list`:

=== "Sync"

```java
McpServer.sync(transportProvider)
.tools(calculatorTool, weatherTool)
.listCache(Duration.ofMinutes(5), CacheScope.PRIVATE)
.build();
```

=== "Async"

```java
McpServer.async(transportProvider)
.tools(calculatorTool, weatherTool)
.listCache(Duration.ofMinutes(5), CacheScope.PRIVATE)
.build();
```

The same `listCache(...)` method is available on the stateless builders.

!!! warning "`PUBLIC` means any caller may be served the response"

`CacheScope.PUBLIC` tells a shared cache, such as an MCP gateway, that it may serve one
principal the response it stored for another. Use it only for a listing that is identical for
every caller. Any registered [tool filter](#filtering-the-tool-listing-per-request) makes the
listing caller-specific, so keep the scope `PRIVATE` whenever you filter.

**How a client uses the hint**

- The TTL is a ceiling, not a promise. A client may re-fetch sooner, and it drops the entry as
soon as it receives the matching `notifications/tools/list_changed`,
`notifications/prompts/list_changed`, or `notifications/resources/list_changed`.
- Set a TTL only on listings the server can invalidate. With `listChanged` disabled, a client has
no way to learn about a change before the TTL lapses.
- A listing that spans several pages isn't cached: pairing a cached first page with a later page
fetched fresh would mix two different views of the catalog.
**Caching a resource read**
`resources/read` carries its own hint, because how long a resource stays valid is a property of
that resource rather than of the server. Set it on the result the read handler returns:
```java
ReadResourceResult.builder(contents)
.ttlMs(Duration.ofMinutes(1).toMillis())
.cacheScope(CacheScope.PRIVATE)
.build();
```
A client drops the entry when it receives `notifications/resources/updated` for that URI. Results
that set neither field default to a TTL of `0` with a `private` scope, which means no caching.
### Resource Specification
Specification of a resource with its handler function.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* Copyright 2026-2026 the original author or authors.
*/

package io.modelcontextprotocol.client;

import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Predicate;
import java.util.function.Supplier;

import io.modelcontextprotocol.util.Assert;

/**
* The {@link McpClientCacheStore} used when none is configured: a bounded, insertion
* ordered map guarded by its own monitor.
*
* @author Sylwester Lachiewicz
*/
class InMemoryMcpClientCacheStore implements McpClientCacheStore {

static final int DEFAULT_MAX_ENTRIES = 512;

private record CacheEntry(Object value, long expiresAtMillis) {

boolean isExpired(long now) {
return now >= this.expiresAtMillis;
}

}

/**
* Insertion-ordered, so that eviction drops the oldest entry.
*/
private final Map<McpClientCacheKey, CacheEntry> cache = new LinkedHashMap<>();

private final int maxEntries;

private final Supplier<Long> timeProvider;

InMemoryMcpClientCacheStore(int maxEntries, Supplier<Long> timeProvider) {
Assert.isTrue(maxEntries > 0, "maxEntries must be positive");
Assert.notNull(timeProvider, "timeProvider must not be null");
this.maxEntries = maxEntries;
this.timeProvider = timeProvider;
}

@Override
public Object get(McpClientCacheKey key) {
synchronized (this.cache) {
CacheEntry entry = this.cache.get(key);
if (entry == null) {
return null;
}
if (entry.isExpired(this.timeProvider.get())) {
this.cache.remove(key);
return null;
}
return entry.value();
}
}

@Override
public void put(McpClientCacheKey key, Object value, long ttlMs) {
long now = this.timeProvider.get();
synchronized (this.cache) {
// Re-insert so that insertion order stays age order.
this.cache.remove(key);
this.cache.put(key, new CacheEntry(value, saturatedAdd(now, ttlMs)));
this.evict(now);
}
}

@Override
public void removeIf(Predicate<McpClientCacheKey> matcher) {
synchronized (this.cache) {
this.cache.keySet().removeIf(matcher);
}
}

@Override
public void clear() {
synchronized (this.cache) {
this.cache.clear();
}
}

int size() {
synchronized (this.cache) {
return this.cache.size();
}
}

private void evict(long now) {
if (this.cache.size() <= this.maxEntries) {
return;
}
this.cache.values().removeIf(entry -> entry.isExpired(now));
Iterator<McpClientCacheKey> oldestFirst = this.cache.keySet().iterator();
while (this.cache.size() > this.maxEntries && oldestFirst.hasNext()) {
oldestFirst.next();
oldestFirst.remove();
}
}

private static long saturatedAdd(long left, long right) {
long sum = left + right;
return ((left ^ sum) & (right ^ sum)) < 0 ? Long.MAX_VALUE : sum;
}

}
Loading
Loading