From 7fad072e9fe25346aae9ac39e09d093daf6f8fee Mon Sep 17 00:00:00 2001 From: Mohammed Aboullaite Date: Thu, 16 Jul 2026 16:06:23 +0200 Subject: [PATCH 1/6] Add ttlMs and cacheScope to cacheable result records (SEP-2549) The draft spec introduces TTL caching hints on list and read results. This adds the optional ttlMs (Integer) and cacheScope (CacheScope enum) fields to ListToolsResult, ListPromptsResult, ListResourcesResult, ListResourceTemplatesResult, and ReadResourceResult, following the wire-record evolution rules in CONTRIBUTING.md. Purely additive, schema-layer only: no server or client behavior changes. Server-side TTL configuration hooks are deferred to #578. --- .../modelcontextprotocol/spec/McpSchema.java | 181 ++++++++++-- .../spec/McpSchemaTests.java | 260 ++++++++++++++++++ 2 files changed, 421 insertions(+), 20 deletions(-) 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..06107952f 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") Integer 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") Integer 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 Integer ttlMs; + + private CacheScope cacheScope; + private Builder(List resources) { Assert.notNull(resources, "resources must not be null"); this.resources = resources; @@ -1659,8 +1689,18 @@ public Builder meta(Map meta) { return this; } + public Builder ttlMs(Integer ttlMs) { + 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 +1713,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") Integer ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope) implements Result { // @formatter:on public ListResourceTemplatesResult { Assert.notNull(resourceTemplates, "resourceTemplates must not be null"); @@ -1688,13 +1733,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") Integer 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 +1766,10 @@ public static class Builder { private Map meta; + private Integer ttlMs; + + private CacheScope cacheScope; + private Builder(List resourceTemplates) { Assert.notNull(resourceTemplates, "resourceTemplates must not be null"); this.resourceTemplates = resourceTemplates; @@ -1729,8 +1785,18 @@ public Builder meta(Map meta) { return this; } + public Builder ttlMs(Integer ttlMs) { + 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 +1867,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") Integer ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope) implements Result { // @formatter:on public ReadResourceResult { Assert.notNull(contents, "contents must not be null"); @@ -1814,13 +1885,19 @@ public record ReadResourceResult( // @formatter:off @JsonCreator static ReadResourceResult fromJson(@JsonProperty("contents") List contents, - @JsonProperty("_meta") Map meta) { + @JsonProperty("_meta") Map meta, @JsonProperty("ttlMs") Integer 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 +1915,10 @@ public static class Builder { private Map meta; + private Integer ttlMs; + + private CacheScope cacheScope; + private Builder(List contents) { Assert.notNull(contents, "contents must not be null"); this.contents = contents; @@ -1848,8 +1929,18 @@ public Builder meta(Map meta) { return this; } + public Builder ttlMs(Integer ttlMs) { + 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 +2502,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") Integer ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope) implements Result { // @formatter:on public ListPromptsResult { Assert.notNull(prompts, "prompts must not be null"); @@ -2425,13 +2521,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") Integer 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 +2553,10 @@ public static class Builder { private Map meta; + private Integer ttlMs; + + private CacheScope cacheScope; + private Builder(List prompts) { Assert.notNull(prompts, "prompts must not be null"); this.prompts = prompts; @@ -2466,8 +2572,18 @@ public Builder meta(Map meta) { return this; } + public Builder ttlMs(Integer ttlMs) { + 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 +2736,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") Integer ttlMs, + @JsonProperty("cacheScope") CacheScope cacheScope) implements Result { // @formatter:on public ListToolsResult { Assert.notNull(tools, "tools must not be null"); @@ -2634,12 +2755,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") Integer 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 +2786,10 @@ public static class Builder { private Map meta; + private Integer ttlMs; + + private CacheScope cacheScope; + private Builder(List tools) { Assert.notNull(tools, "tools must not be null"); this.tools = tools; @@ -2674,8 +2805,18 @@ public Builder meta(Map meta) { return this; } + public Builder ttlMs(Integer ttlMs) { + 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/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index ab9bc8643..a76827402 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,264 @@ 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(60000) + .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(60000); + 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(30000) + .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(30000); + 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(0) + .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(0); + 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(120000) + .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(120000); + 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(300000) + .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(300000); + 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 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(5000); + 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(5000); + 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(10000); + 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(60000); + assertThat(result.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); + } + } From 64fcca14bb6b046042f6f63fdd54979feb912441 Mon Sep 17 00:00:00 2001 From: Mohammed Aboullaite Date: Fri, 17 Jul 2026 10:44:42 +0200 Subject: [PATCH 2/6] Stamp default ttlMs and cacheScope on server responses (SEP-2549) The draft spec requires cacheable results to carry ttlMs and cacheScope on the wire. Other SDKs (Go, Python, TypeScript) all stamp defaults after handlers return: ttlMs=0 (immediately stale) and cacheScope=public. This adds the same default stamping in McpAsyncServer and McpStatelessAsyncServer for all five cacheable result types. For list results the defaults are set at the build site. For ReadResourceResult, which is built by user handlers, a withCacheDefaults helper stamps missing fields while preserving any values the handler set explicitly. --- .../server/McpAsyncServer.java | 34 ++++++++++++++++--- .../server/McpStatelessAsyncServer.java | 34 ++++++++++++++++--- 2 files changed, 58 insertions(+), 10 deletions(-) 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..07374a66d 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -548,7 +548,10 @@ private McpRequestHandler toolsListRequestHandler() { .filterWhen(tool -> this.toolFilter.isVisible(exchange.transportContext(), tool) .onErrorResume(error -> opaqueListFilterError(tool, error))) .collectList() - .map(tools -> McpSchema.ListToolsResult.builder(tools).build()); + .map(tools -> McpSchema.ListToolsResult.builder(tools) + .ttlMs(0) + .cacheScope(McpSchema.CacheScope.PUBLIC) + .build()); }; } @@ -815,7 +818,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(0) + .cacheScope(McpSchema.CacheScope.PUBLIC) + .build()); }; } @@ -825,7 +831,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(0) + .cacheScope(McpSchema.CacheScope.PUBLIC) + .build()); }; } @@ -846,10 +855,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() : 0) + .cacheScope(result.cacheScope() != null ? result.cacheScope() : McpSchema.CacheScope.PUBLIC) + .build(); + } + return result; + } + private Optional findResourceSpecification(String uri) { var result = this.resources.values() .stream() @@ -976,7 +997,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(0) + .cacheScope(McpSchema.CacheScope.PUBLIC) + .build()); }; } 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..0e104d16e 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -427,7 +427,10 @@ private McpStatelessRequestHandler toolsListRequestHa .filterWhen(tool -> this.toolFilter.isVisible(ctx, tool) .onErrorResume(error -> opaqueListFilterError(tool, error))) .collectList() - .map(tools -> McpSchema.ListToolsResult.builder(tools).build()); + .map(tools -> McpSchema.ListToolsResult.builder(tools) + .ttlMs(0) + .cacheScope(McpSchema.CacheScope.PUBLIC) + .build()); }; } @@ -606,7 +609,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(0) + .cacheScope(McpSchema.CacheScope.PUBLIC) + .build()); }; } @@ -616,7 +622,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(0) + .cacheScope(McpSchema.CacheScope.PUBLIC) + .build()); }; } @@ -636,11 +645,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() : 0) + .cacheScope(result.cacheScope() != null ? result.cacheScope() : McpSchema.CacheScope.PUBLIC) + .build(); + } + return result; + } + private Optional findResourceSpecification(String uri) { var result = this.resources.values() .stream() @@ -736,7 +757,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(0) + .cacheScope(McpSchema.CacheScope.PUBLIC) + .build()); }; } From 9bedbaa9650ab64ae9e33251853d02ba39277fb6 Mon Sep 17 00:00:00 2001 From: Mohammed Aboullaite Date: Mon, 20 Jul 2026 11:45:23 +0200 Subject: [PATCH 3/6] Use Long for ttlMs and default resources/read scope to PRIVATE Two fixes from review: 1. Widen ttlMs from Integer to Long across all five cacheable result records. The spec defines ttlMs as a non-negative integer with no upper bound, and Jackson rejects values exceeding Integer.MAX_VALUE. Long handles any realistic TTL without interop failures. 2. Default cacheScope to PRIVATE (not PUBLIC) for resources/read in both server classes. The spec's caching guidance says resources/read results that depend on the authenticated user should be private. List results keep the PUBLIC default since they are not user-specific. --- .../server/McpAsyncServer.java | 12 +++--- .../server/McpStatelessAsyncServer.java | 12 +++--- .../modelcontextprotocol/spec/McpSchema.java | 40 +++++++++---------- .../spec/McpSchemaTests.java | 28 ++++++------- 4 files changed, 46 insertions(+), 46 deletions(-) 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 07374a66d..1c21f03a2 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -549,7 +549,7 @@ private McpRequestHandler toolsListRequestHandler() { .onErrorResume(error -> opaqueListFilterError(tool, error))) .collectList() .map(tools -> McpSchema.ListToolsResult.builder(tools) - .ttlMs(0) + .ttlMs(0L) .cacheScope(McpSchema.CacheScope.PUBLIC) .build()); }; @@ -819,7 +819,7 @@ private McpRequestHandler resourcesListRequestHan .map(McpServerFeatures.AsyncResourceSpecification::resource) .toList(); return Mono.just(McpSchema.ListResourcesResult.builder(resourceList) - .ttlMs(0) + .ttlMs(0L) .cacheScope(McpSchema.CacheScope.PUBLIC) .build()); }; @@ -832,7 +832,7 @@ private McpRequestHandler resourceTemplat .map(McpServerFeatures.AsyncResourceTemplateSpecification::resourceTemplate) .toList(); return Mono.just(McpSchema.ListResourceTemplatesResult.builder(resourceList) - .ttlMs(0) + .ttlMs(0L) .cacheScope(McpSchema.CacheScope.PUBLIC) .build()); }; @@ -864,8 +864,8 @@ private static McpSchema.ReadResourceResult withCacheDefaults(McpSchema.ReadReso if (result.ttlMs() == null || result.cacheScope() == null) { return McpSchema.ReadResourceResult.builder(result.contents()) .meta(result.meta()) - .ttlMs(result.ttlMs() != null ? result.ttlMs() : 0) - .cacheScope(result.cacheScope() != null ? result.cacheScope() : McpSchema.CacheScope.PUBLIC) + .ttlMs(result.ttlMs() != null ? result.ttlMs() : 0L) + .cacheScope(result.cacheScope() != null ? result.cacheScope() : McpSchema.CacheScope.PRIVATE) .build(); } return result; @@ -998,7 +998,7 @@ private McpRequestHandler promptsListRequestHandler .toList(); return Mono.just(McpSchema.ListPromptsResult.builder(promptList) - .ttlMs(0) + .ttlMs(0L) .cacheScope(McpSchema.CacheScope.PUBLIC) .build()); }; 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 0e104d16e..5b885b3aa 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -428,7 +428,7 @@ private McpStatelessRequestHandler toolsListRequestHa .onErrorResume(error -> opaqueListFilterError(tool, error))) .collectList() .map(tools -> McpSchema.ListToolsResult.builder(tools) - .ttlMs(0) + .ttlMs(0L) .cacheScope(McpSchema.CacheScope.PUBLIC) .build()); }; @@ -610,7 +610,7 @@ private McpStatelessRequestHandler resourcesListR .map(McpStatelessServerFeatures.AsyncResourceSpecification::resource) .toList(); return Mono.just(McpSchema.ListResourcesResult.builder(resourceList) - .ttlMs(0) + .ttlMs(0L) .cacheScope(McpSchema.CacheScope.PUBLIC) .build()); }; @@ -623,7 +623,7 @@ private McpStatelessRequestHandler resour .map(AsyncResourceTemplateSpecification::resourceTemplate) .toList(); return Mono.just(McpSchema.ListResourceTemplatesResult.builder(resourceList) - .ttlMs(0) + .ttlMs(0L) .cacheScope(McpSchema.CacheScope.PUBLIC) .build()); }; @@ -655,8 +655,8 @@ private static McpSchema.ReadResourceResult withCacheDefaults(McpSchema.ReadReso if (result.ttlMs() == null || result.cacheScope() == null) { return McpSchema.ReadResourceResult.builder(result.contents()) .meta(result.meta()) - .ttlMs(result.ttlMs() != null ? result.ttlMs() : 0) - .cacheScope(result.cacheScope() != null ? result.cacheScope() : McpSchema.CacheScope.PUBLIC) + .ttlMs(result.ttlMs() != null ? result.ttlMs() : 0L) + .cacheScope(result.cacheScope() != null ? result.cacheScope() : McpSchema.CacheScope.PRIVATE) .build(); } return result; @@ -758,7 +758,7 @@ private McpStatelessRequestHandler promptsListReque .toList(); return Mono.just(McpSchema.ListPromptsResult.builder(promptList) - .ttlMs(0) + .ttlMs(0L) .cacheScope(McpSchema.CacheScope.PUBLIC) .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 06107952f..1c1a0aea6 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -1629,7 +1629,7 @@ public record ListResourcesResult( // @formatter:off @JsonProperty("resources") List resources, @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta, - @JsonProperty("ttlMs") Integer ttlMs, + @JsonProperty("ttlMs") Long ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) implements Result { // @formatter:on public ListResourcesResult { @@ -1639,7 +1639,7 @@ public record ListResourcesResult( // @formatter:off @JsonCreator static ListResourcesResult fromJson(@JsonProperty("resources") List resources, @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta, - @JsonProperty("ttlMs") Integer ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) { + @JsonProperty("ttlMs") Long ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) { if (resources == null) { logger.warn( "ListResourcesResult: missing required field 'resources' during deserialization, using default []"); @@ -1670,7 +1670,7 @@ public static class Builder { private Map meta; - private Integer ttlMs; + private Long ttlMs; private CacheScope cacheScope; @@ -1689,7 +1689,7 @@ public Builder meta(Map meta) { return this; } - public Builder ttlMs(Integer ttlMs) { + public Builder ttlMs(Long ttlMs) { this.ttlMs = ttlMs; return this; } @@ -1723,7 +1723,7 @@ public record ListResourceTemplatesResult( // @formatter:off @JsonProperty("resourceTemplates") List resourceTemplates, @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta, - @JsonProperty("ttlMs") Integer ttlMs, + @JsonProperty("ttlMs") Long ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) implements Result { // @formatter:on public ListResourceTemplatesResult { @@ -1734,7 +1734,7 @@ public record ListResourceTemplatesResult( // @formatter:off static ListResourceTemplatesResult fromJson( @JsonProperty("resourceTemplates") List resourceTemplates, @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta, - @JsonProperty("ttlMs") Integer ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) { + @JsonProperty("ttlMs") Long ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) { if (resourceTemplates == null) { logger.warn( "ListResourceTemplatesResult: missing required field 'resourceTemplates' during deserialization, using default []"); @@ -1766,7 +1766,7 @@ public static class Builder { private Map meta; - private Integer ttlMs; + private Long ttlMs; private CacheScope cacheScope; @@ -1785,7 +1785,7 @@ public Builder meta(Map meta) { return this; } - public Builder ttlMs(Integer ttlMs) { + public Builder ttlMs(Long ttlMs) { this.ttlMs = ttlMs; return this; } @@ -1876,7 +1876,7 @@ public ReadResourceRequest build() { public record ReadResourceResult( // @formatter:off @JsonProperty("contents") List contents, @JsonProperty("_meta") Map meta, - @JsonProperty("ttlMs") Integer ttlMs, + @JsonProperty("ttlMs") Long ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) implements Result { // @formatter:on public ReadResourceResult { @@ -1885,7 +1885,7 @@ public record ReadResourceResult( // @formatter:off @JsonCreator static ReadResourceResult fromJson(@JsonProperty("contents") List contents, - @JsonProperty("_meta") Map meta, @JsonProperty("ttlMs") Integer ttlMs, + @JsonProperty("_meta") Map meta, @JsonProperty("ttlMs") Long ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) { if (contents == null) { logger.warn( @@ -1915,7 +1915,7 @@ public static class Builder { private Map meta; - private Integer ttlMs; + private Long ttlMs; private CacheScope cacheScope; @@ -1929,7 +1929,7 @@ public Builder meta(Map meta) { return this; } - public Builder ttlMs(Integer ttlMs) { + public Builder ttlMs(Long ttlMs) { this.ttlMs = ttlMs; return this; } @@ -2512,7 +2512,7 @@ public record ListPromptsResult( // @formatter:off @JsonProperty("prompts") List prompts, @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta, - @JsonProperty("ttlMs") Integer ttlMs, + @JsonProperty("ttlMs") Long ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) implements Result { // @formatter:on public ListPromptsResult { @@ -2522,7 +2522,7 @@ public record ListPromptsResult( // @formatter:off @JsonCreator static ListPromptsResult fromJson(@JsonProperty("prompts") List prompts, @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta, - @JsonProperty("ttlMs") Integer ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) { + @JsonProperty("ttlMs") Long ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) { if (prompts == null) { logger.warn( "ListPromptsResult: missing required field 'prompts' during deserialization, using default []"); @@ -2553,7 +2553,7 @@ public static class Builder { private Map meta; - private Integer ttlMs; + private Long ttlMs; private CacheScope cacheScope; @@ -2572,7 +2572,7 @@ public Builder meta(Map meta) { return this; } - public Builder ttlMs(Integer ttlMs) { + public Builder ttlMs(Long ttlMs) { this.ttlMs = ttlMs; return this; } @@ -2746,7 +2746,7 @@ public record ListToolsResult( // @formatter:off @JsonProperty("tools") List tools, @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta, - @JsonProperty("ttlMs") Integer ttlMs, + @JsonProperty("ttlMs") Long ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) implements Result { // @formatter:on public ListToolsResult { @@ -2756,7 +2756,7 @@ public record ListToolsResult( // @formatter:off @JsonCreator static ListToolsResult fromJson(@JsonProperty("tools") List tools, @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta, - @JsonProperty("ttlMs") Integer ttlMs, @JsonProperty("cacheScope") CacheScope cacheScope) { + @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(); @@ -2786,7 +2786,7 @@ public static class Builder { private Map meta; - private Integer ttlMs; + private Long ttlMs; private CacheScope cacheScope; @@ -2805,7 +2805,7 @@ public Builder meta(Map meta) { return this; } - public Builder ttlMs(Integer ttlMs) { + public Builder ttlMs(Long ttlMs) { this.ttlMs = ttlMs; return this; } 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 a76827402..e4182bac3 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -3038,7 +3038,7 @@ void testListResourcesResultWithTtl() throws Exception { McpSchema.ListResourcesResult result = McpSchema.ListResourcesResult.builder(List.of(resource)) .nextCursor("next") - .ttlMs(60000) + .ttlMs(60000L) .cacheScope(McpSchema.CacheScope.PUBLIC) .build(); @@ -3050,7 +3050,7 @@ void testListResourcesResultWithTtl() throws Exception { McpSchema.ListResourcesResult deserialized = JSON_MAPPER.readValue(value, McpSchema.ListResourcesResult.class); - assertThat(deserialized.ttlMs()).isEqualTo(60000); + assertThat(deserialized.ttlMs()).isEqualTo(60000L); assertThat(deserialized.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); assertThat(deserialized.resources()).hasSize(1); } @@ -3080,7 +3080,7 @@ void testListResourceTemplatesResultWithTtl() throws Exception { McpSchema.ListResourceTemplatesResult result = McpSchema.ListResourceTemplatesResult .builder(List.of(template)) - .ttlMs(30000) + .ttlMs(30000L) .cacheScope(McpSchema.CacheScope.PRIVATE) .build(); @@ -3089,7 +3089,7 @@ void testListResourceTemplatesResultWithTtl() throws Exception { McpSchema.ListResourceTemplatesResult deserialized = JSON_MAPPER.readValue(value, McpSchema.ListResourceTemplatesResult.class); - assertThat(deserialized.ttlMs()).isEqualTo(30000); + assertThat(deserialized.ttlMs()).isEqualTo(30000L); assertThat(deserialized.cacheScope()).isEqualTo(McpSchema.CacheScope.PRIVATE); } @@ -3118,7 +3118,7 @@ void testReadResourceResultWithTtl() throws Exception { .build(); McpSchema.ReadResourceResult result = McpSchema.ReadResourceResult.builder(List.of(contents)) - .ttlMs(0) + .ttlMs(0L) .cacheScope(McpSchema.CacheScope.PRIVATE) .build(); @@ -3127,7 +3127,7 @@ void testReadResourceResultWithTtl() throws Exception { McpSchema.ReadResourceResult deserialized = JSON_MAPPER.readValue(value, McpSchema.ReadResourceResult.class); - assertThat(deserialized.ttlMs()).isEqualTo(0); + assertThat(deserialized.ttlMs()).isEqualTo(0L); assertThat(deserialized.cacheScope()).isEqualTo(McpSchema.CacheScope.PRIVATE); } @@ -3155,7 +3155,7 @@ void testListPromptsResultWithTtl() throws Exception { .build(); McpSchema.ListPromptsResult result = McpSchema.ListPromptsResult.builder(List.of(prompt)) - .ttlMs(120000) + .ttlMs(120000L) .cacheScope(McpSchema.CacheScope.PUBLIC) .build(); @@ -3164,7 +3164,7 @@ void testListPromptsResultWithTtl() throws Exception { McpSchema.ListPromptsResult deserialized = JSON_MAPPER.readValue(value, McpSchema.ListPromptsResult.class); - assertThat(deserialized.ttlMs()).isEqualTo(120000); + assertThat(deserialized.ttlMs()).isEqualTo(120000L); assertThat(deserialized.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); } @@ -3194,7 +3194,7 @@ void testListToolsResultWithTtl() throws Exception { McpSchema.ListToolsResult result = McpSchema.ListToolsResult.builder(List.of(tool)) .nextCursor("cursor") - .ttlMs(300000) + .ttlMs(300000L) .cacheScope(McpSchema.CacheScope.PUBLIC) .build(); @@ -3205,7 +3205,7 @@ void testListToolsResultWithTtl() throws Exception { .containsEntry("nextCursor", "cursor"); McpSchema.ListToolsResult deserialized = JSON_MAPPER.readValue(value, McpSchema.ListToolsResult.class); - assertThat(deserialized.ttlMs()).isEqualTo(300000); + assertThat(deserialized.ttlMs()).isEqualTo(300000L); assertThat(deserialized.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); assertThat(deserialized.tools()).hasSize(1); assertThat(deserialized.nextCursor()).isEqualTo("cursor"); @@ -3247,7 +3247,7 @@ void testListResourcesResultToleratesUnknownFields() throws Exception { McpSchema.ListResourcesResult result = JSON_MAPPER.readValue(""" {"resources":[],"ttlMs":5000,"cacheScope":"public","futureField":"ignored"}""", McpSchema.ListResourcesResult.class); - assertThat(result.ttlMs()).isEqualTo(5000); + assertThat(result.ttlMs()).isEqualTo(5000L); assertThat(result.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); } @@ -3256,7 +3256,7 @@ void testListResourceTemplatesResultToleratesUnknownFields() throws Exception { McpSchema.ListResourceTemplatesResult result = JSON_MAPPER.readValue(""" {"resourceTemplates":[],"ttlMs":5000,"cacheScope":"private","futureField":"ignored"}""", McpSchema.ListResourceTemplatesResult.class); - assertThat(result.ttlMs()).isEqualTo(5000); + assertThat(result.ttlMs()).isEqualTo(5000L); assertThat(result.cacheScope()).isEqualTo(McpSchema.CacheScope.PRIVATE); } @@ -3274,7 +3274,7 @@ void testListPromptsResultToleratesUnknownFields() throws Exception { McpSchema.ListPromptsResult result = JSON_MAPPER.readValue(""" {"prompts":[],"ttlMs":10000,"cacheScope":"public","futureField":"ignored"}""", McpSchema.ListPromptsResult.class); - assertThat(result.ttlMs()).isEqualTo(10000); + assertThat(result.ttlMs()).isEqualTo(10000L); assertThat(result.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); } @@ -3283,7 +3283,7 @@ void testListToolsResultToleratesUnknownFields() throws Exception { McpSchema.ListToolsResult result = JSON_MAPPER.readValue(""" {"tools":[],"ttlMs":60000,"cacheScope":"public","futureField":"ignored"}""", McpSchema.ListToolsResult.class); - assertThat(result.ttlMs()).isEqualTo(60000); + assertThat(result.ttlMs()).isEqualTo(60000L); assertThat(result.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); } From b64a93b7663402fd23677bc5cba9f88174f25407 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Thu, 27 Aug 2026 02:28:28 +0200 Subject: [PATCH 4/6] style: format SEP-2549 changes with spring-javaformat --- .../spec/McpSchemaTests.java | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) 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 e4182bac3..d6da57888 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -3048,8 +3048,7 @@ void testListResourcesResultWithTtl() throws Exception { .containsEntry("cacheScope", "public") .containsEntry("nextCursor", "next"); - McpSchema.ListResourcesResult deserialized = JSON_MAPPER.readValue(value, - McpSchema.ListResourcesResult.class); + 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); @@ -3078,8 +3077,7 @@ void testListResourceTemplatesResultWithTtl() throws Exception { .builder("resource://{id}/test", "Test Template") .build(); - McpSchema.ListResourceTemplatesResult result = McpSchema.ListResourceTemplatesResult - .builder(List.of(template)) + McpSchema.ListResourceTemplatesResult result = McpSchema.ListResourceTemplatesResult.builder(List.of(template)) .ttlMs(30000L) .cacheScope(McpSchema.CacheScope.PRIVATE) .build(); @@ -3105,16 +3103,14 @@ void testListResourceTemplatesResultWithoutTtl() throws Exception { @Test void testListResourceTemplatesResultNullTtlOmittedFromJson() throws Exception { - McpSchema.ListResourceTemplatesResult result = McpSchema.ListResourceTemplatesResult.builder(List.of()) - .build(); + 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") + McpSchema.TextResourceContents contents = McpSchema.TextResourceContents.builder("resource://test", "content") .build(); McpSchema.ReadResourceResult result = McpSchema.ReadResourceResult.builder(List.of(contents)) @@ -3125,8 +3121,7 @@ void testReadResourceResultWithTtl() throws Exception { 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); + McpSchema.ReadResourceResult deserialized = JSON_MAPPER.readValue(value, McpSchema.ReadResourceResult.class); assertThat(deserialized.ttlMs()).isEqualTo(0L); assertThat(deserialized.cacheScope()).isEqualTo(McpSchema.CacheScope.PRIVATE); } @@ -3162,8 +3157,7 @@ void testListPromptsResultWithTtl() throws Exception { 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); + McpSchema.ListPromptsResult deserialized = JSON_MAPPER.readValue(value, McpSchema.ListPromptsResult.class); assertThat(deserialized.ttlMs()).isEqualTo(120000L); assertThat(deserialized.cacheScope()).isEqualTo(McpSchema.CacheScope.PUBLIC); } From 01981b9037c44bcb5c4ac8368eb032070d62c069 Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Thu, 27 Aug 2026 02:37:59 +0200 Subject: [PATCH 5/6] 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 --- .../client/McpAsyncClient.java | 133 +++++-- .../client/McpClientCache.java | 113 ++++++ .../client/McpClientCacheTests.java | 356 ++++++++++++++++++ 3 files changed, 572 insertions(+), 30 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCache.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientCacheTests.java 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..235c10cbd 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(); this.enableCallToolSchemaCaching = features.enableCallToolSchemaCaching(); this.applyElicitationDefaults = features.applyElicitationDefaults(); @@ -746,7 +753,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,6 +763,10 @@ public Mono listTools(String cursor) { * @return A Mono that emits the list of tools result */ public Mono listTools(String cursor, Map meta) { + McpSchema.ListToolsResult cached = this.clientCache.get(new McpClientCache.ListToolsCacheKey(cursor, meta)); + if (cached != null) { + return Mono.just(cached); + } return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, meta)); } @@ -773,6 +784,9 @@ private Mono listToolsInternal(Initialization init, S if (result.tools() != null) { result.tools().forEach(tool -> ToolNameValidator.validate(tool.name(), false)); } + if (result.ttlMs() != null && result.ttlMs() > 0) { + this.clientCache.put(new McpClientCache.ListToolsCacheKey(cursor, meta), result, result.ttlMs()); + } if (this.enableCallToolSchemaCaching && result.tools() != null) { // Cache tools output schema result.tools() @@ -786,14 +800,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 +855,7 @@ public Mono listResources() { * @see #readResource(McpSchema.Resource) */ public Mono listResources(String cursor) { - return this.listResourcesInternal(cursor, null); + return this.listResources(cursor, null); } /** @@ -851,6 +869,11 @@ public Mono listResources(String cursor) { * @see #readResource(McpSchema.Resource) */ public Mono listResources(String cursor, Map meta) { + McpSchema.ListResourcesResult cached = this.clientCache + .get(new McpClientCache.ListResourcesCacheKey(cursor, meta)); + if (cached != null) { + return Mono.just(cached); + } return this.listResourcesInternal(cursor, meta); } @@ -861,7 +884,13 @@ private Mono listResourcesInternal(String cursor, } 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 (result.ttlMs() != null && result.ttlMs() > 0) { + this.clientCache.put(new McpClientCache.ListResourcesCacheKey(cursor, meta), result, + result.ttlMs()); + } + }); }); } @@ -887,12 +916,23 @@ public Mono readResource(McpSchema.Resource resour * @see McpSchema.ReadResourceResult */ public Mono readResource(McpSchema.ReadResourceRequest readResourceRequest) { + McpSchema.ReadResourceResult cached = this.clientCache + .get(new McpClientCache.ReadResourceCacheKey(readResourceRequest.uri())); + if (cached != null) { + return Mono.just(cached); + } 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")); } return init.mcpSession() - .sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest, READ_RESOURCE_RESULT_TYPE_REF); + .sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest, READ_RESOURCE_RESULT_TYPE_REF) + .doOnNext(result -> { + if (result.ttlMs() != null && result.ttlMs() > 0) { + this.clientCache.put(new McpClientCache.ReadResourceCacheKey(readResourceRequest.uri()), result, + result.ttlMs()); + } + }); }); } @@ -922,7 +962,7 @@ public Mono listResourceTemplates() { * @see McpSchema.ListResourceTemplatesResult */ public Mono listResourceTemplates(String cursor) { - return this.listResourceTemplatesInternal(cursor, null); + return this.listResourceTemplates(cursor, null); } /** @@ -935,6 +975,11 @@ public Mono listResourceTemplates(String * @see McpSchema.ListResourceTemplatesResult */ public Mono listResourceTemplates(String cursor, Map meta) { + McpSchema.ListResourceTemplatesResult cached = this.clientCache + .get(new McpClientCache.ListResourceTemplatesCacheKey(cursor, meta)); + if (cached != null) { + return Mono.just(cached); + } return this.listResourceTemplatesInternal(cursor, meta); } @@ -946,7 +991,13 @@ private Mono listResourceTemplatesInterna } 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 (result.ttlMs() != null && result.ttlMs() > 0) { + this.clientCache.put(new McpClientCache.ListResourceTemplatesCacheKey(cursor, meta), result, + result.ttlMs()); + } + }); }); } @@ -980,13 +1031,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 +1050,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 +1096,7 @@ public Mono listPrompts() { * @see #getPrompt(GetPromptRequest) */ public Mono listPrompts(String cursor) { - return this.listPromptsInternal(cursor, null); + return this.listPrompts(cursor, null); } /** @@ -1052,6 +1108,10 @@ public Mono listPrompts(String cursor) { * @see #getPrompt(GetPromptRequest) */ public Mono listPrompts(String cursor, Map meta) { + McpSchema.ListPromptsResult cached = this.clientCache.get(new McpClientCache.ListPromptsCacheKey(cursor, meta)); + if (cached != null) { + return Mono.just(cached); + } return this.listPromptsInternal(cursor, meta); } @@ -1059,7 +1119,13 @@ private Mono listPromptsInternal(String cursor, Map init.mcpSession() .sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor, meta), - LIST_PROMPTS_RESULT_TYPE_REF)); + LIST_PROMPTS_RESULT_TYPE_REF) + .doOnNext(result -> { + if (result.ttlMs() != null && result.ttlMs() > 0) { + this.clientCache.put(new McpClientCache.ListPromptsCacheKey(cursor, meta), result, + result.ttlMs()); + } + })); } /** @@ -1078,13 +1144,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 +1215,10 @@ void setProtocolVersions(List protocolVersions) { this.initializer.setProtocolVersions(protocolVersions); } + McpClientCache getClientCache() { + return this.clientCache; + } + // -------------------------- // Completions // -------------------------- 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..eb9bac552 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCache.java @@ -0,0 +1,113 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * Thread-safe client cache for caching MCP list and resource operations according to + * server-provided {@code ttlMs} hints (SEP-2549). + * + *

