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()); }; } 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); + } + }