Skip to content

Commit c3ff52d

Browse files
committed
feat(client): implement client-side caching for list and resource operations (SEP-2549)
- Add thread-safe McpClientCache with TTL support and typed invalidations - Integrate caching with McpAsyncClient for tools, prompts, resources, and resource templates - Invalidate cache entries on corresponding change/update notifications - Add comprehensive unit and integration tests in McpClientCacheTests
1 parent 1d722b5 commit c3ff52d

3 files changed

Lines changed: 572 additions & 30 deletions

File tree

‎mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java‎

Lines changed: 103 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,12 @@ public class McpAsyncClient {
185185

186186
private final boolean applyElicitationDefaults;
187187

188+
/**
189+
* Client cache for caching list and resource results based on server ttlMs hints
190+
* (SEP-2549).
191+
*/
192+
private final McpClientCache clientCache;
193+
188194
/**
189195
* Create a new McpAsyncClient with the given transport and session request-response
190196
* timeout.
@@ -208,6 +214,7 @@ public class McpAsyncClient {
208214
this.roots = new ConcurrentHashMap<>(features.roots());
209215
this.jsonSchemaValidator = jsonSchemaValidator;
210216
this.toolsOutputSchemaCache = new ConcurrentHashMap<>();
217+
this.clientCache = new McpClientCache();
211218
this.enableCallToolSchemaCaching = features.enableCallToolSchemaCaching();
212219
this.applyElicitationDefaults = features.applyElicitationDefaults();
213220

@@ -746,7 +753,7 @@ public Mono<McpSchema.ListToolsResult> listTools() {
746753
* @return A Mono that emits the list of tools result
747754
*/
748755
public Mono<McpSchema.ListToolsResult> listTools(String cursor) {
749-
return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, null));
756+
return this.listTools(cursor, null);
750757
}
751758

752759
/**
@@ -756,6 +763,10 @@ public Mono<McpSchema.ListToolsResult> listTools(String cursor) {
756763
* @return A Mono that emits the list of tools result
757764
*/
758765
public Mono<McpSchema.ListToolsResult> listTools(String cursor, Map<String, Object> meta) {
766+
McpSchema.ListToolsResult cached = this.clientCache.get(new McpClientCache.ListToolsCacheKey(cursor, meta));
767+
if (cached != null) {
768+
return Mono.just(cached);
769+
}
759770
return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, meta));
760771
}
761772

@@ -773,6 +784,9 @@ private Mono<McpSchema.ListToolsResult> listToolsInternal(Initialization init, S
773784
if (result.tools() != null) {
774785
result.tools().forEach(tool -> ToolNameValidator.validate(tool.name(), false));
775786
}
787+
if (result.ttlMs() != null && result.ttlMs() > 0) {
788+
this.clientCache.put(new McpClientCache.ListToolsCacheKey(cursor, meta), result, result.ttlMs());
789+
}
776790
if (this.enableCallToolSchemaCaching && result.tools() != null) {
777791
// Cache tools output schema
778792
result.tools()
@@ -786,14 +800,18 @@ private Mono<McpSchema.ListToolsResult> listToolsInternal(Initialization init, S
786800
private NotificationHandler asyncToolsChangeNotificationHandler(
787801
List<Function<List<McpSchema.Tool>, Mono<Void>>> toolsChangeConsumers) {
788802
// TODO: params are not used yet
789-
return params -> this.listTools()
790-
.flatMap(listToolsResult -> Flux.fromIterable(toolsChangeConsumers)
791-
.flatMap(consumer -> consumer.apply(listToolsResult.tools()))
792-
.onErrorResume(error -> {
793-
logger.error("Error handling tools list change notification", error);
794-
return Mono.empty();
795-
})
796-
.then());
803+
return params -> {
804+
this.clientCache.clearTools();
805+
this.toolsOutputSchemaCache.clear();
806+
return this.listTools()
807+
.flatMap(listToolsResult -> Flux.fromIterable(toolsChangeConsumers)
808+
.flatMap(consumer -> consumer.apply(listToolsResult.tools()))
809+
.onErrorResume(error -> {
810+
logger.error("Error handling tools list change notification", error);
811+
return Mono.empty();
812+
})
813+
.then());
814+
};
797815
}
798816

799817
// --------------------------
@@ -837,7 +855,7 @@ public Mono<McpSchema.ListResourcesResult> listResources() {
837855
* @see #readResource(McpSchema.Resource)
838856
*/
839857
public Mono<McpSchema.ListResourcesResult> listResources(String cursor) {
840-
return this.listResourcesInternal(cursor, null);
858+
return this.listResources(cursor, null);
841859
}
842860

843861
/**
@@ -851,6 +869,11 @@ public Mono<McpSchema.ListResourcesResult> listResources(String cursor) {
851869
* @see #readResource(McpSchema.Resource)
852870
*/
853871
public Mono<McpSchema.ListResourcesResult> listResources(String cursor, Map<String, Object> meta) {
872+
McpSchema.ListResourcesResult cached = this.clientCache
873+
.get(new McpClientCache.ListResourcesCacheKey(cursor, meta));
874+
if (cached != null) {
875+
return Mono.just(cached);
876+
}
854877
return this.listResourcesInternal(cursor, meta);
855878
}
856879

@@ -861,7 +884,13 @@ private Mono<McpSchema.ListResourcesResult> listResourcesInternal(String cursor,
861884
}
862885
return init.mcpSession()
863886
.sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor, meta),
864-
LIST_RESOURCES_RESULT_TYPE_REF);
887+
LIST_RESOURCES_RESULT_TYPE_REF)
888+
.doOnNext(result -> {
889+
if (result.ttlMs() != null && result.ttlMs() > 0) {
890+
this.clientCache.put(new McpClientCache.ListResourcesCacheKey(cursor, meta), result,
891+
result.ttlMs());
892+
}
893+
});
865894
});
866895
}
867896

