Skip to content

Commit 24533d7

Browse files
committed
Add listCache(Duration, CacheScope) server option (SEP-2549)
Listings previously hardcoded ttlMs to 0, so the server half of SEP-2549 could not be switched on. Listings default to a PRIVATE scope because tools/list has been filtered per caller since #1111. ttlMs is validated non-negative on the affected result builders.
1 parent 84f046d commit 24533d7

8 files changed

Lines changed: 380 additions & 25 deletions

File tree

‎docs/server.md‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,68 @@ The same `addToolFilter(...)` method is available on the stateless builders.
537537
- With STDIO there is no per-request metadata, so the filter receives `McpTransportContext.EMPTY` and has nothing to key
538538
on.
539539

540+
### Caching Hints for Listings
541+
542+
A server can tell clients how long they may reuse a listing before asking for it again, so that a
543+
client polling `tools/list` on a stable catalog stops paying for a round trip each time. The hint
544+
travels on the response as a time to live (TTL) in milliseconds and a cache scope; a client that
545+
honors it serves the cached listing until the TTL lapses or a `*_changed` notification arrives.
546+
547+
Listings carry no TTL by default. Set one with `listCache(...)`, which applies to `tools/list`,
548+
`prompts/list`, `resources/list`, and `resources/templates/list`:
549+
550+
=== "Sync"
551+
552+
```java
553+
McpServer.sync(transportProvider)
554+
.tools(calculatorTool, weatherTool)
555+
.listCache(Duration.ofMinutes(5), CacheScope.PRIVATE)
556+
.build();
557+
```
558+
559+
=== "Async"
560+
561+
```java
562+
McpServer.async(transportProvider)
563+
.tools(calculatorTool, weatherTool)
564+
.listCache(Duration.ofMinutes(5), CacheScope.PRIVATE)
565+
.build();
566+
```
567+
568+
The same `listCache(...)` method is available on the stateless builders.
569+
570+
!!! warning "`PUBLIC` means any caller may be served the response"
571+
572+
`CacheScope.PUBLIC` tells a shared cache, such as an MCP gateway, that it may serve one
573+
principal the response it stored for another. Use it only for a listing that is identical for
574+
every caller. Any registered [tool filter](#filtering-the-tool-listing-per-request) makes the
575+
listing caller-specific, so keep the scope `PRIVATE` whenever you filter.
576+
577+
**How a client uses the hint**
578+
579+
- The TTL is a ceiling, not a promise. A client may re-fetch sooner, and it drops the entry as
580+
soon as it receives the matching `notifications/tools/list_changed`,
581+
`notifications/prompts/list_changed`, or `notifications/resources/list_changed`.
582+
- Set a TTL only on listings the server can invalidate. With `listChanged` disabled, a client has
583+
no way to learn about a change before the TTL lapses.
584+
- A listing that spans several pages isn't cached: pairing a cached first page with a later page
585+
fetched fresh would mix two different views of the catalog.
586+
587+
**Caching a resource read**
588+
589+
`resources/read` carries its own hint, because how long a resource stays valid is a property of
590+
that resource rather than of the server. Set it on the result the read handler returns:
591+
592+
```java
593+
ReadResourceResult.builder(contents)
594+
.ttlMs(Duration.ofMinutes(1).toMillis())
595+
.cacheScope(CacheScope.PRIVATE)
596+
.build();
597+
```
598+
599+
A client drops the entry when it receives `notifications/resources/updated` for that URI. Results
600+
that set neither field default to a TTL of `0` with a `private` scope, which means no caching.
601+
540602
### Resource Specification
541603
542604
Specification of a resource with its handler function.

‎mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ public class McpAsyncServer {
120120

121121
private final McpAsyncListFilter<McpSchema.Tool> toolFilter;
122122

123+
/**
124+
* The caching hint attached to every listing response (SEP-2549).
125+
*/
126+
private final McpListCacheOptions listCacheOptions;
127+
123128
private List<String> protocolVersions;
124129

125130
private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DefaultMcpUriTemplateManagerFactory();
@@ -134,7 +139,7 @@ public class McpAsyncServer {
134139
McpAsyncServer(McpServerTransportProvider mcpTransportProvider, McpJsonMapper jsonMapper,
135140
McpServerFeatures.Async features, Duration requestTimeout,
136141
McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator,
137-
boolean validateToolInputs) {
142+
boolean validateToolInputs, McpListCacheOptions listCacheOptions) {
138143
this.mcpTransportProvider = mcpTransportProvider;
139144
this.jsonMapper = jsonMapper;
140145
this.serverInfo = features.serverInfo();
@@ -149,6 +154,7 @@ public class McpAsyncServer {
149154
this.jsonSchemaValidator = jsonSchemaValidator;
150155
this.validateToolInputs = validateToolInputs;
151156
this.toolFilter = McpAsyncListFilter.and(features.toolFilters());
157+
this.listCacheOptions = listCacheOptions != null ? listCacheOptions : McpListCacheOptions.NONE;
152158

153159
Map<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
154160
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
@@ -166,7 +172,7 @@ public class McpAsyncServer {
166172
McpAsyncServer(McpStreamableServerTransportProvider mcpTransportProvider, McpJsonMapper jsonMapper,
167173
McpServerFeatures.Async features, Duration requestTimeout,
168174
McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator,
169-
boolean validateToolInputs) {
175+
boolean validateToolInputs, McpListCacheOptions listCacheOptions) {
170176
this.mcpTransportProvider = mcpTransportProvider;
171177
this.jsonMapper = jsonMapper;
172178
this.serverInfo = features.serverInfo();
@@ -181,6 +187,7 @@ public class McpAsyncServer {
181187
this.jsonSchemaValidator = jsonSchemaValidator;
182188
this.validateToolInputs = validateToolInputs;
183189
this.toolFilter = McpAsyncListFilter.and(features.toolFilters());
190+
this.listCacheOptions = listCacheOptions != null ? listCacheOptions : McpListCacheOptions.NONE;
184191

185192
Map<String, McpRequestHandler<?>> requestHandlers = prepareRequestHandlers();
186193
Map<String, McpNotificationHandler> notificationHandlers = prepareNotificationHandlers(features);
@@ -548,9 +555,11 @@ private McpRequestHandler<McpSchema.ListToolsResult> toolsListRequestHandler() {
548555
.filterWhen(tool -> this.toolFilter.isVisible(exchange.transportContext(), tool)
549556
.onErrorResume(error -> opaqueListFilterError(tool, error)))
550557
.collectList()
558+
// PRIVATE, not PUBLIC: the listing is filtered per caller, so a shared
559+
// cache must never serve one principal's response to another.
551560
.map(tools -> McpSchema.ListToolsResult.builder(tools)
552-
.ttlMs(0L)
553-
.cacheScope(McpSchema.CacheScope.PUBLIC)
561+
.ttlMs(this.listCacheOptions.ttlMs())
562+
.cacheScope(this.listCacheOptions.cacheScope())
554563
.build());
555564
};
556565
}
@@ -819,8 +828,8 @@ private McpRequestHandler<McpSchema.ListResourcesResult> resourcesListRequestHan
819828
.map(McpServerFeatures.AsyncResourceSpecification::resource)
820829
.toList();
821830
return Mono.just(McpSchema.ListResourcesResult.builder(resourceList)
822-
.ttlMs(0L)
823-
.cacheScope(McpSchema.CacheScope.PUBLIC)
831+
.ttlMs(this.listCacheOptions.ttlMs())
832+
.cacheScope(this.listCacheOptions.cacheScope())
824833
.build());
825834
};
826835
}
@@ -832,8 +841,8 @@ private McpRequestHandler<McpSchema.ListResourceTemplatesResult> resourceTemplat
832841
.map(McpServerFeatures.AsyncResourceTemplateSpecification::resourceTemplate)
833842
.toList();
834843
return Mono.just(McpSchema.ListResourceTemplatesResult.builder(resourceList)
835-
.ttlMs(0L)
836-
.cacheScope(McpSchema.CacheScope.PUBLIC)
844+
.ttlMs(this.listCacheOptions.ttlMs())
845+
.cacheScope(this.listCacheOptions.cacheScope())
837846
.build());
838847
};
839848
}
@@ -998,8 +1007,8 @@ private McpRequestHandler<McpSchema.ListPromptsResult> promptsListRequestHandler
9981007
.toList();
9991008

10001009
return Mono.just(McpSchema.ListPromptsResult.builder(promptList)
1001-
.ttlMs(0L)
1002-
.cacheScope(McpSchema.CacheScope.PUBLIC)
1010+
.ttlMs(this.listCacheOptions.ttlMs())
1011+
.cacheScope(this.listCacheOptions.cacheScope())
10031012
.build());
10041013
};
10051014
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright 2026-2026 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.server;
6+
7+
import java.time.Duration;
8+
9+
import io.modelcontextprotocol.spec.McpSchema;
10+
import io.modelcontextprotocol.util.Assert;
11+
12+
/**
13+
* The caching hint a server attaches to its listing responses — {@code tools/list},
14+
* {@code prompts/list}, {@code resources/list} and {@code resources/templates/list}
15+
* (SEP-2549).
16+
* <p>
17+
* A {@code resources/read} handler carries its own hint on the
18+
* {@link McpSchema.ReadResourceResult} it builds and is not covered here.
19+
*
20+
* @param ttlMs how long a client may reuse a listing before re-fetching it. Zero disables
21+
* caching.
22+
* @param cacheScope whether a shared cache may serve the listing to another principal.
23+
* @author Sylwester Lachiewicz
24+
* @see <a href=
25+
* "https://spec.modelcontextprotocol.io/specification/draft/server/utilities/caching/">Specification:
26+
* Caching</a>
27+
*/
28+
public record McpListCacheOptions(long ttlMs, McpSchema.CacheScope cacheScope) {
29+
30+
/**
31+
* The default: listings are not cached. Scoped {@code private} so that a gateway
32+
* cannot share a listing across principals should a server later attach a TTL without
33+
* revisiting the scope.
34+
*/
35+
public static final McpListCacheOptions NONE = new McpListCacheOptions(0L, McpSchema.CacheScope.PRIVATE);
36+
37+
public McpListCacheOptions {
38+
Assert.isTrue(ttlMs >= 0, "ttlMs must not be negative");
39+
Assert.notNull(cacheScope, "cacheScope must not be null");
40+
}
41+
42+
/**
43+
* @param ttl how long a client may reuse a listing. MUST NOT be null or negative.
44+
* @param cacheScope whether a shared cache may serve the listing to another
45+
* principal. Use {@link McpSchema.CacheScope#PUBLIC} only for a listing that is
46+
* identical for every caller: a per-request tool filter makes the listing
47+
* caller-specific, and {@code public} would let a gateway hand one principal's
48+
* filtered listing to another.
49+
*/
50+
public static McpListCacheOptions of(Duration ttl, McpSchema.CacheScope cacheScope) {
51+
Assert.notNull(ttl, "ttl must not be null");
52+
return new McpListCacheOptions(ttl.toMillis(), cacheScope);
53+
}
54+
55+
}

0 commit comments

Comments
 (0)