From 4c2348ea30f94d29ed05ff3afdcfbaab896403e0 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Wed, 28 May 2025 14:50:05 -0700 Subject: [PATCH 1/4] feat: implement data models for search --- .../modelcontextprotocol/spec/McpSchema.java | 278 +++++++++++++++++- 1 file changed, 277 insertions(+), 1 deletion(-) diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 8df8a158..289fc006 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -57,6 +57,8 @@ private McpSchema() { public static final String METHOD_TOOLS_CALL = "tools/call"; + public static final String METHOD_TOOLS_SEARCH = "tools/search"; + public static final String METHOD_NOTIFICATION_TOOLS_LIST_CHANGED = "notifications/tools/list_changed"; // Resources Methods @@ -64,10 +66,14 @@ private McpSchema() { public static final String METHOD_RESOURCES_READ = "resources/read"; + public static final String METHOD_RESOURCES_SEARCH = "resources/search"; + public static final String METHOD_NOTIFICATION_RESOURCES_LIST_CHANGED = "notifications/resources/list_changed"; public static final String METHOD_RESOURCES_TEMPLATES_LIST = "resources/templates/list"; + public static final String METHOD_RESOURCES_TEMPLATES_SEARCH = "resources/templates/search"; + public static final String METHOD_RESOURCES_SUBSCRIBE = "resources/subscribe"; public static final String METHOD_RESOURCES_UNSUBSCRIBE = "resources/unsubscribe"; @@ -77,6 +83,8 @@ private McpSchema() { public static final String METHOD_PROMPT_GET = "prompts/get"; + public static final String METHOD_PROMPT_SEARCH = "prompts/search"; + public static final String METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED = "notifications/prompts/list_changed"; public static final String METHOD_COMPLETION_COMPLETE = "completion/complete"; @@ -132,7 +140,8 @@ public static final class ErrorCodes { } public sealed interface Request - permits InitializeRequest, CallToolRequest, CreateMessageRequest, CompleteRequest, GetPromptRequest { + permits InitializeRequest, CallToolRequest, CreateMessageRequest, CompleteRequest, GetPromptRequest, + SearchToolsRequest, SearchResourcesRequest, SearchResourceTemplatesRequest, SearchPromptsRequest { } @@ -883,6 +892,273 @@ public CallToolResult build() { } // @formatter:on + // --------------------------- + // Search Interfaces + // --------------------------- + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record SearchToolsRequest(// @formatter:off + @JsonProperty("query") String query, + @JsonProperty("cursor") String cursor) implements Request { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String query; + private String cursor; + + public Builder query(String query) { + Assert.notNull(query, "query must not be null"); + this.query = query; + return this; + } + + public Builder cursor(String cursor) { + Assert.notNull(cursor, "cursor must not be null"); + this.cursor = cursor; + return this; + } + + public SearchToolsRequest build() { + return new SearchToolsRequest(query, cursor); + } + } + + }// @formatter:off + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record SearchToolsResult( // @formatter:off + @JsonProperty("tools") List tools, + @JsonProperty("nextCursor") String nextCursor) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private List tools = new ArrayList<>(); + private String nextCursor; + + public Builder tools(List tools) { + Assert.notNull(tools, "tools must not be null"); + this.tools = tools; + return this; + } + + public Builder nextCursor(String nextCursor) { + Assert.notNull(nextCursor, "nextCursor must not be null"); + this.nextCursor = nextCursor; + return this; + } + + public SearchToolsResult build() { + return new SearchToolsResult(tools, nextCursor); + } + } + + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record SearchResourcesRequest(// @formatter:off + @JsonProperty("query") String query, + @JsonProperty("cursor") String cursor) implements Request { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String query; + private String cursor; + + public Builder query(String query) { + Assert.notNull(query, "query must not be null"); + this.query = query; + return this; + } + + public Builder cursor(String cursor) { + Assert.notNull(cursor, "cursor must not be null"); + this.cursor = cursor; + return this; + } + + public SearchResourcesRequest build() { + return new SearchResourcesRequest(query, cursor); + } + } + + }// @formatter:off + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record SearchResourcesResult( // @formatter:off + @JsonProperty("resources") List resources, + @JsonProperty("nextCursor") String nextCursor) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private List resources = new ArrayList<>(); + private String nextCursor; + + public Builder resources(List resources) { + Assert.notNull(resources, "resources must not be null"); + this.resources = resources; + return this; + } + + public Builder nextCursor(String nextCursor) { + Assert.notNull(nextCursor, "nextCursor must not be null"); + this.nextCursor = nextCursor; + return this; + } + + public SearchResourcesResult build() { + return new SearchResourcesResult(resources, nextCursor); + } + } + + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record SearchResourceTemplatesRequest(// @formatter:off + @JsonProperty("query") String query, + @JsonProperty("cursor") String cursor) implements Request { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String query; + private String cursor; + + public Builder query(String query) { + Assert.notNull(query, "query must not be null"); + this.query = query; + return this; + } + + public Builder cursor(String cursor) { + Assert.notNull(cursor, "cursor must not be null"); + this.cursor = cursor; + return this; + } + + public SearchResourceTemplatesRequest build() { + return new SearchResourceTemplatesRequest(query, cursor); + } + } + + }// @formatter:off + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record SearchResourceTemplatesResult( // @formatter:off + @JsonProperty("resourceTemplates") List resourceTemplates, + @JsonProperty("nextCursor") String nextCursor) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private List resourceTemplates = new ArrayList<>(); + private String nextCursor; + + public Builder resourceTemplates(List resourceTemplates) { + Assert.notNull(resourceTemplates, "resourceTemplates must not be null"); + this.resourceTemplates = resourceTemplates; + return this; + } + + public Builder nextCursor(String nextCursor) { + Assert.notNull(nextCursor, "nextCursor must not be null"); + this.nextCursor = nextCursor; + return this; + } + + public SearchResourceTemplatesResult build() { + return new SearchResourceTemplatesResult(resourceTemplates, nextCursor); + } + } + + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record SearchPromptsRequest(// @formatter:off + @JsonProperty("query") String query, + @JsonProperty("cursor") String cursor) implements Request { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String query; + private String cursor; + + public Builder query(String query) { + Assert.notNull(query, "query must not be null"); + this.query = query; + return this; + } + + public Builder cursor(String cursor) { + Assert.notNull(cursor, "cursor must not be null"); + this.cursor = cursor; + return this; + } + + public SearchPromptsRequest build() { + return new SearchPromptsRequest(query, cursor); + } + } + + }// @formatter:off + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) + public record SearchPromptsResult( // @formatter:off + @JsonProperty("prompts") List prompts, + @JsonProperty("nextCursor") String nextCursor) { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private List prompts = new ArrayList<>(); + private String nextCursor; + + public Builder prompts(List prompts) { + Assert.notNull(prompts, "prompts must not be null"); + this.prompts = prompts; + return this; + } + + public Builder nextCursor(String nextCursor) { + Assert.notNull(nextCursor, "nextCursor must not be null"); + this.nextCursor = nextCursor; + return this; + } + + public SearchPromptsResult build() { + return new SearchPromptsResult(prompts, nextCursor); + } + } + + } // @formatter:on + // --------------------------- // Sampling Interfaces // --------------------------- From 51fe59441b907c3927116f628646f750b38feb34 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Wed, 28 May 2025 15:59:25 -0700 Subject: [PATCH 2/4] chore: implement serde tests for search data models --- .../spec/McpSchemaTests.java | 452 ++++++++++++++++++ 1 file changed, 452 insertions(+) diff --git a/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index ff78c1bf..05bf2c5a 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -14,6 +14,7 @@ import com.fasterxml.jackson.databind.exc.InvalidTypeIdException; import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; import net.javacrumbs.jsonunit.core.Option; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; @@ -749,6 +750,457 @@ void testCallToolResultStringConstructor() throws Exception { {"content":[{"type":"text","text":"Simple result"}],"isError":false}""")); } + // Tools search + + @Test + void testSearchToolsRequest() throws Exception { + McpSchema.SearchToolsRequest request = McpSchema.SearchToolsRequest.builder() + .query("foo") + .cursor("next-page-token") + .build(); + + String value = mapper.writeValueAsString(request); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"query":"foo","cursor":"next-page-token"}""")); + } + + @Test + void testSearchToolsRequestDeserialization() throws Exception { + // Test deserialization of a search request + String json = """ + {"query":"foo","cursor":"next-page-token"}"""; + + McpSchema.SearchToolsRequest request = mapper.readValue(json, McpSchema.SearchToolsRequest.class); + + assertThat(request.query()).isEqualTo("foo"); + assertThat(request.cursor()).isEqualTo("next-page-token"); + } + + @Test + void testSearchToolsResult() throws Exception { + // Create a simple JSON schema for testing + List tools = getToolList(); + + // Create the search result + McpSchema.SearchToolsResult result = McpSchema.SearchToolsResult.builder() + .tools(tools) + .nextCursor("next-cursor") + .build(); + + String value = mapper.writeValueAsString(result); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"tools":[ + {"name":"foo","description":"A foo tool","inputSchema":{"type":"object","properties":{"param":{"type":"string"}},"required":["param"]}}, + {"name":"bar","description":"A bar tool","inputSchema":{"type":"object","properties":{"param":{"type":"string"}},"required":["param"]}} + ],"nextCursor":"next-cursor"}""")); + } + + private static @NotNull List getToolList() { + String schemaJson = """ + { + "type": "object", + "properties": { + "param": { + "type": "string" + } + }, + "required": ["param"] + } + """; + + // Create tools for the result + McpSchema.Tool foo = new McpSchema.Tool("foo", "A foo tool", schemaJson); + McpSchema.Tool bar = new McpSchema.Tool("bar", "A bar tool", schemaJson); + + return Arrays.asList(foo, bar); + } + + @Test + void testSearchToolsResultDeserialization() throws Exception { + // Test deserialization of a search result + String json = """ + { + "tools": [ + { + "name": "foo", + "description": "A foo tool", + "inputSchema": { + "type": "object", + "properties": { + "param": { + "type": "string" + } + }, + "required": ["param"] + } + } + ], + "nextCursor": "next-cursor" + } + """; + + McpSchema.SearchToolsResult result = mapper.readValue(json, McpSchema.SearchToolsResult.class); + + assertThat(result.tools()).hasSize(1); + assertThat(result.tools().get(0).name()).isEqualTo("foo"); + assertThat(result.tools().get(0).description()).isEqualTo("A foo tool"); + assertThat(result.nextCursor()).isEqualTo("next-cursor"); + } + + @Test + void testSearchToolsResultWithEmptyTools() throws Exception { + // Create a search result with empty tools list + McpSchema.SearchToolsResult result = McpSchema.SearchToolsResult.builder() + .tools(Collections.emptyList()) + .build(); + + String value = mapper.writeValueAsString(result); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"tools":[]}""")); + } + + // Resources search Tests + + @Test + void testSearchResourcesRequest() throws Exception { + McpSchema.SearchResourcesRequest request = McpSchema.SearchResourcesRequest.builder() + .query("foo") + .cursor("next-page-token") + .build(); + + String value = mapper.writeValueAsString(request); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"query":"foo","cursor":"next-page-token"}""")); + } + + @Test + void testSearchResourcesRequestDeserialization() throws Exception { + // Test deserialization of a search request + String json = """ + {"query":"foo","cursor":"next-page-token"}"""; + + McpSchema.SearchResourcesRequest request = mapper.readValue(json, McpSchema.SearchResourcesRequest.class); + + assertThat(request.query()).isEqualTo("foo"); + assertThat(request.cursor()).isEqualTo("next-page-token"); + } + + @Test + void testSearchResourcesResult() throws Exception { + // Create annotations for testing + List resources = getResourceList(); + + // Create the search result + McpSchema.SearchResourcesResult result = McpSchema.SearchResourcesResult.builder() + .resources(resources) + .nextCursor("next-cursor") + .build(); + + String value = mapper.writeValueAsString(result); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"resources":[ + {"uri":"resource://foo","name":"Foo Resource","description":"A foo resource","mimeType":"text/plain","annotations":{"audience":["user","assistant"],"priority":0.8}}, + {"uri":"resource://bar","name":"Bar Resource","description":"A bar resource","mimeType":"text/plain","annotations":{"audience":["user","assistant"],"priority":0.8}} + ],"nextCursor":"next-cursor"}""")); + } + + private static @NotNull List getResourceList() { + McpSchema.Annotations annotations = new McpSchema.Annotations( + Arrays.asList(McpSchema.Role.USER, McpSchema.Role.ASSISTANT), 0.8); + + // Create resources for the result + McpSchema.Resource foo = new McpSchema.Resource("resource://foo", "Foo Resource", "A foo resource", + "text/plain", annotations); + McpSchema.Resource bar = new McpSchema.Resource("resource://bar", "Bar Resource", "A bar resource", + "text/plain", annotations); + + return Arrays.asList(foo, bar); + } + + @Test + void testSearchResourcesResultDeserialization() throws Exception { + // Test deserialization of a search result + String json = """ + { + "resources": [ + { + "uri": "resource://foo", + "name": "Foo Resource", + "description": "A foo resource", + "mimeType": "text/plain", + "annotations": { + "audience": ["user"], + "priority": 0.5 + } + } + ], + "nextCursor": "next-cursor" + } + """; + + McpSchema.SearchResourcesResult result = mapper.readValue(json, McpSchema.SearchResourcesResult.class); + + assertThat(result.resources()).hasSize(1); + assertThat(result.resources().get(0).uri()).isEqualTo("resource://foo"); + assertThat(result.resources().get(0).name()).isEqualTo("Foo Resource"); + assertThat(result.resources().get(0).description()).isEqualTo("A foo resource"); + assertThat(result.nextCursor()).isEqualTo("next-cursor"); + } + + @Test + void testSearchResourcesResultWithEmptyResources() throws Exception { + // Create a search result with empty resources list + McpSchema.SearchResourcesResult result = McpSchema.SearchResourcesResult.builder() + .resources(Collections.emptyList()) + .build(); + + String value = mapper.writeValueAsString(result); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"resources":[]}""")); + } + + // Resource templates search + + @Test + void testSearchResourceTemplatesRequest() throws Exception { + McpSchema.SearchResourceTemplatesRequest request = McpSchema.SearchResourceTemplatesRequest.builder() + .query("foo") + .cursor("next-page-token") + .build(); + + String value = mapper.writeValueAsString(request); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"query":"foo","cursor":"next-page-token"}""")); + } + + @Test + void testSearchResourceTemplatesRequestDeserialization() throws Exception { + // Test deserialization of a search request + String json = """ + {"query":"foo","cursor":"next-page-token"}"""; + + McpSchema.SearchResourceTemplatesRequest request = mapper.readValue(json, + McpSchema.SearchResourceTemplatesRequest.class); + + assertThat(request.query()).isEqualTo("foo"); + assertThat(request.cursor()).isEqualTo("next-page-token"); + } + + @Test + void testSearchResourceTemplatesResult() throws Exception { + // Create annotations for testing + List templates = getResourceTemplateList(); + + // Create the search result + McpSchema.SearchResourceTemplatesResult result = McpSchema.SearchResourceTemplatesResult.builder() + .resourceTemplates(templates) + .nextCursor("next-cursor") + .build(); + + String value = mapper.writeValueAsString(result); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"resourceTemplates":[ + {"uriTemplate":"resource://foo/{id}","name":"Foo Template","description":"A foo template","mimeType":"text/plain","annotations":{"audience":["user","assistant"],"priority":0.8}}, + {"uriTemplate":"resource://bar/{id}","name":"Bar Template","description":"A bar template","mimeType":"text/plain","annotations":{"audience":["user","assistant"],"priority":0.8}} + ],"nextCursor":"next-cursor"}""")); + } + + private static @NotNull List getResourceTemplateList() { + McpSchema.Annotations annotations = new McpSchema.Annotations( + Arrays.asList(McpSchema.Role.USER, McpSchema.Role.ASSISTANT), 0.8); + + // Create resource templates for the result + McpSchema.ResourceTemplate foo = new McpSchema.ResourceTemplate("resource://foo/{id}", "Foo Template", + "A foo template", "text/plain", annotations); + McpSchema.ResourceTemplate bar = new McpSchema.ResourceTemplate("resource://bar/{id}", "Bar Template", + "A bar template", "text/plain", annotations); + + return Arrays.asList(foo, bar); + } + + @Test + void testSearchResourceTemplatesResultDeserialization() throws Exception { + // Test deserialization of a search result + String json = """ + { + "resourceTemplates": [ + { + "uriTemplate": "resource://foo/{id}", + "name": "Foo Template", + "description": "A foo template", + "mimeType": "text/plain", + "annotations": { + "audience": ["user"], + "priority": 0.5 + } + } + ], + "nextCursor": "next-cursor" + } + """; + + McpSchema.SearchResourceTemplatesResult result = mapper.readValue(json, + McpSchema.SearchResourceTemplatesResult.class); + + assertThat(result.resourceTemplates()).hasSize(1); + assertThat(result.resourceTemplates().get(0).uriTemplate()).isEqualTo("resource://foo/{id}"); + assertThat(result.resourceTemplates().get(0).name()).isEqualTo("Foo Template"); + assertThat(result.resourceTemplates().get(0).description()).isEqualTo("A foo template"); + assertThat(result.nextCursor()).isEqualTo("next-cursor"); + } + + @Test + void testSearchResourceTemplatesResultWithEmptyTemplates() throws Exception { + // Create a search result with empty templates list + McpSchema.SearchResourceTemplatesResult result = McpSchema.SearchResourceTemplatesResult.builder() + .resourceTemplates(Collections.emptyList()) + .build(); + + String value = mapper.writeValueAsString(result); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"resourceTemplates":[]}""")); + } + + // Prompts search + + @Test + void testSearchPromptsRequest() throws Exception { + McpSchema.SearchPromptsRequest request = McpSchema.SearchPromptsRequest.builder() + .query("foo") + .cursor("next-page-token") + .build(); + + String value = mapper.writeValueAsString(request); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"query":"foo","cursor":"next-page-token"}""")); + } + + @Test + void testSearchPromptsRequestDeserialization() throws Exception { + // Test deserialization of a search request + String json = """ + {"query":"foo","cursor":"next-page-token"}"""; + + McpSchema.SearchPromptsRequest request = mapper.readValue(json, McpSchema.SearchPromptsRequest.class); + + assertThat(request.query()).isEqualTo("foo"); + assertThat(request.cursor()).isEqualTo("next-page-token"); + } + + @Test + void testSearchPromptsResult() throws Exception { + // Create prompt arguments for testing + List prompts = getPromptList(); + + // Create the search result + McpSchema.SearchPromptsResult result = McpSchema.SearchPromptsResult.builder() + .prompts(prompts) + .nextCursor("next-cursor") + .build(); + + String value = mapper.writeValueAsString(result); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo( + json(""" + {"prompts":[ + {"name":"foo","description":"A foo prompt","arguments":[{"name":"param1","description":"First parameter","required":true},{"name":"param2","description":"Second parameter","required":false}]}, + {"name":"bar","description":"A bar prompt","arguments":[{"name":"param1","description":"First parameter","required":true}]} + ],"nextCursor":"next-cursor"}""")); + } + + private static @NotNull List getPromptList() { + McpSchema.PromptArgument arg1 = new McpSchema.PromptArgument("param1", "First parameter", true); + McpSchema.PromptArgument arg2 = new McpSchema.PromptArgument("param2", "Second parameter", false); + + // Create prompts for the result + McpSchema.Prompt foo = new McpSchema.Prompt("foo", "A foo prompt", Arrays.asList(arg1, arg2)); + McpSchema.Prompt bar = new McpSchema.Prompt("bar", "A bar prompt", Collections.singletonList(arg1)); + + return Arrays.asList(foo, bar); + } + + @Test + void testSearchPromptsResultDeserialization() throws Exception { + // Test deserialization of a search result + String json = """ + { + "prompts": [ + { + "name": "foo", + "description": "A foo prompt", + "arguments": [ + { + "name": "param1", + "description": "First parameter", + "required": true + } + ] + } + ], + "nextCursor": "next-cursor" + } + """; + + McpSchema.SearchPromptsResult result = mapper.readValue(json, McpSchema.SearchPromptsResult.class); + + assertThat(result.prompts()).hasSize(1); + assertThat(result.prompts().get(0).name()).isEqualTo("foo"); + assertThat(result.prompts().get(0).description()).isEqualTo("A foo prompt"); + assertThat(result.prompts().get(0).arguments()).hasSize(1); + assertThat(result.prompts().get(0).arguments().get(0).name()).isEqualTo("param1"); + assertThat(result.nextCursor()).isEqualTo("next-cursor"); + } + + @Test + void testSearchPromptsResultWithEmptyPrompts() throws Exception { + // Create a search result with empty prompts list + McpSchema.SearchPromptsResult result = McpSchema.SearchPromptsResult.builder() + .prompts(Collections.emptyList()) + .build(); + + String value = mapper.writeValueAsString(result); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) + .isObject() + .isEqualTo(json(""" + {"prompts":[]}""")); + } + // Sampling Tests @Test From 03271f1de70b71e6c5acd42ff9c2c7d452f553a0 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Thu, 29 May 2025 16:56:30 -0700 Subject: [PATCH 3/4] feat: search implementation --- .../WebFluxSseIntegrationTests.java | 146 ++++++++++++++++++ .../server/WebMvcSseIntegrationTests.java | 139 +++++++++++++++++ .../client/McpAsyncClient.java | 59 +++++++ .../client/McpSyncClient.java | 21 +++ .../server/McpAsyncServer.java | 111 ++++++++++++- .../server/McpServer.java | 78 +++++++++- .../server/McpServerFeatures.java | 67 +++++++- .../modelcontextprotocol/spec/McpSchema.java | 124 ++++++++++++++- 8 files changed, 727 insertions(+), 18 deletions(-) diff --git a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java index 03fbc996..aa94af22 100644 --- a/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java +++ b/mcp-spring/mcp-spring-webflux/src/test/java/io/modelcontextprotocol/WebFluxSseIntegrationTests.java @@ -559,6 +559,39 @@ void testToolCallSuccess(String clientType) { mcpServer.close(); } + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient", "webflux" }) + void testSearchToolsSuccess(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + var tool1Spec = new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema); + var searchResponse = McpSchema.SearchToolsResult.builder().tools(List.of(tool1Spec)).build(); + McpServerFeatures.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification(tool1Spec, + (exchange, request) -> CallToolResult.builder().textContent(List.of("CALL RESPONSE")).build()); + + var mcpServer = McpServer.sync(mcpServerTransportProvider) + .capabilities(ServerCapabilities.builder() + .tools(ServerCapabilities.ToolCapabilities.builder().search(true).build()) + .build()) + .tools(tool1) + .toolSearchHandler((exchange, request) -> searchResponse) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(mcpClient.searchTools(SearchToolsRequest.builder().query("test").build()).tools()) + .contains(tool1.tool()); + + mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + } + + mcpServer.close(); + } + @ParameterizedTest(name = "{0} : {displayName} ") @ValueSource(strings = { "httpclient", "webflux" }) void testToolListChangeHandlingSuccess(String clientType) { @@ -631,6 +664,119 @@ void testToolListChangeHandlingSuccess(String clientType) { mcpServer.close(); } + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient", "webflux" }) + void testSearchResourcesSuccess(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + var tool1Spec = new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema); + + var resource = new Resource("uri://test", "test", "test", "text/plain", null); + var searchResponse = McpSchema.SearchResourcesResult.builder().resources(List.of(resource)).build(); + + McpServerFeatures.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification(tool1Spec, + (exchange, request) -> CallToolResult.builder().textContent(List.of("CALL RESPONSE")).build()); + + var mcpServer = McpServer.sync(mcpServerTransportProvider) + .capabilities(ServerCapabilities.builder() + .tools(ServerCapabilities.ToolCapabilities.builder().build()) + .resources(ServerCapabilities.ResourceCapabilities.builder().search(true).build()) + .build()) + .tools(tool1) + .resourceSearchHandler((exchange, request) -> searchResponse) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(mcpClient.searchResources(SearchResourcesRequest.builder().query("test").build()).resources()) + .contains(resource); + + mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + } + + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient", "webflux" }) + void testSearchResourceTemplatesSuccess(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + var tool1Spec = new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema); + + var resourceTemplate = new ResourceTemplate("uri://test", "test", "test", "text/plain", null); + var searchResponse = McpSchema.SearchResourceTemplatesResult.builder() + .resourceTemplates(List.of(resourceTemplate)) + .build(); + + McpServerFeatures.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification(tool1Spec, + (exchange, request) -> CallToolResult.builder().textContent(List.of("CALL RESPONSE")).build()); + + var mcpServer = McpServer.sync(mcpServerTransportProvider) + .capabilities(ServerCapabilities.builder() + .tools(ServerCapabilities.ToolCapabilities.builder().build()) + .resources(ServerCapabilities.ResourceCapabilities.builder().search(true).build()) + .build()) + .tools(tool1) + .resourceTemplateSearchHandler((exchange, request) -> searchResponse) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(mcpClient.searchResourceTemplates(SearchResourceTemplatesRequest.builder().query("test").build()) + .resourceTemplates()).contains(resourceTemplate); + + mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + } + + mcpServer.close(); + } + + @ParameterizedTest(name = "{0} : {displayName} ") + @ValueSource(strings = { "httpclient", "webflux" }) + void testSearchPromptsSuccess(String clientType) { + + var clientBuilder = clientBuilders.get(clientType); + + var tool1Spec = new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema); + + var prompt = new Prompt("test", "test", List.of()); + var searchResponse = McpSchema.SearchPromptsResult.builder().prompts(List.of(prompt)).build(); + + McpServerFeatures.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification(tool1Spec, + (exchange, request) -> CallToolResult.builder().textContent(List.of("CALL RESPONSE")).build()); + + var mcpServer = McpServer.sync(mcpServerTransportProvider) + .capabilities(ServerCapabilities.builder() + .tools(ServerCapabilities.ToolCapabilities.builder().build()) + .prompts(ServerCapabilities.PromptCapabilities.builder().search(true).build()) + .build()) + .tools(tool1) + .promptSearchHandler((exchange, request) -> searchResponse) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(mcpClient.searchPrompts(SearchPromptsRequest.builder().query("test").build()).prompts()) + .contains(prompt); + + mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + } + + mcpServer.close(); + } + @ParameterizedTest(name = "{0} : {displayName} ") @ValueSource(strings = { "httpclient", "webflux" }) void testInitialize(String clientType) { diff --git a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationTests.java b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationTests.java index b12d6843..8d0dba12 100644 --- a/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationTests.java +++ b/mcp-spring/mcp-spring-webmvc/src/test/java/io/modelcontextprotocol/server/WebMvcSseIntegrationTests.java @@ -635,6 +635,145 @@ void testToolListChangeHandlingSuccess() { mcpServer.close(); } + // --------------------------------------- + // Search Tests + // --------------------------------------- + @Test + void testSearchToolsSuccess() { + + var tool1Spec = new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema); + var searchResponse = McpSchema.SearchToolsResult.builder().tools(List.of(tool1Spec)).build(); + McpServerFeatures.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification(tool1Spec, + (exchange, request) -> CallToolResult.builder().textContent(List.of("CALL RESPONSE")).build()); + + var mcpServer = McpServer.sync(mcpServerTransportProvider) + .capabilities(ServerCapabilities.builder() + .tools(ServerCapabilities.ToolCapabilities.builder().search(true).build()) + .build()) + .tools(tool1) + .toolSearchHandler((exchange, request) -> searchResponse) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(mcpClient.searchTools(McpSchema.SearchToolsRequest.builder().query("test").build()).tools()) + .contains(tool1.tool()); + + mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + } + + mcpServer.close(); + } + + @Test + void testSearchResourcesSuccess() { + + var tool1Spec = new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema); + + var resource = new McpSchema.Resource("uri://test", "test", "test", "text/plain", null); + var searchResponse = McpSchema.SearchResourcesResult.builder().resources(List.of(resource)).build(); + + McpServerFeatures.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification(tool1Spec, + (exchange, request) -> CallToolResult.builder().textContent(List.of("CALL RESPONSE")).build()); + + var mcpServer = McpServer.sync(mcpServerTransportProvider) + .capabilities(ServerCapabilities.builder() + .tools(ServerCapabilities.ToolCapabilities.builder().build()) + .resources(ServerCapabilities.ResourceCapabilities.builder().search(true).build()) + .build()) + .tools(tool1) + .resourceSearchHandler((exchange, request) -> searchResponse) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(mcpClient.searchResources(McpSchema.SearchResourcesRequest.builder().query("test").build()) + .resources()).contains(resource); + + mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + } + + mcpServer.close(); + } + + @Test + void testSearchResourceTemplatesSuccess() { + + var tool1Spec = new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema); + + var resourceTemplate = new McpSchema.ResourceTemplate("uri://test", "test", "test", "text/plain", null); + var searchResponse = McpSchema.SearchResourceTemplatesResult.builder() + .resourceTemplates(List.of(resourceTemplate)) + .build(); + + McpServerFeatures.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification(tool1Spec, + (exchange, request) -> CallToolResult.builder().textContent(List.of("CALL RESPONSE")).build()); + + var mcpServer = McpServer.sync(mcpServerTransportProvider) + .capabilities(ServerCapabilities.builder() + .tools(ServerCapabilities.ToolCapabilities.builder().build()) + .resources(ServerCapabilities.ResourceCapabilities.builder().search(true).build()) + .build()) + .tools(tool1) + .resourceTemplateSearchHandler((exchange, request) -> searchResponse) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat(mcpClient + .searchResourceTemplates(McpSchema.SearchResourceTemplatesRequest.builder().query("test").build()) + .resourceTemplates()).contains(resourceTemplate); + + mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + } + + mcpServer.close(); + } + + @Test + void testSearchPromptsSuccess() { + + var tool1Spec = new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema); + + var prompt = new McpSchema.Prompt("test", "test", List.of()); + var searchResponse = McpSchema.SearchPromptsResult.builder().prompts(List.of(prompt)).build(); + + McpServerFeatures.SyncToolSpecification tool1 = new McpServerFeatures.SyncToolSpecification(tool1Spec, + (exchange, request) -> CallToolResult.builder().textContent(List.of("CALL RESPONSE")).build()); + + var mcpServer = McpServer.sync(mcpServerTransportProvider) + .capabilities(ServerCapabilities.builder() + .tools(ServerCapabilities.ToolCapabilities.builder().build()) + .prompts(ServerCapabilities.PromptCapabilities.builder().search(true).build()) + .build()) + .tools(tool1) + .promptSearchHandler((exchange, request) -> searchResponse) + .build(); + + try (var mcpClient = clientBuilder.build()) { + + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + assertThat( + mcpClient.searchPrompts(McpSchema.SearchPromptsRequest.builder().query("test").build()).prompts()) + .contains(prompt); + + mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())); + } + + mcpServer.close(); + } + @Test void testInitialize() { diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index e3a997ba..21dd691c 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -838,4 +838,63 @@ public Mono completeCompletion(McpSchema.CompleteReque .sendRequest(McpSchema.METHOD_COMPLETION_COMPLETE, completeRequest, COMPLETION_COMPLETE_RESULT_TYPE_REF)); } + // -------------------------- + // Search + // -------------------------- + + private static final TypeReference SEARCH_TOOLS_RESULT_TYPE_REF = new TypeReference<>() { + }; + + private static final TypeReference SEARCH_RESOURCES_RESULT_TYPE_REF = new TypeReference<>() { + }; + + private static final TypeReference SEARCH_RESOURCE_TEMPLATES_RESULT_TYPE_REF = new TypeReference<>() { + }; + + private static final TypeReference SEARCH_PROMPTS_RESULT_TYPE_REF = new TypeReference<>() { + }; + + public Mono searchTools(McpSchema.SearchToolsRequest searchToolsRequest) { + return this.withInitializationCheck("searching tools", initializedResult -> { + if (this.serverCapabilities.tools() == null || !this.serverCapabilities.tools().search()) { + return Mono.error(new McpError("Server does not provide the tool search capability")); + } + return this.mcpSession.sendRequest(McpSchema.METHOD_TOOLS_SEARCH, searchToolsRequest, + SEARCH_TOOLS_RESULT_TYPE_REF); + }); + } + + public Mono searchResources( + McpSchema.SearchResourcesRequest searchResourcesRequest) { + return this.withInitializationCheck("searching resources", initializedResult -> { + if (this.serverCapabilities.resources() == null || !this.serverCapabilities.resources().search()) { + return Mono.error(new McpError("Server does not provide the resource search capability")); + } + return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_SEARCH, searchResourcesRequest, + SEARCH_RESOURCES_RESULT_TYPE_REF); + }); + } + + public Mono searchResourceTemplates( + McpSchema.SearchResourceTemplatesRequest searchResourceTemplatesRequest) { + return this.withInitializationCheck("searching resource templates", initializedResult -> { + if (this.serverCapabilities.resources() == null || !this.serverCapabilities.resources().search()) { + // shares a capability with resources + return Mono.error(new McpError("Server does not provide the resource search capability")); + } + return this.mcpSession.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_SEARCH, + searchResourceTemplatesRequest, SEARCH_RESOURCE_TEMPLATES_RESULT_TYPE_REF); + }); + } + + public Mono searchPrompts(McpSchema.SearchPromptsRequest searchPromptsRequest) { + return this.withInitializationCheck("searching prompts", initializedResult -> { + if (this.serverCapabilities.prompts() == null || !this.serverCapabilities.prompts().search()) { + return Mono.error(new McpError("Server does not provide the tool search capability")); + } + return this.mcpSession.sendRequest(McpSchema.METHOD_PROMPT_SEARCH, searchPromptsRequest, + SEARCH_PROMPTS_RESULT_TYPE_REF); + }); + } + } diff --git a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java index a8fb979e..8c6b4515 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java +++ b/mcp/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java @@ -14,6 +14,7 @@ import io.modelcontextprotocol.util.Assert; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; /** * A synchronous client implementation for the Model Context Protocol (MCP) that wraps an @@ -353,4 +354,24 @@ public McpSchema.CompleteResult completeCompletion(McpSchema.CompleteRequest com return this.delegate.completeCompletion(completeRequest).block(); } + // -------------------------- + // Search + // -------------------------- + public McpSchema.SearchToolsResult searchTools(McpSchema.SearchToolsRequest searchToolsRequest) { + return this.delegate.searchTools(searchToolsRequest).block(); + } + + public McpSchema.SearchResourcesResult searchResources(McpSchema.SearchResourcesRequest searchResourcesRequest) { + return this.delegate.searchResources(searchResourcesRequest).block(); + } + + public McpSchema.SearchResourceTemplatesResult searchResourceTemplates( + McpSchema.SearchResourceTemplatesRequest searchResourceTemplatesRequest) { + return this.delegate.searchResourceTemplates(searchResourceTemplatesRequest).block(); + } + + public McpSchema.SearchPromptsResult searchPrompts(McpSchema.SearchPromptsRequest searchPromptsRequest) { + return this.delegate.searchPrompts(searchPromptsRequest).block(); + } + } diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index 1efa13de..26d64f9d 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -5,12 +5,7 @@ package io.modelcontextprotocol.server; import java.time.Duration; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.BiFunction; @@ -110,6 +105,14 @@ public class McpAsyncServer { private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DeafaultMcpUriTemplateManagerFactory(); + private final BiFunction> toolSearchHandler; + + private final BiFunction> resourceSearchHandler; + + private final BiFunction> resourceTemplateSearchHandler; + + private final BiFunction> promptSearchHandler; + /** * Create a new McpAsyncServer with the given transport provider and capabilities. * @param mcpTransportProvider The transport layer implementation for MCP @@ -131,6 +134,10 @@ public class McpAsyncServer { this.prompts.putAll(features.prompts()); this.completions.putAll(features.completions()); this.uriTemplateManagerFactory = uriTemplateManagerFactory; + this.toolSearchHandler = features.toolSearchHandler(); + this.resourceSearchHandler = features.resourceSearchHandler(); + this.resourceTemplateSearchHandler = features.resourceTemplateSearchHandler(); + this.promptSearchHandler = features.promptSearchHandler(); Map> requestHandlers = new HashMap<>(); @@ -143,6 +150,10 @@ public class McpAsyncServer { if (this.serverCapabilities.tools() != null) { requestHandlers.put(McpSchema.METHOD_TOOLS_LIST, toolsListRequestHandler()); requestHandlers.put(McpSchema.METHOD_TOOLS_CALL, toolsCallRequestHandler()); + + if (this.serverCapabilities.tools().search()) { + requestHandlers.put(McpSchema.METHOD_TOOLS_SEARCH, searchToolsRequestHandler()); + } } // Add resources API handlers if provided @@ -150,12 +161,22 @@ public class McpAsyncServer { requestHandlers.put(McpSchema.METHOD_RESOURCES_LIST, resourcesListRequestHandler()); requestHandlers.put(McpSchema.METHOD_RESOURCES_READ, resourcesReadRequestHandler()); requestHandlers.put(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, resourceTemplateListRequestHandler()); + + if (this.serverCapabilities.resources().search()) { + requestHandlers.put(McpSchema.METHOD_RESOURCES_SEARCH, searchResourcesRequestHandler()); + requestHandlers.put(McpSchema.METHOD_RESOURCES_TEMPLATES_SEARCH, + searchResourceTemplatesRequestHandler()); + } } // Add prompts API handlers if provider exists if (this.serverCapabilities.prompts() != null) { requestHandlers.put(McpSchema.METHOD_PROMPT_LIST, promptsListRequestHandler()); requestHandlers.put(McpSchema.METHOD_PROMPT_GET, promptsGetRequestHandler()); + + if (this.serverCapabilities.prompts().search()) { + requestHandlers.put(McpSchema.METHOD_PROMPT_SEARCH, searchPromptsRequestHandler()); + } } // Add logging API handlers if the logging capability is enabled @@ -694,6 +715,84 @@ private McpServerSession.RequestHandler completionComp }; } + // --------------------------------------- + // Search + // --------------------------------------- + + private McpServerSession.RequestHandler searchToolsRequestHandler() { + return (exchange, params) -> { + McpSchema.ServerCapabilities.ToolCapabilities toolCapabilities = serverCapabilities.tools(); + if (toolCapabilities == null || !toolCapabilities.search()) { + return Mono.error(new McpError("Server must be configured with tool search capabilities")); + } + + if (toolSearchHandler == null) { + return Mono.error(new McpError("Tool search handler must not be null")); + } + + McpSchema.SearchToolsRequest request = objectMapper.convertValue(params, new TypeReference<>() { + }); + + return toolSearchHandler.apply(exchange, request); + }; + } + + private McpServerSession.RequestHandler searchResourcesRequestHandler() { + return (exchange, params) -> { + McpSchema.ServerCapabilities.ResourceCapabilities resourceCapabilities = serverCapabilities.resources(); + if (resourceCapabilities == null || !resourceCapabilities.search()) { + return Mono.error(new McpError("Server must be configured with resource search capabilities")); + } + + if (resourceSearchHandler == null) { + return Mono.error(new McpError("Resource search handler must not be null")); + } + + McpSchema.SearchResourcesRequest request = objectMapper.convertValue(params, new TypeReference<>() { + }); + + return resourceSearchHandler.apply(exchange, request); + }; + } + + private McpServerSession.RequestHandler searchResourceTemplatesRequestHandler() { + return (exchange, params) -> { + McpSchema.ServerCapabilities.ResourceCapabilities resourceCapabilities = serverCapabilities.resources(); + if (resourceCapabilities == null || !resourceCapabilities.search()) { + // Controlled with the same capability as resources - raise a resource + // error + return Mono.error(new McpError("Server must be configured with resource search capabilities")); + } + + if (resourceTemplateSearchHandler == null) { + return Mono.error(new McpError("Resource template search handler must not be null")); + } + + McpSchema.SearchResourceTemplatesRequest request = objectMapper.convertValue(params, new TypeReference<>() { + }); + + return resourceTemplateSearchHandler.apply(exchange, request); + }; + } + + private McpServerSession.RequestHandler searchPromptsRequestHandler() { + return (exchange, params) -> { + McpSchema.ServerCapabilities.PromptCapabilities promptCapabilities = serverCapabilities.prompts(); + if (promptCapabilities == null || !promptCapabilities.search()) { + return Mono.error(new McpError("Server must be configured with prompt search capabilities")); + } + + if (promptSearchHandler == null) { + return Mono.error(new McpError("Prompt search handler must not be null")); + } + + McpSchema.SearchPromptsRequest request = objectMapper.convertValue(params, new TypeReference<>() { + }); + + return promptSearchHandler.apply(exchange, request); + }; + } + /** * Parses the raw JSON-RPC request parameters into a {@link McpSchema.CompleteRequest} * object. diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java index d6ec2cc3..3c60262b 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -201,6 +201,14 @@ class AsyncSpecification { private final List, Mono>> rootsChangeHandlers = new ArrayList<>(); + private BiFunction> toolSearchHandler; + + private BiFunction> resourceSearchHandler; + + private BiFunction> resourceTemplateSearchHandler; + + private BiFunction> promptSearchHandler; + private Duration requestTimeout = Duration.ofSeconds(10); // Default timeout private AsyncSpecification(McpServerTransportProvider transportProvider) { @@ -612,6 +620,34 @@ public AsyncSpecification rootsChangeHandlers( return this.rootsChangeHandlers(Arrays.asList(handlers)); } + public AsyncSpecification toolSearchHandler( + BiFunction> handler) { + Assert.notNull(handler, "Handler must not be null"); + this.toolSearchHandler = handler; + return this; + } + + public AsyncSpecification resourceSearchHandler( + BiFunction> handler) { + Assert.notNull(handler, "Handler must not be null"); + this.resourceSearchHandler = handler; + return this; + } + + public AsyncSpecification resourceTemplateSearchHandler( + BiFunction> handler) { + Assert.notNull(handler, "Handler must not be null"); + this.resourceTemplateSearchHandler = handler; + return this; + } + + public AsyncSpecification promptSearchHandler( + BiFunction> handler) { + Assert.notNull(handler, "Handler must not be null"); + this.promptSearchHandler = handler; + return this; + } + /** * Sets the object mapper to use for serializing and deserializing JSON messages. * @param objectMapper the instance to use. Must not be null. @@ -632,7 +668,8 @@ public AsyncSpecification objectMapper(ObjectMapper objectMapper) { public McpAsyncServer build() { var features = new McpServerFeatures.Async(this.serverInfo, this.serverCapabilities, this.tools, this.resources, this.resourceTemplates, this.prompts, this.completions, this.rootsChangeHandlers, - this.instructions); + this.instructions, this.toolSearchHandler, this.resourceSearchHandler, + this.resourceTemplateSearchHandler, this.promptSearchHandler); var mapper = this.objectMapper != null ? this.objectMapper : new ObjectMapper(); return new McpAsyncServer(this.transportProvider, mapper, features, this.requestTimeout, this.uriTemplateManagerFactory); @@ -693,6 +730,14 @@ class SyncSpecification { private final List>> rootsChangeHandlers = new ArrayList<>(); + private BiFunction toolSearchHandler; + + private BiFunction resourceSearchHandler; + + private BiFunction resourceTemplateSearchHandler; + + private BiFunction promptSearchHandler; + private Duration requestTimeout = Duration.ofSeconds(10); // Default timeout private SyncSpecification(McpServerTransportProvider transportProvider) { @@ -1104,6 +1149,34 @@ public SyncSpecification rootsChangeHandlers( return this.rootsChangeHandlers(List.of(handlers)); } + public SyncSpecification toolSearchHandler( + BiFunction handler) { + Assert.notNull(handler, "Handler must not be null"); + this.toolSearchHandler = handler; + return this; + } + + public SyncSpecification resourceSearchHandler( + BiFunction handler) { + Assert.notNull(handler, "Handler must not be null"); + this.resourceSearchHandler = handler; + return this; + } + + public SyncSpecification resourceTemplateSearchHandler( + BiFunction handler) { + Assert.notNull(handler, "Handler must not be null"); + this.resourceTemplateSearchHandler = handler; + return this; + } + + public SyncSpecification promptSearchHandler( + BiFunction handler) { + Assert.notNull(handler, "Handler must not be null"); + this.promptSearchHandler = handler; + return this; + } + /** * Sets the object mapper to use for serializing and deserializing JSON messages. * @param objectMapper the instance to use. Must not be null. @@ -1124,7 +1197,8 @@ public SyncSpecification objectMapper(ObjectMapper objectMapper) { public McpSyncServer build() { McpServerFeatures.Sync syncFeatures = new McpServerFeatures.Sync(this.serverInfo, this.serverCapabilities, this.tools, this.resources, this.resourceTemplates, this.prompts, this.completions, - this.rootsChangeHandlers, this.instructions); + this.rootsChangeHandlers, this.instructions, this.toolSearchHandler, this.resourceSearchHandler, + this.resourceTemplateSearchHandler, this.promptSearchHandler); McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures); var mapper = this.objectMapper != null ? this.objectMapper : new ObjectMapper(); var asyncServer = new McpAsyncServer(this.transportProvider, mapper, asyncFeatures, this.requestTimeout, diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java index 8311f5d4..4c30212e 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java @@ -37,6 +37,10 @@ public class McpServerFeatures { * @param rootsChangeConsumers The list of consumers that will be notified when the * roots list changes * @param instructions The server instructions text + * @param toolSearchHandler The tool search provider + * @param resourceSearchHandler The resource search provider + * @param resourceTemplateSearchHandler The resource template search provider + * @param promptSearchHandler The prompt search provider */ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, @@ -44,7 +48,11 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s Map prompts, Map completions, List, Mono>> rootsChangeConsumers, - String instructions) { + String instructions, + BiFunction> toolSearchHandler, + BiFunction> resourceSearchHandler, + BiFunction> resourceTemplateSearchHandler, + BiFunction> promptSearchHandler) { /** * Create an instance and validate the arguments. @@ -57,6 +65,10 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s * @param rootsChangeConsumers The list of consumers that will be notified when * the roots list changes * @param instructions The server instructions text + * @param toolSearchHandler The tool search provider + * @param resourceSearchHandler The resource search provider + * @param resourceTemplateSearchHandler The resource template search provider + * @param promptSearchHandler The prompt search provider */ Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, @@ -64,7 +76,11 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s Map prompts, Map completions, List, Mono>> rootsChangeConsumers, - String instructions) { + String instructions, + BiFunction> toolSearchHandler, + BiFunction> resourceSearchHandler, + BiFunction> resourceTemplateSearchHandler, + BiFunction> promptSearchHandler) { Assert.notNull(serverInfo, "Server info must not be null"); @@ -88,6 +104,10 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s this.completions = (completions != null) ? completions : Map.of(); this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : List.of(); this.instructions = instructions; + this.toolSearchHandler = toolSearchHandler; + this.resourceSearchHandler = resourceSearchHandler; + this.resourceTemplateSearchHandler = resourceTemplateSearchHandler; + this.promptSearchHandler = promptSearchHandler; } /** @@ -127,8 +147,22 @@ static Async fromSync(Sync syncSpec) { .subscribeOn(Schedulers.boundedElastic())); } + BiFunction> toolSearchHandler = ( + exchange, request) -> Mono.fromCallable( + () -> syncSpec.toolSearchHandler().apply(new McpSyncServerExchange(exchange), request)); + BiFunction> resourceSearchHandler = ( + exchange, request) -> Mono.fromCallable( + () -> syncSpec.resourceSearchHandler().apply(new McpSyncServerExchange(exchange), request)); + BiFunction> resourceTemplateSearchHandler = ( + exchange, request) -> Mono.fromCallable(() -> syncSpec.resourceTemplateSearchHandler() + .apply(new McpSyncServerExchange(exchange), request)); + BiFunction> promptSearchHandler = ( + exchange, request) -> Mono.fromCallable( + () -> syncSpec.promptSearchHandler().apply(new McpSyncServerExchange(exchange), request)); + return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, - syncSpec.resourceTemplates(), prompts, completions, rootChangeConsumers, syncSpec.instructions()); + syncSpec.resourceTemplates(), prompts, completions, rootChangeConsumers, syncSpec.instructions(), + toolSearchHandler, resourceSearchHandler, resourceTemplateSearchHandler, promptSearchHandler); } } @@ -144,6 +178,10 @@ static Async fromSync(Sync syncSpec) { * @param rootsChangeConsumers The list of consumers that will be notified when the * roots list changes * @param instructions The server instructions text + * @param toolSearchHandler The tool search provider + * @param resourceSearchHandler The resource search provider + * @param resourceTemplateSearchHandler The resource template search provider + * @param promptSearchHandler The prompt search provider */ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, @@ -151,7 +189,11 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se List resourceTemplates, Map prompts, Map completions, - List>> rootsChangeConsumers, String instructions) { + List>> rootsChangeConsumers, String instructions, + BiFunction toolSearchHandler, + BiFunction resourceSearchHandler, + BiFunction resourceTemplateSearchHandler, + BiFunction promptSearchHandler) { /** * Create an instance and validate the arguments. @@ -164,6 +206,10 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se * @param rootsChangeConsumers The list of consumers that will be notified when * the roots list changes * @param instructions The server instructions text + * @param toolSearchHandler The tool search provider + * @param resourceSearchHandler The resource search provider + * @param resourceTemplateSearchHandler The resource template search provider + * @param promptSearchHandler The prompt search provider */ Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, @@ -171,8 +217,11 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se List resourceTemplates, Map prompts, Map completions, - List>> rootsChangeConsumers, - String instructions) { + List>> rootsChangeConsumers, String instructions, + BiFunction toolSearchHandler, + BiFunction resourceSearchHandler, + BiFunction resourceTemplateSearchHandler, + BiFunction promptSearchHandler) { Assert.notNull(serverInfo, "Server info must not be null"); @@ -196,6 +245,10 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se this.completions = (completions != null) ? completions : new HashMap<>(); this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : new ArrayList<>(); this.instructions = instructions; + this.toolSearchHandler = toolSearchHandler; + this.resourceSearchHandler = resourceSearchHandler; + this.resourceTemplateSearchHandler = resourceTemplateSearchHandler; + this.promptSearchHandler = promptSearchHandler; } } @@ -496,4 +549,4 @@ public record SyncCompletionSpecification(McpSchema.CompleteReference referenceK BiFunction completionHandler) { } -} +} \ No newline at end of file diff --git a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 289fc006..13682237 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -342,18 +342,121 @@ public record LoggingCapabilities() { @JsonInclude(JsonInclude.Include.NON_ABSENT) public record PromptCapabilities( - @JsonProperty("listChanged") Boolean listChanged) { + @JsonProperty("listChanged") Boolean listChanged, + @JsonProperty("search") Boolean search) { + + // old ctor for backwards-compat + public PromptCapabilities(Boolean listChanged) { + this(listChanged, false); + } + + public static Builder builder() { + return new Builder() + .listChanged(false) + .search(false); + } + + public static class Builder { + + private Boolean listChanged; + private Boolean search; + + public Builder listChanged(Boolean listChanged) { + this.listChanged = listChanged; + return this; + } + + public Builder search(Boolean search) { + this.search = search; + return this; + } + + public PromptCapabilities build() { + return new PromptCapabilities(listChanged, search); + } + } } @JsonInclude(JsonInclude.Include.NON_ABSENT) public record ResourceCapabilities( @JsonProperty("subscribe") Boolean subscribe, - @JsonProperty("listChanged") Boolean listChanged) { + @JsonProperty("listChanged") Boolean listChanged, + @JsonProperty("search") Boolean search) { + + // old ctor for backwards-compat + public ResourceCapabilities(Boolean subscribe, Boolean listChanged) { + this(subscribe, listChanged, false); + } + + public static Builder builder() { + return new Builder() + .subscribe(false) + .listChanged(false) + .search(false); + } + + public static class Builder { + + private Boolean subscribe; + private Boolean listChanged; + private Boolean search; + + public Builder subscribe(Boolean subscribe) { + this.subscribe = subscribe; + return this; + } + + public Builder listChanged(Boolean listChanged) { + this.listChanged = listChanged; + return this; + } + + public Builder search(Boolean search) { + this.search = search; + return this; + } + + public ResourceCapabilities build() { + return new ResourceCapabilities(subscribe, listChanged, search); + } + } } @JsonInclude(JsonInclude.Include.NON_ABSENT) public record ToolCapabilities( - @JsonProperty("listChanged") Boolean listChanged) { + @JsonProperty("listChanged") Boolean listChanged, + @JsonProperty("search") Boolean search) { + + // old ctor for backwards-compat + public ToolCapabilities(Boolean listChanged) { + this(listChanged, false); + } + + public static Builder builder() { + return new Builder() + .listChanged(false) + .search(false); + } + + public static class Builder { + + private Boolean listChanged; + private Boolean search; + + public Builder listChanged(Boolean listChanged) { + this.listChanged = listChanged; + return this; + } + + public Builder search(Boolean search) { + this.search = search; + return this; + } + + public ToolCapabilities build() { + return new ToolCapabilities(listChanged, search); + } + } } public static Builder builder() { @@ -389,16 +492,31 @@ public Builder prompts(Boolean listChanged) { return this; } + public Builder prompts(PromptCapabilities promptCapabilities) { + this.prompts = promptCapabilities; + return this; + } + public Builder resources(Boolean subscribe, Boolean listChanged) { this.resources = new ResourceCapabilities(subscribe, listChanged); return this; } + public Builder resources(ResourceCapabilities resourceCapabilities) { + this.resources = resourceCapabilities; + return this; + } + public Builder tools(Boolean listChanged) { this.tools = new ToolCapabilities(listChanged); return this; } + public Builder tools(ToolCapabilities toolCapabilities) { + this.tools = toolCapabilities; + return this; + } + public ServerCapabilities build() { return new ServerCapabilities(completions, experimental, logging, prompts, resources, tools); } From ae4741071d39c49ae25de3daa69e2dbd60f11af5 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Thu, 29 May 2025 17:02:15 -0700 Subject: [PATCH 4/4] chore: fix failing initialize schema test --- .../test/java/io/modelcontextprotocol/spec/McpSchemaTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 05bf2c5a..f27c4099 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -252,7 +252,7 @@ void testInitializeResult() throws Exception { .isObject() .isEqualTo( json(""" - {"protocolVersion":"2024-11-05","capabilities":{"logging":{},"prompts":{"listChanged":true},"resources":{"subscribe":true,"listChanged":true},"tools":{"listChanged":true}},"serverInfo":{"name":"test-server","version":"1.0.0"},"instructions":"Server initialized successfully"}""")); + {"protocolVersion":"2024-11-05","capabilities":{"logging":{},"prompts":{"listChanged":true,"search":false},"resources":{"subscribe":true,"listChanged":true,"search":false},"tools":{"listChanged":true,"search":false}},"serverInfo":{"name":"test-server","version":"1.0.0"},"instructions":"Server initialized successfully"}""")); } // Resource Tests