diff --git a/docs/client.md b/docs/client.md index c2ec9342d..a0e9ce32f 100644 --- a/docs/client.md +++ b/docs/client.md @@ -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: diff --git a/docs/server.md b/docs/server.md index 93fcf68bc..650c08fc7 100644 --- a/docs/server.md +++ b/docs/server.md @@ -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. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/InMemoryMcpClientCacheStore.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/InMemoryMcpClientCacheStore.java new file mode 100644 index 000000000..f6c6fa296 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/InMemoryMcpClientCacheStore.java @@ -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 cache = new LinkedHashMap<>(); + + private final int maxEntries; + + private final Supplier timeProvider; + + InMemoryMcpClientCacheStore(int maxEntries, Supplier 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 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 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; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index 3509b760b..8401480f5 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -185,6 +185,12 @@ public class McpAsyncClient { private final boolean applyElicitationDefaults; + /** + * Client cache for caching list and resource results based on server ttlMs hints + * (SEP-2549). + */ + private final McpClientCache clientCache; + /** * Create a new McpAsyncClient with the given transport and session request-response * timeout. @@ -208,6 +214,7 @@ public class McpAsyncClient { this.roots = new ConcurrentHashMap<>(features.roots()); this.jsonSchemaValidator = jsonSchemaValidator; this.toolsOutputSchemaCache = new ConcurrentHashMap<>(); + this.clientCache = new McpClientCache(features.enableResultCaching(), features.cacheStore()); this.enableCallToolSchemaCaching = features.enableCallToolSchemaCaching(); this.applyElicitationDefaults = features.applyElicitationDefaults(); @@ -334,6 +341,11 @@ public class McpAsyncClient { Function> postInitializationHook = init -> { + // Entries belong to the session that produced them. A re-initialization may + // reach a restarted or different server instance, and any change notification + // sent while the client was disconnected was missed. + this.clientCache.clear(); + if (init.initializeResult().capabilities().tools() == null || !enableCallToolSchemaCaching) { return Mono.empty(); } @@ -423,6 +435,8 @@ public McpSchema.Implementation getClientInfo() { * Closes the client connection immediately. */ public void close() { + this.clientCache.clear(); + this.toolsOutputSchemaCache.clear(); this.initializer.close(); this.transport.close(); } @@ -433,6 +447,8 @@ public void close() { */ public Mono closeGracefully() { return Mono.defer(() -> { + this.clientCache.clear(); + this.toolsOutputSchemaCache.clear(); return this.initializer.closeGracefully().then(transport.closeGracefully()); }); } @@ -746,7 +762,7 @@ public Mono listTools() { * @return A Mono that emits the list of tools result */ public Mono listTools(String cursor) { - return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, null)); + return this.listTools(cursor, null); } /** @@ -756,7 +772,14 @@ public Mono listTools(String cursor) { * @return A Mono that emits the list of tools result */ public Mono listTools(String cursor, Map meta) { - return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, meta)); + return Mono.defer(() -> { + McpSchema.ListToolsResult cached = this.clientCache.get(new McpClientCacheKey.ListTools(cursor, meta)); + if (cached != null) { + return Mono.just(cached); + } + return this.initializer.withInitialization("listing tools", + init -> this.listToolsInternal(init, cursor, meta)); + }); } private Mono listToolsInternal(Initialization init, String cursor, @@ -765,6 +788,7 @@ private Mono listToolsInternal(Initialization init, S if (init.initializeResult().capabilities().tools() == null) { return Mono.error(new IllegalStateException("Server does not provide tools capability")); } + long generation = this.clientCache.generation(); return init.mcpSession() .sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor, meta), LIST_TOOLS_RESULT_TYPE_REF) @@ -773,6 +797,10 @@ private Mono listToolsInternal(Initialization init, S if (result.tools() != null) { result.tools().forEach(tool -> ToolNameValidator.validate(tool.name(), false)); } + if (isCacheableListing(cursor, result.nextCursor())) { + this.clientCache.put(new McpClientCacheKey.ListTools(cursor, meta), result, result.ttlMs(), + generation); + } if (this.enableCallToolSchemaCaching && result.tools() != null) { // Cache tools output schema result.tools() @@ -786,14 +814,18 @@ private Mono listToolsInternal(Initialization init, S private NotificationHandler asyncToolsChangeNotificationHandler( List, Mono>> toolsChangeConsumers) { // TODO: params are not used yet - return params -> this.listTools() - .flatMap(listToolsResult -> Flux.fromIterable(toolsChangeConsumers) - .flatMap(consumer -> consumer.apply(listToolsResult.tools())) - .onErrorResume(error -> { - logger.error("Error handling tools list change notification", error); - return Mono.empty(); - }) - .then()); + return params -> { + this.clientCache.clearTools(); + this.toolsOutputSchemaCache.clear(); + return this.listTools() + .flatMap(listToolsResult -> Flux.fromIterable(toolsChangeConsumers) + .flatMap(consumer -> consumer.apply(listToolsResult.tools())) + .onErrorResume(error -> { + logger.error("Error handling tools list change notification", error); + return Mono.empty(); + }) + .then()); + }; } // -------------------------- @@ -837,7 +869,7 @@ public Mono listResources() { * @see #readResource(McpSchema.Resource) */ public Mono listResources(String cursor) { - return this.listResourcesInternal(cursor, null); + return this.listResources(cursor, null); } /** @@ -851,7 +883,14 @@ public Mono listResources(String cursor) { * @see #readResource(McpSchema.Resource) */ public Mono listResources(String cursor, Map meta) { - return this.listResourcesInternal(cursor, meta); + return Mono.defer(() -> { + McpSchema.ListResourcesResult cached = this.clientCache + .get(new McpClientCacheKey.ListResources(cursor, meta)); + if (cached != null) { + return Mono.just(cached); + } + return this.listResourcesInternal(cursor, meta); + }); } private Mono listResourcesInternal(String cursor, Map meta) { @@ -859,9 +898,16 @@ private Mono listResourcesInternal(String cursor, if (init.initializeResult().capabilities().resources() == null) { return Mono.error(new IllegalStateException("Server does not provide the resources capability")); } + long generation = this.clientCache.generation(); return init.mcpSession() .sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor, meta), - LIST_RESOURCES_RESULT_TYPE_REF); + LIST_RESOURCES_RESULT_TYPE_REF) + .doOnNext(result -> { + if (isCacheableListing(cursor, result.nextCursor())) { + this.clientCache.put(new McpClientCacheKey.ListResources(cursor, meta), result, result.ttlMs(), + generation); + } + }); }); } @@ -887,12 +933,21 @@ public Mono readResource(McpSchema.Resource resour * @see McpSchema.ReadResourceResult */ public Mono readResource(McpSchema.ReadResourceRequest readResourceRequest) { - return this.initializer.withInitialization("reading resources", init -> { - if (init.initializeResult().capabilities().resources() == null) { - return Mono.error(new IllegalStateException("Server does not provide the resources capability")); + var cacheKey = new McpClientCacheKey.ReadResource(readResourceRequest.uri(), readResourceRequest.meta()); + return Mono.defer(() -> { + McpSchema.ReadResourceResult cached = this.clientCache.get(cacheKey); + if (cached != null) { + return Mono.just(cached); } - return init.mcpSession() - .sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest, READ_RESOURCE_RESULT_TYPE_REF); + return this.initializer.withInitialization("reading resources", init -> { + if (init.initializeResult().capabilities().resources() == null) { + return Mono.error(new IllegalStateException("Server does not provide the resources capability")); + } + long generation = this.clientCache.generation(); + return init.mcpSession() + .sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest, READ_RESOURCE_RESULT_TYPE_REF) + .doOnNext(result -> this.clientCache.put(cacheKey, result, result.ttlMs(), generation)); + }); }); } @@ -922,7 +977,7 @@ public Mono listResourceTemplates() { * @see McpSchema.ListResourceTemplatesResult */ public Mono listResourceTemplates(String cursor) { - return this.listResourceTemplatesInternal(cursor, null); + return this.listResourceTemplates(cursor, null); } /** @@ -935,7 +990,14 @@ public Mono listResourceTemplates(String * @see McpSchema.ListResourceTemplatesResult */ public Mono listResourceTemplates(String cursor, Map meta) { - return this.listResourceTemplatesInternal(cursor, meta); + return Mono.defer(() -> { + McpSchema.ListResourceTemplatesResult cached = this.clientCache + .get(new McpClientCacheKey.ListResourceTemplates(cursor, meta)); + if (cached != null) { + return Mono.just(cached); + } + return this.listResourceTemplatesInternal(cursor, meta); + }); } private Mono listResourceTemplatesInternal(String cursor, @@ -944,9 +1006,16 @@ private Mono listResourceTemplatesInterna if (init.initializeResult().capabilities().resources() == null) { return Mono.error(new IllegalStateException("Server does not provide the resources capability")); } + long generation = this.clientCache.generation(); return init.mcpSession() .sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest(cursor, meta), - LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF); + LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF) + .doOnNext(result -> { + if (isCacheableListing(cursor, result.nextCursor())) { + this.clientCache.put(new McpClientCacheKey.ListResourceTemplates(cursor, meta), result, + result.ttlMs(), generation); + } + }); }); } @@ -980,13 +1049,16 @@ public Mono unsubscribeResource(McpSchema.UnsubscribeRequest unsubscribeRe private NotificationHandler asyncResourcesChangeNotificationHandler( List, Mono>> resourcesChangeConsumers) { - return params -> listResources().flatMap(listResourcesResult -> Flux.fromIterable(resourcesChangeConsumers) - .flatMap(consumer -> consumer.apply(listResourcesResult.resources())) - .onErrorResume(error -> { - logger.error("Error handling resources list change notification", error); - return Mono.empty(); - }) - .then()); + return params -> { + this.clientCache.clearResources(); + return listResources().flatMap(listResourcesResult -> Flux.fromIterable(resourcesChangeConsumers) + .flatMap(consumer -> consumer.apply(listResourcesResult.resources())) + .onErrorResume(error -> { + logger.error("Error handling resources list change notification", error); + return Mono.empty(); + }) + .then()); + }; } private NotificationHandler asyncResourcesUpdatedNotificationHandler( @@ -996,6 +1068,8 @@ private NotificationHandler asyncResourcesUpdatedNotificationHandler( new TypeRef<>() { }); + this.clientCache.clearResource(resourcesUpdatedNotification.uri()); + return readResource(McpSchema.ReadResourceRequest.builder(resourcesUpdatedNotification.uri()).build()) .flatMap(readResourceResult -> Flux.fromIterable(resourcesUpdateConsumers) .flatMap(consumer -> consumer.apply(readResourceResult.contents())) @@ -1040,7 +1114,7 @@ public Mono listPrompts() { * @see #getPrompt(GetPromptRequest) */ public Mono listPrompts(String cursor) { - return this.listPromptsInternal(cursor, null); + return this.listPrompts(cursor, null); } /** @@ -1052,14 +1126,28 @@ public Mono listPrompts(String cursor) { * @see #getPrompt(GetPromptRequest) */ public Mono listPrompts(String cursor, Map meta) { - return this.listPromptsInternal(cursor, meta); + return Mono.defer(() -> { + McpSchema.ListPromptsResult cached = this.clientCache.get(new McpClientCacheKey.ListPrompts(cursor, meta)); + if (cached != null) { + return Mono.just(cached); + } + return this.listPromptsInternal(cursor, meta); + }); } private Mono listPromptsInternal(String cursor, Map meta) { - return this.initializer.withInitialization("listing prompts", - init -> init.mcpSession() - .sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor, meta), - LIST_PROMPTS_RESULT_TYPE_REF)); + return this.initializer.withInitialization("listing prompts", init -> { + long generation = this.clientCache.generation(); + return init.mcpSession() + .sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor, meta), + LIST_PROMPTS_RESULT_TYPE_REF) + .doOnNext(result -> { + if (isCacheableListing(cursor, result.nextCursor())) { + this.clientCache.put(new McpClientCacheKey.ListPrompts(cursor, meta), result, result.ttlMs(), + generation); + } + }); + }); } /** @@ -1078,13 +1166,16 @@ public Mono getPrompt(GetPromptRequest getPromptRequest) { private NotificationHandler asyncPromptsChangeNotificationHandler( List, Mono>> promptsChangeConsumers) { - return params -> listPrompts().flatMap(listPromptsResult -> Flux.fromIterable(promptsChangeConsumers) - .flatMap(consumer -> consumer.apply(listPromptsResult.prompts())) - .onErrorResume(error -> { - logger.error("Error handling prompts list change notification", error); - return Mono.empty(); - }) - .then()); + return params -> { + this.clientCache.clearPrompts(); + return listPrompts().flatMap(listPromptsResult -> Flux.fromIterable(promptsChangeConsumers) + .flatMap(consumer -> consumer.apply(listPromptsResult.prompts())) + .onErrorResume(error -> { + logger.error("Error handling prompts list change notification", error); + return Mono.empty(); + }) + .then()); + }; } // -------------------------- @@ -1146,6 +1237,35 @@ void setProtocolVersions(List protocolVersions) { this.initializer.setProtocolVersions(protocolVersions); } + /** + * Drops every cached list and {@code resources/read} result, so that the next call + * re-fetches from the server. Use it when the caller must observe current server + * state and can wait for neither the server's {@code ttlMs} to lapse nor a change + * notification to arrive. + *

+ * The tool output schema cache used by {@link #callTool} is a separate mechanism and + * is not affected. + */ + public void invalidateCache() { + this.clientCache.clear(); + } + + McpClientCache getClientCache() { + return this.clientCache; + } + + /** + * Whether a listing response may be cached. Only a listing that is complete in a + * single page qualifies: serving page one from the cache while the remaining pages + * are fetched fresh would aggregate pages taken from two different server snapshots, + * using a cursor the server may already have invalidated. + * @param cursor the cursor the request was made with + * @param nextCursor the cursor the server returned + */ + private static boolean isCacheableListing(String cursor, String nextCursor) { + return cursor == null && (nextCursor == null || nextCursor.isEmpty()); + } + // -------------------------- // Completions // -------------------------- diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java index 328566a69..31ad9cffe 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -202,6 +202,10 @@ class SyncSpec { private boolean applyElicitationDefaults = false; // Default to false + private boolean enableResultCaching = true; // Default to true + + private McpClientCacheStore cacheStore; // Defaults to an in-memory store + private SyncSpec(McpClientTransport transport) { Assert.notNull(transport, "Transport must not be null"); this.transport = transport; @@ -545,6 +549,33 @@ public SyncSpec applyElicitationDefaults(boolean applyElicitationDefaults) { return this; } + /** + * Whether the client honours the {@code ttlMs} caching hints servers attach to + * list and {@code resources/read} results (SEP-2549). Enabled by default; turn it + * off when the caller must observe current server state on every call. See + * {@link McpSyncClient#invalidateCache()} to drop cached entries without + * disabling caching. + * @param enableResultCaching true to enable, false to disable + * @return This builder instance for method chaining + */ + public SyncSpec enableResultCaching(boolean enableResultCaching) { + this.enableResultCaching = enableResultCaching; + return this; + } + + /** + * Where results cached under a server {@code ttlMs} hint are kept. Defaults to a + * bounded in-memory store; supply your own to back the cache with a cache library + * or to share one store across clients. + * @param cacheStore the store to use, or null for the default + * @return This builder instance for method chaining + * @see McpClientCacheStore + */ + public SyncSpec cacheStore(McpClientCacheStore cacheStore) { + this.cacheStore = cacheStore; + return this; + } + /** * Create an instance of {@link McpSyncClient} with the provided configurations or * sensible defaults. @@ -555,7 +586,8 @@ public McpSyncClient build() { this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers, this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, this.elicitationCompleteConsumers, this.samplingHandler, this.formElicitationHandler, - this.urlElicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults); + this.urlElicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults, + this.enableResultCaching, this.cacheStore); McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures); @@ -637,6 +669,10 @@ class AsyncSpec { private boolean applyElicitationDefaults = false; // Default to false + private boolean enableResultCaching = true; // Default to true + + private McpClientCacheStore cacheStore; // Defaults to an in-memory store + private AsyncSpec(McpClientTransport transport) { Assert.notNull(transport, "Transport must not be null"); this.transport = transport; @@ -966,6 +1002,33 @@ public AsyncSpec applyElicitationDefaults(boolean applyElicitationDefaults) { return this; } + /** + * Whether the client honours the {@code ttlMs} caching hints servers attach to + * list and {@code resources/read} results (SEP-2549). Enabled by default; turn it + * off when the caller must observe current server state on every call. See + * {@link McpAsyncClient#invalidateCache()} to drop cached entries without + * disabling caching. + * @param enableResultCaching true to enable, false to disable + * @return This builder instance for method chaining + */ + public AsyncSpec enableResultCaching(boolean enableResultCaching) { + this.enableResultCaching = enableResultCaching; + return this; + } + + /** + * Where results cached under a server {@code ttlMs} hint are kept. Defaults to a + * bounded in-memory store; supply your own to back the cache with a cache library + * or to share one store across clients. + * @param cacheStore the store to use, or null for the default + * @return This builder instance for method chaining + * @see McpClientCacheStore + */ + public AsyncSpec cacheStore(McpClientCacheStore cacheStore) { + this.cacheStore = cacheStore; + return this; + } + /** * Create an instance of {@link McpAsyncClient} with the provided configurations * or sensible defaults. @@ -980,8 +1043,8 @@ public McpAsyncClient build() { this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers, this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, this.elicitationCompleteConsumers, this.samplingHandler, this.formElicitationHandler, - this.urlElicitationHandler, this.enableCallToolSchemaCaching, - this.applyElicitationDefaults)); + this.urlElicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults, + this.enableResultCaching, this.cacheStore)); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCache.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCache.java new file mode 100644 index 000000000..54d56eda5 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCache.java @@ -0,0 +1,124 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Predicate; + +/** + * Caching policy for MCP list and resource operations, applied on top of an + * {@link McpClientCacheStore} that does the actual storing (SEP-2549). + * + *

+ * Entries are invalidated when their TTL expires, when a corresponding change + * notification arrives from the server, or when the client starts a new session. + * + * @author Sylwester Lachiewicz + */ +class McpClientCache { + + /** + * Upper bound applied to a server-supplied TTL. Caps how long a client keeps serving + * a response the server can no longer invalidate, and keeps the expiry computation + * away from {@link Long#MAX_VALUE}. + */ + static final long MAX_TTL_MS = Duration.ofHours(24).toMillis(); + + private final McpClientCacheStore store; + + private final boolean enabled; + + /** + * Incremented by every invalidation. A response that was already in flight when its + * generation was invalidated is not stored, otherwise the pre-invalidation value + * would be pinned for the whole TTL with no further notification to evict it. + */ + private final AtomicLong generation = new AtomicLong(); + + /** + * Held so that reading the generation and writing to the store are one step, and a + * response cannot slip in between an invalidation's two halves. + */ + private final Object invalidationLock = new Object(); + + McpClientCache() { + this(true, McpClientCacheStore.inMemory()); + } + + McpClientCache(McpClientCacheStore store) { + this(true, store); + } + + /** + * @param enabled when false the cache never stores or returns anything, so that a + * caller who opted out always observes current server state. + */ + McpClientCache(boolean enabled, McpClientCacheStore store) { + this.enabled = enabled; + this.store = store; + } + + /** + * The current generation, to be read before a request is sent and passed back to + * {@link #put(McpClientCacheKey, Object, Long, long)} when its response arrives. + */ + long generation() { + return this.generation.get(); + } + + @SuppressWarnings("unchecked") + T get(McpClientCacheKey key) { + return this.enabled ? (T) this.store.get(key) : null; + } + + void put(McpClientCacheKey key, T value, Long ttlMs) { + this.put(key, value, ttlMs, this.generation.get()); + } + + void put(McpClientCacheKey key, T value, Long ttlMs, long generation) { + if (!this.enabled || ttlMs == null || ttlMs <= 0 || value == null) { + return; + } + synchronized (this.invalidationLock) { + if (generation != this.generation.get()) { + return; + } + this.store.put(key, value, Math.min(ttlMs, MAX_TTL_MS)); + } + } + + void clearTools() { + this.invalidate(k -> k instanceof McpClientCacheKey.ListTools); + } + + void clearPrompts() { + this.invalidate(k -> k instanceof McpClientCacheKey.ListPrompts); + } + + void clearResources() { + this.invalidate(k -> k instanceof McpClientCacheKey.ListResources + || k instanceof McpClientCacheKey.ListResourceTemplates || k instanceof McpClientCacheKey.ReadResource); + } + + void clearResource(String uri) { + this.invalidate(k -> k instanceof McpClientCacheKey.ReadResource readKey && readKey.uri().equals(uri)); + } + + void clear() { + synchronized (this.invalidationLock) { + this.generation.incrementAndGet(); + this.store.clear(); + } + } + + private void invalidate(Predicate matcher) { + synchronized (this.invalidationLock) { + this.generation.incrementAndGet(); + this.store.removeIf(matcher); + } + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCacheKey.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCacheKey.java new file mode 100644 index 000000000..fefcd90b1 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCacheKey.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Identifies one entry in an {@link McpClientCacheStore}: the request whose result was + * cached. + *

+ * Keys are value types with well-behaved {@code equals} and {@code hashCode}, and the + * {@code _meta} they carry is copied on construction, so a key stays usable as a map key + * even if the caller mutates the map it passed. + * + * @author Sylwester Lachiewicz + * @see McpClientCacheStore + */ +public sealed interface McpClientCacheKey { + + /** + * A {@code tools/list} page. + */ + record ListTools(String cursor, Map meta) implements McpClientCacheKey { + + public ListTools { + meta = snapshot(meta); + } + + } + + /** + * A {@code prompts/list} page. + */ + record ListPrompts(String cursor, Map meta) implements McpClientCacheKey { + + public ListPrompts { + meta = snapshot(meta); + } + + } + + /** + * A {@code resources/list} page. + */ + record ListResources(String cursor, Map meta) implements McpClientCacheKey { + + public ListResources { + meta = snapshot(meta); + } + + } + + /** + * A {@code resources/templates/list} page. + */ + record ListResourceTemplates(String cursor, Map meta) implements McpClientCacheKey { + + public ListResourceTemplates { + meta = snapshot(meta); + } + + } + + /** + * One {@code resources/read} result. Keyed on the {@code _meta} as well as the URI, + * because the server's read handler sees {@code _meta} and may branch on it. + */ + record ReadResource(String uri, Map meta) implements McpClientCacheKey { + + public ReadResource { + meta = snapshot(meta); + } + + public ReadResource(String uri) { + this(uri, null); + } + + } + + private static Map snapshot(Map meta) { + return meta == null ? null : Collections.unmodifiableMap(new HashMap<>(meta)); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCacheStore.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCacheStore.java new file mode 100644 index 000000000..7d3842c3a --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCacheStore.java @@ -0,0 +1,67 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client; + +import java.util.function.Predicate; + +/** + * Where a client keeps the list and {@code resources/read} results a server marked + * cacheable with a {@code ttlMs} hint (SEP-2549). + *

+ * The SDK ships an in-memory store and uses it by default; implement this interface to + * back the cache with a library such as Caffeine, or with a store shared across clients, + * and register it with {@code McpClient.sync(transport).cacheStore(...)}. + *

+ * The client decides what may be cached and when an entry must go; a + * store only has to honour those decisions. It MUST be safe for concurrent use, and it + * MUST NOT return an entry whose TTL has lapsed. + * + * @author Sylwester Lachiewicz + * @see McpClientCacheKey + */ +public interface McpClientCacheStore { + + /** + * The in-memory store used when none is configured: bounded at {@code maxEntries}, + * evicting the oldest entry first. + * @param maxEntries the most entries to retain. MUST be positive. + */ + static McpClientCacheStore inMemory(int maxEntries) { + return new InMemoryMcpClientCacheStore(maxEntries, System::currentTimeMillis); + } + + /** + * The in-memory store used when none is configured, bounded at 512 entries. + */ + static McpClientCacheStore inMemory() { + return inMemory(InMemoryMcpClientCacheStore.DEFAULT_MAX_ENTRIES); + } + + /** + * The value cached under {@code key}, or {@code null} when there is none or its TTL + * has lapsed. + */ + Object get(McpClientCacheKey key); + + /** + * Store {@code value} under {@code key} for at most {@code ttlMs} milliseconds, + * replacing any entry already there. + * @param ttlMs the lifetime in milliseconds. Always positive; the client has already + * dropped non-positive hints and clamped excessive ones. + */ + void put(McpClientCacheKey key, Object value, long ttlMs); + + /** + * Remove every entry whose key matches, called when a change notification invalidates + * a group of entries. + */ + void removeIf(Predicate matcher); + + /** + * Remove every entry, called when the client connects to a new session or closes. + */ + void clear(); + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java index f61123da0..a120801f0 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java @@ -66,6 +66,9 @@ class McpClientFeatures { * @param applyElicitationDefaults whether the client should fill in missing fields of * an accepted {@code ElicitResult.content} with the {@code default} values declared * in the {@code requestedSchema}. + * @param enableResultCaching whether the client honours server {@code ttlMs} caching + * hints (SEP-2549). + * @param cacheStore where those cached results are kept. */ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, List, Mono>> toolsChangeConsumers, @@ -78,7 +81,8 @@ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c Function> samplingHandler, Function> formElicitationHandler, Function> urlElicitationHandler, - boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults, boolean enableResultCaching, + McpClientCacheStore cacheStore) { /** * Create an instance and validate the arguments. @@ -95,6 +99,9 @@ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c * @param applyElicitationDefaults whether the client should fill in missing * fields of an accepted {@code ElicitResult.content} with the {@code default} * values declared in the {@code requestedSchema}. + * @param enableResultCaching whether the client honours server {@code ttlMs} + * caching hints (SEP-2549). + * @param cacheStore where those cached results are kept. */ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, @@ -108,7 +115,8 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c Function> samplingHandler, Function> formElicitationHandler, Function> urlElicitationHandler, - boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults, boolean enableResultCaching, + McpClientCacheStore cacheStore) { Assert.notNull(clientInfo, "Client info must not be null"); this.clientInfo = clientInfo; @@ -132,6 +140,8 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c this.urlElicitationHandler = urlElicitationHandler; this.enableCallToolSchemaCaching = enableCallToolSchemaCaching; this.applyElicitationDefaults = applyElicitationDefaults; + this.enableResultCaching = enableResultCaching; + this.cacheStore = cacheStore != null ? cacheStore : McpClientCacheStore.inMemory(); } /** @@ -148,7 +158,7 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c Function> elicitationHandler) { this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), List.of(), - samplingHandler, elicitationHandler, null, false, false); + samplingHandler, elicitationHandler, null, false, false, true, null); } /** @@ -223,7 +233,7 @@ public static Async fromSync(Sync syncSpec) { toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, progressConsumers, elicitationCompleteConsumers, samplingHandler, formElicitationHandler, urlElicitationHandler, syncSpec.enableCallToolSchemaCaching, - syncSpec.applyElicitationDefaults); + syncSpec.applyElicitationDefaults, syncSpec.enableResultCaching, syncSpec.cacheStore); } } @@ -246,6 +256,9 @@ public static Async fromSync(Sync syncSpec) { * @param applyElicitationDefaults whether the client should fill in missing fields of * an accepted {@code ElicitResult.content} with the {@code default} values declared * in the {@code requestedSchema}. + * @param enableResultCaching whether the client honours server {@code ttlMs} caching + * hints (SEP-2549). + * @param cacheStore where those cached results are kept. */ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, List>> toolsChangeConsumers, @@ -258,7 +271,8 @@ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabili Function samplingHandler, Function formElicitationHandler, Function urlElicitationHandler, - boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults, boolean enableResultCaching, + McpClientCacheStore cacheStore) { /** * Create an instance and validate the arguments. @@ -277,6 +291,9 @@ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabili * @param applyElicitationDefaults whether the client should fill in missing * fields of an accepted {@code ElicitResult.content} with the {@code default} * values declared in the {@code requestedSchema}. + * @param enableResultCaching whether the client honours server {@code ttlMs} + * caching hints (SEP-2549). + * @param cacheStore where those cached results are kept. */ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, List>> toolsChangeConsumers, @@ -289,7 +306,8 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl Function samplingHandler, Function formElicitationHandler, Function urlElicitationHandler, - boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) { + boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults, boolean enableResultCaching, + McpClientCacheStore cacheStore) { Assert.notNull(clientInfo, "Client info must not be null"); this.clientInfo = clientInfo; @@ -313,6 +331,8 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl this.urlElicitationHandler = urlElicitationHandler; this.enableCallToolSchemaCaching = enableCallToolSchemaCaching; this.applyElicitationDefaults = applyElicitationDefaults; + this.enableResultCaching = enableResultCaching; + this.cacheStore = cacheStore != null ? cacheStore : McpClientCacheStore.inMemory(); } /** @@ -329,7 +349,7 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl Function urlElicitationHandler) { this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), List.of(), - samplingHandler, formElicitationHandler, urlElicitationHandler, false, false); + samplingHandler, formElicitationHandler, urlElicitationHandler, false, false, true, null); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java index 7e08f83a0..d21d7b873 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java @@ -140,6 +140,19 @@ public McpSchema.Implementation getClientInfo() { return this.delegate.getClientInfo(); } + /** + * Drops every cached list and {@code resources/read} result, so that the next call + * re-fetches from the server. Use it when the caller must observe current server + * state and can wait for neither the server's {@code ttlMs} to lapse nor a change + * notification to arrive. + *

+ * The tool output schema cache used by {@link #callTool} is a separate mechanism and + * is not affected. + */ + public void invalidateCache() { + this.delegate.invalidateCache(); + } + @Override public void close() { this.delegate.close(); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index fc1f8a47c..72d90a2a0 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -120,6 +120,11 @@ public class McpAsyncServer { private final McpAsyncListFilter toolFilter; + /** + * The caching hint attached to every listing response (SEP-2549). + */ + private final McpListCacheOptions listCacheOptions; + private List protocolVersions; private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DefaultMcpUriTemplateManagerFactory(); @@ -134,7 +139,7 @@ public class McpAsyncServer { McpAsyncServer(McpServerTransportProvider mcpTransportProvider, McpJsonMapper jsonMapper, McpServerFeatures.Async features, Duration requestTimeout, McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator, - boolean validateToolInputs) { + boolean validateToolInputs, McpListCacheOptions listCacheOptions) { this.mcpTransportProvider = mcpTransportProvider; this.jsonMapper = jsonMapper; this.serverInfo = features.serverInfo(); @@ -149,6 +154,7 @@ public class McpAsyncServer { this.jsonSchemaValidator = jsonSchemaValidator; this.validateToolInputs = validateToolInputs; this.toolFilter = McpAsyncListFilter.and(features.toolFilters()); + this.listCacheOptions = listCacheOptions != null ? listCacheOptions : McpListCacheOptions.NONE; Map> requestHandlers = prepareRequestHandlers(); Map notificationHandlers = prepareNotificationHandlers(features); @@ -166,7 +172,7 @@ public class McpAsyncServer { McpAsyncServer(McpStreamableServerTransportProvider mcpTransportProvider, McpJsonMapper jsonMapper, McpServerFeatures.Async features, Duration requestTimeout, McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator, - boolean validateToolInputs) { + boolean validateToolInputs, McpListCacheOptions listCacheOptions) { this.mcpTransportProvider = mcpTransportProvider; this.jsonMapper = jsonMapper; this.serverInfo = features.serverInfo(); @@ -181,6 +187,7 @@ public class McpAsyncServer { this.jsonSchemaValidator = jsonSchemaValidator; this.validateToolInputs = validateToolInputs; this.toolFilter = McpAsyncListFilter.and(features.toolFilters()); + this.listCacheOptions = listCacheOptions != null ? listCacheOptions : McpListCacheOptions.NONE; Map> requestHandlers = prepareRequestHandlers(); Map notificationHandlers = prepareNotificationHandlers(features); @@ -548,7 +555,12 @@ private McpRequestHandler toolsListRequestHandler() { .filterWhen(tool -> this.toolFilter.isVisible(exchange.transportContext(), tool) .onErrorResume(error -> opaqueListFilterError(tool, error))) .collectList() - .map(tools -> McpSchema.ListToolsResult.builder(tools).build()); + // PRIVATE, not PUBLIC: the listing is filtered per caller, so a shared + // cache must never serve one principal's response to another. + .map(tools -> McpSchema.ListToolsResult.builder(tools) + .ttlMs(this.listCacheOptions.ttlMs()) + .cacheScope(this.listCacheOptions.cacheScope()) + .build()); }; } @@ -815,7 +827,10 @@ private McpRequestHandler resourcesListRequestHan .stream() .map(McpServerFeatures.AsyncResourceSpecification::resource) .toList(); - return Mono.just(McpSchema.ListResourcesResult.builder(resourceList).build()); + return Mono.just(McpSchema.ListResourcesResult.builder(resourceList) + .ttlMs(this.listCacheOptions.ttlMs()) + .cacheScope(this.listCacheOptions.cacheScope()) + .build()); }; } @@ -825,7 +840,10 @@ private McpRequestHandler resourceTemplat .stream() .map(McpServerFeatures.AsyncResourceTemplateSpecification::resourceTemplate) .toList(); - return Mono.just(McpSchema.ListResourceTemplatesResult.builder(resourceList).build()); + return Mono.just(McpSchema.ListResourceTemplatesResult.builder(resourceList) + .ttlMs(this.listCacheOptions.ttlMs()) + .cacheScope(this.listCacheOptions.cacheScope()) + .build()); }; } @@ -846,10 +864,22 @@ private McpRequestHandler resourcesReadRequestHand return this.findResourceTemplateSpecification(resourceUri) .map(spec -> spec.readHandler().apply(ex, resourceRequest)) .orElseGet(() -> Mono.error(RESOURCE_NOT_FOUND.apply(resourceUri))); - }); + }) + .map(McpAsyncServer::withCacheDefaults); }; } + private static McpSchema.ReadResourceResult withCacheDefaults(McpSchema.ReadResourceResult result) { + if (result.ttlMs() == null || result.cacheScope() == null) { + return McpSchema.ReadResourceResult.builder(result.contents()) + .meta(result.meta()) + .ttlMs(result.ttlMs() != null ? result.ttlMs() : 0L) + .cacheScope(result.cacheScope() != null ? result.cacheScope() : McpSchema.CacheScope.PRIVATE) + .build(); + } + return result; + } + private Optional findResourceSpecification(String uri) { var result = this.resources.values() .stream() @@ -976,7 +1006,10 @@ private McpRequestHandler promptsListRequestHandler .map(McpServerFeatures.AsyncPromptSpecification::prompt) .toList(); - return Mono.just(McpSchema.ListPromptsResult.builder(promptList).build()); + return Mono.just(McpSchema.ListPromptsResult.builder(promptList) + .ttlMs(this.listCacheOptions.ttlMs()) + .cacheScope(this.listCacheOptions.cacheScope()) + .build()); }; } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpListCacheOptions.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpListCacheOptions.java new file mode 100644 index 000000000..a3ea67009 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpListCacheOptions.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import java.time.Duration; + +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.util.Assert; + +/** + * The caching hint a server attaches to its listing responses — {@code tools/list}, + * {@code prompts/list}, {@code resources/list} and {@code resources/templates/list} + * (SEP-2549). + *

+ * A {@code resources/read} handler carries its own hint on the + * {@link McpSchema.ReadResourceResult} it builds and is not covered here. + * + * @param ttlMs how long a client may reuse a listing before re-fetching it. Zero disables + * caching. + * @param cacheScope whether a shared cache may serve the listing to another principal. + * @author Sylwester Lachiewicz + * @see Specification: + * Caching + */ +public record McpListCacheOptions(long ttlMs, McpSchema.CacheScope cacheScope) { + + /** + * The default: listings are not cached. Scoped {@code private} so that a gateway + * cannot share a listing across principals should a server later attach a TTL without + * revisiting the scope. + */ + public static final McpListCacheOptions NONE = new McpListCacheOptions(0L, McpSchema.CacheScope.PRIVATE); + + public McpListCacheOptions { + Assert.isTrue(ttlMs >= 0, "ttlMs must not be negative"); + Assert.notNull(cacheScope, "cacheScope must not be null"); + } + + /** + * @param ttl how long a client may reuse a listing. MUST NOT be null or negative. + * @param cacheScope whether a shared cache may serve the listing to another + * principal. Use {@link McpSchema.CacheScope#PUBLIC} only for a listing that is + * identical for every caller: a per-request tool filter makes the listing + * caller-specific, and {@code public} would let a gateway hand one principal's + * filtered listing to another. + */ + public static McpListCacheOptions of(Duration ttl, McpSchema.CacheScope cacheScope) { + Assert.notNull(ttl, "ttl must not be null"); + return new McpListCacheOptions(ttl.toMillis(), cacheScope); + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java index 2113fdb48..6925dc4e1 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -247,7 +247,8 @@ public McpAsyncServer build() { validateAsyncToolSchemas(jsonSchemaValidator, this.tools); return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, - features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); + features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs, + listCacheOptions); } } @@ -276,7 +277,8 @@ public McpAsyncServer build() { validateAsyncToolSchemas(jsonSchemaValidator, this.tools); return new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, - features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); + features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs, + listCacheOptions); } } @@ -302,6 +304,8 @@ abstract class AsyncSpecification> { boolean validateToolInputs = true; + McpListCacheOptions listCacheOptions = McpListCacheOptions.NONE; + final List> toolFilters = new ArrayList<>(); /** @@ -443,6 +447,26 @@ public AsyncSpecification validateToolInputs(boolean validate) { return this; } + /** + * The caching hint attached to {@code tools/list}, {@code prompts/list}, + * {@code resources/list} and {@code resources/templates/list} responses + * (SEP-2549). Listings are not cached by default. + *

+ * Keep the scope {@code private} whenever a listing varies per caller, which any + * registered list filter makes true: with {@code public} a shared cache may serve + * one principal's filtered listing to another. + * @param ttl how long a client may reuse a listing before re-fetching it. MUST + * NOT be null or negative. + * @param cacheScope whether a shared cache may serve the listing to another + * principal. MUST NOT be null. + * @return This builder instance for method chaining + * @see McpListCacheOptions + */ + public AsyncSpecification listCache(Duration ttl, McpSchema.CacheScope cacheScope) { + this.listCacheOptions = McpListCacheOptions.of(ttl, cacheScope); + return this; + } + /** * Adds a per-request filter deciding which tools are advertised in * {@code tools/list}, for example to hide tools the caller is not authorized to @@ -881,7 +905,7 @@ public McpSyncServer build() { var asyncServer = new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout, - uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); + uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs, listCacheOptions); return new McpSyncServer(asyncServer, this.immediateExecution); } @@ -915,7 +939,7 @@ public McpSyncServer build() { var asyncServer = new McpAsyncServer(transportProvider, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, this.requestTimeout, - this.uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); + this.uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs, listCacheOptions); return new McpSyncServer(asyncServer, this.immediateExecution); } @@ -940,6 +964,8 @@ abstract class SyncSpecification> { boolean validateToolInputs = true; + McpListCacheOptions listCacheOptions = McpListCacheOptions.NONE; + final List> toolFilters = new ArrayList<>(); /** @@ -1085,6 +1111,26 @@ public SyncSpecification validateToolInputs(boolean validate) { return this; } + /** + * The caching hint attached to {@code tools/list}, {@code prompts/list}, + * {@code resources/list} and {@code resources/templates/list} responses + * (SEP-2549). Listings are not cached by default. + *

+ * Keep the scope {@code private} whenever a listing varies per caller, which any + * registered list filter makes true: with {@code public} a shared cache may serve + * one principal's filtered listing to another. + * @param ttl how long a client may reuse a listing before re-fetching it. MUST + * NOT be null or negative. + * @param cacheScope whether a shared cache may serve the listing to another + * principal. MUST NOT be null. + * @return This builder instance for method chaining + * @see McpListCacheOptions + */ + public SyncSpecification listCache(Duration ttl, McpSchema.CacheScope cacheScope) { + this.listCacheOptions = McpListCacheOptions.of(ttl, cacheScope); + return this; + } + /** * Adds a per-request filter deciding which tools are advertised in * {@code tools/list}, for example to hide tools the caller is not authorized to @@ -1522,6 +1568,8 @@ class StatelessAsyncSpecification { boolean validateToolInputs = true; + McpListCacheOptions listCacheOptions = McpListCacheOptions.NONE; + final List> toolFilters = new ArrayList<>(); /** @@ -1664,6 +1712,26 @@ public StatelessAsyncSpecification validateToolInputs(boolean validate) { return this; } + /** + * The caching hint attached to {@code tools/list}, {@code prompts/list}, + * {@code resources/list} and {@code resources/templates/list} responses + * (SEP-2549). Listings are not cached by default. + *

+ * Keep the scope {@code private} whenever a listing varies per caller, which any + * registered list filter makes true: with {@code public} a shared cache may serve + * one principal's filtered listing to another. + * @param ttl how long a client may reuse a listing before re-fetching it. MUST + * NOT be null or negative. + * @param cacheScope whether a shared cache may serve the listing to another + * principal. MUST NOT be null. + * @return This builder instance for method chaining + * @see McpListCacheOptions + */ + public StatelessAsyncSpecification listCache(Duration ttl, McpSchema.CacheScope cacheScope) { + this.listCacheOptions = McpListCacheOptions.of(ttl, cacheScope); + return this; + } + /** * Adds a per-request filter deciding which tools are advertised in * {@code tools/list}, for example to hide tools the caller is not authorized to @@ -2032,7 +2100,8 @@ public McpStatelessAsyncServer build() { validateStatelessAsyncToolSchemas(jsonSchemaValidator, this.tools); return new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, - features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); + features, requestTimeout, uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs, + listCacheOptions); } } @@ -2059,6 +2128,8 @@ class StatelessSyncSpecification { boolean validateToolInputs = true; + McpListCacheOptions listCacheOptions = McpListCacheOptions.NONE; + final List> toolFilters = new ArrayList<>(); /** @@ -2201,6 +2272,26 @@ public StatelessSyncSpecification validateToolInputs(boolean validate) { return this; } + /** + * The caching hint attached to {@code tools/list}, {@code prompts/list}, + * {@code resources/list} and {@code resources/templates/list} responses + * (SEP-2549). Listings are not cached by default. + *

+ * Keep the scope {@code private} whenever a listing varies per caller, which any + * registered list filter makes true: with {@code public} a shared cache may serve + * one principal's filtered listing to another. + * @param ttl how long a client may reuse a listing before re-fetching it. MUST + * NOT be null or negative. + * @param cacheScope whether a shared cache may serve the listing to another + * principal. MUST NOT be null. + * @return This builder instance for method chaining + * @see McpListCacheOptions + */ + public StatelessSyncSpecification listCache(Duration ttl, McpSchema.CacheScope cacheScope) { + this.listCacheOptions = McpListCacheOptions.of(ttl, cacheScope); + return this; + } + /** * Adds a per-request filter deciding which tools are advertised in * {@code tools/list}, for example to hide tools the caller is not authorized to @@ -2589,7 +2680,7 @@ public McpStatelessSyncServer build() { var asyncServer = new McpStatelessAsyncServer(transport, jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, asyncFeatures, requestTimeout, - uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs); + uriTemplateManagerFactory, jsonSchemaValidator, validateToolInputs, listCacheOptions); return new McpStatelessSyncServer(asyncServer, this.immediateExecution); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index 95b694e7f..a2f251e52 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -82,10 +82,15 @@ public class McpStatelessAsyncServer { private final McpAsyncListFilter toolFilter; + /** + * The caching hint attached to every listing response (SEP-2549). + */ + private final McpListCacheOptions listCacheOptions; + McpStatelessAsyncServer(McpStatelessServerTransport mcpTransport, McpJsonMapper jsonMapper, McpStatelessServerFeatures.Async features, Duration requestTimeout, McpUriTemplateManagerFactory uriTemplateManagerFactory, JsonSchemaValidator jsonSchemaValidator, - boolean validateToolInputs) { + boolean validateToolInputs, McpListCacheOptions listCacheOptions) { this.mcpTransportProvider = mcpTransport; this.jsonMapper = jsonMapper; this.serverInfo = features.serverInfo(); @@ -100,6 +105,7 @@ public class McpStatelessAsyncServer { this.jsonSchemaValidator = jsonSchemaValidator; this.validateToolInputs = validateToolInputs; this.toolFilter = McpAsyncListFilter.and(features.toolFilters()); + this.listCacheOptions = listCacheOptions != null ? listCacheOptions : McpListCacheOptions.NONE; Map> requestHandlers = new HashMap<>(); @@ -427,7 +433,12 @@ private McpStatelessRequestHandler toolsListRequestHa .filterWhen(tool -> this.toolFilter.isVisible(ctx, tool) .onErrorResume(error -> opaqueListFilterError(tool, error))) .collectList() - .map(tools -> McpSchema.ListToolsResult.builder(tools).build()); + // PRIVATE, not PUBLIC: the listing is filtered per caller, so a shared + // cache must never serve one principal's response to another. + .map(tools -> McpSchema.ListToolsResult.builder(tools) + .ttlMs(this.listCacheOptions.ttlMs()) + .cacheScope(this.listCacheOptions.cacheScope()) + .build()); }; } @@ -606,7 +617,10 @@ private McpStatelessRequestHandler resourcesListR .stream() .map(McpStatelessServerFeatures.AsyncResourceSpecification::resource) .toList(); - return Mono.just(McpSchema.ListResourcesResult.builder(resourceList).build()); + return Mono.just(McpSchema.ListResourcesResult.builder(resourceList) + .ttlMs(this.listCacheOptions.ttlMs()) + .cacheScope(this.listCacheOptions.cacheScope()) + .build()); }; } @@ -616,7 +630,10 @@ private McpStatelessRequestHandler resour .stream() .map(AsyncResourceTemplateSpecification::resourceTemplate) .toList(); - return Mono.just(McpSchema.ListResourceTemplatesResult.builder(resourceList).build()); + return Mono.just(McpSchema.ListResourceTemplatesResult.builder(resourceList) + .ttlMs(this.listCacheOptions.ttlMs()) + .cacheScope(this.listCacheOptions.cacheScope()) + .build()); }; } @@ -636,11 +653,23 @@ private McpStatelessRequestHandler resourcesReadRe return this.findResourceTemplateSpecification(resourceUri) .map(spec -> spec.readHandler().apply(ctx, resourceRequest)) .orElseGet(() -> Mono.error(RESOURCE_NOT_FOUND.apply(resourceUri))); - }); + }) + .map(McpStatelessAsyncServer::withCacheDefaults); }; } + private static McpSchema.ReadResourceResult withCacheDefaults(McpSchema.ReadResourceResult result) { + if (result.ttlMs() == null || result.cacheScope() == null) { + return McpSchema.ReadResourceResult.builder(result.contents()) + .meta(result.meta()) + .ttlMs(result.ttlMs() != null ? result.ttlMs() : 0L) + .cacheScope(result.cacheScope() != null ? result.cacheScope() : McpSchema.CacheScope.PRIVATE) + .build(); + } + return result; + } + private Optional findResourceSpecification(String uri) { var result = this.resources.values() .stream() @@ -736,7 +765,10 @@ private McpStatelessRequestHandler promptsListReque .map(McpStatelessServerFeatures.AsyncPromptSpecification::prompt) .toList(); - return Mono.just(McpSchema.ListPromptsResult.builder(promptList).build()); + return Mono.just(McpSchema.ListPromptsResult.builder(promptList) + .ttlMs(this.listCacheOptions.ttlMs()) + .cacheScope(this.listCacheOptions.cacheScope()) + .build()); }; } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 648be8b4b..f904bfc86 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -190,6 +190,21 @@ public interface Result extends Meta { } + /** + * Indicates the intended scope of a cached response, analogous to HTTP + * {@code Cache-Control: public} vs {@code Cache-Control: private}. + * + * @see Specification: + * Caching + */ + public enum CacheScope { + + // @formatter:off + @JsonProperty("private") PRIVATE, + @JsonProperty("public") PUBLIC + } // @formatter:on + public interface Notification extends Meta { } @@ -1604,13 +1619,18 @@ public ResourceTemplate build() { * @param nextCursor An opaque token representing the pagination position after the * last returned result. If present, there may be more results available * @param meta See specification for notes on _meta usage + * @param ttlMs A hint from the server indicating how long (in milliseconds) the + * client may cache this response before re-fetching + * @param cacheScope Indicates the intended scope of the cached response */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record ListResourcesResult( // @formatter:off @JsonProperty("resources") List resources, @JsonProperty("nextCursor") String nextCursor, - @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + @JsonProperty("_meta") Map meta, + @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope) implements Result { // @formatter:on public ListResourcesResult { Assert.notNull(resources, "resources must not be null"); @@ -1618,13 +1638,19 @@ public record ListResourcesResult( // @formatter:off @JsonCreator static ListResourcesResult fromJson(@JsonProperty("resources") List resources, - @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta, + @JsonProperty("ttlMs") Long ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) { if (resources == null) { logger.warn( "ListResourcesResult: missing required field 'resources' during deserialization, using default []"); resources = List.of(); } - return new ListResourcesResult(resources, nextCursor, meta); + return new ListResourcesResult(resources, nextCursor, meta, ttlMs, cacheScope); + } + + @Deprecated + public ListResourcesResult(List resources, String nextCursor, Map meta) { + this(resources, nextCursor, meta, null, null); } @Deprecated @@ -1644,6 +1670,10 @@ public static class Builder { private Map meta; + private Long ttlMs; + + private CacheScope cacheScope; + private Builder(List resources) { Assert.notNull(resources, "resources must not be null"); this.resources = resources; @@ -1659,8 +1689,19 @@ public Builder meta(Map meta) { return this; } + public Builder ttlMs(Long ttlMs) { + Assert.isTrue(ttlMs == null || ttlMs >= 0, "ttlMs must not be negative"); + this.ttlMs = ttlMs; + return this; + } + + public Builder cacheScope(CacheScope cacheScope) { + this.cacheScope = cacheScope; + return this; + } + public ListResourcesResult build() { - return new ListResourcesResult(resources, nextCursor, meta); + return new ListResourcesResult(resources, nextCursor, meta, ttlMs, cacheScope); } } @@ -1673,13 +1714,18 @@ public ListResourcesResult build() { * @param nextCursor An opaque token representing the pagination position after the * last returned result. If present, there may be more results available * @param meta See specification for notes on _meta usage + * @param ttlMs A hint from the server indicating how long (in milliseconds) the + * client may cache this response before re-fetching + * @param cacheScope Indicates the intended scope of the cached response */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record ListResourceTemplatesResult( // @formatter:off @JsonProperty("resourceTemplates") List resourceTemplates, @JsonProperty("nextCursor") String nextCursor, - @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + @JsonProperty("_meta") Map meta, + @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope) implements Result { // @formatter:on public ListResourceTemplatesResult { Assert.notNull(resourceTemplates, "resourceTemplates must not be null"); @@ -1688,13 +1734,20 @@ public record ListResourceTemplatesResult( // @formatter:off @JsonCreator static ListResourceTemplatesResult fromJson( @JsonProperty("resourceTemplates") List resourceTemplates, - @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta, + @JsonProperty("ttlMs") Long ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) { if (resourceTemplates == null) { logger.warn( "ListResourceTemplatesResult: missing required field 'resourceTemplates' during deserialization, using default []"); resourceTemplates = List.of(); } - return new ListResourceTemplatesResult(resourceTemplates, nextCursor, meta); + return new ListResourceTemplatesResult(resourceTemplates, nextCursor, meta, ttlMs, cacheScope); + } + + @Deprecated + public ListResourceTemplatesResult(List resourceTemplates, String nextCursor, + Map meta) { + this(resourceTemplates, nextCursor, meta, null, null); } @Deprecated @@ -1714,6 +1767,10 @@ public static class Builder { private Map meta; + private Long ttlMs; + + private CacheScope cacheScope; + private Builder(List resourceTemplates) { Assert.notNull(resourceTemplates, "resourceTemplates must not be null"); this.resourceTemplates = resourceTemplates; @@ -1729,8 +1786,19 @@ public Builder meta(Map meta) { return this; } + public Builder ttlMs(Long ttlMs) { + Assert.isTrue(ttlMs == null || ttlMs >= 0, "ttlMs must not be negative"); + this.ttlMs = ttlMs; + return this; + } + + public Builder cacheScope(CacheScope cacheScope) { + this.cacheScope = cacheScope; + return this; + } + public ListResourceTemplatesResult build() { - return new ListResourceTemplatesResult(resourceTemplates, nextCursor, meta); + return new ListResourceTemplatesResult(resourceTemplates, nextCursor, meta, ttlMs, cacheScope); } } @@ -1801,12 +1869,17 @@ public ReadResourceRequest build() { * * @param contents The contents of the resource * @param meta See specification for notes on _meta usage + * @param ttlMs A hint from the server indicating how long (in milliseconds) the + * client may cache this response before re-fetching + * @param cacheScope Indicates the intended scope of the cached response */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record ReadResourceResult( // @formatter:off @JsonProperty("contents") List contents, - @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + @JsonProperty("_meta") Map meta, + @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope) implements Result { // @formatter:on public ReadResourceResult { Assert.notNull(contents, "contents must not be null"); @@ -1814,13 +1887,19 @@ public record ReadResourceResult( // @formatter:off @JsonCreator static ReadResourceResult fromJson(@JsonProperty("contents") List contents, - @JsonProperty("_meta") Map meta) { + @JsonProperty("_meta") Map meta, @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope) { if (contents == null) { logger.warn( "ReadResourceResult: missing required field 'contents' during deserialization, using default []"); contents = List.of(); } - return new ReadResourceResult(contents, meta); + return new ReadResourceResult(contents, meta, ttlMs, cacheScope); + } + + @Deprecated + public ReadResourceResult(List contents, Map meta) { + this(contents, meta, null, null); } @Deprecated @@ -1838,6 +1917,10 @@ public static class Builder { private Map meta; + private Long ttlMs; + + private CacheScope cacheScope; + private Builder(List contents) { Assert.notNull(contents, "contents must not be null"); this.contents = contents; @@ -1848,8 +1931,19 @@ public Builder meta(Map meta) { return this; } + public Builder ttlMs(Long ttlMs) { + Assert.isTrue(ttlMs == null || ttlMs >= 0, "ttlMs must not be negative"); + this.ttlMs = ttlMs; + return this; + } + + public Builder cacheScope(CacheScope cacheScope) { + this.cacheScope = cacheScope; + return this; + } + public ReadResourceResult build() { - return new ReadResourceResult(contents, meta); + return new ReadResourceResult(contents, meta, ttlMs, cacheScope); } } @@ -2411,13 +2505,18 @@ public PromptMessage build() { * @param nextCursor An optional cursor for pagination. If present, indicates there * are more prompts available. * @param meta See specification for notes on _meta usage + * @param ttlMs A hint from the server indicating how long (in milliseconds) the + * client may cache this response before re-fetching + * @param cacheScope Indicates the intended scope of the cached response */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record ListPromptsResult( // @formatter:off @JsonProperty("prompts") List prompts, @JsonProperty("nextCursor") String nextCursor, - @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + @JsonProperty("_meta") Map meta, + @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope) implements Result { // @formatter:on public ListPromptsResult { Assert.notNull(prompts, "prompts must not be null"); @@ -2425,13 +2524,19 @@ public record ListPromptsResult( // @formatter:off @JsonCreator static ListPromptsResult fromJson(@JsonProperty("prompts") List prompts, - @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta, + @JsonProperty("ttlMs") Long ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) { if (prompts == null) { logger.warn( "ListPromptsResult: missing required field 'prompts' during deserialization, using default []"); prompts = List.of(); } - return new ListPromptsResult(prompts, nextCursor, meta); + return new ListPromptsResult(prompts, nextCursor, meta, ttlMs, cacheScope); + } + + @Deprecated + public ListPromptsResult(List prompts, String nextCursor, Map meta) { + this(prompts, nextCursor, meta, null, null); } @Deprecated @@ -2451,6 +2556,10 @@ public static class Builder { private Map meta; + private Long ttlMs; + + private CacheScope cacheScope; + private Builder(List prompts) { Assert.notNull(prompts, "prompts must not be null"); this.prompts = prompts; @@ -2466,8 +2575,19 @@ public Builder meta(Map meta) { return this; } + public Builder ttlMs(Long ttlMs) { + Assert.isTrue(ttlMs == null || ttlMs >= 0, "ttlMs must not be negative"); + this.ttlMs = ttlMs; + return this; + } + + public Builder cacheScope(CacheScope cacheScope) { + this.cacheScope = cacheScope; + return this; + } + public ListPromptsResult build() { - return new ListPromptsResult(prompts, nextCursor, meta); + return new ListPromptsResult(prompts, nextCursor, meta, ttlMs, cacheScope); } } @@ -2620,13 +2740,18 @@ public GetPromptResult build() { * @param nextCursor An optional cursor for pagination. If present, indicates there * are more tools available. * @param meta See specification for notes on _meta usage + * @param ttlMs A hint from the server indicating how long (in milliseconds) the + * client may cache this response before re-fetching + * @param cacheScope Indicates the intended scope of the cached response */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record ListToolsResult( // @formatter:off @JsonProperty("tools") List tools, @JsonProperty("nextCursor") String nextCursor, - @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + @JsonProperty("_meta") Map meta, + @JsonProperty("ttlMs") Long ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope) implements Result { // @formatter:on public ListToolsResult { Assert.notNull(tools, "tools must not be null"); @@ -2634,12 +2759,18 @@ public record ListToolsResult( // @formatter:off @JsonCreator static ListToolsResult fromJson(@JsonProperty("tools") List tools, - @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta, + @JsonProperty("ttlMs") Long ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) { if (tools == null) { logger.warn("ListToolsResult: missing required field 'tools' during deserialization, using default []"); tools = List.of(); } - return new ListToolsResult(tools, nextCursor, meta); + return new ListToolsResult(tools, nextCursor, meta, ttlMs, cacheScope); + } + + @Deprecated + public ListToolsResult(List tools, String nextCursor, Map meta) { + this(tools, nextCursor, meta, null, null); } @Deprecated @@ -2659,6 +2790,10 @@ public static class Builder { private Map meta; + private Long ttlMs; + + private CacheScope cacheScope; + private Builder(List tools) { Assert.notNull(tools, "tools must not be null"); this.tools = tools; @@ -2674,8 +2809,19 @@ public Builder meta(Map meta) { return this; } + public Builder ttlMs(Long ttlMs) { + Assert.isTrue(ttlMs == null || ttlMs >= 0, "ttlMs must not be negative"); + this.ttlMs = ttlMs; + return this; + } + + public Builder cacheScope(CacheScope cacheScope) { + this.cacheScope = cacheScope; + return this; + } + public ListToolsResult build() { - return new ListToolsResult(tools, nextCursor, meta); + return new ListToolsResult(tools, nextCursor, meta, ttlMs, cacheScope); } } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientCacheTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientCacheTests.java new file mode 100644 index 000000000..6dfbef754 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientCacheTests.java @@ -0,0 +1,678 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; +import java.util.function.Function; + +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.CacheScope; +import io.modelcontextprotocol.spec.ProtocolVersions; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit and integration tests for client-side caching of list and resource results + * according to server-provided {@code ttlMs} hints (SEP-2549). + */ +class McpClientCacheTests { + + @Test + void cacheDirectOperationsAndTtlExpiration() { + AtomicLong currentTime = new AtomicLong(1000L); + McpClientCache cache = new McpClientCache( + new InMemoryMcpClientCacheStore(InMemoryMcpClientCacheStore.DEFAULT_MAX_ENTRIES, currentTime::get)); + + var toolsKey = new McpClientCacheKey.ListTools("", null); + var toolsResult = McpSchema.ListToolsResult.builder(List.of()) + .ttlMs(500L) + .cacheScope(CacheScope.PUBLIC) + .build(); + + // Put with TTL 500ms + cache.put(toolsKey, toolsResult, 500L); + assertThat(cache.get(toolsKey)).isSameAs(toolsResult); + + // Before expiration + currentTime.set(1499L); + assertThat(cache.get(toolsKey)).isSameAs(toolsResult); + + // At expiration time + currentTime.set(1500L); + assertThat(cache.get(toolsKey)).isNull(); + } + + @Test + void cacheInvalidationsByType() { + var store = new InMemoryMcpClientCacheStore(InMemoryMcpClientCacheStore.DEFAULT_MAX_ENTRIES, + System::currentTimeMillis); + McpClientCache cache = new McpClientCache(store); + + var toolsKey = new McpClientCacheKey.ListTools("", null); + var promptsKey = new McpClientCacheKey.ListPrompts("", null); + var resourcesKey = new McpClientCacheKey.ListResources("", null); + var resourceReadUri = "resource://test"; + var readResourceKey = new McpClientCacheKey.ReadResource(resourceReadUri); + + cache.put(toolsKey, McpSchema.ListToolsResult.builder(List.of()).build(), 10000L); + cache.put(promptsKey, McpSchema.ListPromptsResult.builder(List.of()).build(), 10000L); + cache.put(resourcesKey, McpSchema.ListResourcesResult.builder(List.of()).build(), 10000L); + cache.put(readResourceKey, McpSchema.ReadResourceResult.builder(List.of()).build(), 10000L); + + assertThat(store.size()).isEqualTo(4); + + cache.clearTools(); + assertThat(cache.get(toolsKey)).isNull(); + assertThat(cache.get(promptsKey)).isNotNull(); + + cache.clearPrompts(); + assertThat(cache.get(promptsKey)).isNull(); + assertThat(cache.get(resourcesKey)).isNotNull(); + + cache.clearResource(resourceReadUri); + assertThat(cache.get(readResourceKey)).isNull(); + assertThat(cache.get(resourcesKey)).isNotNull(); + + cache.clearResources(); + assertThat(cache.get(resourcesKey)).isNull(); + } + + @Test + void asyncClientHonorsTtlAndCachesListTools() { + AtomicInteger toolListRequestsReceived = new AtomicInteger(0); + AtomicReference, Mono>> handlerRef = new AtomicReference<>(); + + var tool = McpSchema.Tool.builder("calc", Map.of("type", "object")).build(); + var toolsResultWithTtl = McpSchema.ListToolsResult.builder(List.of(tool)) + .ttlMs(10000L) + .cacheScope(CacheScope.PUBLIC) + .build(); + + McpClientTransport transport = new McpClientTransport() { + @Override + public Mono connect( + Function, Mono> handler) { + handlerRef.set(handler); + return Mono.empty(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (message instanceof McpSchema.JSONRPCRequest request) { + if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { + var serverCaps = McpSchema.ServerCapabilities.builder().tools(true).build(); + var initResult = McpSchema.InitializeResult + .builder(ProtocolVersions.MCP_2024_11_05, serverCaps, + McpSchema.Implementation.builder("test-server", "1.0.0").build()) + .build(); + return handlerRef.get() + .apply(Mono.just(McpSchema.JSONRPCResponse.result(request.id(), initResult))) + .then(); + } + if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) { + toolListRequestsReceived.incrementAndGet(); + return handlerRef.get() + .apply(Mono.just(McpSchema.JSONRPCResponse.result(request.id(), toolsResultWithTtl))) + .then(); + } + } + return Mono.empty(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, new TypeRef<>() { + @Override + public java.lang.reflect.Type getType() { + return typeRef.getType(); + } + }); + } + }; + + McpAsyncClient client = McpClient.async(transport).build(); + + // First call hits the transport + StepVerifier.create(client.listTools()).assertNext(res -> assertThat(res.tools()).hasSize(1)).verifyComplete(); + assertThat(toolListRequestsReceived.get()).isEqualTo(1); + + // Second call uses cache, no extra transport request + StepVerifier.create(client.listTools()).assertNext(res -> assertThat(res.tools()).hasSize(1)).verifyComplete(); + assertThat(toolListRequestsReceived.get()).isEqualTo(1); + + // Invalidate via change notification (the notification handler clears cache and + // re-fetches tools) + var notification = new McpSchema.JSONRPCNotification(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, + Map.of()); + StepVerifier.create(handlerRef.get().apply(Mono.just(notification))).expectNext(notification).verifyComplete(); + assertThat(toolListRequestsReceived.get()).isEqualTo(2); // 1 initial + 1 from + // notification + // handler re-fetch + + // Third call uses the newly cached result populated during notification handling + StepVerifier.create(client.listTools()).assertNext(res -> assertThat(res.tools()).hasSize(1)).verifyComplete(); + assertThat(toolListRequestsReceived.get()).isEqualTo(2); + } + + @Test + void asyncClientDoesNotCacheWhenTtlIsNull() { + AtomicInteger toolListRequestsReceived = new AtomicInteger(0); + AtomicReference, Mono>> handlerRef = new AtomicReference<>(); + + var tool = McpSchema.Tool.builder("calc", Map.of("type", "object")).build(); + var toolsResultNoTtl = McpSchema.ListToolsResult.builder(List.of(tool)).build(); + + McpClientTransport transport = new McpClientTransport() { + @Override + public Mono connect( + Function, Mono> handler) { + handlerRef.set(handler); + return Mono.empty(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (message instanceof McpSchema.JSONRPCRequest request) { + if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { + var serverCaps = McpSchema.ServerCapabilities.builder().tools(true).build(); + var initResult = McpSchema.InitializeResult + .builder(ProtocolVersions.MCP_2024_11_05, serverCaps, + McpSchema.Implementation.builder("test-server", "1.0.0").build()) + .build(); + return handlerRef.get() + .apply(Mono.just(McpSchema.JSONRPCResponse.result(request.id(), initResult))) + .then(); + } + if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) { + toolListRequestsReceived.incrementAndGet(); + return handlerRef.get() + .apply(Mono.just(McpSchema.JSONRPCResponse.result(request.id(), toolsResultNoTtl))) + .then(); + } + } + return Mono.empty(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, new TypeRef<>() { + @Override + public java.lang.reflect.Type getType() { + return typeRef.getType(); + } + }); + } + }; + + McpAsyncClient client = McpClient.async(transport).build(); + + // First call + StepVerifier.create(client.listTools()).expectNextCount(1).verifyComplete(); + assertThat(toolListRequestsReceived.get()).isEqualTo(1); + + // Second call is NOT cached because ttlMs is null + StepVerifier.create(client.listTools()).expectNextCount(1).verifyComplete(); + assertThat(toolListRequestsReceived.get()).isEqualTo(2); + } + + @Test + void asyncClientHonorsTtlAndCachesPromptsAndResources() { + AtomicInteger promptListRequestsReceived = new AtomicInteger(0); + AtomicInteger resourceReadRequestsReceived = new AtomicInteger(0); + AtomicReference, Mono>> handlerRef = new AtomicReference<>(); + + var prompt = new McpSchema.Prompt("test-prompt", "desc", List.of()); + var promptsResultWithTtl = McpSchema.ListPromptsResult.builder(List.of(prompt)) + .ttlMs(10000L) + .cacheScope(CacheScope.PUBLIC) + .build(); + + var resourceContents = new McpSchema.TextResourceContents("resource://test", "text/plain", "hello world"); + var readResultWithTtl = McpSchema.ReadResourceResult.builder(List.of(resourceContents)) + .ttlMs(10000L) + .cacheScope(CacheScope.PRIVATE) + .build(); + + McpClientTransport transport = new McpClientTransport() { + @Override + public Mono connect( + Function, Mono> handler) { + handlerRef.set(handler); + return Mono.empty(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (message instanceof McpSchema.JSONRPCRequest request) { + if (McpSchema.METHOD_INITIALIZE.equals(request.method())) { + var serverCaps = McpSchema.ServerCapabilities.builder() + .prompts(true) + .resources(true, true) + .build(); + var initResult = McpSchema.InitializeResult + .builder(ProtocolVersions.MCP_2024_11_05, serverCaps, + McpSchema.Implementation.builder("test-server", "1.0.0").build()) + .build(); + return handlerRef.get() + .apply(Mono.just(McpSchema.JSONRPCResponse.result(request.id(), initResult))) + .then(); + } + if (McpSchema.METHOD_PROMPT_LIST.equals(request.method())) { + promptListRequestsReceived.incrementAndGet(); + return handlerRef.get() + .apply(Mono.just(McpSchema.JSONRPCResponse.result(request.id(), promptsResultWithTtl))) + .then(); + } + if (McpSchema.METHOD_RESOURCES_READ.equals(request.method())) { + resourceReadRequestsReceived.incrementAndGet(); + return handlerRef.get() + .apply(Mono.just(McpSchema.JSONRPCResponse.result(request.id(), readResultWithTtl))) + .then(); + } + } + return Mono.empty(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, new TypeRef<>() { + @Override + public java.lang.reflect.Type getType() { + return typeRef.getType(); + } + }); + } + }; + + McpAsyncClient client = McpClient.async(transport).build(); + + // Prompts caching + StepVerifier.create(client.listPrompts()) + .assertNext(res -> assertThat(res.prompts()).hasSize(1)) + .verifyComplete(); + assertThat(promptListRequestsReceived.get()).isEqualTo(1); + + // Subsequent listPrompts uses cache + StepVerifier.create(client.listPrompts()) + .assertNext(res -> assertThat(res.prompts()).hasSize(1)) + .verifyComplete(); + assertThat(promptListRequestsReceived.get()).isEqualTo(1); + + // Resources reading caching + var req = McpSchema.ReadResourceRequest.builder("resource://test").build(); + StepVerifier.create(client.readResource(req)) + .assertNext(res -> assertThat(res.contents()).hasSize(1)) + .verifyComplete(); + assertThat(resourceReadRequestsReceived.get()).isEqualTo(1); + + // Subsequent readResource uses cache + StepVerifier.create(client.readResource(req)) + .assertNext(res -> assertThat(res.contents()).hasSize(1)) + .verifyComplete(); + assertThat(resourceReadRequestsReceived.get()).isEqualTo(1); + + // Notification updates resource URI -> invalidates cache for that resource + var updatedNotification = new McpSchema.JSONRPCNotification(McpSchema.METHOD_NOTIFICATION_RESOURCES_UPDATED, + Map.of("uri", "resource://test")); + StepVerifier.create(handlerRef.get().apply(Mono.just(updatedNotification))) + .expectNext(updatedNotification) + .verifyComplete(); + assertThat(resourceReadRequestsReceived.get()).isEqualTo(2); // notification + // handler + // re-reads the + // resource + + // Subsequent call uses re-cached result + StepVerifier.create(client.readResource(req)) + .assertNext(res -> assertThat(res.contents()).hasSize(1)) + .verifyComplete(); + assertThat(resourceReadRequestsReceived.get()).isEqualTo(2); + } + + @Test + void ttlIsClampedToTheMaximumAndNeverOverflows() { + AtomicLong currentTime = new AtomicLong(1000L); + McpClientCache cache = new McpClientCache( + new InMemoryMcpClientCacheStore(InMemoryMcpClientCacheStore.DEFAULT_MAX_ENTRIES, currentTime::get)); + + var toolsKey = new McpClientCacheKey.ListTools(null, null); + cache.put(toolsKey, McpSchema.ListToolsResult.builder(List.of()).build(), Long.MAX_VALUE); + + // A TTL that would overflow the expiry must still yield a live entry + assertThat(cache.get(toolsKey)).isNotNull(); + + currentTime.set(1000L + McpClientCache.MAX_TTL_MS - 1); + assertThat(cache.get(toolsKey)).isNotNull(); + + currentTime.set(1000L + McpClientCache.MAX_TTL_MS); + assertThat(cache.get(toolsKey)).isNull(); + } + + @Test + void responseInvalidatedWhileInFlightIsNotCached() { + McpClientCache cache = new McpClientCache(); + var toolsKey = new McpClientCacheKey.ListTools(null, null); + + long generation = cache.generation(); + // notifications/tools/list_changed arrives while the response is in flight + cache.clearTools(); + cache.put(toolsKey, McpSchema.ListToolsResult.builder(List.of()).build(), 10000L, generation); + + assertThat(cache.get(toolsKey)).isNull(); + + // A response to a request started after the invalidation is cached + cache.put(toolsKey, McpSchema.ListToolsResult.builder(List.of()).build(), 10000L, cache.generation()); + assertThat(cache.get(toolsKey)).isNotNull(); + } + + @Test + void evictsOldestEntriesBeyondTheMaximumSize() { + var store = new InMemoryMcpClientCacheStore(InMemoryMcpClientCacheStore.DEFAULT_MAX_ENTRIES, + System::currentTimeMillis); + McpClientCache cache = new McpClientCache(store); + + var oldest = new McpClientCacheKey.ReadResource("resource://0"); + for (int i = 0; i <= InMemoryMcpClientCacheStore.DEFAULT_MAX_ENTRIES; i++) { + cache.put(new McpClientCacheKey.ReadResource("resource://" + i), + McpSchema.ReadResourceResult.builder(List.of()).build(), 60000L); + } + + assertThat(store.size()).isEqualTo(InMemoryMcpClientCacheStore.DEFAULT_MAX_ENTRIES); + assertThat(cache.get(oldest)).isNull(); + assertThat(cache.get( + new McpClientCacheKey.ReadResource("resource://" + InMemoryMcpClientCacheStore.DEFAULT_MAX_ENTRIES))) + .isNotNull(); + } + + @Test + void readResourceEntriesAreKeyedByMetaAndClearedByUri() { + var store = new InMemoryMcpClientCacheStore(InMemoryMcpClientCacheStore.DEFAULT_MAX_ENTRIES, + System::currentTimeMillis); + McpClientCache cache = new McpClientCache(store); + + var withoutMeta = new McpClientCacheKey.ReadResource("resource://test", null); + var withMeta = new McpClientCacheKey.ReadResource("resource://test", Map.of("tenant", "a")); + + cache.put(withoutMeta, McpSchema.ReadResourceResult.builder(List.of()).build(), 10000L); + cache.put(withMeta, McpSchema.ReadResourceResult.builder(List.of()).build(), 10000L); + assertThat(store.size()).isEqualTo(2); + + cache.clearResource("resource://test"); + assertThat(cache.get(withoutMeta)).isNull(); + assertThat(cache.get(withMeta)).isNull(); + } + + @Test + void keyMetaIsSnapshotSoCallerMutationCannotOrphanTheEntry() { + McpClientCache cache = new McpClientCache(); + Map meta = new HashMap<>(Map.of("tenant", "a")); + + cache.put(new McpClientCacheKey.ListTools(null, meta), McpSchema.ListToolsResult.builder(List.of()).build(), + 10000L); + meta.put("tenant", "b"); + + assertThat(cache.get(new McpClientCacheKey.ListTools(null, Map.of("tenant", "a")))).isNotNull(); + } + + @Test + void assembledListToolsMonoStaysCold() { + AtomicInteger toolListRequestsReceived = new AtomicInteger(0); + var toolsResultWithTtl = McpSchema.ListToolsResult + .builder(List.of(McpSchema.Tool.builder("calc", Map.of("type", "object")).build())) + .ttlMs(10000L) + .build(); + + var transport = new ScriptedTransport(McpSchema.ServerCapabilities.builder().tools(true).build(), + (method, params) -> { + if (McpSchema.METHOD_TOOLS_LIST.equals(method)) { + toolListRequestsReceived.incrementAndGet(); + return toolsResultWithTtl; + } + return null; + }); + McpAsyncClient client = McpClient.async(transport).build(); + + StepVerifier.create(client.listTools()).expectNextCount(1).verifyComplete(); + assertThat(toolListRequestsReceived.get()).isEqualTo(1); + + // Assembled while the entry is fresh, subscribed after it was invalidated: the + // cache must be consulted at subscription time, not at assembly time. + Mono assembled = client.listTools(); + client.getClientCache().clearTools(); + + StepVerifier.create(assembled).expectNextCount(1).verifyComplete(); + assertThat(toolListRequestsReceived.get()).isEqualTo(2); + } + + @Test + void multiPageListingIsNotCached() { + AtomicInteger toolListRequestsReceived = new AtomicInteger(0); + var firstPage = McpSchema.ListToolsResult + .builder(List.of(McpSchema.Tool.builder("calc", Map.of("type", "object")).build())) + .nextCursor("page-2") + .ttlMs(10000L) + .build(); + var secondPage = McpSchema.ListToolsResult + .builder(List.of(McpSchema.Tool.builder("clock", Map.of("type", "object")).build())) + .ttlMs(10000L) + .build(); + + var transport = new ScriptedTransport(McpSchema.ServerCapabilities.builder().tools(true).build(), + (method, params) -> { + if (!McpSchema.METHOD_TOOLS_LIST.equals(method)) { + return null; + } + toolListRequestsReceived.incrementAndGet(); + return "page-2".equals(cursorOf(params)) ? secondPage : firstPage; + }); + McpAsyncClient client = McpClient.async(transport).build(); + + StepVerifier.create(client.listTools()).assertNext(res -> assertThat(res.tools()).hasSize(2)).verifyComplete(); + assertThat(toolListRequestsReceived.get()).isEqualTo(2); + + // Caching page one would aggregate it with a page two fetched from a later server + // snapshot, so a listing spanning several pages is not cached at all. + StepVerifier.create(client.listTools()).assertNext(res -> assertThat(res.tools()).hasSize(2)).verifyComplete(); + assertThat(toolListRequestsReceived.get()).isEqualTo(4); + } + + @Test + void cachingCanBeDisabledOnTheBuilder() { + AtomicInteger toolListRequestsReceived = new AtomicInteger(0); + var toolsResultWithTtl = McpSchema.ListToolsResult + .builder(List.of(McpSchema.Tool.builder("calc", Map.of("type", "object")).build())) + .ttlMs(10000L) + .build(); + + var transport = new ScriptedTransport(McpSchema.ServerCapabilities.builder().tools(true).build(), + (method, params) -> { + if (McpSchema.METHOD_TOOLS_LIST.equals(method)) { + toolListRequestsReceived.incrementAndGet(); + return toolsResultWithTtl; + } + return null; + }); + McpAsyncClient client = McpClient.async(transport).enableResultCaching(false).build(); + + StepVerifier.create(client.listTools()).expectNextCount(1).verifyComplete(); + StepVerifier.create(client.listTools()).expectNextCount(1).verifyComplete(); + + // The server's ttlMs hint is ignored, so every call reaches the transport + assertThat(toolListRequestsReceived.get()).isEqualTo(2); + } + + @Test + void invalidateCacheForcesARefetch() { + AtomicInteger toolListRequestsReceived = new AtomicInteger(0); + var toolsResultWithTtl = McpSchema.ListToolsResult + .builder(List.of(McpSchema.Tool.builder("calc", Map.of("type", "object")).build())) + .ttlMs(10000L) + .build(); + + var transport = new ScriptedTransport(McpSchema.ServerCapabilities.builder().tools(true).build(), + (method, params) -> { + if (McpSchema.METHOD_TOOLS_LIST.equals(method)) { + toolListRequestsReceived.incrementAndGet(); + return toolsResultWithTtl; + } + return null; + }); + McpAsyncClient client = McpClient.async(transport).build(); + + StepVerifier.create(client.listTools()).expectNextCount(1).verifyComplete(); + StepVerifier.create(client.listTools()).expectNextCount(1).verifyComplete(); + assertThat(toolListRequestsReceived.get()).isEqualTo(1); + + client.invalidateCache(); + + StepVerifier.create(client.listTools()).expectNextCount(1).verifyComplete(); + assertThat(toolListRequestsReceived.get()).isEqualTo(2); + } + + @Test + void aSuppliedCacheStoreIsUsed() { + var store = new RecordingCacheStore(); + var toolsResultWithTtl = McpSchema.ListToolsResult + .builder(List.of(McpSchema.Tool.builder("calc", Map.of("type", "object")).build())) + .ttlMs(10000L) + .build(); + + var transport = new ScriptedTransport(McpSchema.ServerCapabilities.builder().tools(true).build(), + (method, params) -> McpSchema.METHOD_TOOLS_LIST.equals(method) ? toolsResultWithTtl : null); + McpAsyncClient client = McpClient.async(transport).cacheStore(store).build(); + + StepVerifier.create(client.listTools()).expectNextCount(1).verifyComplete(); + + assertThat(store.puts).containsExactly(new McpClientCacheKey.ListTools(null, null)); + } + + /** + * A store that delegates to the in-memory one and records what it was asked to keep. + */ + private static final class RecordingCacheStore implements McpClientCacheStore { + + private final McpClientCacheStore delegate = McpClientCacheStore.inMemory(); + + private final List puts = new java.util.ArrayList<>(); + + @Override + public Object get(McpClientCacheKey key) { + return this.delegate.get(key); + } + + @Override + public void put(McpClientCacheKey key, Object value, long ttlMs) { + this.puts.add(key); + this.delegate.put(key, value, ttlMs); + } + + @Override + public void removeIf(java.util.function.Predicate matcher) { + this.delegate.removeIf(matcher); + } + + @Override + public void clear() { + this.delegate.clear(); + } + + } + + private static String cursorOf(Object params) { + if (params instanceof McpSchema.PaginatedRequest paginated) { + return paginated.cursor(); + } + if (params instanceof Map map) { + return (String) map.get("cursor"); + } + return null; + } + + /** + * A transport that answers {@code initialize} itself and delegates every other + * request to {@code responder}, which returns the result to reply with or + * {@code null} to stay silent. + */ + private static final class ScriptedTransport implements McpClientTransport { + + private final AtomicReference, Mono>> handlerRef = new AtomicReference<>(); + + private final McpSchema.ServerCapabilities capabilities; + + private final BiFunction responder; + + ScriptedTransport(McpSchema.ServerCapabilities capabilities, BiFunction responder) { + this.capabilities = capabilities; + this.responder = responder; + } + + @Override + public Mono connect(Function, Mono> handler) { + this.handlerRef.set(handler); + return Mono.empty(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public Mono sendMessage(McpSchema.JSONRPCMessage message) { + if (!(message instanceof McpSchema.JSONRPCRequest request)) { + return Mono.empty(); + } + Object result = McpSchema.METHOD_INITIALIZE.equals(request.method()) ? McpSchema.InitializeResult + .builder(ProtocolVersions.MCP_2024_11_05, this.capabilities, + McpSchema.Implementation.builder("test-server", "1.0.0").build()) + .build() : this.responder.apply(request.method(), request.params()); + if (result == null) { + return Mono.empty(); + } + return this.handlerRef.get() + .apply(Mono.just(McpSchema.JSONRPCResponse.result(request.id(), result))) + .then(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return JSON_MAPPER.convertValue(data, new TypeRef<>() { + @Override + public java.lang.reflect.Type getType() { + return typeRef.getType(); + } + }); + } + + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpListCacheOptionsTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpListCacheOptionsTests.java new file mode 100644 index 000000000..9bea8f07f --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpListCacheOptionsTests.java @@ -0,0 +1,101 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.server; + +import java.time.Duration; +import java.util.Map; +import java.util.UUID; +import java.util.function.UnaryOperator; + +import io.modelcontextprotocol.MockMcpServerTransport; +import io.modelcontextprotocol.MockMcpServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.ProtocolVersions; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests the caching hint a server attaches to its listing responses (SEP-2549). + */ +class McpListCacheOptionsTests { + + private static final McpSchema.Implementation CLIENT_INFO = McpSchema.Implementation.builder("test-client", "1.0.0") + .build(); + + private static final McpSchema.Tool TOOL = McpSchema.Tool.builder("calc", EMPTY_JSON_SCHEMA).build(); + + @Test + void rejectsANegativeTtlAndAMissingScope() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new McpListCacheOptions(-1L, McpSchema.CacheScope.PRIVATE)) + .withMessage("ttlMs must not be negative"); + assertThatIllegalArgumentException() + .isThrownBy(() -> McpListCacheOptions.of(Duration.ofMinutes(-5), McpSchema.CacheScope.PRIVATE)) + .withMessage("ttlMs must not be negative"); + assertThatIllegalArgumentException().isThrownBy(() -> new McpListCacheOptions(1000L, null)) + .withMessage("cacheScope must not be null"); + assertThatIllegalArgumentException() + .isThrownBy(() -> McpListCacheOptions.of(null, McpSchema.CacheScope.PRIVATE)) + .withMessage("ttl must not be null"); + } + + @Test + void listingsAreNotCacheableByDefaultAndAreScopedPrivate() { + assertThat(McpListCacheOptions.NONE.ttlMs()).isZero(); + assertThat(McpListCacheOptions.NONE.cacheScope()).isEqualTo(McpSchema.CacheScope.PRIVATE); + + var result = listTools(UnaryOperator.identity()); + assertThat(result.ttlMs()).isZero(); + assertThat(result.cacheScope()).isEqualTo(McpSchema.CacheScope.PRIVATE); + } + + @Test + void attachesTheConfiguredHintToListings() { + var result = listTools(spec -> spec.listCache(Duration.ofMinutes(5), McpSchema.CacheScope.PUBLIC)); + + assertThat(result.ttlMs()).isEqualTo(Duration.ofMinutes(5).toMillis()); + assertThat(result.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); + } + + /** + * Drive one {@code tools/list} against a server built by {@code customizer} and + * return the result it put on the wire. + */ + private static McpSchema.ListToolsResult listTools(UnaryOperator> customizer) { + var transport = new MockMcpServerTransport(); + var transportProvider = new MockMcpServerTransportProvider(transport); + + customizer.apply(McpServer.async(transportProvider)) + .serverInfo("test-server", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .tools(McpServerFeatures.AsyncToolSpecification.builder() + .tool(TOOL) + .callHandler((exchange, request) -> Mono.just(McpSchema.CallToolResult.builder().build())) + .build()) + .build(); + + transportProvider.simulateIncomingMessage( + new McpSchema.JSONRPCRequest(McpSchema.METHOD_INITIALIZE, UUID.randomUUID().toString(), + McpSchema.InitializeRequest + .builder(ProtocolVersions.MCP_2025_11_25, McpSchema.ClientCapabilities.builder().build(), + CLIENT_INFO) + .build())); + transportProvider + .simulateIncomingMessage(new McpSchema.JSONRPCNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED)); + transport.clearSentMessages(); + + transportProvider.simulateIncomingMessage( + new McpSchema.JSONRPCRequest(McpSchema.METHOD_TOOLS_LIST, UUID.randomUUID().toString(), Map.of())); + + var response = (McpSchema.JSONRPCResponse) transport.getLastSentMessage(); + assertThat(response.error()).isNull(); + return (McpSchema.ListToolsResult) response.result(); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index ab9bc8643..fb87320c6 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -3027,4 +3027,282 @@ void testToolToleratesUnknownFields() throws Exception { assertThat(tool.name()).isEqualTo("test-tool"); } + // TTL / CacheScope Tests (SEP-2549) + + @Test + void testListResourcesResultWithTtl() throws Exception { + McpSchema.Resource resource = McpSchema.Resource.builder("resource://test", "Test Resource") + .description("A test resource") + .mimeType("text/plain") + .build(); + + McpSchema.ListResourcesResult result = McpSchema.ListResourcesResult.builder(List.of(resource)) + .nextCursor("next") + .ttlMs(60000L) + .cacheScope(McpSchema.CacheScope.PUBLIC) + .build(); + + String value = JSON_MAPPER.writeValueAsString(result); + assertThatJson(value).isObject() + .containsEntry("ttlMs", 60000) + .containsEntry("cacheScope", "public") + .containsEntry("nextCursor", "next"); + + McpSchema.ListResourcesResult deserialized = JSON_MAPPER.readValue(value, McpSchema.ListResourcesResult.class); + assertThat(deserialized.ttlMs()).isEqualTo(60000L); + assertThat(deserialized.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); + assertThat(deserialized.resources()).hasSize(1); + } + + @Test + void testListResourcesResultWithoutTtl() throws Exception { + String json = """ + {"resources":[{"uri":"resource://test","name":"Test"}]}"""; + McpSchema.ListResourcesResult result = JSON_MAPPER.readValue(json, McpSchema.ListResourcesResult.class); + assertThat(result.ttlMs()).isNull(); + assertThat(result.cacheScope()).isNull(); + assertThat(result.resources()).hasSize(1); + } + + @Test + void testListResourcesResultNullTtlOmittedFromJson() throws Exception { + McpSchema.ListResourcesResult result = McpSchema.ListResourcesResult.builder(List.of()).build(); + String value = JSON_MAPPER.writeValueAsString(result); + assertThatJson(value).isObject().doesNotContainKey("ttlMs").doesNotContainKey("cacheScope"); + } + + @Test + void testListResourceTemplatesResultWithTtl() throws Exception { + McpSchema.ResourceTemplate template = McpSchema.ResourceTemplate + .builder("resource://{id}/test", "Test Template") + .build(); + + McpSchema.ListResourceTemplatesResult result = McpSchema.ListResourceTemplatesResult.builder(List.of(template)) + .ttlMs(30000L) + .cacheScope(McpSchema.CacheScope.PRIVATE) + .build(); + + String value = JSON_MAPPER.writeValueAsString(result); + assertThatJson(value).isObject().containsEntry("ttlMs", 30000).containsEntry("cacheScope", "private"); + + McpSchema.ListResourceTemplatesResult deserialized = JSON_MAPPER.readValue(value, + McpSchema.ListResourceTemplatesResult.class); + assertThat(deserialized.ttlMs()).isEqualTo(30000L); + assertThat(deserialized.cacheScope()).isEqualTo(McpSchema.CacheScope.PRIVATE); + } + + @Test + void testListResourceTemplatesResultWithoutTtl() throws Exception { + String json = """ + {"resourceTemplates":[{"uriTemplate":"resource://{id}/test","name":"T"}]}"""; + McpSchema.ListResourceTemplatesResult result = JSON_MAPPER.readValue(json, + McpSchema.ListResourceTemplatesResult.class); + assertThat(result.ttlMs()).isNull(); + assertThat(result.cacheScope()).isNull(); + } + + @Test + void testListResourceTemplatesResultNullTtlOmittedFromJson() throws Exception { + McpSchema.ListResourceTemplatesResult result = McpSchema.ListResourceTemplatesResult.builder(List.of()).build(); + String value = JSON_MAPPER.writeValueAsString(result); + assertThatJson(value).isObject().doesNotContainKey("ttlMs").doesNotContainKey("cacheScope"); + } + + @Test + void testReadResourceResultWithTtl() throws Exception { + McpSchema.TextResourceContents contents = McpSchema.TextResourceContents.builder("resource://test", "content") + .build(); + + McpSchema.ReadResourceResult result = McpSchema.ReadResourceResult.builder(List.of(contents)) + .ttlMs(0L) + .cacheScope(McpSchema.CacheScope.PRIVATE) + .build(); + + String value = JSON_MAPPER.writeValueAsString(result); + assertThatJson(value).isObject().containsEntry("ttlMs", 0).containsEntry("cacheScope", "private"); + + McpSchema.ReadResourceResult deserialized = JSON_MAPPER.readValue(value, McpSchema.ReadResourceResult.class); + assertThat(deserialized.ttlMs()).isEqualTo(0L); + assertThat(deserialized.cacheScope()).isEqualTo(McpSchema.CacheScope.PRIVATE); + } + + @Test + void testReadResourceResultWithoutTtl() throws Exception { + String json = """ + {"contents":[{"uri":"resource://test","text":"content"}]}"""; + McpSchema.ReadResourceResult result = JSON_MAPPER.readValue(json, McpSchema.ReadResourceResult.class); + assertThat(result.ttlMs()).isNull(); + assertThat(result.cacheScope()).isNull(); + } + + @Test + void testReadResourceResultNullTtlOmittedFromJson() throws Exception { + McpSchema.ReadResourceResult result = McpSchema.ReadResourceResult.builder(List.of()).build(); + String value = JSON_MAPPER.writeValueAsString(result); + assertThatJson(value).isObject().doesNotContainKey("ttlMs").doesNotContainKey("cacheScope"); + } + + @Test + void testListPromptsResultWithTtl() throws Exception { + McpSchema.Prompt prompt = McpSchema.Prompt.builder("test-prompt") + .title("Test") + .description("A test prompt") + .build(); + + McpSchema.ListPromptsResult result = McpSchema.ListPromptsResult.builder(List.of(prompt)) + .ttlMs(120000L) + .cacheScope(McpSchema.CacheScope.PUBLIC) + .build(); + + String value = JSON_MAPPER.writeValueAsString(result); + assertThatJson(value).isObject().containsEntry("ttlMs", 120000).containsEntry("cacheScope", "public"); + + McpSchema.ListPromptsResult deserialized = JSON_MAPPER.readValue(value, McpSchema.ListPromptsResult.class); + assertThat(deserialized.ttlMs()).isEqualTo(120000L); + assertThat(deserialized.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); + } + + @Test + void testListPromptsResultWithoutTtl() throws Exception { + String json = """ + {"prompts":[{"name":"p","title":"P","description":"A prompt"}]}"""; + McpSchema.ListPromptsResult result = JSON_MAPPER.readValue(json, McpSchema.ListPromptsResult.class); + assertThat(result.ttlMs()).isNull(); + assertThat(result.cacheScope()).isNull(); + } + + @Test + void testListPromptsResultNullTtlOmittedFromJson() throws Exception { + McpSchema.ListPromptsResult result = McpSchema.ListPromptsResult.builder(List.of()).build(); + String value = JSON_MAPPER.writeValueAsString(result); + assertThatJson(value).isObject().doesNotContainKey("ttlMs").doesNotContainKey("cacheScope"); + } + + @Test + void testListToolsResultWithTtl() throws Exception { + McpSchema.Tool tool = McpSchema.Tool.builder("test-tool") + .title("Test Tool") + .description("A test tool") + .inputSchema(Map.of("type", "object")) + .build(); + + McpSchema.ListToolsResult result = McpSchema.ListToolsResult.builder(List.of(tool)) + .nextCursor("cursor") + .ttlMs(300000L) + .cacheScope(McpSchema.CacheScope.PUBLIC) + .build(); + + String value = JSON_MAPPER.writeValueAsString(result); + assertThatJson(value).isObject() + .containsEntry("ttlMs", 300000) + .containsEntry("cacheScope", "public") + .containsEntry("nextCursor", "cursor"); + + McpSchema.ListToolsResult deserialized = JSON_MAPPER.readValue(value, McpSchema.ListToolsResult.class); + assertThat(deserialized.ttlMs()).isEqualTo(300000L); + assertThat(deserialized.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); + assertThat(deserialized.tools()).hasSize(1); + assertThat(deserialized.nextCursor()).isEqualTo("cursor"); + } + + @Test + void testListToolsResultWithoutTtl() throws Exception { + String json = """ + {"tools":[{"name":"t","inputSchema":{"type":"object"}}]}"""; + McpSchema.ListToolsResult result = JSON_MAPPER.readValue(json, McpSchema.ListToolsResult.class); + assertThat(result.ttlMs()).isNull(); + assertThat(result.cacheScope()).isNull(); + assertThat(result.tools()).hasSize(1); + } + + @Test + void testListToolsResultNullTtlOmittedFromJson() throws Exception { + McpSchema.ListToolsResult result = McpSchema.ListToolsResult.builder(List.of()).build(); + String value = JSON_MAPPER.writeValueAsString(result); + assertThatJson(value).isObject().doesNotContainKey("ttlMs").doesNotContainKey("cacheScope"); + } + + @Test + void testNegativeTtlIsRejectedByBuilders() { + assertThatThrownBy(() -> McpSchema.ListToolsResult.builder(List.of()).ttlMs(-1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("ttlMs must not be negative"); + assertThatThrownBy(() -> McpSchema.ListPromptsResult.builder(List.of()).ttlMs(-1L)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> McpSchema.ListResourcesResult.builder(List.of()).ttlMs(-1L)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> McpSchema.ListResourceTemplatesResult.builder(List.of()).ttlMs(-1L)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> McpSchema.ReadResourceResult.builder(List.of()).ttlMs(-1L)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testNegativeTtlOnTheWireIsToleratedRatherThanFailingTheResponse() throws Exception { + // A bad server hint must not make the whole listing unparseable; the client + // simply does not cache it. + McpSchema.ListToolsResult result = JSON_MAPPER.readValue(""" + {"tools":[],"ttlMs":-1}""", McpSchema.ListToolsResult.class); + assertThat(result.ttlMs()).isEqualTo(-1L); + } + + @Test + void testCacheScopeSerialization() throws Exception { + assertThat(JSON_MAPPER.writeValueAsString(McpSchema.CacheScope.PUBLIC)).isEqualTo("\"public\""); + assertThat(JSON_MAPPER.writeValueAsString(McpSchema.CacheScope.PRIVATE)).isEqualTo("\"private\""); + } + + @Test + void testCacheScopeDeserialization() throws Exception { + assertThat(JSON_MAPPER.readValue("\"public\"", McpSchema.CacheScope.class)) + .isEqualTo(McpSchema.CacheScope.PUBLIC); + assertThat(JSON_MAPPER.readValue("\"private\"", McpSchema.CacheScope.class)) + .isEqualTo(McpSchema.CacheScope.PRIVATE); + } + + @Test + void testListResourcesResultToleratesUnknownFields() throws Exception { + McpSchema.ListResourcesResult result = JSON_MAPPER.readValue(""" + {"resources":[],"ttlMs":5000,"cacheScope":"public","futureField":"ignored"}""", + McpSchema.ListResourcesResult.class); + assertThat(result.ttlMs()).isEqualTo(5000L); + assertThat(result.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); + } + + @Test + void testListResourceTemplatesResultToleratesUnknownFields() throws Exception { + McpSchema.ListResourceTemplatesResult result = JSON_MAPPER.readValue(""" + {"resourceTemplates":[],"ttlMs":5000,"cacheScope":"private","futureField":"ignored"}""", + McpSchema.ListResourceTemplatesResult.class); + assertThat(result.ttlMs()).isEqualTo(5000L); + assertThat(result.cacheScope()).isEqualTo(McpSchema.CacheScope.PRIVATE); + } + + @Test + void testReadResourceResultToleratesUnknownFields() throws Exception { + McpSchema.ReadResourceResult result = JSON_MAPPER.readValue(""" + {"contents":[],"ttlMs":0,"cacheScope":"private","futureField":"ignored"}""", + McpSchema.ReadResourceResult.class); + assertThat(result.ttlMs()).isEqualTo(0); + assertThat(result.cacheScope()).isEqualTo(McpSchema.CacheScope.PRIVATE); + } + + @Test + void testListPromptsResultToleratesUnknownFields() throws Exception { + McpSchema.ListPromptsResult result = JSON_MAPPER.readValue(""" + {"prompts":[],"ttlMs":10000,"cacheScope":"public","futureField":"ignored"}""", + McpSchema.ListPromptsResult.class); + assertThat(result.ttlMs()).isEqualTo(10000L); + assertThat(result.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); + } + + @Test + void testListToolsResultToleratesUnknownFields() throws Exception { + McpSchema.ListToolsResult result = JSON_MAPPER.readValue(""" + {"tools":[],"ttlMs":60000,"cacheScope":"public","futureField":"ignored"}""", + McpSchema.ListToolsResult.class); + assertThat(result.ttlMs()).isEqualTo(60000L); + assertThat(result.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); + } + }