@@ -887,12 +916,23 @@ public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.Resource resour
887916
* @see McpSchema.ReadResourceResult
888917
*/
889918
public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.ReadResourceRequest readResourceRequest) {
919+
McpSchema.ReadResourceResult cached = this.clientCache
920+
.get(new McpClientCache.ReadResourceCacheKey(readResourceRequest.uri()));
921+
if (cached != null) {
922+
return Mono.just(cached);
923+
}
890924
return this.initializer.withInitialization("reading resources", init -> {
891925
if (init.initializeResult().capabilities().resources() == null) {
892926
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
893927
}
894928
return init.mcpSession()
895-
.sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest, READ_RESOURCE_RESULT_TYPE_REF);
929+
.sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest, READ_RESOURCE_RESULT_TYPE_REF)
930+
.doOnNext(result -> {
931+
if (result.ttlMs() != null && result.ttlMs() > 0) {
932+
this.clientCache.put(new McpClientCache.ReadResourceCacheKey(readResourceRequest.uri()), result,
933+
result.ttlMs());
934+
}
935+
});
896936
});
897937
}
898938

@@ -922,7 +962,7 @@ public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates() {
922962
* @see McpSchema.ListResourceTemplatesResult
923963
*/
924964
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String cursor) {
925-
return this.listResourceTemplatesInternal(cursor, null);
965+
return this.listResourceTemplates(cursor, null);
926966
}
927967

928968
/**
@@ -935,6 +975,11 @@ public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String
935975
* @see McpSchema.ListResourceTemplatesResult
936976
*/
937977
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String cursor, Map<String, Object> meta) {
978+
McpSchema.ListResourceTemplatesResult cached = this.clientCache
979+
.get(new McpClientCache.ListResourceTemplatesCacheKey(cursor, meta));
980+
if (cached != null) {
981+
return Mono.just(cached);
982+
}
938983
return this.listResourceTemplatesInternal(cursor, meta);
939984
}
940985

@@ -946,7 +991,13 @@ private Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplatesInterna
946991
}
947992
return init.mcpSession()
948993
.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest(cursor, meta),
949-
LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF);
994+
LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF)
995+
.doOnNext(result -> {
996+
if (result.ttlMs() != null && result.ttlMs() > 0) {
997+
this.clientCache.put(new McpClientCache.ListResourceTemplatesCacheKey(cursor, meta), result,
998+
result.ttlMs());
999+
}
1000+
});
9501001
});
9511002
}
9521003