+ * Cached entries are invalidated either when their TTL expires or when corresponding + * change notifications are received from the server. + * + * @author Sylwester Lachiewicz + */ +class McpClientCache { + + sealed interface CacheKey { + + } + + record ListToolsCacheKey(String cursor, Map meta) implements CacheKey { + + } + + record ListPromptsCacheKey(String cursor, Map meta) implements CacheKey { + + } + + record ListResourcesCacheKey(String cursor, Map meta) implements CacheKey { + + } + + record ListResourceTemplatesCacheKey(String cursor, Map meta) implements CacheKey { + + } + + record ReadResourceCacheKey(String uri) implements CacheKey { + + } + + private record CacheEntry(T value, long expiresAtMillis) { + + boolean isExpired(long now) { + return now >= this.expiresAtMillis; + } + + } + + private final ConcurrentHashMap> cache = new ConcurrentHashMap<>(); + + private final Supplier timeProvider; + + McpClientCache() { + this(System::currentTimeMillis); + } + + McpClientCache(Supplier timeProvider) { + this.timeProvider = timeProvider; + } + + @SuppressWarnings("unchecked") + T get(CacheKey key) { + CacheEntry entry = this.cache.get(key); + if (entry == null) { + return null; + } + if (entry.isExpired(this.timeProvider.get())) { + this.cache.remove(key, entry); + return null; + } + return (T) entry.value(); + } + + void put(CacheKey key, T value, Long ttlMs) { + if (ttlMs != null && ttlMs > 0 && value != null) { + long expiresAt = this.timeProvider.get() + ttlMs; + this.cache.put(key, new CacheEntry<>(value, expiresAt)); + } + } + + void clearTools() { + this.cache.keySet().removeIf(k -> k instanceof ListToolsCacheKey); + } + + void clearPrompts() { + this.cache.keySet().removeIf(k -> k instanceof ListPromptsCacheKey); + } + + void clearResources() { + this.cache.keySet() + .removeIf(k -> k instanceof ListResourcesCacheKey || k instanceof ListResourceTemplatesCacheKey + || k instanceof ReadResourceCacheKey); + } + + void clearResource(String uri) { + this.cache.remove(new ReadResourceCacheKey(uri)); + } + + void clear() { + this.cache.clear(); + } + + int size() { + return this.cache.size(); + } + +} 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..9d4e0c0ed --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientCacheTests.java @@ -0,0 +1,356 @@ +/* + * Copyright 2024-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client; + +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.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(currentTime::get); + + var toolsKey = new McpClientCache.ListToolsCacheKey("", 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() { + McpClientCache cache = new McpClientCache(); + + var toolsKey = new McpClientCache.ListToolsCacheKey("", null); + var promptsKey = new McpClientCache.ListPromptsCacheKey("", null); + var resourcesKey = new McpClientCache.ListResourcesCacheKey("", null); + var resourceReadUri = "resource://test"; + var readResourceKey = new McpClientCache.ReadResourceCacheKey(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(cache.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); + } + +} From 07e8a0c229edb766122ccb1aa5576feea254c52c Mon Sep 17 00:00:00 2001 From: Sylwester Lachiewicz Date: Thu, 3 Sep 2026 10:57:18 +0200 Subject: [PATCH 6/6] fix(client,server): correct SEP-2549 cache invalidation and make caching configurable Listings are scoped private, not public. tools/list is filtered per caller (#1111), so a shared cache must never serve one principal's listing to another; do not restore PUBLIC without gating it on the absence of a list filter. Client-side, the cache lookup moved inside Mono.defer so an assembled Mono stays cold, a generation counter drops a response that was in flight when its group was invalidated, and a listing spanning several pages is not cached at all, because stitching a cached first page to a freshly fetched later page mixes two server snapshots. ttlMs is validated on the result builders rather than in the compact constructor: a bad server hint must not make the whole listing unparseable. McpClientCacheStore keeps a Caffeine or shared-store implementation a user choice, so mcp-core stays at its three compile dependencies. --- docs/client.md | 56 +++ docs/server.md | 62 ++++ .../client/InMemoryMcpClientCacheStore.java | 112 ++++++ .../client/McpAsyncClient.java | 161 ++++++--- .../client/McpClient.java | 69 +++- .../client/McpClientCache.java | 129 ++++--- .../client/McpClientCacheKey.java | 88 +++++ .../client/McpClientCacheStore.java | 67 ++++ .../client/McpClientFeatures.java | 34 +- .../client/McpSyncClient.java | 13 + .../server/McpAsyncServer.java | 29 +- .../server/McpListCacheOptions.java | 55 +++ .../server/McpServer.java | 103 +++++- .../server/McpStatelessAsyncServer.java | 26 +- .../modelcontextprotocol/spec/McpSchema.java | 5 + .../client/McpClientCacheTests.java | 338 +++++++++++++++++- .../server/McpListCacheOptionsTests.java | 101 ++++++ .../spec/McpSchemaTests.java | 24 ++ 18 files changed, 1313 insertions(+), 159 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/InMemoryMcpClientCacheStore.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCacheKey.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCacheStore.java create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/server/McpListCacheOptions.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/server/McpListCacheOptionsTests.java 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 235c10cbd..8401480f5 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -214,7 +214,7 @@ public class McpAsyncClient { this.roots = new ConcurrentHashMap<>(features.roots()); this.jsonSchemaValidator = jsonSchemaValidator; this.toolsOutputSchemaCache = new ConcurrentHashMap<>(); - this.clientCache = new McpClientCache(); + this.clientCache = new McpClientCache(features.enableResultCaching(), features.cacheStore()); this.enableCallToolSchemaCaching = features.enableCallToolSchemaCaching(); this.applyElicitationDefaults = features.applyElicitationDefaults(); @@ -341,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(); } @@ -430,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(); } @@ -440,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()); }); } @@ -763,11 +772,14 @@ public Mono listTools(String cursor) { * @return A Mono that emits the list of tools result */ public Mono listTools(String cursor, Map meta) { - McpSchema.ListToolsResult cached = this.clientCache.get(new McpClientCache.ListToolsCacheKey(cursor, meta)); - if (cached != null) { - return Mono.just(cached); - } - 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, @@ -776,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) @@ -784,8 +797,9 @@ private Mono listToolsInternal(Initialization init, S if (result.tools() != null) { result.tools().forEach(tool -> ToolNameValidator.validate(tool.name(), false)); } - if (result.ttlMs() != null && result.ttlMs() > 0) { - this.clientCache.put(new McpClientCache.ListToolsCacheKey(cursor, meta), result, result.ttlMs()); + 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 @@ -869,12 +883,14 @@ public Mono listResources(String cursor) { * @see #readResource(McpSchema.Resource) */ public Mono listResources(String cursor, Map meta) { - McpSchema.ListResourcesResult cached = this.clientCache - .get(new McpClientCache.ListResourcesCacheKey(cursor, meta)); - if (cached != null) { - return Mono.just(cached); - } - 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) { @@ -882,13 +898,14 @@ 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) .doOnNext(result -> { - if (result.ttlMs() != null && result.ttlMs() > 0) { - this.clientCache.put(new McpClientCache.ListResourcesCacheKey(cursor, meta), result, - result.ttlMs()); + if (isCacheableListing(cursor, result.nextCursor())) { + this.clientCache.put(new McpClientCacheKey.ListResources(cursor, meta), result, result.ttlMs(), + generation); } }); }); @@ -916,23 +933,21 @@ public Mono readResource(McpSchema.Resource resour * @see McpSchema.ReadResourceResult */ public Mono readResource(McpSchema.ReadResourceRequest readResourceRequest) { - McpSchema.ReadResourceResult cached = this.clientCache - .get(new McpClientCache.ReadResourceCacheKey(readResourceRequest.uri())); - if (cached != null) { - return Mono.just(cached); - } - 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) - .doOnNext(result -> { - if (result.ttlMs() != null && result.ttlMs() > 0) { - this.clientCache.put(new McpClientCache.ReadResourceCacheKey(readResourceRequest.uri()), result, - result.ttlMs()); - } - }); + 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)); + }); }); } @@ -975,12 +990,14 @@ public Mono listResourceTemplates(String * @see McpSchema.ListResourceTemplatesResult */ public Mono listResourceTemplates(String cursor, Map meta) { - McpSchema.ListResourceTemplatesResult cached = this.clientCache - .get(new McpClientCache.ListResourceTemplatesCacheKey(cursor, meta)); - if (cached != null) { - return Mono.just(cached); - } - 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, @@ -989,13 +1006,14 @@ 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) .doOnNext(result -> { - if (result.ttlMs() != null && result.ttlMs() > 0) { - this.clientCache.put(new McpClientCache.ListResourceTemplatesCacheKey(cursor, meta), result, - result.ttlMs()); + if (isCacheableListing(cursor, result.nextCursor())) { + this.clientCache.put(new McpClientCacheKey.ListResourceTemplates(cursor, meta), result, + result.ttlMs(), generation); } }); }); @@ -1108,24 +1126,28 @@ public Mono listPrompts(String cursor) { * @see #getPrompt(GetPromptRequest) */ public Mono listPrompts(String cursor, Map meta) { - McpSchema.ListPromptsResult cached = this.clientCache.get(new McpClientCache.ListPromptsCacheKey(cursor, meta)); - if (cached != null) { - return Mono.just(cached); - } - 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) - .doOnNext(result -> { - if (result.ttlMs() != null && result.ttlMs() > 0) { - this.clientCache.put(new McpClientCache.ListPromptsCacheKey(cursor, meta), result, - result.ttlMs()); - } - })); + 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); + } + }); + }); } /** @@ -1215,10 +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 index eb9bac552..54d56eda5 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCache.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientCache.java @@ -4,110 +4,121 @@ package io.modelcontextprotocol.client; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Supplier; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Predicate; /** - * Thread-safe client cache for caching MCP list and resource operations according to - * server-provided {@code ttlMs} hints (SEP-2549). + * Caching policy for MCP list and resource operations, applied on top of an + * {@link McpClientCacheStore} that does the actual storing (SEP-2549). * *

- * Cached entries are invalidated either when their TTL expires or when corresponding - * change notifications are received from the server. + * 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 { - sealed interface CacheKey { + /** + * 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; - record ListToolsCacheKey(String cursor, Map meta) implements CacheKey { + 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(); - record ListPromptsCacheKey(String cursor, Map meta) implements CacheKey { + /** + * 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()); } - record ListResourcesCacheKey(String cursor, Map meta) implements CacheKey { - + McpClientCache(McpClientCacheStore store) { + this(true, store); } - record ListResourceTemplatesCacheKey(String cursor, Map meta) implements CacheKey { - + /** + * @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; } - record ReadResourceCacheKey(String uri) implements CacheKey { - - } - - private record CacheEntry(T value, long expiresAtMillis) { - - boolean isExpired(long now) { - return now >= this.expiresAtMillis; - } - + /** + * 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(); } - private final ConcurrentHashMap> cache = new ConcurrentHashMap<>(); - - private final Supplier timeProvider; - - McpClientCache() { - this(System::currentTimeMillis); + @SuppressWarnings("unchecked") + T get(McpClientCacheKey key) { + return this.enabled ? (T) this.store.get(key) : null; } - McpClientCache(Supplier timeProvider) { - this.timeProvider = timeProvider; + void put(McpClientCacheKey key, T value, Long ttlMs) { + this.put(key, value, ttlMs, this.generation.get()); } - @SuppressWarnings("unchecked") - T get(CacheKey key) { - CacheEntry entry = this.cache.get(key); - if (entry == null) { - return null; + void put(McpClientCacheKey key, T value, Long ttlMs, long generation) { + if (!this.enabled || ttlMs == null || ttlMs <= 0 || value == null) { + return; } - if (entry.isExpired(this.timeProvider.get())) { - this.cache.remove(key, entry); - return null; - } - return (T) entry.value(); - } - - void put(CacheKey key, T value, Long ttlMs) { - if (ttlMs != null && ttlMs > 0 && value != null) { - long expiresAt = this.timeProvider.get() + ttlMs; - this.cache.put(key, new CacheEntry<>(value, expiresAt)); + synchronized (this.invalidationLock) { + if (generation != this.generation.get()) { + return; + } + this.store.put(key, value, Math.min(ttlMs, MAX_TTL_MS)); } } void clearTools() { - this.cache.keySet().removeIf(k -> k instanceof ListToolsCacheKey); + this.invalidate(k -> k instanceof McpClientCacheKey.ListTools); } void clearPrompts() { - this.cache.keySet().removeIf(k -> k instanceof ListPromptsCacheKey); + this.invalidate(k -> k instanceof McpClientCacheKey.ListPrompts); } void clearResources() { - this.cache.keySet() - .removeIf(k -> k instanceof ListResourcesCacheKey || k instanceof ListResourceTemplatesCacheKey - || k instanceof ReadResourceCacheKey); + this.invalidate(k -> k instanceof McpClientCacheKey.ListResources + || k instanceof McpClientCacheKey.ListResourceTemplates || k instanceof McpClientCacheKey.ReadResource); } void clearResource(String uri) { - this.cache.remove(new ReadResourceCacheKey(uri)); + this.invalidate(k -> k instanceof McpClientCacheKey.ReadResource readKey && readKey.uri().equals(uri)); } void clear() { - this.cache.clear(); + synchronized (this.invalidationLock) { + this.generation.incrementAndGet(); + this.store.clear(); + } } - int size() { - return this.cache.size(); + 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 1c21f03a2..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,9 +555,11 @@ private McpRequestHandler toolsListRequestHandler() { .filterWhen(tool -> this.toolFilter.isVisible(exchange.transportContext(), tool) .onErrorResume(error -> opaqueListFilterError(tool, error))) .collectList() + // 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(0L) - .cacheScope(McpSchema.CacheScope.PUBLIC) + .ttlMs(this.listCacheOptions.ttlMs()) + .cacheScope(this.listCacheOptions.cacheScope()) .build()); }; } @@ -819,8 +828,8 @@ private McpRequestHandler resourcesListRequestHan .map(McpServerFeatures.AsyncResourceSpecification::resource) .toList(); return Mono.just(McpSchema.ListResourcesResult.builder(resourceList) - .ttlMs(0L) - .cacheScope(McpSchema.CacheScope.PUBLIC) + .ttlMs(this.listCacheOptions.ttlMs()) + .cacheScope(this.listCacheOptions.cacheScope()) .build()); }; } @@ -832,8 +841,8 @@ private McpRequestHandler resourceTemplat .map(McpServerFeatures.AsyncResourceTemplateSpecification::resourceTemplate) .toList(); return Mono.just(McpSchema.ListResourceTemplatesResult.builder(resourceList) - .ttlMs(0L) - .cacheScope(McpSchema.CacheScope.PUBLIC) + .ttlMs(this.listCacheOptions.ttlMs()) + .cacheScope(this.listCacheOptions.cacheScope()) .build()); }; } @@ -998,8 +1007,8 @@ private McpRequestHandler promptsListRequestHandler .toList(); return Mono.just(McpSchema.ListPromptsResult.builder(promptList) - .ttlMs(0L) - .cacheScope(McpSchema.CacheScope.PUBLIC) + .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 5b885b3aa..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,9 +433,11 @@ private McpStatelessRequestHandler toolsListRequestHa .filterWhen(tool -> this.toolFilter.isVisible(ctx, tool) .onErrorResume(error -> opaqueListFilterError(tool, error))) .collectList() + // 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(0L) - .cacheScope(McpSchema.CacheScope.PUBLIC) + .ttlMs(this.listCacheOptions.ttlMs()) + .cacheScope(this.listCacheOptions.cacheScope()) .build()); }; } @@ -610,8 +618,8 @@ private McpStatelessRequestHandler resourcesListR .map(McpStatelessServerFeatures.AsyncResourceSpecification::resource) .toList(); return Mono.just(McpSchema.ListResourcesResult.builder(resourceList) - .ttlMs(0L) - .cacheScope(McpSchema.CacheScope.PUBLIC) + .ttlMs(this.listCacheOptions.ttlMs()) + .cacheScope(this.listCacheOptions.cacheScope()) .build()); }; } @@ -623,8 +631,8 @@ private McpStatelessRequestHandler resour .map(AsyncResourceTemplateSpecification::resourceTemplate) .toList(); return Mono.just(McpSchema.ListResourceTemplatesResult.builder(resourceList) - .ttlMs(0L) - .cacheScope(McpSchema.CacheScope.PUBLIC) + .ttlMs(this.listCacheOptions.ttlMs()) + .cacheScope(this.listCacheOptions.cacheScope()) .build()); }; } @@ -758,8 +766,8 @@ private McpStatelessRequestHandler promptsListReque .toList(); return Mono.just(McpSchema.ListPromptsResult.builder(promptList) - .ttlMs(0L) - .cacheScope(McpSchema.CacheScope.PUBLIC) + .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 1c1a0aea6..f904bfc86 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -1690,6 +1690,7 @@ public Builder meta(Map meta) { } public Builder ttlMs(Long ttlMs) { + Assert.isTrue(ttlMs == null || ttlMs >= 0, "ttlMs must not be negative"); this.ttlMs = ttlMs; return this; } @@ -1786,6 +1787,7 @@ public Builder meta(Map meta) { } public Builder ttlMs(Long ttlMs) { + Assert.isTrue(ttlMs == null || ttlMs >= 0, "ttlMs must not be negative"); this.ttlMs = ttlMs; return this; } @@ -1930,6 +1932,7 @@ public Builder meta(Map meta) { } public Builder ttlMs(Long ttlMs) { + Assert.isTrue(ttlMs == null || ttlMs >= 0, "ttlMs must not be negative"); this.ttlMs = ttlMs; return this; } @@ -2573,6 +2576,7 @@ public Builder meta(Map meta) { } public Builder ttlMs(Long ttlMs) { + Assert.isTrue(ttlMs == null || ttlMs >= 0, "ttlMs must not be negative"); this.ttlMs = ttlMs; return this; } @@ -2806,6 +2810,7 @@ public Builder meta(Map meta) { } public Builder ttlMs(Long ttlMs) { + Assert.isTrue(ttlMs == null || ttlMs >= 0, "ttlMs must not be negative"); this.ttlMs = ttlMs; return this; } diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientCacheTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientCacheTests.java index 9d4e0c0ed..6dfbef754 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientCacheTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpClientCacheTests.java @@ -4,11 +4,13 @@ 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; @@ -32,9 +34,10 @@ class McpClientCacheTests { @Test void cacheDirectOperationsAndTtlExpiration() { AtomicLong currentTime = new AtomicLong(1000L); - McpClientCache cache = new McpClientCache(currentTime::get); + McpClientCache cache = new McpClientCache( + new InMemoryMcpClientCacheStore(InMemoryMcpClientCacheStore.DEFAULT_MAX_ENTRIES, currentTime::get)); - var toolsKey = new McpClientCache.ListToolsCacheKey("", null); + var toolsKey = new McpClientCacheKey.ListTools("", null); var toolsResult = McpSchema.ListToolsResult.builder(List.of()) .ttlMs(500L) .cacheScope(CacheScope.PUBLIC) @@ -55,20 +58,22 @@ void cacheDirectOperationsAndTtlExpiration() { @Test void cacheInvalidationsByType() { - McpClientCache cache = new McpClientCache(); + var store = new InMemoryMcpClientCacheStore(InMemoryMcpClientCacheStore.DEFAULT_MAX_ENTRIES, + System::currentTimeMillis); + McpClientCache cache = new McpClientCache(store); - var toolsKey = new McpClientCache.ListToolsCacheKey("", null); - var promptsKey = new McpClientCache.ListPromptsCacheKey("", null); - var resourcesKey = new McpClientCache.ListResourcesCacheKey("", null); + 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 McpClientCache.ReadResourceCacheKey(resourceReadUri); + 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(cache.size()).isEqualTo(4); + assertThat(store.size()).isEqualTo(4); cache.clearTools(); assertThat(cache.get(toolsKey)).isNull(); @@ -353,4 +358,321 @@ public java.lang.reflect.Type getType() { 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 d6da57888..fb87320c6 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -3222,6 +3222,30 @@ void testListToolsResultNullTtlOmittedFromJson() throws Exception { 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\"");