From 1c25b7c1475675f7ff85a062d31cefe862d78213 Mon Sep 17 00:00:00 2001 From: Mohammed Aboullaite Date: Thu, 16 Jul 2026 16:06:23 +0200 Subject: [PATCH 1/2] 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 64a3659e41ce3276746dd59acc2868f153eded9e Mon Sep 17 00:00:00 2001 From: Mohammed Aboullaite Date: Fri, 17 Jul 2026 10:44:42 +0200 Subject: [PATCH 2/2] 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 ac78c4ff0..292ddd7c6 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -539,7 +539,10 @@ private McpRequestHandler toolsListRequestHandler() { return (exchange, params) -> { List tools = this.tools.stream().map(McpServerFeatures.AsyncToolSpecification::tool).toList(); - return Mono.just(McpSchema.ListToolsResult.builder(tools).build()); + return Mono.just(McpSchema.ListToolsResult.builder(tools) + .ttlMs(0) + .cacheScope(McpSchema.CacheScope.PUBLIC) + .build()); }; } @@ -791,7 +794,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()); }; } @@ -801,7 +807,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()); }; } @@ -822,10 +831,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() @@ -952,7 +973,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 42112334e..65e827d93 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -420,7 +420,10 @@ private McpStatelessRequestHandler toolsListRequestHa List tools = this.tools.stream() .map(McpStatelessServerFeatures.AsyncToolSpecification::tool) .toList(); - return Mono.just(McpSchema.ListToolsResult.builder(tools).build()); + return Mono.just(McpSchema.ListToolsResult.builder(tools) + .ttlMs(0) + .cacheScope(McpSchema.CacheScope.PUBLIC) + .build()); }; } @@ -584,7 +587,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()); }; } @@ -594,7 +600,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()); }; } @@ -614,11 +623,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() @@ -714,7 +735,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()); }; }