@@ -980,13 +1031,16 @@ public Mono<Void> unsubscribeResource(McpSchema.UnsubscribeRequest unsubscribeRe
9801031

9811032
private NotificationHandler asyncResourcesChangeNotificationHandler(
9821033
List<Function<List<McpSchema.Resource>, Mono<Void>>> resourcesChangeConsumers) {
983-
return params -> listResources().flatMap(listResourcesResult -> Flux.fromIterable(resourcesChangeConsumers)
984-
.flatMap(consumer -> consumer.apply(listResourcesResult.resources()))
985-
.onErrorResume(error -> {
986-
logger.error("Error handling resources list change notification", error);
987-
return Mono.empty();
988-
})
989-
.then());
1034+
return params -> {
1035+
this.clientCache.clearResources();
1036+
return listResources().flatMap(listResourcesResult -> Flux.fromIterable(resourcesChangeConsumers)
1037+
.flatMap(consumer -> consumer.apply(listResourcesResult.resources()))
1038+
.onErrorResume(error -> {
1039+
logger.error("Error handling resources list change notification", error);
1040+
return Mono.empty();
1041+
})
1042+
.then());
1043+
};
9901044
}
9911045

9921046
private NotificationHandler asyncResourcesUpdatedNotificationHandler(
@@ -996,6 +1050,8 @@ private NotificationHandler asyncResourcesUpdatedNotificationHandler(
9961050
new TypeRef<>() {
9971051
});
9981052

1053+
this.clientCache.clearResource(resourcesUpdatedNotification.uri());
1054+
9991055
return readResource(McpSchema.ReadResourceRequest.builder(resourcesUpdatedNotification.uri()).build())
10001056
.flatMap(readResourceResult -> Flux.fromIterable(resourcesUpdateConsumers)
10011057
.flatMap(consumer -> consumer.apply(readResourceResult.contents()))
@@ -1040,7 +1096,7 @@ public Mono<ListPromptsResult> listPrompts() {
10401096
* @see #getPrompt(GetPromptRequest)
10411097
*/
10421098
public Mono<ListPromptsResult> listPrompts(String cursor) {
1043-
return this.listPromptsInternal(cursor, null);
1099+
return this.listPrompts(cursor, null);
10441100
}
10451101

10461102
/**
@@ -1052,14 +1108,24 @@ public Mono<ListPromptsResult> listPrompts(String cursor) {
10521108
* @see #getPrompt(GetPromptRequest)
10531109
*/
10541110
public Mono<ListPromptsResult> listPrompts(String cursor, Map<String, Object> meta) {
1111+
McpSchema.ListPromptsResult cached = this.clientCache.get(new McpClientCache.ListPromptsCacheKey(cursor, meta));
1112+
if (cached != null) {
1113+
return Mono.just(cached);
1114+
}
10551115
return this.listPromptsInternal(cursor, meta);
10561116
}
10571117

10581118
private Mono<ListPromptsResult> listPromptsInternal(String cursor, Map<String, Object> meta) {
10591119
return this.initializer.withInitialization("listing prompts",
10601120
init -> init.mcpSession()
10611121
.sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor, meta),
1062-
LIST_PROMPTS_RESULT_TYPE_REF));
1122+
LIST_PROMPTS_RESULT_TYPE_REF)
1123+
.doOnNext(result -> {
1124+
if (result.ttlMs() != null && result.ttlMs() > 0) {
1125+
this.clientCache.put(new McpClientCache.ListPromptsCacheKey(cursor, meta), result,
1126+
result.ttlMs());
1127+
}
1128+
}));
10631129
}
10641130

10651131
/**
@@ -1078,13 +1144,16 @@ public Mono<GetPromptResult> getPrompt(GetPromptRequest getPromptRequest) {
10781144

10791145
private NotificationHandler asyncPromptsChangeNotificationHandler(
10801146
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers) {
1081-
return params -> listPrompts().flatMap(listPromptsResult -> Flux.fromIterable(promptsChangeConsumers)
1082-
.flatMap(consumer -> consumer.apply(listPromptsResult.prompts()))
1083-
.onErrorResume(error -> {
1084-
logger.error("Error handling prompts list change notification", error);
1085-
return Mono.empty();
1086-
})
1087-
.then());
1147+
return params -> {
1148+
this.clientCache.clearPrompts();
1149+
return listPrompts().flatMap(listPromptsResult -> Flux.fromIterable(promptsChangeConsumers)
1150+
.flatMap(consumer -> consumer.apply(listPromptsResult.prompts()))
1151+
.onErrorResume(error -> {
1152+
logger.error("Error handling prompts list change notification", error);
1153+
return Mono.empty();
1154+
})
1155+
.then());
1156+
};
10881157
}
10891158

10901159
// --------------------------
@@ -1146,6 +1215,10 @@ void setProtocolVersions(List<String> protocolVersions) {
11461215
this.initializer.setProtocolVersions(protocolVersions);
11471216
}
11481217

1218+
McpClientCache getClientCache() {
1219+
return this.clientCache;
1220+
}
1221+
11491222
// --------------------------
11501223
// Completions
11511224
// --------------------------
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright 2024-2026 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.client;
6+
7+
import java.util.Map;
8+
import java.util.concurrent.ConcurrentHashMap;
9+
import java.util.function.Supplier;
10+
11+
/**
12+
* Thread-safe client cache for caching MCP list and resource operations according to
13+
* server-provided {@code ttlMs} hints (SEP-2549).
14+
*
15+
* <p>
16+
* Cached entries are invalidated either when their TTL expires or when corresponding
17+
* change notifications are received from the server.
18+
*
19+
* @author Sylwester Lachiewicz
20+
*/
21+
class McpClientCache {
22+
23+
sealed interface CacheKey {
24+
25+
}
26+
27+
record ListToolsCacheKey(String cursor, Map<String, Object> meta) implements CacheKey {
28+
29+
}
30+
31+
record ListPromptsCacheKey(String cursor, Map<String, Object> meta) implements CacheKey {
32+
33+
}
34+
35+
record ListResourcesCacheKey(String cursor, Map<String, Object> meta) implements CacheKey {
36+
37+
}
38+
39+
record ListResourceTemplatesCacheKey(String cursor, Map<String, Object> meta) implements CacheKey {
40+
41+
}
42+
43+
record ReadResourceCacheKey(String uri) implements CacheKey {
44+
45+
}
46+
47+
private record CacheEntry<T>(T value, long expiresAtMillis) {
48+
49+
boolean isExpired(long now) {
50+
return now >= this.expiresAtMillis;
51+
}
52+
53+
}
54+
55+
private final ConcurrentHashMap<CacheKey, CacheEntry<?>> cache = new ConcurrentHashMap<>();
56+
57+
private final Supplier<Long> timeProvider;
58+
59+
McpClientCache() {
60+
this(System::currentTimeMillis);
61+
}
62+
63+
McpClientCache(Supplier<Long> timeProvider) {
64+
this.timeProvider = timeProvider;
65+
}
66+
67+
@SuppressWarnings("unchecked")
68+
<T> T get(CacheKey key) {
69+
CacheEntry<?> entry = this.cache.get(key);
70+
if (entry == null) {
71+
return null;
72+
}
73+
if (entry.isExpired(this.timeProvider.get())) {
74+
this.cache.remove(key, entry);
75+
return null;
76+
}
77+
return (T) entry.value();
78+
}
79+
80+
<T> void put(CacheKey key, T value, Long ttlMs) {
81+
if (ttlMs != null && ttlMs > 0 && value != null) {
82+
long expiresAt = this.timeProvider.get() + ttlMs;
83+
this.cache.put(key, new CacheEntry<>(value, expiresAt));
84+
}
85+
}
86+
87+
void clearTools() {
88+
this.cache.keySet().removeIf(k -> k instanceof ListToolsCacheKey);
89+
}
90+
91+
void clearPrompts() {
92+
this.cache.keySet().removeIf(k -> k instanceof ListPromptsCacheKey);
93+
}
94+
95+
void clearResources() {
96+
this.cache.keySet()
97+
.removeIf(k -> k instanceof ListResourcesCacheKey || k instanceof ListResourceTemplatesCacheKey
98+
|| k instanceof ReadResourceCacheKey);
99+
}
100+
101+
void clearResource(String uri) {
102+
this.cache.remove(new ReadResourceCacheKey(uri));
103+
}
104+
105+
void clear() {
106+
this.cache.clear();
107+
}
108+
109+
int size() {
110+
return this.cache.size();
111+
}
112+
113+
}

0 commit comments

Comments
 (0)