You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@shardingsphere.apache.org by zh...@apache.org on 2026/07/04 05:38:48 UTC
(shardingsphere) branch master updated: Refactor MCP large class responsibilities (#38988)
This is an automated email from the ASF dual-hosted git repository.
terrymanu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shardingsphere.git
The following commit(s) were added to refs/heads/master by this push:
new 7a03f99acdc Refactor MCP large class responsibilities (#38988)
7a03f99acdc is described below
commit 7a03f99acdc277dd809665699e296072e5e9176d
Author: Liang Zhang <zh...@apache.org>
AuthorDate: Sat Jul 4 13:38:42 2026 +0800
Refactor MCP large class responsibilities (#38988)
* Refactor MCP large class responsibilities
- Split descriptor catalog validation into focused validators
- Split bootstrap tool specification tests by result and elicitation flows
- Extract sharding workflow input and lifecycle planning helpers
- Extract MCP E2E runtime, Docker, LLM readiness and trace helpers
- Split JDBC metadata loader tests by focused scenarios
* Refactor MCP large class responsibilities
- Split descriptor catalog validation into focused validators
- Split bootstrap tool specification tests by result and elicitation flows
- Extract sharding workflow input and lifecycle planning helpers
- Extract MCP E2E runtime, Docker, LLM readiness and trace helpers
- Split JDBC metadata loader tests by focused scenarios
---
.../tool/MCPCallToolResultFactoryTest.java | 297 ++++++++++
...ryTest.java => MCPToolElicitationFlowTest.java} | 369 +-----------
.../tool/MCPToolSpecificationFactoryTest.java | 611 +-------------------
.../service/ShardingWorkflowInputValidator.java | 180 ++++++
.../service/ShardingWorkflowLifecycleSpec.java | 84 +++
.../service/ShardingWorkflowPlanningKernel.java | 224 ++------
.../MCPCompletionTargetDescriptorValidator.java | 127 +++++
.../descriptor/MCPDescriptorCatalogValidator.java | 605 +-------------------
.../descriptor/MCPPromptDescriptorValidator.java | 125 +++++
.../descriptor/MCPResourceDescriptorValidator.java | 96 ++++
.../MCPResourceNavigationDescriptorValidator.java | 63 +++
.../MCPToolDescriptorCatalogValidator.java | 159 ++++++
.../descriptor/MCPToolOutputSchemaValidator.java | 285 ++++++++++
.../jdbc/AbstractMCPJdbcMetadataLoaderTest.java | 454 +++++++++++++++
.../jdbc/MCPJdbcMetadataLoaderFailureTest.java | 43 ++
.../MCPJdbcMetadataLoaderSchemaFilteringTest.java | 174 ++++++
.../jdbc/MCPJdbcMetadataLoaderSequenceTest.java | 90 +++
.../metadata/jdbc/MCPJdbcMetadataLoaderTest.java | 620 +--------------------
.../MCPDescriptorComponentValidatorsTest.java | 115 ++++
.../llm/conversation/LLMMCPConversationRunner.java | 43 +-
.../LLMMCPConversationRunnerNextActionTest.java | 30 +
.../LLMMCPConversationTurnPlanner.java | 16 +-
.../LLMMCPConversationTurnPlannerTest.java | 11 +
.../llm/conversation/LLMMCPTraceRecordFactory.java | 54 ++
.../conversation/client/LLMChatModelClient.java | 104 +---
.../conversation/client/LLMChatReadinessProbe.java | 125 +++++
.../AbstractProductionMySQLRuntimeE2ETest.java | 107 +---
.../ProductionMCPClientTransportFactory.java | 117 ++++
.../ProductionRuntimeTransportCases.java | 80 +++
.../support/runtime/MySQLRuntimeDockerSupport.java | 87 +++
.../support/runtime/MySQLRuntimeTestSupport.java | 50 +-
31 files changed, 2888 insertions(+), 2657 deletions(-)
diff --git a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPCallToolResultFactoryTest.java b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPCallToolResultFactoryTest.java
new file mode 100644
index 00000000000..80a5d9a28ac
--- /dev/null
+++ b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPCallToolResultFactoryTest.java
@@ -0,0 +1,297 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.mcp.bootstrap.transport.capability.tool;
+
+import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
+import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse;
+import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
+import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
+import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
+import io.modelcontextprotocol.spec.McpSchema.ResourceLink;
+import io.modelcontextprotocol.spec.McpSchema.TextContent;
+import org.apache.shardingsphere.mcp.api.protocol.response.MCPResponse;
+import org.apache.shardingsphere.mcp.api.tool.MCPToolCall;
+import org.apache.shardingsphere.mcp.api.tool.descriptor.MCPToolDescriptor;
+import org.apache.shardingsphere.mcp.core.protocol.response.MCPErrorResponse;
+import org.apache.shardingsphere.mcp.core.tool.handler.MCPToolDefinition;
+import org.apache.shardingsphere.mcp.core.tool.handler.ToolDefinitionRegistry;
+import org.apache.shardingsphere.mcp.core.workflow.InMemoryWorkflowSessionContext;
+import org.apache.shardingsphere.mcp.feature.encrypt.EncryptFeatureDefinition;
+import org.apache.shardingsphere.mcp.feature.encrypt.tool.handler.PlanEncryptRuleToolHandler;
+import org.apache.shardingsphere.mcp.feature.encrypt.tool.model.EncryptWorkflowRequest;
+import org.apache.shardingsphere.mcp.feature.encrypt.tool.service.EncryptWorkflowPlanningService;
+import org.apache.shardingsphere.mcp.feature.mask.MaskFeatureDefinition;
+import org.apache.shardingsphere.mcp.feature.mask.tool.handler.PlanMaskRuleToolHandler;
+import org.apache.shardingsphere.mcp.feature.mask.tool.service.MaskWorkflowPlanningService;
+import org.apache.shardingsphere.mcp.support.database.MCPDatabaseHandlerContext;
+import org.apache.shardingsphere.mcp.support.database.spi.MCPFeatureQueryFacade;
+import org.apache.shardingsphere.mcp.support.database.spi.MCPMetadataQueryFacade;
+import org.apache.shardingsphere.mcp.support.descriptor.MCPShardingSphereMetadataKeys;
+import org.apache.shardingsphere.mcp.support.protocol.MCPResourceHintUtils;
+import org.apache.shardingsphere.mcp.support.protocol.response.MCPMapResponse;
+import org.apache.shardingsphere.mcp.support.workflow.MCPWorkflowHandlerContext;
+import org.apache.shardingsphere.mcp.support.workflow.WorkflowSessionContext;
+import org.apache.shardingsphere.mcp.support.workflow.model.AlgorithmPropertyRequirement;
+import org.apache.shardingsphere.mcp.support.workflow.model.ClarifiedIntent;
+import org.apache.shardingsphere.mcp.support.workflow.model.InteractionPlan;
+import org.apache.shardingsphere.mcp.support.workflow.model.RuleArtifact;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowContextSnapshot;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowKind;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowLifecycle;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowRequest;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.internal.configuration.plugins.Plugins;
+
+import java.lang.reflect.Field;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.isA;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+class MCPCallToolResultFactoryTest extends AbstractMCPToolSpecificationFactoryTest {
+
+ @Test
+ void assertCreateToolSpecificationsHandleNullArguments() {
+ try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
+ MCPResponse response = new MCPMapResponse(Map.of("status", "ok"));
+ MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createToolDescriptor("database_gateway_search_metadata"));
+ mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
+ CallToolResult actual = createToolSpecification("stdio").callHandler().apply(createExchange(), new CallToolRequest(
+ "database_gateway_search_metadata", null));
+ assertThat(actual.structuredContent(), is(Map.of("status", "ok")));
+ assertThat(((TextContent) actual.content().getFirst()).text(), is("{\"status\":\"ok\"}"));
+ assertFalse(actual.isError());
+ }
+ }
+
+ @Test
+ void assertCreateToolSpecificationsHandleErrorResponse() {
+ try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
+ MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createToolDescriptor("database_gateway_search_metadata"));
+ mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of("query", "foo_query"), new MCPErrorResponse(""));
+ CallToolResult actual = callTool(createToolSpecification("stdio"), createExchange(), "database_gateway_search_metadata", Map.of("query", "foo_query"));
+ @SuppressWarnings("unchecked")
+ Map<String, Object> actualPayload = (Map<String, Object>) actual.structuredContent();
+ assertThat(actualPayload.get("response_mode"), is("recovery"));
+ assertThat(actualPayload.get("message"), is(""));
+ assertTrue(actual.isError());
+ }
+ }
+
+ @Test
+ void assertCreateToolSpecificationsHandlePlainPayload() {
+ CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(Map.of("message", "invalid_request")));
+ assertFalse(actual.isError());
+ }
+
+ @Test
+ void assertCreateWithMaskWorkflowPlanSchema() throws ReflectiveOperationException {
+ CallToolResult actual = createRealDescriptorCallToolResult(MaskFeatureDefinition.PLAN_TOOL_NAME, createMaskPlanResponse());
+ assertFalse(actual.isError(), () -> String.valueOf(actual.structuredContent()));
+ assertThat(getStructuredContent(actual).get("status"), is("planned"));
+ }
+
+ @Test
+ void assertCreateWithEncryptWorkflowPlanSchema() throws ReflectiveOperationException {
+ CallToolResult actual = createRealDescriptorCallToolResult(EncryptFeatureDefinition.PLAN_TOOL_NAME, createEncryptPlanResponse());
+ assertFalse(actual.isError(), () -> String.valueOf(actual.structuredContent()));
+ assertThat(getStructuredContent(actual).get("status"), is("planned"));
+ }
+
+ @Test
+ void assertCreateToolSpecificationsHandleResourceLinks() {
+ Map<String, Object> payload = Map.of("resources_to_read", List.of(
+ MCPResourceHintUtils.create("shardingsphere://databases/logic_db", "logical-database", "read_first", "Read logical database.", "resources_to_read")));
+ CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(payload));
+ assertThat(actual.structuredContent(), is(payload));
+ assertThat(actual.content().get(1), isA(ResourceLink.class));
+ ResourceLink actualLink = (ResourceLink) actual.content().get(1);
+ assertThat(actualLink.uri(), is("shardingsphere://databases/logic_db"));
+ assertThat(actualLink.title(), is("logical-database"));
+ assertThat(actualLink.mimeType(), is("application/json"));
+ }
+
+ @Test
+ void assertCreateToolSpecificationsHandleItemResourceLinks() {
+ Map<String, Object> payload = Map.of("items", List.of(Map.of(
+ "resource", MCPResourceHintUtils.create("shardingsphere://databases/logic_db/tables/t_order", "table", "inspect_detail", "Read table.", "resource"),
+ "parent_resource", MCPResourceHintUtils.create("shardingsphere://databases/logic_db", "logical-database", "inspect_parent", "Read database.", "parent_resource"),
+ "next_resources", List.of(MCPResourceHintUtils.create(
+ "shardingsphere://databases/logic_db/tables/t_order/columns", "column-list", "inspect_children", "Read columns.", "next_resources")))));
+ CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(payload));
+ assertThat(actual.content().size(), is(4));
+ assertThat(((ResourceLink) actual.content().get(1)).uri(), is("shardingsphere://databases/logic_db/tables/t_order"));
+ assertThat(((ResourceLink) actual.content().get(2)).uri(), is("shardingsphere://databases/logic_db"));
+ assertThat(((ResourceLink) actual.content().get(3)).uri(), is("shardingsphere://databases/logic_db/tables/t_order/columns"));
+ assertThat(((Map<?, ?>) actual.content().get(1).meta()).get(MCPShardingSphereMetadataKeys.SOURCE_FIELD), is("resource"));
+ }
+
+ @Test
+ void assertCreateToolSpecificationsHandleRecoveryResourceLinks() {
+ Map<String, Object> recovery = Map.of("resources_to_read", List.of(
+ MCPResourceHintUtils.create("shardingsphere://capabilities", "capability", "read_first", "Read capabilities.", "resources_to_read")));
+ CallToolResult actual = createCallToolResult("fixture_ping", new MCPErrorResponse("", recovery));
+ assertTrue(actual.isError());
+ assertThat(actual.content().get(1), isA(ResourceLink.class));
+ assertThat(((ResourceLink) actual.content().get(1)).uri(), is("shardingsphere://capabilities"));
+ }
+
+ @Test
+ void assertCreateToolSpecificationsHandleBoundedResourceLinks() {
+ Map<String, Object> payload = Map.of(
+ "next_resources", createResourceHints("shardingsphere://databases/next_", "next_resources", 30),
+ "parent_resource", MCPResourceHintUtils.create("shardingsphere://databases", "logical-database", "inspect_parent", "Read parent.", "parent_resource"),
+ "resource", MCPResourceHintUtils.create("shardingsphere://databases/logic_db", "logical-database", "inspect_detail", "Read detail.", "resource"),
+ "resources_to_read", List.of(MCPResourceHintUtils.create("shardingsphere://capabilities", "capability", "read_first", "Read capabilities.", "resources_to_read")));
+ CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(payload));
+ assertThat(actual.structuredContent(), is(payload));
+ assertThat(actual.content().size(), is(25));
+ assertThat(actual.meta().get(MCPShardingSphereMetadataKeys.RESOURCE_LINKS_EMITTED), is(24));
+ assertThat(actual.meta().get(MCPShardingSphereMetadataKeys.RESOURCE_LINKS_OMITTED), is(9));
+ assertThat(((ResourceLink) actual.content().get(1)).uri(), is("shardingsphere://capabilities"));
+ assertThat(((ResourceLink) actual.content().get(2)).uri(), is("shardingsphere://databases/logic_db"));
+ assertThat(((ResourceLink) actual.content().get(3)).uri(), is("shardingsphere://databases"));
+ assertThat(((Map<?, ?>) actual.content().get(1).meta()).get(MCPShardingSphereMetadataKeys.SOURCE_FIELD), is("resources_to_read"));
+ }
+
+ @Test
+ void assertCreateToolSpecificationsIgnoreRawUriLink() {
+ CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(Map.of("resource_uri", "shardingsphere://databases/logic_db")));
+ assertThat(actual.content().size(), is(1));
+ }
+
+ @Test
+ void assertCreateToolSpecificationsIgnoreArbitraryNestedResourceHint() {
+ Map<String, Object> payload = Map.of("debug", Map.of("resource", MCPResourceHintUtils.create(
+ "shardingsphere://databases/logic_db", "logical-database", "inspect_detail", "Read logical database.", "resource")));
+ CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(payload));
+ assertThat(actual.content().size(), is(1));
+ }
+
+ @Test
+ void assertToolOutputSchemaExamplesMatchSchemas() {
+ JsonSchemaValidator validator = new DefaultJsonSchemaValidator();
+ for (MCPToolDescriptor each : ToolDefinitionRegistry.getSupportedToolDescriptors()) {
+ for (Map<String, Object> example : getOutputSchemaExamples(each)) {
+ ValidationResponse actual = validator.validate(each.getOutputSchema(), example);
+ assertTrue(actual.valid(), () -> String.format("Invalid outputSchema example for `%s`: %s", each.getName(), actual.errorMessage()));
+ }
+ }
+ }
+
+ private CallToolResult createRealDescriptorCallToolResult(final String toolName, final MCPResponse response) {
+ MCPToolDescriptor descriptor = ToolDefinitionRegistry.getToolDefinition(toolName).getDescriptor();
+ return new MCPCallToolResultFactory().create(descriptor, response);
+ }
+
+ private MCPResponse createMaskPlanResponse() throws ReflectiveOperationException {
+ PlanMaskRuleToolHandler handler = new PlanMaskRuleToolHandler();
+ MaskWorkflowPlanningService planningService = mock(MaskWorkflowPlanningService.class);
+ when(planningService.plan(any(), any(), any(), any(), any())).thenReturn(createMaskSnapshot());
+ setField(handler, "planningService", planningService);
+ return handler.handle(createWorkflowContext(), new MCPToolCall("session-id", Map.of("database", "logic_db", "table", "orders", "column", "phone")));
+ }
+
+ private WorkflowContextSnapshot createMaskSnapshot() {
+ WorkflowRequest request = createWorkflowRequest();
+ request.getPrimaryAlgorithmProperties().put("first-n", "3");
+ request.getPrimaryAlgorithmProperties().put("last-m", "2");
+ request.getPrimaryAlgorithmProperties().put("replace-char", "*");
+ WorkflowContextSnapshot result = createWorkflowContextSnapshot(MaskFeatureDefinition.WORKFLOW_KIND, request);
+ result.getPropertyRequirements().add(new AlgorithmPropertyRequirement("primary", "first-n", true, false, "from", ""));
+ result.getRuleArtifacts().add(new RuleArtifact("create", "CREATE MASK RULE orders (TYPE(NAME='keep_first_n_last_m'))"));
+ return result;
+ }
+
+ private MCPResponse createEncryptPlanResponse() throws ReflectiveOperationException {
+ PlanEncryptRuleToolHandler handler = new PlanEncryptRuleToolHandler();
+ EncryptWorkflowPlanningService planningService = mock(EncryptWorkflowPlanningService.class);
+ when(planningService.plan(any(), any(), any(), any(), any())).thenReturn(createEncryptSnapshot());
+ setField(handler, "planningService", planningService);
+ return handler.handle(createWorkflowContext(), new MCPToolCall("session-id", Map.of("database", "logic_db", "table", "orders", "column", "phone")));
+ }
+
+ private WorkflowContextSnapshot createEncryptSnapshot() {
+ EncryptWorkflowRequest request = new EncryptWorkflowRequest();
+ request.setDatabase("logic_db");
+ request.setSchema("public");
+ request.setTable("orders");
+ request.getPrimaryAlgorithmProperties().put("aes-key-value", "123456");
+ WorkflowContextSnapshot result = createWorkflowContextSnapshot(EncryptFeatureDefinition.WORKFLOW_KIND, request);
+ result.getPropertyRequirements().add(new AlgorithmPropertyRequirement("primary", "aes-key-value", true, true, "key", ""));
+ result.getRuleArtifacts().add(new RuleArtifact("create", "CREATE ENCRYPT RULE orders (PROPERTIES('aes-key-value'='123456'))"));
+ return result;
+ }
+
+ private WorkflowRequest createWorkflowRequest() {
+ WorkflowRequest result = new WorkflowRequest();
+ result.setDatabase("logic_db");
+ result.setSchema("public");
+ result.setTable("orders");
+ return result;
+ }
+
+ private WorkflowContextSnapshot createWorkflowContextSnapshot(final WorkflowKind workflowKind, final WorkflowRequest request) {
+ WorkflowContextSnapshot result = new WorkflowContextSnapshot();
+ result.setPlanId("plan-1");
+ result.setWorkflowKind(workflowKind);
+ result.setStatus(WorkflowLifecycle.STATUS_PLANNED);
+ result.setRequest(request);
+ result.setClarifiedIntent(new ClarifiedIntent());
+ result.setInteractionPlan(createInteractionPlan());
+ return result;
+ }
+
+ private InteractionPlan createInteractionPlan() {
+ InteractionPlan result = new InteractionPlan();
+ result.setCurrentStep("review");
+ result.setDeliveryMode("interactive");
+ result.setExecutionMode("review-then-execute");
+ return result;
+ }
+
+ private MCPWorkflowHandlerContext createWorkflowContext() {
+ MCPWorkflowHandlerContext result = mock(MCPWorkflowHandlerContext.class);
+ MCPDatabaseHandlerContext databaseContext = mock(MCPDatabaseHandlerContext.class);
+ WorkflowSessionContext workflowSessionContext = new InMemoryWorkflowSessionContext();
+ when(result.getDatabaseContext()).thenReturn(databaseContext);
+ when(result.getWorkflowSessionContext()).thenReturn(workflowSessionContext);
+ when(databaseContext.getMetadataQueryFacade()).thenReturn(mock(MCPMetadataQueryFacade.class));
+ when(databaseContext.getQueryFacade()).thenReturn(mock(MCPFeatureQueryFacade.class));
+ return result;
+ }
+
+ private void setField(final Object target, final String fieldName, final Object value) throws ReflectiveOperationException {
+ Field field = target.getClass().getDeclaredField(fieldName);
+ Plugins.getMemberAccessor().set(field, target, value);
+ }
+
+ @SuppressWarnings("unchecked")
+ private List<Map<String, Object>> getOutputSchemaExamples(final MCPToolDescriptor toolDescriptor) {
+ return toolDescriptor.getOutputSchema().containsKey("examples") ? (List<Map<String, Object>>) toolDescriptor.getOutputSchema().get("examples") : List.of();
+ }
+}
diff --git a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolSpecificationFactoryTest.java b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFlowTest.java
similarity index 51%
copy from mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolSpecificationFactoryTest.java
copy to mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFlowTest.java
index 3bc9b54783a..250484461bb 100644
--- a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolSpecificationFactoryTest.java
+++ b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFlowTest.java
@@ -17,57 +17,20 @@
package org.apache.shardingsphere.mcp.bootstrap.transport.capability.tool;
-import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
-import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse;
-import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
-import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
import io.modelcontextprotocol.server.McpSyncServerExchange;
-import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
-import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
-import io.modelcontextprotocol.spec.McpSchema.ResourceLink;
-import io.modelcontextprotocol.spec.McpSchema.TextContent;
import org.apache.shardingsphere.mcp.api.protocol.response.MCPResponse;
-import org.apache.shardingsphere.mcp.api.tool.MCPToolCall;
-import org.apache.shardingsphere.mcp.api.tool.descriptor.MCPToolAnnotations;
-import org.apache.shardingsphere.mcp.api.tool.descriptor.MCPToolDescriptor;
import org.apache.shardingsphere.mcp.core.context.MCPRequestScope;
import org.apache.shardingsphere.mcp.core.context.MCPRuntimeContext;
-import org.apache.shardingsphere.mcp.core.protocol.exception.UnsupportedToolException;
-import org.apache.shardingsphere.mcp.core.protocol.response.MCPErrorResponse;
import org.apache.shardingsphere.mcp.core.tool.handler.MCPToolDefinition;
import org.apache.shardingsphere.mcp.core.tool.handler.ToolDefinitionRegistry;
-import org.apache.shardingsphere.mcp.core.workflow.InMemoryWorkflowSessionContext;
-import org.apache.shardingsphere.mcp.feature.encrypt.EncryptFeatureDefinition;
-import org.apache.shardingsphere.mcp.feature.encrypt.tool.handler.PlanEncryptRuleToolHandler;
-import org.apache.shardingsphere.mcp.feature.encrypt.tool.model.EncryptWorkflowRequest;
-import org.apache.shardingsphere.mcp.feature.encrypt.tool.service.EncryptWorkflowPlanningService;
-import org.apache.shardingsphere.mcp.feature.mask.MaskFeatureDefinition;
-import org.apache.shardingsphere.mcp.feature.mask.tool.handler.PlanMaskRuleToolHandler;
-import org.apache.shardingsphere.mcp.feature.mask.tool.service.MaskWorkflowPlanningService;
-import org.apache.shardingsphere.mcp.support.database.MCPDatabaseHandlerContext;
-import org.apache.shardingsphere.mcp.support.database.spi.MCPFeatureQueryFacade;
-import org.apache.shardingsphere.mcp.support.database.spi.MCPMetadataQueryFacade;
import org.apache.shardingsphere.mcp.support.descriptor.MCPShardingSphereMetadataKeys;
-import org.apache.shardingsphere.mcp.support.protocol.MCPResourceHintUtils;
import org.apache.shardingsphere.mcp.support.protocol.response.MCPMapResponse;
-import org.apache.shardingsphere.mcp.support.workflow.MCPWorkflowHandlerContext;
-import org.apache.shardingsphere.mcp.support.workflow.WorkflowSessionContext;
-import org.apache.shardingsphere.mcp.support.workflow.model.AlgorithmPropertyRequirement;
-import org.apache.shardingsphere.mcp.support.workflow.model.ClarifiedIntent;
-import org.apache.shardingsphere.mcp.support.workflow.model.InteractionPlan;
-import org.apache.shardingsphere.mcp.support.workflow.model.RuleArtifact;
-import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowContextSnapshot;
-import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowKind;
-import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowLifecycle;
-import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowRequest;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
-import org.mockito.internal.configuration.plugins.Plugins;
-import java.lang.reflect.Field;
import java.time.Clock;
import java.time.Duration;
import java.util.Collections;
@@ -77,11 +40,6 @@ import java.util.Map;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.isA;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
@@ -91,228 +49,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-class MCPToolSpecificationFactoryTest extends AbstractMCPToolSpecificationFactoryTest {
-
- @Test
- void assertCreateToolSpecifications() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- mockedToolDefinitionRegistry.when(ToolDefinitionRegistry::getSupportedToolDescriptors).thenReturn(List.of(createToolDescriptor("database_gateway_search_metadata")));
- MCPToolSpecificationFactory actualFactory = new MCPToolSpecificationFactory(mock(MCPRuntimeContext.class, RETURNS_DEEP_STUBS));
- List<SyncToolSpecification> actual = actualFactory.createToolSpecifications();
- assertThat(actual.size(), is(1));
- assertThat(actual.getFirst().tool().name(), is("database_gateway_search_metadata"));
- assertThat(actual.getFirst().tool().title(), is("Search Metadata"));
- assertThat(actual.getFirst().tool().description(), is("Search database metadata."));
- assertThat(actual.getFirst().tool().inputSchema().type(), is("object"));
- assertThat(actual.getFirst().tool().inputSchema().required(), is(List.of("query")));
- assertFalse(actual.getFirst().tool().inputSchema().additionalProperties());
- assertThat(actual.getFirst().tool().inputSchema().properties().get("query"), is(Map.of("type", "string", "description", "Search query.")));
- assertThat(actual.getFirst().tool().inputSchema().properties().get("object_types"), is(Map.of(
- "type", "array",
- "description", "Optional object-type filter.",
- "items", Map.of("type", "string", "description", "Object type.", "enum", List.of("TABLE", "VIEW")))));
- assertThat(actual.getFirst().tool().outputSchema(), is(Map.of("type", "object")));
- assertTrue(actual.getFirst().tool().annotations().readOnlyHint());
- assertNull(actual.getFirst().tool().annotations().returnDirect());
- assertThat(actual.getFirst().tool().meta(), is(Map.of(MCPShardingSphereMetadataKeys.RELATED_RESOURCE_URIS, List.of("shardingsphere://databases"))));
- assertNotNull(actual.getFirst().callHandler());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsMapAnnotationPresence() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- mockedToolDefinitionRegistry.when(ToolDefinitionRegistry::getSupportedToolDescriptors).thenReturn(List.of(createToolDescriptor(
- "fixture_declared_defaults", new MCPToolAnnotations(null, false, true, false, true))));
- MCPToolSpecificationFactory actualFactory = new MCPToolSpecificationFactory(mock(MCPRuntimeContext.class, RETURNS_DEEP_STUBS));
- List<SyncToolSpecification> actual = actualFactory.createToolSpecifications();
- assertNotNull(actual.getFirst().tool().annotations());
- assertFalse(actual.getFirst().tool().annotations().readOnlyHint());
- assertTrue(actual.getFirst().tool().annotations().destructiveHint());
- assertFalse(actual.getFirst().tool().annotations().idempotentHint());
- assertTrue(actual.getFirst().tool().annotations().openWorldHint());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsHandleNullArguments() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- MCPResponse response = new MCPMapResponse(Map.of("status", "ok"));
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createToolDescriptor("database_gateway_search_metadata"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- SyncToolSpecification actualSpecification = createToolSpecification("stdio");
- McpSyncServerExchange exchange = createExchange();
- CallToolResult actual = actualSpecification.callHandler().apply(exchange, new CallToolRequest("database_gateway_search_metadata", null));
- assertThat(actual.structuredContent(), is(Map.of("status", "ok")));
- assertThat(((TextContent) actual.content().getFirst()).text(), is("{\"status\":\"ok\"}"));
- assertFalse(actual.isError());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsHandleErrorResponse() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createToolDescriptor("database_gateway_search_metadata"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of("query", "foo_query"), new MCPErrorResponse(""));
- CallToolResult actual = callTool(createToolSpecification("stdio"), createExchange(), "database_gateway_search_metadata", Map.of("query", "foo_query"));
- @SuppressWarnings("unchecked")
- Map<String, Object> actualPayload = (Map<String, Object>) actual.structuredContent();
- assertThat(actualPayload.get("response_mode"), is("recovery"));
- assertThat(actualPayload.get("message"), is(""));
- assertTrue(actual.isError());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsHandlePlainPayload() {
- CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(Map.of("message", "invalid_request")));
- assertFalse(actual.isError());
- }
-
- @Test
- void assertCreateWithMaskWorkflowPlanSchema() throws ReflectiveOperationException {
- CallToolResult actual = createRealDescriptorCallToolResult(MaskFeatureDefinition.PLAN_TOOL_NAME, createMaskPlanResponse());
- assertFalse(actual.isError(), () -> String.valueOf(actual.structuredContent()));
- assertThat(getStructuredContent(actual).get("status"), is("planned"));
- }
-
- @Test
- void assertCreateWithEncryptWorkflowPlanSchema() throws ReflectiveOperationException {
- CallToolResult actual = createRealDescriptorCallToolResult(EncryptFeatureDefinition.PLAN_TOOL_NAME, createEncryptPlanResponse());
- assertFalse(actual.isError(), () -> String.valueOf(actual.structuredContent()));
- assertThat(getStructuredContent(actual).get("status"), is("planned"));
- }
-
- @Test
- void assertCreateToolSpecificationsHandleResourceLinks() {
- Map<String, Object> payload = Map.of("resources_to_read", List.of(
- MCPResourceHintUtils.create("shardingsphere://databases/logic_db", "logical-database", "read_first", "Read logical database.", "resources_to_read")));
- CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(payload));
- assertThat(actual.structuredContent(), is(payload));
- assertThat(actual.content().get(1), isA(ResourceLink.class));
- ResourceLink actualLink = (ResourceLink) actual.content().get(1);
- assertThat(actualLink.uri(), is("shardingsphere://databases/logic_db"));
- assertThat(actualLink.title(), is("logical-database"));
- assertThat(actualLink.mimeType(), is("application/json"));
- }
-
- @Test
- void assertCreateToolSpecificationsHandleItemResourceLinks() {
- Map<String, Object> payload = Map.of("items", List.of(Map.of(
- "resource", MCPResourceHintUtils.create("shardingsphere://databases/logic_db/tables/t_order", "table", "inspect_detail", "Read table.", "resource"),
- "parent_resource", MCPResourceHintUtils.create("shardingsphere://databases/logic_db", "logical-database", "inspect_parent", "Read database.", "parent_resource"),
- "next_resources", List.of(MCPResourceHintUtils.create(
- "shardingsphere://databases/logic_db/tables/t_order/columns", "column-list", "inspect_children", "Read columns.", "next_resources")))));
- CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(payload));
- assertThat(actual.content().size(), is(4));
- assertThat(((ResourceLink) actual.content().get(1)).uri(), is("shardingsphere://databases/logic_db/tables/t_order"));
- assertThat(((ResourceLink) actual.content().get(2)).uri(), is("shardingsphere://databases/logic_db"));
- assertThat(((ResourceLink) actual.content().get(3)).uri(), is("shardingsphere://databases/logic_db/tables/t_order/columns"));
- assertThat(((Map<?, ?>) actual.content().get(1).meta()).get(MCPShardingSphereMetadataKeys.SOURCE_FIELD), is("resource"));
- }
-
- @Test
- void assertCreateToolSpecificationsHandleRecoveryResourceLinks() {
- Map<String, Object> recovery = Map.of("resources_to_read", List.of(
- MCPResourceHintUtils.create("shardingsphere://capabilities", "capability", "read_first", "Read capabilities.", "resources_to_read")));
- CallToolResult actual = createCallToolResult("fixture_ping", new MCPErrorResponse("", recovery));
- assertTrue(actual.isError());
- assertThat(actual.content().get(1), isA(ResourceLink.class));
- assertThat(((ResourceLink) actual.content().get(1)).uri(), is("shardingsphere://capabilities"));
- }
-
- @Test
- void assertCreateToolSpecificationsHandleBoundedResourceLinks() {
- Map<String, Object> payload = Map.of(
- "next_resources", createResourceHints("shardingsphere://databases/next_", "next_resources", 30),
- "parent_resource", MCPResourceHintUtils.create("shardingsphere://databases", "logical-database", "inspect_parent", "Read parent.", "parent_resource"),
- "resource", MCPResourceHintUtils.create("shardingsphere://databases/logic_db", "logical-database", "inspect_detail", "Read detail.", "resource"),
- "resources_to_read", List.of(MCPResourceHintUtils.create("shardingsphere://capabilities", "capability", "read_first", "Read capabilities.", "resources_to_read")));
- CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(payload));
- assertThat(actual.structuredContent(), is(payload));
- assertThat(actual.content().size(), is(25));
- assertThat(actual.meta().get(MCPShardingSphereMetadataKeys.RESOURCE_LINKS_EMITTED), is(24));
- assertThat(actual.meta().get(MCPShardingSphereMetadataKeys.RESOURCE_LINKS_OMITTED), is(9));
- assertThat(((ResourceLink) actual.content().get(1)).uri(), is("shardingsphere://capabilities"));
- assertThat(((ResourceLink) actual.content().get(2)).uri(), is("shardingsphere://databases/logic_db"));
- assertThat(((ResourceLink) actual.content().get(3)).uri(), is("shardingsphere://databases"));
- assertThat(((Map<?, ?>) actual.content().get(1).meta()).get(MCPShardingSphereMetadataKeys.SOURCE_FIELD), is("resources_to_read"));
- }
-
- @Test
- void assertCreateToolSpecificationsIgnoreRawUriLink() {
- CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(Map.of("resource_uri", "shardingsphere://databases/logic_db")));
- assertThat(actual.content().size(), is(1));
- }
-
- @Test
- void assertCreateToolSpecificationsIgnoreArbitraryNestedResourceHint() {
- Map<String, Object> payload = Map.of("debug", Map.of("resource", MCPResourceHintUtils.create(
- "shardingsphere://databases/logic_db", "logical-database", "inspect_detail", "Read logical database.", "resource")));
- CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(payload));
- assertThat(actual.content().size(), is(1));
- }
-
- @Test
- void assertCreateToolSpecificationsHandleToolDefinitionDescriptor() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createToolDescriptor("fixture_ping"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), new MCPMapResponse(Map.of("status", "ok")));
- CallToolResult actual = callTool(createToolSpecification("stdio"), createExchange(), "fixture_ping", Map.of());
- assertThat(actual.structuredContent(), is(Map.of("status", "ok")));
- assertThat(((TextContent) actual.content().getFirst()).text(), is("{\"status\":\"ok\"}"));
- }
- }
-
- @Test
- void assertCreateToolSpecificationsHandleUnsupportedToolAsProtocolError() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- mockedToolDefinitionRegistry.when(ToolDefinitionRegistry::getSupportedToolDescriptors).thenReturn(List.of(createToolDescriptor("database_gateway_search_metadata")));
- mockedToolDefinitionRegistry.when(() -> ToolDefinitionRegistry.getToolDefinition("database_gateway_search_metadata")).thenThrow(UnsupportedToolException.class);
- MCPRuntimeContext runtimeContext = mock(MCPRuntimeContext.class, RETURNS_DEEP_STUBS);
- when(runtimeContext.getSessionManager().getTransactionResourceManager().getRuntimeDatabases()).thenReturn(Collections.emptyMap());
- SyncToolSpecification actualSpecification = createToolSpecification(runtimeContext);
- McpSyncServerExchange exchange = createExchange();
- McpError actual = assertThrows(McpError.class, () -> callTool(actualSpecification, exchange, "database_gateway_search_metadata", Map.of()));
- assertThat(actual.getJsonRpcError().code(), is(McpSchema.ErrorCodes.INVALID_PARAMS));
- assertThat(actual.getJsonRpcError().message(), is("Unsupported tool `database_gateway_search_metadata`."));
- @SuppressWarnings("unchecked")
- Map<String, Object> actualData = (Map<String, Object>) actual.getJsonRpcError().data();
- assertThat(actualData.get("message"), is("Unsupported tool `database_gateway_search_metadata`."));
- }
- }
-
- @Test
- void assertCreateToolSpecificationsRejectInvalidInputSchema() {
- MCPRuntimeContext runtimeContext = mock(MCPRuntimeContext.class, RETURNS_DEEP_STUBS);
- when(runtimeContext.getSessionManager().getTransactionResourceManager().getRuntimeDatabases()).thenReturn(Collections.emptyMap());
- SyncToolSpecification actualSpecification = findToolSpecification(new MCPToolSpecificationFactory(runtimeContext).createToolSpecifications(), "database_gateway_search_metadata");
- CallToolResult actual = callTool(actualSpecification, createExchange(), "database_gateway_search_metadata", Map.of("query", "order", "object_types", List.of("TABLE")));
- @SuppressWarnings("unchecked")
- Map<String, Object> actualPayload = (Map<String, Object>) actual.structuredContent();
- Map<?, ?> actualRecovery = (Map<?, ?>) actualPayload.get("recovery");
- assertThat(actualPayload.get("message"), is("object_types[0] must be one of [database, schema, table, view, column, index, storage_unit, sequence]."));
- assertThat(actualRecovery.get("category"), is("invalid_enum_value"));
- assertThat(actualRecovery.get("field"), is("object_types[0]"));
- assertThat(actualRecovery.get("allowed_values"), is(List.of("database", "schema", "table", "view", "column", "index", "storage_unit", "sequence")));
- assertThat(actualRecovery.get("suggested_arguments"), is(Map.of("query", "order")));
- assertTrue(actual.isError());
- }
-
- @Test
- void assertCreateToolSpecificationsValidateStructuredOutput() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createStrictToolDescriptor("database_gateway_search_metadata"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), new MCPMapResponse(Map.of("count", 1)));
- MCPRuntimeContext runtimeContext = mock(MCPRuntimeContext.class, RETURNS_DEEP_STUBS);
- when(runtimeContext.getSessionManager().getTransactionResourceManager().getRuntimeDatabases()).thenReturn(Collections.emptyMap());
- CallToolResult actual = callTool(createToolSpecification(runtimeContext), createExchange(), "database_gateway_search_metadata", Map.of());
- @SuppressWarnings("unchecked")
- Map<String, Object> actualPayload = (Map<String, Object>) actual.structuredContent();
- assertTrue(String.valueOf(actualPayload.get("message")).contains("database_gateway_search_metadata"));
- assertTrue(actual.isError());
- }
- }
+class MCPToolElicitationFlowTest extends AbstractMCPToolSpecificationFactoryTest {
@Test
void assertCreateToolSpecificationsHandleInteractiveElicitation() {
@@ -640,93 +377,6 @@ class MCPToolSpecificationFactoryTest extends AbstractMCPToolSpecificationFactor
}
}
- private CallToolResult createRealDescriptorCallToolResult(final String toolName, final MCPResponse response) {
- MCPToolDescriptor descriptor = ToolDefinitionRegistry.getToolDefinition(toolName).getDescriptor();
- return new MCPCallToolResultFactory().create(descriptor, response);
- }
-
- private MCPResponse createMaskPlanResponse() throws ReflectiveOperationException {
- PlanMaskRuleToolHandler handler = new PlanMaskRuleToolHandler();
- MaskWorkflowPlanningService planningService = mock(MaskWorkflowPlanningService.class);
- when(planningService.plan(any(), any(), any(), any(), any())).thenReturn(createMaskSnapshot());
- setField(handler, "planningService", planningService);
- return handler.handle(createWorkflowContext(), new MCPToolCall("session-id", Map.of("database", "logic_db", "table", "orders", "column", "phone")));
- }
-
- private WorkflowContextSnapshot createMaskSnapshot() {
- WorkflowRequest request = createWorkflowRequest();
- request.getPrimaryAlgorithmProperties().put("first-n", "3");
- request.getPrimaryAlgorithmProperties().put("last-m", "2");
- request.getPrimaryAlgorithmProperties().put("replace-char", "*");
- WorkflowContextSnapshot result = createWorkflowContextSnapshot(MaskFeatureDefinition.WORKFLOW_KIND, request);
- result.getPropertyRequirements().add(new AlgorithmPropertyRequirement("primary", "first-n", true, false, "from", ""));
- result.getRuleArtifacts().add(new RuleArtifact("create", "CREATE MASK RULE orders (TYPE(NAME='keep_first_n_last_m'))"));
- return result;
- }
-
- private MCPResponse createEncryptPlanResponse() throws ReflectiveOperationException {
- PlanEncryptRuleToolHandler handler = new PlanEncryptRuleToolHandler();
- EncryptWorkflowPlanningService planningService = mock(EncryptWorkflowPlanningService.class);
- when(planningService.plan(any(), any(), any(), any(), any())).thenReturn(createEncryptSnapshot());
- setField(handler, "planningService", planningService);
- return handler.handle(createWorkflowContext(), new MCPToolCall("session-id", Map.of("database", "logic_db", "table", "orders", "column", "phone")));
- }
-
- private WorkflowContextSnapshot createEncryptSnapshot() {
- EncryptWorkflowRequest request = new EncryptWorkflowRequest();
- request.setDatabase("logic_db");
- request.setSchema("public");
- request.setTable("orders");
- request.getPrimaryAlgorithmProperties().put("aes-key-value", "123456");
- WorkflowContextSnapshot result = createWorkflowContextSnapshot(EncryptFeatureDefinition.WORKFLOW_KIND, request);
- result.getPropertyRequirements().add(new AlgorithmPropertyRequirement("primary", "aes-key-value", true, true, "key", ""));
- result.getRuleArtifacts().add(new RuleArtifact("create", "CREATE ENCRYPT RULE orders (PROPERTIES('aes-key-value'='123456'))"));
- return result;
- }
-
- private WorkflowRequest createWorkflowRequest() {
- WorkflowRequest result = new WorkflowRequest();
- result.setDatabase("logic_db");
- result.setSchema("public");
- result.setTable("orders");
- return result;
- }
-
- private WorkflowContextSnapshot createWorkflowContextSnapshot(final WorkflowKind workflowKind, final WorkflowRequest request) {
- WorkflowContextSnapshot result = new WorkflowContextSnapshot();
- result.setPlanId("plan-1");
- result.setWorkflowKind(workflowKind);
- result.setStatus(WorkflowLifecycle.STATUS_PLANNED);
- result.setRequest(request);
- result.setClarifiedIntent(new ClarifiedIntent());
- result.setInteractionPlan(createInteractionPlan());
- return result;
- }
-
- private InteractionPlan createInteractionPlan() {
- InteractionPlan result = new InteractionPlan();
- result.setCurrentStep("review");
- result.setDeliveryMode("interactive");
- result.setExecutionMode("review-then-execute");
- return result;
- }
-
- private MCPWorkflowHandlerContext createWorkflowContext() {
- MCPWorkflowHandlerContext result = mock(MCPWorkflowHandlerContext.class);
- MCPDatabaseHandlerContext databaseContext = mock(MCPDatabaseHandlerContext.class);
- WorkflowSessionContext workflowSessionContext = new InMemoryWorkflowSessionContext();
- when(result.getDatabaseContext()).thenReturn(databaseContext);
- when(result.getWorkflowSessionContext()).thenReturn(workflowSessionContext);
- when(databaseContext.getMetadataQueryFacade()).thenReturn(mock(MCPMetadataQueryFacade.class));
- when(databaseContext.getQueryFacade()).thenReturn(mock(MCPFeatureQueryFacade.class));
- return result;
- }
-
- private void setField(final Object target, final String fieldName, final Object value) throws ReflectiveOperationException {
- Field field = target.getClass().getDeclaredField(fieldName);
- Plugins.getMemberAccessor().set(field, target, value);
- }
-
private void assertCreateToolSpecificationsFallbackWhenElicitationAction(final McpSchema.ElicitResult.Action action) {
try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
Map<String, Object> expectedPayload = createClarifyingPayload();
@@ -739,21 +389,4 @@ class MCPToolSpecificationFactoryTest extends AbstractMCPToolSpecificationFactor
verify(exchange).createElicitation(any());
}
}
-
- @Test
- void assertToolOutputSchemaExamplesMatchSchemas() {
- JsonSchemaValidator validator = new DefaultJsonSchemaValidator();
- for (MCPToolDescriptor each : ToolDefinitionRegistry.getSupportedToolDescriptors()) {
- for (Map<String, Object> example : getOutputSchemaExamples(each)) {
- ValidationResponse actual = validator.validate(each.getOutputSchema(), example);
- assertTrue(actual.valid(), () -> String.format("Invalid outputSchema example for `%s`: %s", each.getName(), actual.errorMessage()));
- }
- }
- }
-
- @SuppressWarnings("unchecked")
- private List<Map<String, Object>> getOutputSchemaExamples(final MCPToolDescriptor toolDescriptor) {
- return toolDescriptor.getOutputSchema().containsKey("examples") ? (List<Map<String, Object>>) toolDescriptor.getOutputSchema().get("examples") : List.of();
- }
-
}
diff --git a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolSpecificationFactoryTest.java b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolSpecificationFactoryTest.java
index 3bc9b54783a..dc9e6cde946 100644
--- a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolSpecificationFactoryTest.java
+++ b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolSpecificationFactoryTest.java
@@ -17,78 +17,35 @@
package org.apache.shardingsphere.mcp.bootstrap.transport.capability.tool;
-import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
-import io.modelcontextprotocol.json.schema.JsonSchemaValidator.ValidationResponse;
-import io.modelcontextprotocol.json.schema.jackson2.DefaultJsonSchemaValidator;
import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification;
import io.modelcontextprotocol.server.McpSyncServerExchange;
import io.modelcontextprotocol.spec.McpError;
-import io.modelcontextprotocol.spec.McpSchema;
-import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
+import io.modelcontextprotocol.spec.McpSchema.ErrorCodes;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
-import io.modelcontextprotocol.spec.McpSchema.ResourceLink;
-import io.modelcontextprotocol.spec.McpSchema.TextContent;
-import org.apache.shardingsphere.mcp.api.protocol.response.MCPResponse;
-import org.apache.shardingsphere.mcp.api.tool.MCPToolCall;
import org.apache.shardingsphere.mcp.api.tool.descriptor.MCPToolAnnotations;
-import org.apache.shardingsphere.mcp.api.tool.descriptor.MCPToolDescriptor;
-import org.apache.shardingsphere.mcp.core.context.MCPRequestScope;
import org.apache.shardingsphere.mcp.core.context.MCPRuntimeContext;
import org.apache.shardingsphere.mcp.core.protocol.exception.UnsupportedToolException;
-import org.apache.shardingsphere.mcp.core.protocol.response.MCPErrorResponse;
import org.apache.shardingsphere.mcp.core.tool.handler.MCPToolDefinition;
import org.apache.shardingsphere.mcp.core.tool.handler.ToolDefinitionRegistry;
-import org.apache.shardingsphere.mcp.core.workflow.InMemoryWorkflowSessionContext;
-import org.apache.shardingsphere.mcp.feature.encrypt.EncryptFeatureDefinition;
-import org.apache.shardingsphere.mcp.feature.encrypt.tool.handler.PlanEncryptRuleToolHandler;
-import org.apache.shardingsphere.mcp.feature.encrypt.tool.model.EncryptWorkflowRequest;
-import org.apache.shardingsphere.mcp.feature.encrypt.tool.service.EncryptWorkflowPlanningService;
-import org.apache.shardingsphere.mcp.feature.mask.MaskFeatureDefinition;
-import org.apache.shardingsphere.mcp.feature.mask.tool.handler.PlanMaskRuleToolHandler;
-import org.apache.shardingsphere.mcp.feature.mask.tool.service.MaskWorkflowPlanningService;
-import org.apache.shardingsphere.mcp.support.database.MCPDatabaseHandlerContext;
-import org.apache.shardingsphere.mcp.support.database.spi.MCPFeatureQueryFacade;
-import org.apache.shardingsphere.mcp.support.database.spi.MCPMetadataQueryFacade;
import org.apache.shardingsphere.mcp.support.descriptor.MCPShardingSphereMetadataKeys;
-import org.apache.shardingsphere.mcp.support.protocol.MCPResourceHintUtils;
import org.apache.shardingsphere.mcp.support.protocol.response.MCPMapResponse;
-import org.apache.shardingsphere.mcp.support.workflow.MCPWorkflowHandlerContext;
-import org.apache.shardingsphere.mcp.support.workflow.WorkflowSessionContext;
-import org.apache.shardingsphere.mcp.support.workflow.model.AlgorithmPropertyRequirement;
-import org.apache.shardingsphere.mcp.support.workflow.model.ClarifiedIntent;
-import org.apache.shardingsphere.mcp.support.workflow.model.InteractionPlan;
-import org.apache.shardingsphere.mcp.support.workflow.model.RuleArtifact;
-import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowContextSnapshot;
-import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowKind;
-import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowLifecycle;
-import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowRequest;
import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
-import org.mockito.internal.configuration.plugins.Plugins;
-import java.lang.reflect.Field;
-import java.time.Clock;
-import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
-import static org.hamcrest.Matchers.isA;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
-import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MCPToolSpecificationFactoryTest extends AbstractMCPToolSpecificationFactoryTest {
@@ -134,125 +91,6 @@ class MCPToolSpecificationFactoryTest extends AbstractMCPToolSpecificationFactor
}
}
- @Test
- void assertCreateToolSpecificationsHandleNullArguments() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- MCPResponse response = new MCPMapResponse(Map.of("status", "ok"));
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createToolDescriptor("database_gateway_search_metadata"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- SyncToolSpecification actualSpecification = createToolSpecification("stdio");
- McpSyncServerExchange exchange = createExchange();
- CallToolResult actual = actualSpecification.callHandler().apply(exchange, new CallToolRequest("database_gateway_search_metadata", null));
- assertThat(actual.structuredContent(), is(Map.of("status", "ok")));
- assertThat(((TextContent) actual.content().getFirst()).text(), is("{\"status\":\"ok\"}"));
- assertFalse(actual.isError());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsHandleErrorResponse() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createToolDescriptor("database_gateway_search_metadata"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of("query", "foo_query"), new MCPErrorResponse(""));
- CallToolResult actual = callTool(createToolSpecification("stdio"), createExchange(), "database_gateway_search_metadata", Map.of("query", "foo_query"));
- @SuppressWarnings("unchecked")
- Map<String, Object> actualPayload = (Map<String, Object>) actual.structuredContent();
- assertThat(actualPayload.get("response_mode"), is("recovery"));
- assertThat(actualPayload.get("message"), is(""));
- assertTrue(actual.isError());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsHandlePlainPayload() {
- CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(Map.of("message", "invalid_request")));
- assertFalse(actual.isError());
- }
-
- @Test
- void assertCreateWithMaskWorkflowPlanSchema() throws ReflectiveOperationException {
- CallToolResult actual = createRealDescriptorCallToolResult(MaskFeatureDefinition.PLAN_TOOL_NAME, createMaskPlanResponse());
- assertFalse(actual.isError(), () -> String.valueOf(actual.structuredContent()));
- assertThat(getStructuredContent(actual).get("status"), is("planned"));
- }
-
- @Test
- void assertCreateWithEncryptWorkflowPlanSchema() throws ReflectiveOperationException {
- CallToolResult actual = createRealDescriptorCallToolResult(EncryptFeatureDefinition.PLAN_TOOL_NAME, createEncryptPlanResponse());
- assertFalse(actual.isError(), () -> String.valueOf(actual.structuredContent()));
- assertThat(getStructuredContent(actual).get("status"), is("planned"));
- }
-
- @Test
- void assertCreateToolSpecificationsHandleResourceLinks() {
- Map<String, Object> payload = Map.of("resources_to_read", List.of(
- MCPResourceHintUtils.create("shardingsphere://databases/logic_db", "logical-database", "read_first", "Read logical database.", "resources_to_read")));
- CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(payload));
- assertThat(actual.structuredContent(), is(payload));
- assertThat(actual.content().get(1), isA(ResourceLink.class));
- ResourceLink actualLink = (ResourceLink) actual.content().get(1);
- assertThat(actualLink.uri(), is("shardingsphere://databases/logic_db"));
- assertThat(actualLink.title(), is("logical-database"));
- assertThat(actualLink.mimeType(), is("application/json"));
- }
-
- @Test
- void assertCreateToolSpecificationsHandleItemResourceLinks() {
- Map<String, Object> payload = Map.of("items", List.of(Map.of(
- "resource", MCPResourceHintUtils.create("shardingsphere://databases/logic_db/tables/t_order", "table", "inspect_detail", "Read table.", "resource"),
- "parent_resource", MCPResourceHintUtils.create("shardingsphere://databases/logic_db", "logical-database", "inspect_parent", "Read database.", "parent_resource"),
- "next_resources", List.of(MCPResourceHintUtils.create(
- "shardingsphere://databases/logic_db/tables/t_order/columns", "column-list", "inspect_children", "Read columns.", "next_resources")))));
- CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(payload));
- assertThat(actual.content().size(), is(4));
- assertThat(((ResourceLink) actual.content().get(1)).uri(), is("shardingsphere://databases/logic_db/tables/t_order"));
- assertThat(((ResourceLink) actual.content().get(2)).uri(), is("shardingsphere://databases/logic_db"));
- assertThat(((ResourceLink) actual.content().get(3)).uri(), is("shardingsphere://databases/logic_db/tables/t_order/columns"));
- assertThat(((Map<?, ?>) actual.content().get(1).meta()).get(MCPShardingSphereMetadataKeys.SOURCE_FIELD), is("resource"));
- }
-
- @Test
- void assertCreateToolSpecificationsHandleRecoveryResourceLinks() {
- Map<String, Object> recovery = Map.of("resources_to_read", List.of(
- MCPResourceHintUtils.create("shardingsphere://capabilities", "capability", "read_first", "Read capabilities.", "resources_to_read")));
- CallToolResult actual = createCallToolResult("fixture_ping", new MCPErrorResponse("", recovery));
- assertTrue(actual.isError());
- assertThat(actual.content().get(1), isA(ResourceLink.class));
- assertThat(((ResourceLink) actual.content().get(1)).uri(), is("shardingsphere://capabilities"));
- }
-
- @Test
- void assertCreateToolSpecificationsHandleBoundedResourceLinks() {
- Map<String, Object> payload = Map.of(
- "next_resources", createResourceHints("shardingsphere://databases/next_", "next_resources", 30),
- "parent_resource", MCPResourceHintUtils.create("shardingsphere://databases", "logical-database", "inspect_parent", "Read parent.", "parent_resource"),
- "resource", MCPResourceHintUtils.create("shardingsphere://databases/logic_db", "logical-database", "inspect_detail", "Read detail.", "resource"),
- "resources_to_read", List.of(MCPResourceHintUtils.create("shardingsphere://capabilities", "capability", "read_first", "Read capabilities.", "resources_to_read")));
- CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(payload));
- assertThat(actual.structuredContent(), is(payload));
- assertThat(actual.content().size(), is(25));
- assertThat(actual.meta().get(MCPShardingSphereMetadataKeys.RESOURCE_LINKS_EMITTED), is(24));
- assertThat(actual.meta().get(MCPShardingSphereMetadataKeys.RESOURCE_LINKS_OMITTED), is(9));
- assertThat(((ResourceLink) actual.content().get(1)).uri(), is("shardingsphere://capabilities"));
- assertThat(((ResourceLink) actual.content().get(2)).uri(), is("shardingsphere://databases/logic_db"));
- assertThat(((ResourceLink) actual.content().get(3)).uri(), is("shardingsphere://databases"));
- assertThat(((Map<?, ?>) actual.content().get(1).meta()).get(MCPShardingSphereMetadataKeys.SOURCE_FIELD), is("resources_to_read"));
- }
-
- @Test
- void assertCreateToolSpecificationsIgnoreRawUriLink() {
- CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(Map.of("resource_uri", "shardingsphere://databases/logic_db")));
- assertThat(actual.content().size(), is(1));
- }
-
- @Test
- void assertCreateToolSpecificationsIgnoreArbitraryNestedResourceHint() {
- Map<String, Object> payload = Map.of("debug", Map.of("resource", MCPResourceHintUtils.create(
- "shardingsphere://databases/logic_db", "logical-database", "inspect_detail", "Read logical database.", "resource")));
- CallToolResult actual = createCallToolResult("fixture_ping", new MCPMapResponse(payload));
- assertThat(actual.content().size(), is(1));
- }
-
@Test
void assertCreateToolSpecificationsHandleToolDefinitionDescriptor() {
try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
@@ -260,7 +98,7 @@ class MCPToolSpecificationFactoryTest extends AbstractMCPToolSpecificationFactor
mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), new MCPMapResponse(Map.of("status", "ok")));
CallToolResult actual = callTool(createToolSpecification("stdio"), createExchange(), "fixture_ping", Map.of());
assertThat(actual.structuredContent(), is(Map.of("status", "ok")));
- assertThat(((TextContent) actual.content().getFirst()).text(), is("{\"status\":\"ok\"}"));
+ assertThat(actual.content().getFirst().type(), is("text"));
}
}
@@ -274,7 +112,7 @@ class MCPToolSpecificationFactoryTest extends AbstractMCPToolSpecificationFactor
SyncToolSpecification actualSpecification = createToolSpecification(runtimeContext);
McpSyncServerExchange exchange = createExchange();
McpError actual = assertThrows(McpError.class, () -> callTool(actualSpecification, exchange, "database_gateway_search_metadata", Map.of()));
- assertThat(actual.getJsonRpcError().code(), is(McpSchema.ErrorCodes.INVALID_PARAMS));
+ assertThat(actual.getJsonRpcError().code(), is(ErrorCodes.INVALID_PARAMS));
assertThat(actual.getJsonRpcError().message(), is("Unsupported tool `database_gateway_search_metadata`."));
@SuppressWarnings("unchecked")
Map<String, Object> actualData = (Map<String, Object>) actual.getJsonRpcError().data();
@@ -313,447 +151,4 @@ class MCPToolSpecificationFactoryTest extends AbstractMCPToolSpecificationFactor
assertTrue(actual.isError());
}
}
-
- @Test
- void assertCreateToolSpecificationsHandleInteractiveElicitation() {
- assertInteractiveElicitation(McpSchema.ClientCapabilities.builder().elicitation().build());
- }
-
- @Test
- void assertCreateToolSpecificationsHandleInteractiveElicitationWithFormOnlyClient() {
- assertInteractiveElicitation(createFormOnlyClientCapabilities());
- }
-
- @Test
- void assertCreateToolSpecificationsHandleInteractiveElicitationWithFormAndUrlClient() {
- assertInteractiveElicitation(createFormAndUrlClientCapabilities());
- }
-
- private void assertInteractiveElicitation(final McpSchema.ClientCapabilities clientCapabilities) {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- String toolName = "database_gateway_plan_encrypt_rule";
- MCPResponse clarifyingResponse = new MCPMapResponse(createClarifyingPayload());
- MCPResponse plannedResponse = new MCPMapResponse(Map.of("status", "planned"));
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createPlanningToolDescriptor(toolName));
- mockedToolDefinitionRegistry.when(() -> ToolDefinitionRegistry.dispatch(any(MCPRequestScope.class), eq(toolDefinition), eq("session-id"), any()))
- .thenReturn(clarifyingResponse, plannedResponse);
- McpSyncServerExchange exchange = createElicitationExchange(new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT,
- Map.of("field_1", "foo_display", "field_2", true)), clientCapabilities);
- CallToolResult actual = callTool(createToolSpecification("stdio"), exchange, toolName, Map.of());
- assertThat(actual.structuredContent(), is(Map.of("status", "planned")));
- ArgumentCaptor<McpSchema.ElicitRequest> requestCaptor = ArgumentCaptor.forClass(McpSchema.ElicitRequest.class);
- verify(exchange).createElicitation(requestCaptor.capture());
- assertThat(requestCaptor.getValue().meta().get(MCPShardingSphereMetadataKeys.TOOL), is(toolName));
- assertThat(requestCaptor.getValue().meta().get(MCPShardingSphereMetadataKeys.PLAN_ID), is("plan-1"));
- assertThat(requestCaptor.getValue().meta().get(MCPShardingSphereMetadataKeys.FORM_REQUEST_ID), isA(String.class));
- assertThat(requestCaptor.getValue().requestedSchema(), is(createExpectedElicitRequestedSchema()));
- mockedToolDefinitionRegistry.verify(() -> ToolDefinitionRegistry.dispatch(any(MCPRequestScope.class), eq(toolDefinition), eq("session-id"), eq(createElicitedArguments())));
- }
- }
-
- @Test
- void assertCreateToolSpecificationsSkipElicitationWithSecretQuestion() {
- assertCreateToolSpecificationsSkipUnsafeElicitation(createClarifyingQuestion("custom_properties.display-name", "string", true, "Provide display name."));
- }
-
- @Test
- void assertCreateToolSpecificationsSkipElicitationWithSecretInputType() {
- assertCreateToolSpecificationsSkipUnsafeElicitation(createClarifyingQuestion("custom_properties.display-name", "secret", false, "Provide display name."));
- }
-
- @Test
- void assertCreateToolSpecificationsSkipElicitationWithSensitiveFieldName() {
- assertCreateToolSpecificationsSkipUnsafeElicitation(createClarifyingQuestion("primary_algorithm_properties.access-token", "string", false, "Provide access token."));
- }
-
- @Test
- void assertCreateToolSpecificationsFallbackWithUrlModeForSensitiveQuestion() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- String toolName = "database_gateway_plan_encrypt_rule";
- MCPResponse response = new MCPMapResponse(createClarifyingPayload(
- createClarifyingQuestion("primary_algorithm_properties.access-token", "string", false, "Provide access token.")));
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createPlanningToolDescriptor(toolName));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- McpSyncServerExchange exchange = createElicitationExchange(new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of()), createFormAndUrlClientCapabilities());
- CallToolResult actual = callTool(createToolSpecification("stdio"), exchange, toolName, Map.of());
- assertStructuredFallback(actual, "url_mode_not_implemented", true, true, "url_fallback");
- assertSanitizedSensitiveFallback(actual);
- verify(exchange, never()).createElicitation(any());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsSkipElicitationWithoutPlanId() {
- assertCreateToolSpecificationsSkipUnsafeElicitationWithPayload(createClarifyingPayloadWithoutPlanId(), "missing_plan_id", "structured_fallback");
- }
-
- @Test
- void assertCreateToolSpecificationsSkipElicitationWithUnknownAlgorithmSecretFlag() {
- assertCreateToolSpecificationsSkipUnsafeElicitationWithPayload(Map.of(
- "plan_id", "plan-1",
- "status", "clarifying",
- "clarification_questions", List.of(Map.of(
- "field", "primary_algorithm_properties.props",
- "input_type", "string",
- "display_message", "Provide props."))),
- "sensitive_form_blocked", "url_fallback");
- }
-
- @Test
- void assertCreateToolSpecificationsSkipElicitationWithAmbiguousFieldBinding() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- String toolName = "database_gateway_plan_encrypt_rule";
- Map<String, Object> expectedPayload = createClarifyingPayload(createClarifyingQuestion("requires_review", "boolean", false, "Require review?"));
- MCPResponse response = new MCPMapResponse(expectedPayload);
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createAmbiguousPlanningToolDescriptor(toolName));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- McpSyncServerExchange exchange = createElicitationExchange(new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of("field_1", true)));
- CallToolResult actual = callTool(createToolSpecification("stdio"), exchange, toolName, Map.of());
- assertStructuredFallback(actual, "ambiguous_field_binding", true, false, "structured_fallback");
- verify(exchange, never()).createElicitation(any());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsSkipElicitationForUrlOnlyClient() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- String toolName = "database_gateway_plan_encrypt_rule";
- Map<String, Object> expectedPayload = createClarifyingPayload();
- MCPResponse response = new MCPMapResponse(expectedPayload);
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createPlanningToolDescriptor(toolName));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- McpSyncServerExchange exchange = createUrlOnlyElicitationExchange();
- CallToolResult actual = callTool(createToolSpecification("stdio"), exchange, toolName, Map.of());
- assertStructuredFallback(actual, "client_unsupported", false, true, "structured_fallback");
- verify(exchange, never()).createElicitation(any());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsFallbackForStreamableHttp() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- String toolName = "database_gateway_plan_encrypt_rule";
- MCPResponse response = new MCPMapResponse(createClarifyingPayload());
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createPlanningToolDescriptor(toolName));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- McpSyncServerExchange exchange = createElicitationExchange(new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of("field_1", "foo_display")));
- CallToolResult actual = callTool(createToolSpecification("http"), exchange, toolName, Map.of());
- assertStructuredFallback(actual, "remote_identity_required", true, false, "structured_fallback");
- verify(exchange, never()).createElicitation(any());
- }
- }
-
- private void assertCreateToolSpecificationsSkipUnsafeElicitation(final Map<String, Object> question) {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- String toolName = "database_gateway_plan_encrypt_rule";
- Map<String, Object> expectedPayload = createClarifyingPayload(question);
- MCPResponse response = new MCPMapResponse(expectedPayload);
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createPlanningToolDescriptor(toolName));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- McpSyncServerExchange exchange = createElicitationExchange(new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of("custom_properties.display-name", "foo_display")));
- CallToolResult actual = callTool(createToolSpecification("stdio"), exchange, toolName, Map.of());
- assertStructuredFallback(actual, "sensitive_form_blocked", true, false, "url_fallback");
- assertSanitizedSensitiveFallback(actual);
- verify(exchange, never()).createElicitation(any());
- }
- }
-
- private void assertCreateToolSpecificationsSkipUnsafeElicitationWithPayload(final Map<String, Object> expectedPayload, final String expectedReason,
- final String expectedInteraction) {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- String toolName = "database_gateway_plan_encrypt_rule";
- MCPResponse response = new MCPMapResponse(expectedPayload);
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createPlanningToolDescriptor(toolName));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- McpSyncServerExchange exchange = createElicitationExchange(new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of("field_1", "foo_display")));
- CallToolResult actual = callTool(createToolSpecification("stdio"), exchange, toolName, Map.of());
- assertStructuredFallback(actual, expectedReason, true, false, expectedInteraction);
- if ("url_fallback".equals(expectedInteraction)) {
- assertSanitizedSensitiveFallback(actual);
- }
- verify(exchange, never()).createElicitation(any());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsSkipElicitationForNonPlanningTool() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- Map<String, Object> expectedPayload = createClarifyingPayload();
- MCPResponse response = new MCPMapResponse(expectedPayload);
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createToolDescriptor("database_gateway_search_metadata"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- MCPRuntimeContext runtimeContext = mock(MCPRuntimeContext.class, RETURNS_DEEP_STUBS);
- when(runtimeContext.getSessionManager().getTransactionResourceManager().getRuntimeDatabases()).thenReturn(Collections.emptyMap());
- McpSyncServerExchange exchange = createElicitationExchange(new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of()));
- CallToolResult actual = callTool(createToolSpecification(runtimeContext), exchange, "database_gateway_search_metadata", Map.of());
- assertThat(actual.structuredContent(), is(expectedPayload));
- verify(exchange, never()).createElicitation(any());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsSkipElicitationWithoutRuntimeDescriptor() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- Map<String, Object> expectedPayload = createClarifyingPayload();
- MCPResponse response = new MCPMapResponse(expectedPayload);
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createToolDescriptor("fixture_ping"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- MCPRuntimeContext runtimeContext = mock(MCPRuntimeContext.class, RETURNS_DEEP_STUBS);
- when(runtimeContext.getSessionManager().getTransactionResourceManager().getRuntimeDatabases()).thenReturn(Collections.emptyMap());
- McpSyncServerExchange exchange = createElicitationExchange(new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of()));
- CallToolResult actual = callTool(createToolSpecification(runtimeContext), exchange, "fixture_ping", Map.of());
- assertThat(actual.structuredContent(), is(expectedPayload));
- verify(exchange, never()).createElicitation(any());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsFallbackWithoutElicitation() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- MCPResponse response = new MCPMapResponse(createClarifyingPayload());
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createPlanningToolDescriptor("database_gateway_plan_encrypt_rule"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- McpSyncServerExchange exchange = createExchange();
- CallToolResult actual = callTool(createToolSpecification("stdio"), exchange, "database_gateway_plan_encrypt_rule", Map.of());
- assertStructuredFallback(actual, "client_unsupported", false, false, "structured_fallback");
- verify(exchange, never()).createElicitation(any());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsFallbackWithoutElicitationCapabilities() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- MCPResponse response = new MCPMapResponse(createClarifyingPayload());
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createPlanningToolDescriptor("database_gateway_plan_encrypt_rule"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- McpSchema.ClientCapabilities clientCapabilities = new McpSchema.ClientCapabilities(Collections.emptyMap(), null, null, null);
- McpSyncServerExchange exchange = createElicitationExchange(new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of()), clientCapabilities);
- CallToolResult actual = callTool(createToolSpecification("stdio"), exchange, "database_gateway_plan_encrypt_rule", Map.of());
- assertStructuredFallback(actual, "client_unsupported", false, false, "structured_fallback");
- verify(exchange, never()).createElicitation(any());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsFallbackWhenElicitationDeclined() {
- assertCreateToolSpecificationsFallbackWhenElicitationAction(McpSchema.ElicitResult.Action.DECLINE);
- }
-
- @Test
- void assertCreateToolSpecificationsFallbackWhenElicitationCancelled() {
- assertCreateToolSpecificationsFallbackWhenElicitationAction(McpSchema.ElicitResult.Action.CANCEL);
- }
-
- @Test
- void assertCreateToolSpecificationsFallbackWhenElicitedContentHasExtraField() {
- assertCreateToolSpecificationsFallbackWhenElicitedContentInvalid(Map.of("field_1", "foo_display", "field_2", true, "field_3", "unexpected"));
- }
-
- @Test
- void assertCreateToolSpecificationsFallbackWhenElicitedContentMissesField() {
- assertCreateToolSpecificationsFallbackWhenElicitedContentInvalid(Map.of("field_1", "foo_display"));
- }
-
- @Test
- void assertCreateToolSpecificationsFallbackWhenElicitedContentHasBlankRequiredValue() {
- assertCreateToolSpecificationsFallbackWhenElicitedContentInvalid(Map.of("field_1", " ", "field_2", true));
- }
-
- @Test
- void assertCreateToolSpecificationsFallbackWhenElicitedContentTypeMismatches() {
- assertCreateToolSpecificationsFallbackWhenElicitedContentInvalid(Map.of("field_1", 1, "field_2", true));
- }
-
- @Test
- void assertCreateToolSpecificationsFallbackWhenElicitedContentViolatesAllowedValues() {
- Map<String, Object> expectedPayload = createClarifyingPayload(createClarifyingQuestion(
- "custom_properties.display-name", "string", false, "Provide display name.", List.of("foo_display")));
- assertCreateToolSpecificationsFallbackWhenElicitedContentInvalid(expectedPayload, Map.of("field_1", "bar_display"));
- }
-
- @Test
- void assertCreateToolSpecificationsFallbackWhenElicitationFails() {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- MCPResponse response = new MCPMapResponse(createClarifyingPayload());
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createPlanningToolDescriptor("database_gateway_plan_encrypt_rule"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- McpSyncServerExchange exchange = createThrowingElicitationExchange();
- CallToolResult actual = callTool(createToolSpecification("stdio"), exchange, "database_gateway_plan_encrypt_rule", Map.of());
- assertStructuredFallback(actual, "elicitation_failed", true, false, "structured_fallback");
- verify(exchange).createElicitation(any());
- }
- }
-
- @Test
- void assertCreateToolSpecificationsFallbackWhenElicitationResultMalformed() {
- assertCreateToolSpecificationsFallbackWhenElicitedContentInvalid("malformed_elicitation_result", new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, null));
- }
-
- @Test
- void assertCreateToolSpecificationsFallbackWhenElicitationExpires() {
- try (
- MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class);
- MockedStatic<Clock> mockedClock = mockStatic(Clock.class)) {
- MutableClock clock = new MutableClock();
- mockedClock.when(Clock::systemUTC).thenReturn(clock);
- MCPResponse response = new MCPMapResponse(createClarifyingPayload());
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createPlanningToolDescriptor("database_gateway_plan_encrypt_rule"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- McpSyncServerExchange exchange = createElicitationExchange(new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of("field_1", "foo_display", "field_2", true)));
- when(exchange.createElicitation(any())).thenAnswer(invocation -> {
- clock.advance(Duration.ofMinutes(11L));
- return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of("field_1", "foo_display", "field_2", true));
- });
- CallToolResult actual = callTool(createToolSpecification("stdio"), exchange, "database_gateway_plan_encrypt_rule", Map.of());
- assertStructuredFallback(actual, "stale_elicitation", true, false, "structured_fallback");
- mockedToolDefinitionRegistry.verify(() -> ToolDefinitionRegistry.dispatch(any(MCPRequestScope.class), eq(toolDefinition), eq("session-id"), eq(createElicitedArguments())), never());
- }
- }
-
- private void assertCreateToolSpecificationsFallbackWhenElicitedContentInvalid(final Map<String, Object> elicitedContent) {
- assertCreateToolSpecificationsFallbackWhenElicitedContentInvalid(createClarifyingPayload(), elicitedContent);
- }
-
- private void assertCreateToolSpecificationsFallbackWhenElicitedContentInvalid(final Map<String, Object> expectedPayload, final Map<String, Object> elicitedContent) {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- MCPResponse response = new MCPMapResponse(expectedPayload);
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createPlanningToolDescriptor("database_gateway_plan_encrypt_rule"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- McpSyncServerExchange exchange = createElicitationExchange(new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, elicitedContent));
- CallToolResult actual = callTool(createToolSpecification("stdio"), exchange, "database_gateway_plan_encrypt_rule", Map.of());
- assertStructuredFallback(actual, "invalid_elicited_content", true, false, "structured_fallback");
- verify(exchange).createElicitation(any());
- mockedToolDefinitionRegistry.verify(() -> ToolDefinitionRegistry.dispatch(any(MCPRequestScope.class), eq(toolDefinition), eq("session-id"), eq(createElicitedArguments())), never());
- }
- }
-
- private void assertCreateToolSpecificationsFallbackWhenElicitedContentInvalid(final String expectedReason, final McpSchema.ElicitResult elicitedResult) {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- MCPResponse response = new MCPMapResponse(createClarifyingPayload());
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createPlanningToolDescriptor("database_gateway_plan_encrypt_rule"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- McpSyncServerExchange exchange = createElicitationExchange(elicitedResult);
- CallToolResult actual = callTool(createToolSpecification("stdio"), exchange, "database_gateway_plan_encrypt_rule", Map.of());
- assertStructuredFallback(actual, expectedReason, true, false, "structured_fallback");
- verify(exchange).createElicitation(any());
- mockedToolDefinitionRegistry.verify(() -> ToolDefinitionRegistry.dispatch(any(MCPRequestScope.class), eq(toolDefinition), eq("session-id"), eq(createElicitedArguments())), never());
- }
- }
-
- private CallToolResult createRealDescriptorCallToolResult(final String toolName, final MCPResponse response) {
- MCPToolDescriptor descriptor = ToolDefinitionRegistry.getToolDefinition(toolName).getDescriptor();
- return new MCPCallToolResultFactory().create(descriptor, response);
- }
-
- private MCPResponse createMaskPlanResponse() throws ReflectiveOperationException {
- PlanMaskRuleToolHandler handler = new PlanMaskRuleToolHandler();
- MaskWorkflowPlanningService planningService = mock(MaskWorkflowPlanningService.class);
- when(planningService.plan(any(), any(), any(), any(), any())).thenReturn(createMaskSnapshot());
- setField(handler, "planningService", planningService);
- return handler.handle(createWorkflowContext(), new MCPToolCall("session-id", Map.of("database", "logic_db", "table", "orders", "column", "phone")));
- }
-
- private WorkflowContextSnapshot createMaskSnapshot() {
- WorkflowRequest request = createWorkflowRequest();
- request.getPrimaryAlgorithmProperties().put("first-n", "3");
- request.getPrimaryAlgorithmProperties().put("last-m", "2");
- request.getPrimaryAlgorithmProperties().put("replace-char", "*");
- WorkflowContextSnapshot result = createWorkflowContextSnapshot(MaskFeatureDefinition.WORKFLOW_KIND, request);
- result.getPropertyRequirements().add(new AlgorithmPropertyRequirement("primary", "first-n", true, false, "from", ""));
- result.getRuleArtifacts().add(new RuleArtifact("create", "CREATE MASK RULE orders (TYPE(NAME='keep_first_n_last_m'))"));
- return result;
- }
-
- private MCPResponse createEncryptPlanResponse() throws ReflectiveOperationException {
- PlanEncryptRuleToolHandler handler = new PlanEncryptRuleToolHandler();
- EncryptWorkflowPlanningService planningService = mock(EncryptWorkflowPlanningService.class);
- when(planningService.plan(any(), any(), any(), any(), any())).thenReturn(createEncryptSnapshot());
- setField(handler, "planningService", planningService);
- return handler.handle(createWorkflowContext(), new MCPToolCall("session-id", Map.of("database", "logic_db", "table", "orders", "column", "phone")));
- }
-
- private WorkflowContextSnapshot createEncryptSnapshot() {
- EncryptWorkflowRequest request = new EncryptWorkflowRequest();
- request.setDatabase("logic_db");
- request.setSchema("public");
- request.setTable("orders");
- request.getPrimaryAlgorithmProperties().put("aes-key-value", "123456");
- WorkflowContextSnapshot result = createWorkflowContextSnapshot(EncryptFeatureDefinition.WORKFLOW_KIND, request);
- result.getPropertyRequirements().add(new AlgorithmPropertyRequirement("primary", "aes-key-value", true, true, "key", ""));
- result.getRuleArtifacts().add(new RuleArtifact("create", "CREATE ENCRYPT RULE orders (PROPERTIES('aes-key-value'='123456'))"));
- return result;
- }
-
- private WorkflowRequest createWorkflowRequest() {
- WorkflowRequest result = new WorkflowRequest();
- result.setDatabase("logic_db");
- result.setSchema("public");
- result.setTable("orders");
- return result;
- }
-
- private WorkflowContextSnapshot createWorkflowContextSnapshot(final WorkflowKind workflowKind, final WorkflowRequest request) {
- WorkflowContextSnapshot result = new WorkflowContextSnapshot();
- result.setPlanId("plan-1");
- result.setWorkflowKind(workflowKind);
- result.setStatus(WorkflowLifecycle.STATUS_PLANNED);
- result.setRequest(request);
- result.setClarifiedIntent(new ClarifiedIntent());
- result.setInteractionPlan(createInteractionPlan());
- return result;
- }
-
- private InteractionPlan createInteractionPlan() {
- InteractionPlan result = new InteractionPlan();
- result.setCurrentStep("review");
- result.setDeliveryMode("interactive");
- result.setExecutionMode("review-then-execute");
- return result;
- }
-
- private MCPWorkflowHandlerContext createWorkflowContext() {
- MCPWorkflowHandlerContext result = mock(MCPWorkflowHandlerContext.class);
- MCPDatabaseHandlerContext databaseContext = mock(MCPDatabaseHandlerContext.class);
- WorkflowSessionContext workflowSessionContext = new InMemoryWorkflowSessionContext();
- when(result.getDatabaseContext()).thenReturn(databaseContext);
- when(result.getWorkflowSessionContext()).thenReturn(workflowSessionContext);
- when(databaseContext.getMetadataQueryFacade()).thenReturn(mock(MCPMetadataQueryFacade.class));
- when(databaseContext.getQueryFacade()).thenReturn(mock(MCPFeatureQueryFacade.class));
- return result;
- }
-
- private void setField(final Object target, final String fieldName, final Object value) throws ReflectiveOperationException {
- Field field = target.getClass().getDeclaredField(fieldName);
- Plugins.getMemberAccessor().set(field, target, value);
- }
-
- private void assertCreateToolSpecificationsFallbackWhenElicitationAction(final McpSchema.ElicitResult.Action action) {
- try (MockedStatic<ToolDefinitionRegistry> mockedToolDefinitionRegistry = mockStatic(ToolDefinitionRegistry.class)) {
- Map<String, Object> expectedPayload = createClarifyingPayload();
- MCPResponse response = new MCPMapResponse(expectedPayload);
- MCPToolDefinition toolDefinition = mockSupportedTool(mockedToolDefinitionRegistry, createPlanningToolDescriptor("database_gateway_plan_encrypt_rule"));
- mockToolDispatch(mockedToolDefinitionRegistry, toolDefinition, Map.of(), response);
- McpSyncServerExchange exchange = createElicitationExchange(new McpSchema.ElicitResult(action, Map.of()));
- CallToolResult actual = callTool(createToolSpecification("stdio"), exchange, "database_gateway_plan_encrypt_rule", Map.of());
- assertThat(actual.structuredContent(), is(expectedPayload));
- verify(exchange).createElicitation(any());
- }
- }
-
- @Test
- void assertToolOutputSchemaExamplesMatchSchemas() {
- JsonSchemaValidator validator = new DefaultJsonSchemaValidator();
- for (MCPToolDescriptor each : ToolDefinitionRegistry.getSupportedToolDescriptors()) {
- for (Map<String, Object> example : getOutputSchemaExamples(each)) {
- ValidationResponse actual = validator.validate(each.getOutputSchema(), example);
- assertTrue(actual.valid(), () -> String.format("Invalid outputSchema example for `%s`: %s", each.getName(), actual.errorMessage()));
- }
- }
- }
-
- @SuppressWarnings("unchecked")
- private List<Map<String, Object>> getOutputSchemaExamples(final MCPToolDescriptor toolDescriptor) {
- return toolDescriptor.getOutputSchema().containsKey("examples") ? (List<Map<String, Object>>) toolDescriptor.getOutputSchema().get("examples") : List.of();
- }
-
}
diff --git a/mcp/features/sharding/src/main/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingWorkflowInputValidator.java b/mcp/features/sharding/src/main/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingWorkflowInputValidator.java
new file mode 100644
index 00000000000..60523a39dfc
--- /dev/null
+++ b/mcp/features/sharding/src/main/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingWorkflowInputValidator.java
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.mcp.feature.sharding.tool.service;
+
+import org.apache.shardingsphere.mcp.feature.sharding.tool.model.ShardingWorkflowRequest;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowContextSnapshot;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowIssue;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowIssueCode;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowLifecycle;
+import org.apache.shardingsphere.mcp.support.workflow.service.WorkflowSQLUtils;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+final class ShardingWorkflowInputValidator {
+
+ boolean hasDatabase(final ShardingWorkflowRequest request, final WorkflowContextSnapshot snapshot) {
+ if (!request.getDatabase().isEmpty()) {
+ return true;
+ }
+ snapshot.getClarifiedIntent().getClarificationMessages().add("Please provide logical database first.");
+ snapshot.setStatus(WorkflowLifecycle.STATUS_CLARIFYING);
+ return false;
+ }
+
+ boolean ensureIdentifiers(final ShardingWorkflowRequest request, final WorkflowContextSnapshot snapshot) {
+ if (areSupportedIdentifiers(List.of(
+ request.getDatabase(), request.getTable(), request.getColumn(), request.getRuleName(), request.getKeyGeneratorName(), request.getKeyGenerateStrategyName()))
+ && areSupportedIdentifiers(splitCsv(request.getShardingColumns()))) {
+ return true;
+ }
+ snapshot.getIssues().add(new WorkflowIssue(WorkflowIssueCode.UNSUPPORTED_IDENTIFIER, "error", "intaking",
+ "A sharding identifier contains unsupported characters.", "Use reviewable DistSQL identifiers only.", false, Map.of()));
+ snapshot.setStatus(WorkflowLifecycle.STATUS_FAILED);
+ return false;
+ }
+
+ boolean hasRequiredTableRuleInputs(final ShardingWorkflowRequest request) {
+ if (WorkflowLifecycle.OPERATION_DROP.equalsIgnoreCase(request.getOperationType())) {
+ return require(request, request.getTable(), "Please provide target logical table.");
+ }
+ if (!require(request, request.getTable(), "Please provide target logical table.")
+ || !require(request, request.getDataNodes().isEmpty() ? request.getStorageUnits() : request.getDataNodes(), "Please provide data nodes or storage units.")) {
+ return false;
+ }
+ return (request.getStorageUnits().isEmpty() ? hasRequiredStrategyInputs(request) : hasRequiredAutoTableRuleInputs(request))
+ && hasRequiredTableRuleKeyGenerateInputs(request);
+ }
+
+ private boolean hasRequiredAutoTableRuleInputs(final ShardingWorkflowRequest request) {
+ return require(request, request.getColumn(), "Please provide auto table sharding column.")
+ && require(request, request.getAlgorithmType(), "Please provide auto table sharding algorithm type.");
+ }
+
+ private boolean hasRequiredTableRuleKeyGenerateInputs(final ShardingWorkflowRequest request) {
+ if (request.getKeyGenerateColumn().isEmpty() || !request.getKeyGeneratorName().isEmpty()) {
+ return true;
+ }
+ return require(request, request.getKeyGeneratorType(), "Please provide key generator type or key generator name for key generate strategy.");
+ }
+
+ boolean hasRequiredReferenceRuleInputs(final ShardingWorkflowRequest request) {
+ if (WorkflowLifecycle.OPERATION_DROP.equalsIgnoreCase(request.getOperationType())) {
+ return require(request, request.getRuleName(), "Please provide table reference rule name.");
+ }
+ return require(request, request.getRuleName(), "Please provide table reference rule name.")
+ && require(request, request.getReferenceTables().isEmpty() ? "" : "ok", "Please provide reference tables.");
+ }
+
+ boolean hasRequiredDefaultStrategyInputs(final ShardingWorkflowRequest request) {
+ if (!require(request, request.getDefaultStrategyType(), "Please provide DATABASE or TABLE default strategy type.")) {
+ return false;
+ }
+ if (!isDefaultStrategyType(request.getDefaultStrategyType())) {
+ request.setFieldSemantics("Please provide default_strategy_type as DATABASE or TABLE.");
+ return false;
+ }
+ if (WorkflowLifecycle.OPERATION_DROP.equalsIgnoreCase(request.getOperationType()) || "none".equalsIgnoreCase(request.getStrategyType())) {
+ return true;
+ }
+ return hasRequiredStrategyInputs(request);
+ }
+
+ private boolean isDefaultStrategyType(final String defaultStrategyType) {
+ return "DATABASE".equalsIgnoreCase(defaultStrategyType) || "TABLE".equalsIgnoreCase(defaultStrategyType);
+ }
+
+ boolean hasRequiredStrategyInputs(final ShardingWorkflowRequest request) {
+ switch (normalizeStrategyType(request)) {
+ case "standard":
+ return require(request, request.getColumn(), "Please provide sharding column.")
+ && require(request, request.getAlgorithmType(), "Please provide sharding algorithm type.");
+ case "complex":
+ return hasRequiredComplexStrategyInputs(request);
+ case "hint":
+ return require(request, request.getAlgorithmType(), "Please provide sharding algorithm type.");
+ case "none":
+ return true;
+ default:
+ request.setFieldSemantics("Please provide strategy_type as standard, complex, hint, or none.");
+ return false;
+ }
+ }
+
+ private boolean hasRequiredComplexStrategyInputs(final ShardingWorkflowRequest request) {
+ if (!require(request, request.getShardingColumns(), "Please provide at least two sharding columns for complex strategy.")) {
+ return false;
+ }
+ if (splitCsv(request.getShardingColumns()).size() < 2) {
+ request.setFieldSemantics("Please provide at least two sharding columns for complex strategy.");
+ return false;
+ }
+ return require(request, request.getAlgorithmType(), "Please provide sharding algorithm type.");
+ }
+
+ boolean hasRequiredKeyGeneratorInputs(final ShardingWorkflowRequest request) {
+ if (WorkflowLifecycle.OPERATION_DROP.equalsIgnoreCase(request.getOperationType())) {
+ return require(request, request.getKeyGeneratorName(), "Please provide key generator name.");
+ }
+ return require(request, request.getKeyGeneratorName(), "Please provide key generator name.")
+ && require(request, request.getKeyGeneratorType(), "Please provide key generator type.");
+ }
+
+ boolean hasRequiredKeyGenerateStrategyInputs(final ShardingWorkflowRequest request) {
+ if (WorkflowLifecycle.OPERATION_DROP.equalsIgnoreCase(request.getOperationType())) {
+ return require(request, request.getKeyGenerateStrategyName(), "Please provide key generate strategy name.");
+ }
+ return require(request, request.getKeyGenerateStrategyName(), "Please provide key generate strategy name.")
+ && require(request, request.getSequenceName().isEmpty() ? request.getTable() : request.getSequenceName(), "Please provide table or sequence.")
+ && require(request, request.getSequenceName().isEmpty() ? request.getColumn() : "ok", "Please provide key generate column.")
+ && require(request, request.getKeyGeneratorName().isEmpty() ? request.getKeyGeneratorType() : request.getKeyGeneratorName(), "Please provide generator or key generator type.");
+ }
+
+ boolean hasRequiredCleanupInputs(final ShardingWorkflowRequest request, final WorkflowContextSnapshot snapshot) {
+ if (!request.getComponentType().isEmpty() && !request.getComponentName().isEmpty()) {
+ return true;
+ }
+ snapshot.getClarifiedIntent().getClarificationMessages().add("Please provide component type and component name.");
+ snapshot.setStatus(WorkflowLifecycle.STATUS_CLARIFYING);
+ return false;
+ }
+
+ private boolean require(final ShardingWorkflowRequest request, final String value, final String message) {
+ if (!value.isEmpty()) {
+ return true;
+ }
+ request.setFieldSemantics(message);
+ return false;
+ }
+
+ List<String> splitCsv(final String value) {
+ return Arrays.stream(value.split(",")).map(String::trim).filter(each -> !each.isEmpty()).toList();
+ }
+
+ String normalizeStrategyType(final ShardingWorkflowRequest request) {
+ return request.getStrategyType().isEmpty() ? "standard" : request.getStrategyType().trim().toLowerCase(Locale.ENGLISH);
+ }
+
+ private boolean areSupportedIdentifiers(final Collection<String> identifiers) {
+ return identifiers.stream().allMatch(WorkflowSQLUtils::isSupportedIdentifier);
+ }
+}
diff --git a/mcp/features/sharding/src/main/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingWorkflowLifecycleSpec.java b/mcp/features/sharding/src/main/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingWorkflowLifecycleSpec.java
new file mode 100644
index 00000000000..d2ef957a67c
--- /dev/null
+++ b/mcp/features/sharding/src/main/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingWorkflowLifecycleSpec.java
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.mcp.feature.sharding.tool.service;
+
+import org.apache.shardingsphere.mcp.feature.sharding.tool.model.ShardingWorkflowRequest;
+import org.apache.shardingsphere.mcp.support.workflow.model.RuleArtifact;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowContextSnapshot;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowKind;
+
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+final class ShardingWorkflowLifecycleSpec {
+
+ private final WorkflowKind workflowKind;
+
+ private final String defaultOperationType;
+
+ private final String summary;
+
+ private final Function<ShardingWorkflowRequest, Boolean> existsSupplier;
+
+ private final Function<ShardingWorkflowRequest, Boolean> requiredInputSupplier;
+
+ private final BiFunction<ShardingWorkflowRequest, WorkflowContextSnapshot, Boolean> algorithmPlanSupplier;
+
+ private final Function<ShardingWorkflowRequest, RuleArtifact> artifactSupplier;
+
+ ShardingWorkflowLifecycleSpec(final WorkflowKind workflowKind, final String defaultOperationType, final String summary,
+ final Function<ShardingWorkflowRequest, Boolean> existsSupplier, final Function<ShardingWorkflowRequest, Boolean> requiredInputSupplier,
+ final BiFunction<ShardingWorkflowRequest, WorkflowContextSnapshot, Boolean> algorithmPlanSupplier,
+ final Function<ShardingWorkflowRequest, RuleArtifact> artifactSupplier) {
+ this.workflowKind = workflowKind;
+ this.defaultOperationType = defaultOperationType;
+ this.summary = summary;
+ this.existsSupplier = existsSupplier;
+ this.requiredInputSupplier = requiredInputSupplier;
+ this.algorithmPlanSupplier = algorithmPlanSupplier;
+ this.artifactSupplier = artifactSupplier;
+ }
+
+ WorkflowKind getWorkflowKind() {
+ return workflowKind;
+ }
+
+ String getDefaultOperationType() {
+ return defaultOperationType;
+ }
+
+ String getSummary() {
+ return summary;
+ }
+
+ Function<ShardingWorkflowRequest, Boolean> getExistsSupplier() {
+ return existsSupplier;
+ }
+
+ Function<ShardingWorkflowRequest, Boolean> getRequiredInputSupplier() {
+ return requiredInputSupplier;
+ }
+
+ BiFunction<ShardingWorkflowRequest, WorkflowContextSnapshot, Boolean> getAlgorithmPlanSupplier() {
+ return algorithmPlanSupplier;
+ }
+
+ Function<ShardingWorkflowRequest, RuleArtifact> getArtifactSupplier() {
+ return artifactSupplier;
+ }
+}
diff --git a/mcp/features/sharding/src/main/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingWorkflowPlanningKernel.java b/mcp/features/sharding/src/main/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingWorkflowPlanningKernel.java
index 1ba48b384b7..a13295005b7 100644
--- a/mcp/features/sharding/src/main/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingWorkflowPlanningKernel.java
+++ b/mcp/features/sharding/src/main/java/org/apache/shardingsphere/mcp/feature/sharding/tool/service/ShardingWorkflowPlanningKernel.java
@@ -24,7 +24,6 @@ import org.apache.shardingsphere.mcp.support.workflow.WorkflowSessionContext;
import org.apache.shardingsphere.mcp.support.workflow.model.AlgorithmCandidate;
import org.apache.shardingsphere.mcp.support.workflow.model.AlgorithmPropertyRequirement;
import org.apache.shardingsphere.mcp.support.workflow.model.ClarifiedIntent;
-import org.apache.shardingsphere.mcp.support.workflow.model.RuleArtifact;
import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowContextSnapshot;
import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowIssue;
import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowIssueCode;
@@ -35,13 +34,10 @@ import org.apache.shardingsphere.mcp.support.workflow.service.WorkflowPlanningSu
import org.apache.shardingsphere.mcp.support.workflow.service.WorkflowRuleValueUtils;
import org.apache.shardingsphere.mcp.support.workflow.service.WorkflowSQLUtils;
-import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
-import java.util.function.BiFunction;
-import java.util.function.Function;
/**
* Sharding workflow planning kernel.
@@ -60,6 +56,8 @@ final class ShardingWorkflowPlanningKernel {
private final WorkflowPlanningSupport planningSupport = new WorkflowPlanningSupport();
+ private final ShardingWorkflowInputValidator inputValidator = new ShardingWorkflowInputValidator();
+
private final ShardingInspectionService inspectionService;
private final ShardingAlgorithmRecommendationService algorithmRecommendationService;
@@ -75,13 +73,6 @@ final class ShardingWorkflowPlanningKernel {
distSQLPlanningService = new ShardingDistSQLPlanningService();
}
- ShardingWorkflowPlanningKernel(final ShardingInspectionService inspectionService, final ShardingDistSQLPlanningService distSQLPlanningService) {
- this.inspectionService = inspectionService;
- algorithmRecommendationService = new ShardingAlgorithmRecommendationService();
- algorithmPropertyTemplateService = new ShardingAlgorithmPropertyTemplateService();
- this.distSQLPlanningService = distSQLPlanningService;
- }
-
/**
* Plan sharding table rule workflow.
*
@@ -93,11 +84,12 @@ final class ShardingWorkflowPlanningKernel {
*/
public WorkflowContextSnapshot planTableRule(final WorkflowSessionContext workflowSessionContext, final MCPFeatureQueryFacade queryFacade,
final String sessionId, final ShardingWorkflowRequest request) {
- return planLifecycleWorkflow(workflowSessionContext, sessionId, request, ShardingFeatureDefinition.TABLE_RULE_WORKFLOW_KIND,
- "create", "Sharding table rule workflow plan.", actual -> !inspectionService.queryTableRule(queryFacade, actual.getDatabase(), actual.getTable()).isEmpty(),
- this::hasRequiredTableRuleInputs, (actual, snapshot) -> planAlgorithms(queryFacade, actual,
+ return planLifecycleWorkflow(workflowSessionContext, sessionId, request, new ShardingWorkflowLifecycleSpec(
+ ShardingFeatureDefinition.TABLE_RULE_WORKFLOW_KIND, "create", "Sharding table rule workflow plan.",
+ actual -> !inspectionService.queryTableRule(queryFacade, actual.getDatabase(), actual.getTable()).isEmpty(),
+ inputValidator::hasRequiredTableRuleInputs, (actual, snapshot) -> planAlgorithms(queryFacade, actual,
shouldPlanShardingAlgorithm(actual), shouldPlanTableRuleKeyGenerator(actual), snapshot),
- actual -> distSQLPlanningService.planTableRule(actual, actual.getOperationType()));
+ actual -> distSQLPlanningService.planTableRule(actual, actual.getOperationType())));
}
/**
@@ -111,9 +103,10 @@ final class ShardingWorkflowPlanningKernel {
*/
public WorkflowContextSnapshot planTableReferenceRule(final WorkflowSessionContext workflowSessionContext, final MCPFeatureQueryFacade queryFacade,
final String sessionId, final ShardingWorkflowRequest request) {
- return planLifecycleWorkflow(workflowSessionContext, sessionId, request, ShardingFeatureDefinition.TABLE_REFERENCE_WORKFLOW_KIND,
- "create", "Sharding table reference rule workflow plan.", actual -> !inspectionService.queryTableReferenceRule(queryFacade, actual.getDatabase(), actual.getRuleName()).isEmpty(),
- this::hasRequiredReferenceRuleInputs, (actual, snapshot) -> true, actual -> distSQLPlanningService.planTableReferenceRule(actual, actual.getOperationType()));
+ return planLifecycleWorkflow(workflowSessionContext, sessionId, request, new ShardingWorkflowLifecycleSpec(
+ ShardingFeatureDefinition.TABLE_REFERENCE_WORKFLOW_KIND, "create", "Sharding table reference rule workflow plan.",
+ actual -> !inspectionService.queryTableReferenceRule(queryFacade, actual.getDatabase(), actual.getRuleName()).isEmpty(),
+ inputValidator::hasRequiredReferenceRuleInputs, (actual, snapshot) -> true, actual -> distSQLPlanningService.planTableReferenceRule(actual, actual.getOperationType())));
}
/**
@@ -127,12 +120,13 @@ final class ShardingWorkflowPlanningKernel {
*/
public WorkflowContextSnapshot planDefaultStrategy(final WorkflowSessionContext workflowSessionContext, final MCPFeatureQueryFacade queryFacade,
final String sessionId, final ShardingWorkflowRequest request) {
- return planLifecycleWorkflow(workflowSessionContext, sessionId, request, ShardingFeatureDefinition.DEFAULT_STRATEGY_WORKFLOW_KIND,
- "create", "Default sharding strategy workflow plan.", actual -> containsDefaultStrategy(inspectionService.queryDefaultStrategy(queryFacade, actual.getDatabase()),
+ return planLifecycleWorkflow(workflowSessionContext, sessionId, request, new ShardingWorkflowLifecycleSpec(
+ ShardingFeatureDefinition.DEFAULT_STRATEGY_WORKFLOW_KIND, "create", "Default sharding strategy workflow plan.",
+ actual -> containsDefaultStrategy(inspectionService.queryDefaultStrategy(queryFacade, actual.getDatabase()),
queryFacade.getDatabaseType(actual.getDatabase()), actual.getDefaultStrategyType()),
- this::hasRequiredDefaultStrategyInputs,
+ inputValidator::hasRequiredDefaultStrategyInputs,
(actual, snapshot) -> planAlgorithms(queryFacade, actual, shouldPlanShardingAlgorithm(actual), false, snapshot),
- actual -> distSQLPlanningService.planDefaultStrategy(actual, actual.getOperationType()));
+ actual -> distSQLPlanningService.planDefaultStrategy(actual, actual.getOperationType())));
}
/**
@@ -146,10 +140,11 @@ final class ShardingWorkflowPlanningKernel {
*/
public WorkflowContextSnapshot planKeyGenerator(final WorkflowSessionContext workflowSessionContext, final MCPFeatureQueryFacade queryFacade,
final String sessionId, final ShardingWorkflowRequest request) {
- return planLifecycleWorkflow(workflowSessionContext, sessionId, request, ShardingFeatureDefinition.KEY_GENERATOR_WORKFLOW_KIND,
- "create", "Sharding key generator workflow plan.", actual -> !inspectionService.queryKeyGenerator(queryFacade, actual.getDatabase(), actual.getKeyGeneratorName()).isEmpty(),
- this::hasRequiredKeyGeneratorInputs, (actual, snapshot) -> planAlgorithms(queryFacade, actual, false, true, snapshot),
- actual -> distSQLPlanningService.planKeyGenerator(actual, actual.getOperationType()));
+ return planLifecycleWorkflow(workflowSessionContext, sessionId, request, new ShardingWorkflowLifecycleSpec(
+ ShardingFeatureDefinition.KEY_GENERATOR_WORKFLOW_KIND, "create", "Sharding key generator workflow plan.",
+ actual -> !inspectionService.queryKeyGenerator(queryFacade, actual.getDatabase(), actual.getKeyGeneratorName()).isEmpty(),
+ inputValidator::hasRequiredKeyGeneratorInputs, (actual, snapshot) -> planAlgorithms(queryFacade, actual, false, true, snapshot),
+ actual -> distSQLPlanningService.planKeyGenerator(actual, actual.getOperationType())));
}
/**
@@ -163,12 +158,12 @@ final class ShardingWorkflowPlanningKernel {
*/
public WorkflowContextSnapshot planKeyGenerateStrategy(final WorkflowSessionContext workflowSessionContext, final MCPFeatureQueryFacade queryFacade,
final String sessionId, final ShardingWorkflowRequest request) {
- return planLifecycleWorkflow(workflowSessionContext, sessionId, request, ShardingFeatureDefinition.KEY_GENERATE_STRATEGY_WORKFLOW_KIND,
- "create", "Sharding key generate strategy workflow plan.",
+ return planLifecycleWorkflow(workflowSessionContext, sessionId, request, new ShardingWorkflowLifecycleSpec(
+ ShardingFeatureDefinition.KEY_GENERATE_STRATEGY_WORKFLOW_KIND, "create", "Sharding key generate strategy workflow plan.",
actual -> !inspectionService.queryKeyGenerateStrategy(queryFacade, actual.getDatabase(), actual.getKeyGenerateStrategyName()).isEmpty(),
- this::hasRequiredKeyGenerateStrategyInputs, (actual, snapshot) -> planAlgorithms(queryFacade, actual, false,
+ inputValidator::hasRequiredKeyGenerateStrategyInputs, (actual, snapshot) -> planAlgorithms(queryFacade, actual, false,
shouldPlanKeyGenerateStrategyGenerator(actual), snapshot),
- actual -> distSQLPlanningService.planKeyGenerateStrategy(actual, actual.getOperationType()));
+ actual -> distSQLPlanningService.planKeyGenerateStrategy(actual, actual.getOperationType())));
}
/**
@@ -185,7 +180,7 @@ final class ShardingWorkflowPlanningKernel {
WorkflowContextSnapshot result = prepareSnapshot(workflowSessionContext, sessionId, request, ShardingFeatureDefinition.COMPONENT_CLEANUP_WORKFLOW_KIND,
WorkflowLifecycle.OPERATION_DROP, "Sharding rule component cleanup workflow plan.");
ShardingWorkflowRequest mergedRequest = (ShardingWorkflowRequest) result.getRequest();
- if (!hasDatabase(mergedRequest, result) || !hasRequiredCleanupInputs(mergedRequest, result)) {
+ if (!inputValidator.hasDatabase(mergedRequest, result) || !inputValidator.hasRequiredCleanupInputs(mergedRequest, result)) {
return workflowSessionContext.persist(result, WorkflowLifecycle.STEP_CLARIFYING, result.getStatus());
}
if (!ensureCleanupDropOnly(mergedRequest, result)) {
@@ -213,31 +208,28 @@ final class ShardingWorkflowPlanningKernel {
}
private WorkflowContextSnapshot planLifecycleWorkflow(final WorkflowSessionContext workflowSessionContext, final String sessionId, final ShardingWorkflowRequest request,
- final WorkflowKind workflowKind, final String defaultOperationType, final String summary,
- final Function<ShardingWorkflowRequest, Boolean> existsSupplier, final Function<ShardingWorkflowRequest, Boolean> requiredInputSupplier,
- final BiFunction<ShardingWorkflowRequest, WorkflowContextSnapshot, Boolean> algorithmPlanSupplier,
- final Function<ShardingWorkflowRequest, RuleArtifact> artifactSupplier) {
- WorkflowContextSnapshot result = prepareSnapshot(workflowSessionContext, sessionId, request, workflowKind, defaultOperationType, summary);
+ final ShardingWorkflowLifecycleSpec spec) {
+ WorkflowContextSnapshot result = prepareSnapshot(workflowSessionContext, sessionId, request, spec.getWorkflowKind(), spec.getDefaultOperationType(), spec.getSummary());
ShardingWorkflowRequest mergedRequest = (ShardingWorkflowRequest) result.getRequest();
- if (!hasDatabase(mergedRequest, result)) {
+ if (!inputValidator.hasDatabase(mergedRequest, result)) {
return workflowSessionContext.persist(result, WorkflowLifecycle.STEP_CLARIFYING, result.getStatus());
}
- if (!ensureIdentifiers(mergedRequest, result)) {
+ if (!inputValidator.ensureIdentifiers(mergedRequest, result)) {
return workflowSessionContext.persist(result, WorkflowLifecycle.STEP_FAILED, result.getStatus());
}
- if (!requiredInputSupplier.apply(mergedRequest)) {
+ if (!spec.getRequiredInputSupplier().apply(mergedRequest)) {
result.getClarifiedIntent().getClarificationMessages().add(
mergedRequest.getFieldSemantics().isEmpty() ? "Please provide the missing sharding planning inputs." : mergedRequest.getFieldSemantics());
result.setStatus(WorkflowLifecycle.STATUS_CLARIFYING);
return workflowSessionContext.persist(result, WorkflowLifecycle.STEP_CLARIFYING, result.getStatus());
}
- if (!planningSupport.ensureLifecycleState(summary, result.getClarifiedIntent(), existsSupplier.apply(mergedRequest), result)) {
+ if (!planningSupport.ensureLifecycleState(spec.getSummary(), result.getClarifiedIntent(), spec.getExistsSupplier().apply(mergedRequest), result)) {
return workflowSessionContext.persist(result, WorkflowLifecycle.STEP_FAILED, WorkflowLifecycle.STATUS_FAILED);
}
- if (!algorithmPlanSupplier.apply(mergedRequest, result)) {
+ if (!spec.getAlgorithmPlanSupplier().apply(mergedRequest, result)) {
return workflowSessionContext.persist(result, WorkflowLifecycle.STEP_CLARIFYING, WorkflowLifecycle.STATUS_CLARIFYING);
}
- result.getRuleArtifacts().add(artifactSupplier.apply(mergedRequest));
+ result.getRuleArtifacts().add(spec.getArtifactSupplier().apply(mergedRequest));
return workflowSessionContext.persist(result, WorkflowLifecycle.STEP_REVIEW, WorkflowLifecycle.STATUS_PLANNED);
}
@@ -258,140 +250,6 @@ final class ShardingWorkflowPlanningKernel {
return result;
}
- private boolean hasDatabase(final ShardingWorkflowRequest request, final WorkflowContextSnapshot snapshot) {
- if (!request.getDatabase().isEmpty()) {
- return true;
- }
- snapshot.getClarifiedIntent().getClarificationMessages().add("Please provide logical database first.");
- snapshot.setStatus(WorkflowLifecycle.STATUS_CLARIFYING);
- return false;
- }
-
- private boolean ensureIdentifiers(final ShardingWorkflowRequest request, final WorkflowContextSnapshot snapshot) {
- if (areSupportedIdentifiers(List.of(
- request.getDatabase(), request.getTable(), request.getColumn(), request.getRuleName(), request.getKeyGeneratorName(), request.getKeyGenerateStrategyName()))
- && areSupportedIdentifiers(splitCsv(request.getShardingColumns()))) {
- return true;
- }
- snapshot.getIssues().add(new WorkflowIssue(WorkflowIssueCode.UNSUPPORTED_IDENTIFIER, "error", "intaking",
- "A sharding identifier contains unsupported characters.", "Use reviewable DistSQL identifiers only.", false, Map.of()));
- snapshot.setStatus(WorkflowLifecycle.STATUS_FAILED);
- return false;
- }
-
- private boolean hasRequiredTableRuleInputs(final ShardingWorkflowRequest request) {
- if (WorkflowLifecycle.OPERATION_DROP.equalsIgnoreCase(request.getOperationType())) {
- return require(request, request.getTable(), "Please provide target logical table.");
- }
- if (!require(request, request.getTable(), "Please provide target logical table.")
- || !require(request, request.getDataNodes().isEmpty() ? request.getStorageUnits() : request.getDataNodes(), "Please provide data nodes or storage units.")) {
- return false;
- }
- return (request.getStorageUnits().isEmpty() ? hasRequiredStrategyInputs(request) : hasRequiredAutoTableRuleInputs(request))
- && hasRequiredTableRuleKeyGenerateInputs(request);
- }
-
- private boolean hasRequiredAutoTableRuleInputs(final ShardingWorkflowRequest request) {
- return require(request, request.getColumn(), "Please provide auto table sharding column.")
- && require(request, request.getAlgorithmType(), "Please provide auto table sharding algorithm type.");
- }
-
- private boolean hasRequiredTableRuleKeyGenerateInputs(final ShardingWorkflowRequest request) {
- if (request.getKeyGenerateColumn().isEmpty() || !request.getKeyGeneratorName().isEmpty()) {
- return true;
- }
- return require(request, request.getKeyGeneratorType(), "Please provide key generator type or key generator name for key generate strategy.");
- }
-
- private boolean hasRequiredReferenceRuleInputs(final ShardingWorkflowRequest request) {
- if (WorkflowLifecycle.OPERATION_DROP.equalsIgnoreCase(request.getOperationType())) {
- return require(request, request.getRuleName(), "Please provide table reference rule name.");
- }
- return require(request, request.getRuleName(), "Please provide table reference rule name.")
- && require(request, request.getReferenceTables().isEmpty() ? "" : "ok", "Please provide reference tables.");
- }
-
- private boolean hasRequiredDefaultStrategyInputs(final ShardingWorkflowRequest request) {
- if (!require(request, request.getDefaultStrategyType(), "Please provide DATABASE or TABLE default strategy type.")) {
- return false;
- }
- if (!isDefaultStrategyType(request.getDefaultStrategyType())) {
- request.setFieldSemantics("Please provide default_strategy_type as DATABASE or TABLE.");
- return false;
- }
- if (WorkflowLifecycle.OPERATION_DROP.equalsIgnoreCase(request.getOperationType()) || "none".equalsIgnoreCase(request.getStrategyType())) {
- return true;
- }
- return hasRequiredStrategyInputs(request);
- }
-
- private boolean isDefaultStrategyType(final String defaultStrategyType) {
- return "DATABASE".equalsIgnoreCase(defaultStrategyType) || "TABLE".equalsIgnoreCase(defaultStrategyType);
- }
-
- private boolean hasRequiredStrategyInputs(final ShardingWorkflowRequest request) {
- switch (normalizeStrategyType(request)) {
- case "standard":
- return require(request, request.getColumn(), "Please provide sharding column.")
- && require(request, request.getAlgorithmType(), "Please provide sharding algorithm type.");
- case "complex":
- return hasRequiredComplexStrategyInputs(request);
- case "hint":
- return require(request, request.getAlgorithmType(), "Please provide sharding algorithm type.");
- case "none":
- return true;
- default:
- request.setFieldSemantics("Please provide strategy_type as standard, complex, hint, or none.");
- return false;
- }
- }
-
- private boolean hasRequiredComplexStrategyInputs(final ShardingWorkflowRequest request) {
- if (!require(request, request.getShardingColumns(), "Please provide at least two sharding columns for complex strategy.")) {
- return false;
- }
- if (splitCsv(request.getShardingColumns()).size() < 2) {
- request.setFieldSemantics("Please provide at least two sharding columns for complex strategy.");
- return false;
- }
- return require(request, request.getAlgorithmType(), "Please provide sharding algorithm type.");
- }
-
- private boolean hasRequiredKeyGeneratorInputs(final ShardingWorkflowRequest request) {
- if (WorkflowLifecycle.OPERATION_DROP.equalsIgnoreCase(request.getOperationType())) {
- return require(request, request.getKeyGeneratorName(), "Please provide key generator name.");
- }
- return require(request, request.getKeyGeneratorName(), "Please provide key generator name.")
- && require(request, request.getKeyGeneratorType(), "Please provide key generator type.");
- }
-
- private boolean hasRequiredKeyGenerateStrategyInputs(final ShardingWorkflowRequest request) {
- if (WorkflowLifecycle.OPERATION_DROP.equalsIgnoreCase(request.getOperationType())) {
- return require(request, request.getKeyGenerateStrategyName(), "Please provide key generate strategy name.");
- }
- return require(request, request.getKeyGenerateStrategyName(), "Please provide key generate strategy name.")
- && require(request, request.getSequenceName().isEmpty() ? request.getTable() : request.getSequenceName(), "Please provide table or sequence.")
- && require(request, request.getSequenceName().isEmpty() ? request.getColumn() : "ok", "Please provide key generate column.")
- && require(request, request.getKeyGeneratorName().isEmpty() ? request.getKeyGeneratorType() : request.getKeyGeneratorName(), "Please provide generator or key generator type.");
- }
-
- private boolean hasRequiredCleanupInputs(final ShardingWorkflowRequest request, final WorkflowContextSnapshot snapshot) {
- if (!request.getComponentType().isEmpty() && !request.getComponentName().isEmpty()) {
- return true;
- }
- snapshot.getClarifiedIntent().getClarificationMessages().add("Please provide component type and component name.");
- snapshot.setStatus(WorkflowLifecycle.STATUS_CLARIFYING);
- return false;
- }
-
- private boolean require(final ShardingWorkflowRequest request, final String value, final String message) {
- if (!value.isEmpty()) {
- return true;
- }
- request.setFieldSemantics(message);
- return false;
- }
-
private boolean containsDefaultStrategy(final List<Map<String, Object>> rows, final String databaseType, final String defaultStrategyType) {
return rows.stream().anyMatch(each -> WorkflowSQLUtils.isSameIdentifier(
databaseType, defaultStrategyType, WorkflowRuleValueUtils.getRuleValue(each, "name")) && !WorkflowRuleValueUtils.getRuleValue(each, "type").isEmpty());
@@ -433,7 +291,7 @@ final class ShardingWorkflowPlanningKernel {
}
private boolean shouldPlanShardingAlgorithm(final ShardingWorkflowRequest request) {
- return !WorkflowLifecycle.OPERATION_DROP.equalsIgnoreCase(request.getOperationType()) && !"none".equalsIgnoreCase(normalizeStrategyType(request));
+ return !WorkflowLifecycle.OPERATION_DROP.equalsIgnoreCase(request.getOperationType()) && !"none".equalsIgnoreCase(inputValidator.normalizeStrategyType(request));
}
private boolean shouldPlanTableRuleKeyGenerator(final ShardingWorkflowRequest request) {
@@ -474,18 +332,6 @@ final class ShardingWorkflowPlanningKernel {
return rows.stream().anyMatch(each -> WorkflowSQLUtils.isSameIdentifier(databaseType, expected, WorkflowRuleValueUtils.getRuleValue(each, fieldName)));
}
- private boolean areSupportedIdentifiers(final Collection<String> identifiers) {
- return identifiers.stream().allMatch(WorkflowSQLUtils::isSupportedIdentifier);
- }
-
- private List<String> splitCsv(final String value) {
- return Arrays.stream(value.split(",")).map(String::trim).filter(each -> !each.isEmpty()).toList();
- }
-
- private String normalizeStrategyType(final ShardingWorkflowRequest request) {
- return request.getStrategyType().isEmpty() ? "standard" : request.getStrategyType().trim().toLowerCase(Locale.ENGLISH);
- }
-
private String normalizeComponentType(final String componentType) {
return componentType.trim().toLowerCase(Locale.ENGLISH).replace('_', '-');
}
diff --git a/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPCompletionTargetDescriptorValidator.java b/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPCompletionTargetDescriptorValidator.java
new file mode 100644
index 00000000000..e914ba5eb92
--- /dev/null
+++ b/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPCompletionTargetDescriptorValidator.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.mcp.support.descriptor;
+
+import org.apache.shardingsphere.infra.exception.ShardingSpherePreconditions;
+import org.apache.shardingsphere.mcp.api.prompt.descriptor.MCPPromptArgumentDescriptor;
+import org.apache.shardingsphere.mcp.api.prompt.descriptor.MCPPromptDescriptor;
+import org.apache.shardingsphere.mcp.api.resource.descriptor.MCPResourceDescriptor;
+import org.apache.shardingsphere.mcp.support.resource.MCPUriTemplate;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Validator for MCP completion targets.
+ */
+public final class MCPCompletionTargetDescriptorValidator {
+
+ private MCPCompletionTargetDescriptorValidator() {
+ }
+
+ /**
+ * Validate completion targets against declared prompt and resource descriptors.
+ *
+ * @param descriptors completion target descriptors
+ * @param prompts prompt descriptors
+ * @param resources resource descriptors
+ */
+ public static void validate(final Collection<MCPCompletionTargetDescriptor> descriptors, final Collection<MCPPromptDescriptor> prompts,
+ final Collection<MCPResourceDescriptor> resources) {
+ Map<String, Set<String>> promptArguments = prompts.stream().collect(Collectors.toMap(MCPPromptDescriptor::getName,
+ each -> each.getArguments().stream().map(MCPPromptArgumentDescriptor::getName).collect(Collectors.toSet())));
+ Set<String> promptNames = promptArguments.keySet();
+ Map<String, MCPResourceDescriptor> resourceDescriptors = resources.stream().collect(Collectors.toMap(MCPResourceDescriptor::getUriTemplate, each -> each));
+ Map<String, MCPCompletionTargetDescriptor> registered = new LinkedHashMap<>(descriptors.size(), 1F);
+ for (MCPCompletionTargetDescriptor each : descriptors) {
+ validateCompletionReference(each, promptNames, resourceDescriptors.keySet());
+ validatePromptCompletionArguments(each, promptArguments);
+ validateResourceCompletionArguments(each, resourceDescriptors);
+ validateCompletionRequiredContextArguments(each);
+ ShardingSpherePreconditions.checkState(null == registered.putIfAbsent(each.getReferenceType() + ":" + each.getReference(), each),
+ () -> new IllegalStateException(String.format("Duplicate MCP completion target `%s:%s`.", each.getReferenceType(), each.getReference())));
+ }
+ }
+
+ private static void validateCompletionRequiredContextArguments(final MCPCompletionTargetDescriptor descriptor) {
+ Object value = descriptor.getMeta().get(MCPShardingSphereMetadataKeys.REQUIRED_CONTEXT_ARGUMENTS);
+ if (null == value) {
+ return;
+ }
+ ShardingSpherePreconditions.checkState(value instanceof Map,
+ () -> new IllegalStateException(String.format("Completion target `%s:%s` metadata `%s` must be an object.",
+ descriptor.getReferenceType(), descriptor.getReference(), MCPShardingSphereMetadataKeys.REQUIRED_CONTEXT_ARGUMENTS)));
+ Set<String> argumentNames = new HashSet<>(descriptor.getArguments());
+ for (Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
+ String argumentName = String.valueOf(entry.getKey());
+ ShardingSpherePreconditions.checkState(argumentNames.contains(argumentName),
+ () -> new IllegalStateException(String.format("Completion target `%s:%s` required context argument `%s` is not declared by the target.",
+ descriptor.getReferenceType(), descriptor.getReference(), argumentName)));
+ ShardingSpherePreconditions.checkState(entry.getValue() instanceof Collection,
+ () -> new IllegalStateException(String.format("Completion target `%s:%s` required context for `%s` must be a list.",
+ descriptor.getReferenceType(), descriptor.getReference(), argumentName)));
+ for (Object each : (Collection<?>) entry.getValue()) {
+ ShardingSpherePreconditions.checkState(argumentNames.contains(String.valueOf(each)),
+ () -> new IllegalStateException(String.format("Completion target `%s:%s` context argument `%s` for `%s` is not declared by the target.",
+ descriptor.getReferenceType(), descriptor.getReference(), each, argumentName)));
+ }
+ }
+ }
+
+ private static void validatePromptCompletionArguments(final MCPCompletionTargetDescriptor descriptor, final Map<String, Set<String>> promptArguments) {
+ if (!"prompt".equals(descriptor.getReferenceType())) {
+ return;
+ }
+ Set<String> argumentNames = promptArguments.getOrDefault(descriptor.getReference(), Set.of());
+ for (String each : descriptor.getArguments()) {
+ ShardingSpherePreconditions.checkState(argumentNames.contains(each), () -> new IllegalStateException(
+ String.format("Completion target `prompt:%s` argument `%s` is not declared by prompt `%s`.", descriptor.getReference(), each, descriptor.getReference())));
+ }
+ }
+
+ private static void validateResourceCompletionArguments(final MCPCompletionTargetDescriptor descriptor, final Map<String, MCPResourceDescriptor> resources) {
+ if (!"resource".equals(descriptor.getReferenceType())) {
+ return;
+ }
+ MCPResourceDescriptor resource = resources.get(descriptor.getReference());
+ ShardingSpherePreconditions.checkState(resource.isTemplated(),
+ () -> new IllegalStateException(String.format("Completion target `resource:%s` must reference a resource template.", descriptor.getReference())));
+ Collection<String> templateVariables = new MCPUriTemplate(resource.getUriTemplate()).getVariableNames();
+ for (String each : descriptor.getArguments()) {
+ ShardingSpherePreconditions.checkState(templateVariables.contains(each), () -> new IllegalStateException(
+ String.format("Completion target `resource:%s` argument `%s` is not a URI template variable.", descriptor.getReference(), each)));
+ }
+ }
+
+ private static void validateCompletionReference(final MCPCompletionTargetDescriptor descriptor, final Set<String> promptNames, final Set<String> resourceUris) {
+ if ("prompt".equals(descriptor.getReferenceType())) {
+ ShardingSpherePreconditions.checkState(promptNames.contains(descriptor.getReference()),
+ () -> new IllegalStateException(String.format("Completion target references unknown prompt `%s`.", descriptor.getReference())));
+ return;
+ }
+ ShardingSpherePreconditions.checkState("resource".equals(descriptor.getReferenceType()),
+ () -> new IllegalStateException(String.format("Unsupported completion reference type `%s`.", descriptor.getReferenceType())));
+ ShardingSpherePreconditions.checkState(resourceUris.contains(descriptor.getReference()),
+ () -> new IllegalStateException(String.format("Completion target references unknown resource `%s`.", descriptor.getReference())));
+ }
+}
diff --git a/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorCatalogValidator.java b/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorCatalogValidator.java
index 9dc9a6a8361..fbc4315a14e 100644
--- a/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorCatalogValidator.java
+++ b/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorCatalogValidator.java
@@ -17,611 +17,16 @@
package org.apache.shardingsphere.mcp.support.descriptor;
-import org.apache.shardingsphere.infra.exception.ShardingSpherePreconditions;
-import org.apache.shardingsphere.mcp.api.prompt.descriptor.MCPPromptArgumentDescriptor;
-import org.apache.shardingsphere.mcp.api.prompt.descriptor.MCPPromptDescriptor;
-import org.apache.shardingsphere.mcp.api.resource.descriptor.MCPResourceDescriptor;
-import org.apache.shardingsphere.mcp.api.tool.descriptor.MCPToolDescriptor;
-import org.apache.shardingsphere.mcp.support.protocol.MCPPayloadFieldNames;
-import org.apache.shardingsphere.mcp.support.protocol.MCPResponseMode;
-import org.apache.shardingsphere.mcp.support.resource.MCPUriTemplate;
-
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Optional;
-import java.util.Set;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import java.util.stream.Collectors;
-
final class MCPDescriptorCatalogValidator {
- private static final Collection<String> REMOVED_MODEL_FACING_FIELDS = Set.of(
- "target_tool", "target_resource", "required_arguments", "action_kind", "suggested_next_tool", "suggested_next_tools", "recommended_next_tool",
- "recommended_recovery", "suggested_next_action", "approved_by_user", "requires_user_approval", "approval_required", "user_overrides");
-
- private static final Collection<String> SUPPORTED_INPUT_SCHEMA_TOP_LEVEL_FIELDS = Set.of("type", "properties", "required", "additionalProperties");
-
- private static final Map<String, Collection<String>> NEXT_ACTION_ALLOWED_FIELDS = createNextActionAllowedFields();
-
- private static final Collection<String> NEXT_ACTION_SCHEMA_ALLOWED_FIELDS = createNextActionSchemaAllowedFields();
-
- private static final Collection<String> MODEL_CRITICAL_HINT_FIELDS = List.of(
- MCPPayloadFieldNames.NEXT_ACTIONS, MCPPayloadFieldNames.RESOURCES_TO_READ, MCPPayloadFieldNames.RESOURCE, MCPPayloadFieldNames.PARENT_RESOURCE,
- MCPPayloadFieldNames.NEXT_RESOURCES, "manual_artifact_summary", "manual_follow_up", "empty_state", "ambiguity_state", MCPPayloadFieldNames.RECOVERY, "recovery_guidance",
- "remediation");
-
- private static final Collection<String> CONTINUATION_MODES = List.of("none", "pagination", "metadata_search");
-
- private static final Collection<String> RECOVERY_CATEGORIES = List.of("not_found", "ambiguous", "empty_scope", "missing_context", "validation", "terminal",
- "unsupported_target", "invalid_enum", "unsafe_sql", "stale_workflow", "unavailable_runtime", "terminal_operator_action");
-
- private static final String CLIENT_FORM_ONLY_ARGUMENTS = "org.apache.shardingsphere/client-form-only-arguments";
-
- private static final Pattern SINGLE_BRACE_PLACEHOLDER_PATTERN = Pattern.compile("(?<!\\{)\\{\\s*([a-zA-Z0-9_.-]+)\\s*}(?!})");
-
private MCPDescriptorCatalogValidator() {
}
- private static Map<String, Collection<String>> createNextActionAllowedFields() {
- return Map.of(
- "resource_read", Set.of("order", "type", "title", "resource_uri", "reason", "depends_on"),
- "tool_call", Set.of("order", "type", "title", "tool_name", "arguments", "reason", "depends_on"),
- "completion", Set.of("order", "type", "title", "ref", "argument", "context", "missing_context_arguments", "resume_ref", "resume_arguments", "reason", "depends_on"),
- "ask_user", Set.of("order", "type", "title", "question", "required_inputs", "reason", "depends_on"),
- "terminal", Set.of("order", "type", "title", "reason", "depends_on"));
- }
-
- private static Collection<String> createNextActionSchemaAllowedFields() {
- Set<String> result = new HashSet<>();
- for (Collection<String> each : NEXT_ACTION_ALLOWED_FIELDS.values()) {
- result.addAll(each);
- }
- return result;
- }
-
static void validate(final MCPDescriptorCatalog catalog) {
- validateResourceDescriptors(catalog);
- validateToolDescriptors(catalog);
- validatePromptDescriptors(catalog.getPromptDescriptors(), catalog.getPromptTemplateBindings());
- validateCompletionTargetDescriptors(catalog.getCompletionTargetDescriptors(), catalog.getPromptDescriptors(), catalog.getAllResourceDescriptors());
- validateResourceNavigationDescriptors(catalog.getResourceNavigationDescriptors(), catalog);
- }
-
- private static void validateResourceDescriptors(final MCPDescriptorCatalog catalog) {
- Map<String, MCPResourceDescriptor> registered = new LinkedHashMap<>(catalog.getResourceDescriptors().size() + catalog.getResourceTemplateDescriptors().size(), 1F);
- for (MCPResourceDescriptor each : catalog.getResourceDescriptors()) {
- checkNotBlank(each.getUriTemplate(), "Resource URI");
- ShardingSpherePreconditions.checkState(!each.isTemplated(),
- () -> new IllegalStateException(String.format("Fixed resource `%s` must not contain template variables.", each.getUriTemplate())));
- validateResourceDescriptor(each, registered);
- }
- for (MCPResourceDescriptor each : catalog.getResourceTemplateDescriptors()) {
- checkNotBlank(each.getUriTemplate(), "Resource template URI template");
- ShardingSpherePreconditions.checkState(each.isTemplated(),
- () -> new IllegalStateException(String.format("Resource template `%s` must contain template variables.", each.getUriTemplate())));
- validateResourceDescriptor(each, registered);
- validateResourceVariables(each, findShardingSphereResourceMetadata(catalog, each.getUriTemplate()).map(ShardingSphereMCPResourceMetadata::getUriVariables).orElse(List.of()));
- }
- }
-
- private static void validateResourceDescriptor(final MCPResourceDescriptor descriptor, final Map<String, MCPResourceDescriptor> registered) {
- ShardingSpherePreconditions.checkState(null == registered.putIfAbsent(descriptor.getUriTemplate(), descriptor),
- () -> new IllegalStateException(String.format("Duplicate MCP resource descriptor `%s`.", descriptor.getUriTemplate())));
- }
-
- private static Optional<ShardingSphereMCPResourceMetadata> findShardingSphereResourceMetadata(final MCPDescriptorCatalog catalog, final String uriOrTemplate) {
- return catalog.getShardingSphereResourceMetadata().stream().filter(each -> uriOrTemplate.equals(each.getUriOrTemplate())).findFirst();
- }
-
- private static void validateResourceVariables(final MCPResourceDescriptor descriptor, final Collection<MCPUriVariableDescriptor> uriVariables) {
- List<String> templateVariables = new MCPUriTemplate(descriptor.getUriTemplate()).getVariableNames();
- Set<String> registeredTemplateVariables = new HashSet<>();
- for (String each : templateVariables) {
- ShardingSpherePreconditions.checkState(registeredTemplateVariables.add(each),
- () -> new IllegalStateException(String.format("Duplicate URI template variable `%s` in resource descriptor `%s`.", each, descriptor.getUriTemplate())));
- }
- Map<String, MCPUriVariableDescriptor> declaredParameters = new LinkedHashMap<>(uriVariables.size(), 1F);
- for (MCPUriVariableDescriptor each : uriVariables) {
- ShardingSpherePreconditions.checkState(each.isRequired(), () -> new IllegalStateException(
- String.format("Resource parameter `%s.%s` must be required because URI template variables are required.", descriptor.getUriTemplate(), each.getName())));
- ShardingSpherePreconditions.checkState(registeredTemplateVariables.contains(each.getName()),
- () -> new IllegalStateException(String.format("Resource descriptor `%s` declares non-template parameter `%s`.", descriptor.getUriTemplate(), each.getName())));
- ShardingSpherePreconditions.checkState(null == declaredParameters.putIfAbsent(each.getName(), each),
- () -> new IllegalStateException(String.format("Duplicate MCP resource parameter `%s.%s`.", descriptor.getUriTemplate(), each.getName())));
- }
- for (String variableName : templateVariables) {
- ShardingSpherePreconditions.checkState(declaredParameters.containsKey(variableName),
- () -> new IllegalStateException(String.format("Resource descriptor `%s` must describe URI template variable `%s`.", descriptor.getUriTemplate(), variableName)));
- }
- }
-
- private static void validateToolDescriptors(final MCPDescriptorCatalog catalog) {
- Collection<MCPToolDescriptor> descriptors = catalog.getToolDescriptors();
- Collection<MCPToolRuntimeDescriptor> runtimeDescriptors = catalog.getToolRuntimeDescriptors();
- Collection<MCPToolDescriptorValidator> descriptorValidators = MCPToolDescriptorValidatorLoader.load();
- Map<String, MCPToolDescriptor> registered = new LinkedHashMap<>(descriptors.size(), 1F);
- Map<String, MCPToolRuntimeDescriptor> runtimes = runtimeDescriptors.stream()
- .collect(Collectors.toMap(MCPToolRuntimeDescriptor::getToolName, each -> each));
- Set<String> resourceIdentifiers = catalog.getAllResourceDescriptors().stream().map(MCPResourceDescriptor::getUriTemplate).collect(Collectors.toSet());
- Set<String> shardingSphereResourceIdentifiers = catalog.getShardingSphereResourceMetadata().stream()
- .map(ShardingSphereMCPResourceMetadata::getUriOrTemplate).collect(Collectors.toSet());
- for (MCPToolDescriptor each : descriptors) {
- ShardingSpherePreconditions.checkState(null == registered.putIfAbsent(each.getName(), each),
- () -> new IllegalStateException(String.format("Duplicate MCP tool descriptor `%s`.", each.getName())));
- validateToolInputSchema(each);
- validateToolOutputSchema(each, descriptorValidators);
- validateDestructiveToolDescriptor(each, runtimes.get(each.getName()));
- validatePlanningExecutionMode(each);
- validateRelatedResourceUris(each, resourceIdentifiers, shardingSphereResourceIdentifiers);
- }
- validatePlanningToolRuntimeDescriptors(registered, runtimeDescriptors);
- }
-
- private static void validateToolInputSchema(final MCPToolDescriptor descriptor) {
- Map<String, Object> inputSchema = descriptor.getInputSchema();
- for (String each : inputSchema.keySet()) {
- ShardingSpherePreconditions.checkState(SUPPORTED_INPUT_SCHEMA_TOP_LEVEL_FIELDS.contains(each),
- () -> new IllegalStateException(String.format("Tool `%s` inputSchema contains unsupported top-level field `%s`.", descriptor.getName(), each)));
- }
- ShardingSpherePreconditions.checkState("object".equals(inputSchema.get("type")),
- () -> new IllegalStateException(String.format("Tool `%s` inputSchema must be an object.", descriptor.getName())));
- Object properties = inputSchema.get("properties");
- ShardingSpherePreconditions.checkState(properties instanceof Map, () -> new IllegalStateException(String.format("Tool `%s` inputSchema must declare properties.", descriptor.getName())));
- validateNoRemovedModelFacingFields(descriptor, inputSchema);
- }
-
- private static void validateRelatedResourceUris(final MCPToolDescriptor descriptor, final Set<String> resourceIdentifiers, final Set<String> shardingSphereResourceIdentifiers) {
- Object value = descriptor.getMeta().get(MCPShardingSphereMetadataKeys.RELATED_RESOURCE_URIS);
- if (null == value) {
- return;
- }
- ShardingSpherePreconditions.checkState(value instanceof Collection,
- () -> new IllegalStateException(String.format("Tool `%s` metadata `%s` must be a list.", descriptor.getName(), MCPShardingSphereMetadataKeys.RELATED_RESOURCE_URIS)));
- for (Object each : (Collection<?>) value) {
- String uri = String.valueOf(each);
- ShardingSpherePreconditions.checkState(resourceIdentifiers.contains(uri),
- () -> new IllegalStateException(String.format("Tool `%s` metadata `%s` references unknown resource `%s`.",
- descriptor.getName(), MCPShardingSphereMetadataKeys.RELATED_RESOURCE_URIS, uri)));
- ShardingSpherePreconditions.checkState(shardingSphereResourceIdentifiers.contains(uri),
- () -> new IllegalStateException(String.format("Tool `%s` metadata `%s` references resource `%s` without ShardingSphere metadata.",
- descriptor.getName(), MCPShardingSphereMetadataKeys.RELATED_RESOURCE_URIS, uri)));
- }
- }
-
- private static void validatePlanningToolRuntimeDescriptors(final Map<String, MCPToolDescriptor> descriptors, final Collection<MCPToolRuntimeDescriptor> runtimeDescriptors) {
- Map<String, String> workflowKinds = new LinkedHashMap<>(runtimeDescriptors.size(), 1F);
- for (MCPToolRuntimeDescriptor each : runtimeDescriptors) {
- if (!"plan".equals(each.getWorkflowRole())) {
- continue;
- }
- MCPToolDescriptor descriptor = descriptors.get(each.getToolName());
- ShardingSpherePreconditions.checkState(null != descriptor,
- () -> new IllegalStateException(String.format("Planning runtime tool `%s` must reference a registered tool descriptor.", each.getToolName())));
- Object workflowKind = descriptor.getMeta().get(MCPShardingSphereMetadataKeys.WORKFLOW_KIND);
- ShardingSpherePreconditions.checkState(null != workflowKind && !workflowKind.toString().isBlank(),
- () -> new IllegalStateException(String.format("Planning tool `%s` metadata must declare `%s`.", each.getToolName(), MCPShardingSphereMetadataKeys.WORKFLOW_KIND)));
- String previousToolName = workflowKinds.putIfAbsent(workflowKind.toString(), each.getToolName());
- ShardingSpherePreconditions.checkState(null == previousToolName, () -> new IllegalStateException(
- String.format("Planning workflow kind `%s` is used by both `%s` and `%s`.", workflowKind, previousToolName, each.getToolName())));
- }
- }
-
- private static void validateToolOutputSchema(final MCPToolDescriptor descriptor, final Collection<MCPToolDescriptorValidator> descriptorValidators) {
- Map<String, Object> outputSchema = descriptor.getOutputSchema();
- ShardingSpherePreconditions.checkState("object".equals(outputSchema.get("type")),
- () -> new IllegalStateException(String.format("Tool `%s` outputSchema must be an object.", descriptor.getName())));
- Object properties = outputSchema.get("properties");
- ShardingSpherePreconditions.checkState(properties instanceof Map && !((Map<?, ?>) properties).isEmpty(),
- () -> new IllegalStateException(String.format("Tool `%s` outputSchema must declare properties.", descriptor.getName())));
- validateNoRemovedModelFacingFields(descriptor, outputSchema);
- validateOutputExamples(descriptor, outputSchema);
- validateOutputExampleContractValues(descriptor, outputSchema);
- validateModelCriticalOutputHints(descriptor, (Map<?, ?>) properties);
- validateToolDescriptorExtensions(descriptor, descriptorValidators);
- }
-
- private static void validateToolDescriptorExtensions(final MCPToolDescriptor descriptor, final Collection<MCPToolDescriptorValidator> descriptorValidators) {
- for (MCPToolDescriptorValidator each : descriptorValidators) {
- if (each.supports(descriptor)) {
- each.validate(descriptor);
- }
- }
- }
-
- private static void validateOutputExamples(final MCPToolDescriptor descriptor, final Map<String, Object> outputSchema) {
- ShardingSpherePreconditions.checkState(isNonEmptyCollection(outputSchema.get("examples")),
- () -> new IllegalStateException(String.format("Tool `%s` outputSchema must declare examples.", descriptor.getName())));
- }
-
- private static void validateOutputExampleContractValues(final MCPToolDescriptor descriptor, final Map<String, Object> outputSchema) {
- Object examples = outputSchema.get("examples");
- if (!(examples instanceof Collection)) {
- return;
- }
- for (Object each : (Collection<?>) examples) {
- validateExampleContractValue(descriptor, each);
- }
- }
-
- private static void validateExampleContractValue(final MCPToolDescriptor descriptor, final Object value) {
- if (value instanceof Map) {
- validateExampleContractMap(descriptor, (Map<?, ?>) value);
- } else if (value instanceof Collection) {
- for (Object each : (Collection<?>) value) {
- validateExampleContractValue(descriptor, each);
- }
- }
- }
-
- private static void validateExampleContractMap(final MCPToolDescriptor descriptor, final Map<?, ?> value) {
- Object responseMode = value.get("response_mode");
- if (null != responseMode) {
- ShardingSpherePreconditions.checkState(MCPResponseMode.isAllowed(responseMode.toString()),
- () -> new IllegalStateException(String.format("Tool `%s` output example uses unknown response_mode `%s`.", descriptor.getName(), responseMode)));
- }
- Object continuationMode = value.get("continuation_mode");
- if (null != continuationMode) {
- ShardingSpherePreconditions.checkState(CONTINUATION_MODES.contains(continuationMode.toString()),
- () -> new IllegalStateException(String.format("Tool `%s` output example uses unknown continuation_mode `%s`.", descriptor.getName(), continuationMode)));
- }
- Object recoveryCategory = value.get("recovery_category");
- if (null != recoveryCategory) {
- ShardingSpherePreconditions.checkState(RECOVERY_CATEGORIES.contains(recoveryCategory.toString()),
- () -> new IllegalStateException(String.format("Tool `%s` output example uses unknown recovery_category `%s`.", descriptor.getName(), recoveryCategory)));
- }
- for (Object each : value.values()) {
- validateExampleContractValue(descriptor, each);
- }
- Object nextActions = value.get(MCPPayloadFieldNames.NEXT_ACTIONS);
- if (null != nextActions) {
- validateConcreteNextActions(descriptor, nextActions);
- }
- }
-
- private static void validateNoRemovedModelFacingFields(final MCPToolDescriptor descriptor, final Object value) {
- if (value instanceof Map) {
- validateNoRemovedModelFacingFieldMap(descriptor, (Map<?, ?>) value);
- } else if (value instanceof Collection) {
- for (Object each : (Collection<?>) value) {
- validateNoRemovedModelFacingFields(descriptor, each);
- }
- }
- }
-
- private static void validateNoRemovedModelFacingFieldMap(final MCPToolDescriptor descriptor, final Map<?, ?> value) {
- for (Entry<?, ?> entry : value.entrySet()) {
- String key = String.valueOf(entry.getKey());
- validateNoRemovedModelFacingField(descriptor, key);
- if ("required".equals(key)) {
- validateNoRemovedModelFacingRequiredFields(descriptor, entry.getValue());
- }
- validateNoRemovedModelFacingFields(descriptor, entry.getValue());
- }
- }
-
- private static void validateNoRemovedModelFacingRequiredFields(final MCPToolDescriptor descriptor, final Object value) {
- if (!(value instanceof Collection)) {
- return;
- }
- for (Object each : (Collection<?>) value) {
- validateNoRemovedModelFacingField(descriptor, String.valueOf(each));
- }
- }
-
- private static void validateNoRemovedModelFacingField(final MCPToolDescriptor descriptor, final String fieldName) {
- ShardingSpherePreconditions.checkState(!REMOVED_MODEL_FACING_FIELDS.contains(fieldName),
- () -> new IllegalStateException(String.format("Tool `%s` model-facing contract must use canonical fields instead of removed `%s`.", descriptor.getName(), fieldName)));
- }
-
- private static void validateConcreteNextActions(final MCPToolDescriptor descriptor, final Object value) {
- ShardingSpherePreconditions.checkState(value instanceof Collection,
- () -> new IllegalStateException(String.format("Tool `%s` next_actions example must be an array.", descriptor.getName())));
- for (Object each : (Collection<?>) value) {
- ShardingSpherePreconditions.checkState(each instanceof Map,
- () -> new IllegalStateException(String.format("Tool `%s` next_actions example item must be an object.", descriptor.getName())));
- validateConcreteNextAction(descriptor, (Map<?, ?>) each);
- }
- }
-
- private static void validateConcreteNextAction(final MCPToolDescriptor descriptor, final Map<?, ?> action) {
- String type = String.valueOf(action.get("type"));
- Collection<String> allowedFields = NEXT_ACTION_ALLOWED_FIELDS.get(type);
- ShardingSpherePreconditions.checkState(null != allowedFields,
- () -> new IllegalStateException(String.format("Tool `%s` next_actions example uses unknown type `%s`.", descriptor.getName(), type)));
- for (Object each : action.keySet()) {
- String fieldName = String.valueOf(each);
- ShardingSpherePreconditions.checkState(allowedFields.contains(fieldName),
- () -> new IllegalStateException(String.format("Tool `%s` next_actions example `%s` contains unsupported field `%s`.", descriptor.getName(), type, fieldName)));
- }
- }
-
- private static void validateModelCriticalOutputHints(final MCPToolDescriptor descriptor, final Map<?, ?> properties) {
- for (Entry<?, ?> entry : properties.entrySet()) {
- String fieldName = String.valueOf(entry.getKey());
- if (MODEL_CRITICAL_HINT_FIELDS.contains(fieldName)) {
- validateModelCriticalOutputHint(descriptor, fieldName, entry.getValue());
- }
- validateNestedModelCriticalOutputHints(descriptor, entry.getValue());
- }
- }
-
- private static void validateNestedModelCriticalOutputHints(final MCPToolDescriptor descriptor, final Object value) {
- if (!(value instanceof Map)) {
- return;
- }
- Object properties = ((Map<?, ?>) value).get("properties");
- if (properties instanceof Map) {
- validateModelCriticalOutputHints(descriptor, (Map<?, ?>) properties);
- }
- Object items = ((Map<?, ?>) value).get("items");
- if (items instanceof Map) {
- validateNestedModelCriticalOutputHints(descriptor, items);
- }
- }
-
- private static void validateModelCriticalOutputHint(final MCPToolDescriptor descriptor, final String fieldName, final Object property) {
- ShardingSpherePreconditions.checkState(property instanceof Map,
- () -> new IllegalStateException(String.format("Tool `%s` model-critical output field `%s` must be an object.", descriptor.getName(), fieldName)));
- Object description = ((Map<?, ?>) property).get("description");
- checkDescription(null == description ? "" : description.toString(), String.format("Tool model-critical output field `%s.%s` description", descriptor.getName(), fieldName));
- if (MCPPayloadFieldNames.NEXT_ACTIONS.equals(fieldName)) {
- validateNextActionsSchema(descriptor, (Map<?, ?>) property);
- }
- }
-
- private static void validateNextActionsSchema(final MCPToolDescriptor descriptor, final Map<?, ?> property) {
- ShardingSpherePreconditions.checkState("array".equals(property.get("type")), () -> new IllegalStateException(String.format("Tool `%s` next_actions must be an array.", descriptor.getName())));
- Object items = property.get("items");
- ShardingSpherePreconditions.checkState(items instanceof Map, () -> new IllegalStateException(String.format("Tool `%s` next_actions items must be an object.", descriptor.getName())));
- Object properties = ((Map<?, ?>) items).get("properties");
- ShardingSpherePreconditions.checkState(properties instanceof Map,
- () -> new IllegalStateException(String.format("Tool `%s` next_actions items must declare properties.", descriptor.getName())));
- for (String each : List.of("order", "type", "title")) {
- ShardingSpherePreconditions.checkState(((Map<?, ?>) properties).containsKey(each),
- () -> new IllegalStateException(String.format("Tool `%s` next_actions item must declare `%s`.", descriptor.getName(), each)));
- Object field = ((Map<?, ?>) properties).get(each);
- ShardingSpherePreconditions.checkState(field instanceof Map,
- () -> new IllegalStateException(String.format("Tool `%s` next_actions item field `%s` must be an object.", descriptor.getName(), each)));
- Object description = ((Map<?, ?>) field).get("description");
- checkDescription(null == description ? "" : description.toString(), String.format("Tool next_actions item field `%s.%s` description", descriptor.getName(), each));
- }
- for (Object each : ((Map<?, ?>) properties).keySet()) {
- String fieldName = String.valueOf(each);
- ShardingSpherePreconditions.checkState(NEXT_ACTION_SCHEMA_ALLOWED_FIELDS.contains(fieldName),
- () -> new IllegalStateException(String.format("Tool `%s` next_actions item contains unsupported field `%s`.", descriptor.getName(), fieldName)));
- }
- }
-
- private static void validatePromptDescriptors(final Collection<MCPPromptDescriptor> descriptors, final Collection<MCPPromptTemplateBinding> templateBindings) {
- Map<String, MCPPromptDescriptor> registered = new LinkedHashMap<>(descriptors.size(), 1F);
- Map<String, MCPPromptTemplateBinding> bindings = templateBindings.stream()
- .collect(Collectors.toMap(MCPPromptTemplateBinding::getPromptName, each -> each));
- for (MCPPromptDescriptor each : descriptors) {
- MCPPromptTemplateBinding binding = bindings.get(each.getName());
- ShardingSpherePreconditions.checkState(null != binding, () -> new IllegalStateException(String.format("Prompt `%s` must declare an internal template binding.", each.getName())));
- ShardingSpherePreconditions.checkState(null == registered.putIfAbsent(each.getName(), each),
- () -> new IllegalStateException(String.format("Duplicate MCP prompt descriptor `%s`.", each.getName())));
- validatePromptTemplate(each, binding);
- }
- }
-
- private static void validatePromptTemplate(final MCPPromptDescriptor descriptor, final MCPPromptTemplateBinding binding) {
- String template = MCPPromptTemplateLoader.load(binding.getTemplateResource());
- validateNoUnsupportedModelFacingPlaceholders(binding, template);
- Set<String> declaredArguments = descriptor.getArguments().stream().map(MCPPromptArgumentDescriptor::getName).collect(Collectors.toSet());
- Set<String> renderedArguments = MCPPromptTemplateLoader.extractPlaceholders(template);
- for (String each : renderedArguments) {
- ShardingSpherePreconditions.checkState(declaredArguments.contains(each),
- () -> new IllegalStateException(String.format("Prompt template `%s` has undeclared placeholder `%s`.", binding.getTemplateResource(), each)));
- }
- validateDeclaredPromptArgumentsRendered(descriptor, binding, declaredArguments, renderedArguments);
- }
-
- private static void validateNoUnsupportedModelFacingPlaceholders(final MCPPromptTemplateBinding binding, final String template) {
- for (String each : template.lines().toList()) {
- validateNoUnsupportedModelFacingPlaceholderInLine(binding, each);
- }
- }
-
- private static void validateNoUnsupportedModelFacingPlaceholderInLine(final MCPPromptTemplateBinding binding, final String line) {
- Matcher matcher = SINGLE_BRACE_PLACEHOLDER_PATTERN.matcher(line);
- while (matcher.find()) {
- if (!isResourceUriTemplateVariable(line, matcher.start())) {
- throw new IllegalStateException(String.format("Prompt template `%s` contains unsupported model-facing placeholder `{%s}`.",
- binding.getTemplateResource(), matcher.group(1)));
- }
- }
- }
-
- private static boolean isResourceUriTemplateVariable(final String line, final int placeholderStartIndex) {
- int tokenStartIndex = findTokenStartIndex(line, placeholderStartIndex);
- int resourceUriStartIndex = line.lastIndexOf("shardingsphere://", placeholderStartIndex);
- return resourceUriStartIndex >= tokenStartIndex;
- }
-
- private static int findTokenStartIndex(final String line, final int index) {
- for (int i = index - 1; i >= 0; i--) {
- if (Character.isWhitespace(line.charAt(i))) {
- return i + 1;
- }
- }
- return 0;
- }
-
- private static void validateDeclaredPromptArgumentsRendered(final MCPPromptDescriptor descriptor, final MCPPromptTemplateBinding binding,
- final Set<String> declaredArguments, final Set<String> renderedArguments) {
- Set<String> clientFormOnlyArguments = getClientFormOnlyArguments(descriptor);
- for (String each : declaredArguments) {
- ShardingSpherePreconditions.checkState(renderedArguments.contains(each) || clientFormOnlyArguments.contains(each),
- () -> new IllegalStateException(String.format("Prompt `%s` declares argument `%s` but template `%s` does not render it.",
- descriptor.getName(), each, binding.getTemplateResource())));
- }
- }
-
- private static Set<String> getClientFormOnlyArguments(final MCPPromptDescriptor descriptor) {
- Object value = descriptor.getMeta().get(CLIENT_FORM_ONLY_ARGUMENTS);
- if (null == value) {
- return Set.of();
- }
- ShardingSpherePreconditions.checkState(value instanceof Collection,
- () -> new IllegalStateException(String.format("Prompt `%s` metadata `%s` must be a list.", descriptor.getName(), CLIENT_FORM_ONLY_ARGUMENTS)));
- return ((Collection<?>) value).stream().map(String::valueOf).collect(Collectors.toSet());
- }
-
- private static void validateCompletionTargetDescriptors(final Collection<MCPCompletionTargetDescriptor> descriptors, final Collection<MCPPromptDescriptor> prompts,
- final Collection<MCPResourceDescriptor> resources) {
- Map<String, Set<String>> promptArguments = prompts.stream().collect(Collectors.toMap(MCPPromptDescriptor::getName,
- each -> each.getArguments().stream().map(MCPPromptArgumentDescriptor::getName).collect(Collectors.toSet())));
- Set<String> promptNames = promptArguments.keySet();
- Map<String, MCPResourceDescriptor> resourceDescriptors = resources.stream().collect(Collectors.toMap(MCPResourceDescriptor::getUriTemplate, each -> each));
- Map<String, MCPCompletionTargetDescriptor> registered = new LinkedHashMap<>(descriptors.size(), 1F);
- for (MCPCompletionTargetDescriptor each : descriptors) {
- validateCompletionReference(each, promptNames, resourceDescriptors.keySet());
- validatePromptCompletionArguments(each, promptArguments);
- validateResourceCompletionArguments(each, resourceDescriptors);
- validateCompletionRequiredContextArguments(each);
- ShardingSpherePreconditions.checkState(null == registered.putIfAbsent(each.getReferenceType() + ":" + each.getReference(), each),
- () -> new IllegalStateException(String.format("Duplicate MCP completion target `%s:%s`.", each.getReferenceType(), each.getReference())));
- }
- }
-
- private static void validateCompletionRequiredContextArguments(final MCPCompletionTargetDescriptor descriptor) {
- Object value = descriptor.getMeta().get(MCPShardingSphereMetadataKeys.REQUIRED_CONTEXT_ARGUMENTS);
- if (null == value) {
- return;
- }
- ShardingSpherePreconditions.checkState(value instanceof Map,
- () -> new IllegalStateException(String.format("Completion target `%s:%s` metadata `%s` must be an object.",
- descriptor.getReferenceType(), descriptor.getReference(), MCPShardingSphereMetadataKeys.REQUIRED_CONTEXT_ARGUMENTS)));
- Set<String> argumentNames = new HashSet<>(descriptor.getArguments());
- for (Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
- String argumentName = String.valueOf(entry.getKey());
- ShardingSpherePreconditions.checkState(argumentNames.contains(argumentName),
- () -> new IllegalStateException(String.format("Completion target `%s:%s` required context argument `%s` is not declared by the target.",
- descriptor.getReferenceType(), descriptor.getReference(), argumentName)));
- ShardingSpherePreconditions.checkState(entry.getValue() instanceof Collection,
- () -> new IllegalStateException(String.format("Completion target `%s:%s` required context for `%s` must be a list.",
- descriptor.getReferenceType(), descriptor.getReference(), argumentName)));
- for (Object each : (Collection<?>) entry.getValue()) {
- ShardingSpherePreconditions.checkState(argumentNames.contains(String.valueOf(each)),
- () -> new IllegalStateException(String.format("Completion target `%s:%s` context argument `%s` for `%s` is not declared by the target.",
- descriptor.getReferenceType(), descriptor.getReference(), each, argumentName)));
- }
- }
- }
-
- private static void validatePromptCompletionArguments(final MCPCompletionTargetDescriptor descriptor, final Map<String, Set<String>> promptArguments) {
- if (!"prompt".equals(descriptor.getReferenceType())) {
- return;
- }
- Set<String> argumentNames = promptArguments.getOrDefault(descriptor.getReference(), Set.of());
- for (String each : descriptor.getArguments()) {
- ShardingSpherePreconditions.checkState(argumentNames.contains(each), () -> new IllegalStateException(
- String.format("Completion target `prompt:%s` argument `%s` is not declared by prompt `%s`.", descriptor.getReference(), each, descriptor.getReference())));
- }
- }
-
- private static void validateResourceCompletionArguments(final MCPCompletionTargetDescriptor descriptor, final Map<String, MCPResourceDescriptor> resources) {
- if (!"resource".equals(descriptor.getReferenceType())) {
- return;
- }
- MCPResourceDescriptor resource = resources.get(descriptor.getReference());
- ShardingSpherePreconditions.checkState(resource.isTemplated(),
- () -> new IllegalStateException(String.format("Completion target `resource:%s` must reference a resource template.", descriptor.getReference())));
- Collection<String> templateVariables = new MCPUriTemplate(resource.getUriTemplate()).getVariableNames();
- for (String each : descriptor.getArguments()) {
- ShardingSpherePreconditions.checkState(templateVariables.contains(each), () -> new IllegalStateException(
- String.format("Completion target `resource:%s` argument `%s` is not a URI template variable.", descriptor.getReference(), each)));
- }
- }
-
- private static void validateCompletionReference(final MCPCompletionTargetDescriptor descriptor, final Set<String> promptNames, final Set<String> resourceUris) {
- if ("prompt".equals(descriptor.getReferenceType())) {
- ShardingSpherePreconditions.checkState(promptNames.contains(descriptor.getReference()),
- () -> new IllegalStateException(String.format("Completion target references unknown prompt `%s`.", descriptor.getReference())));
- return;
- }
- ShardingSpherePreconditions.checkState("resource".equals(descriptor.getReferenceType()),
- () -> new IllegalStateException(String.format("Unsupported completion reference type `%s`.", descriptor.getReferenceType())));
- ShardingSpherePreconditions.checkState(resourceUris.contains(descriptor.getReference()),
- () -> new IllegalStateException(String.format("Completion target references unknown resource `%s`.", descriptor.getReference())));
- }
-
- private static void validateResourceNavigationDescriptors(final Collection<MCPResourceNavigationDescriptor> descriptors, final MCPDescriptorCatalog catalog) {
- Set<String> publicIdentifiers = createPublicIdentifiers(catalog);
- Set<String> registered = new HashSet<>();
- for (MCPResourceNavigationDescriptor each : descriptors) {
- ShardingSpherePreconditions.checkState(publicIdentifiers.contains(each.getFrom()),
- () -> new IllegalStateException(String.format("Resource navigation references unknown source `%s`.", each.getFrom())));
- ShardingSpherePreconditions.checkState(publicIdentifiers.contains(each.getTo()),
- () -> new IllegalStateException(String.format("Resource navigation references unknown target `%s`.", each.getTo())));
- ShardingSpherePreconditions.checkState(registered.add(each.getFrom() + "->" + each.getTo()),
- () -> new IllegalStateException(String.format("Duplicate MCP resource navigation `%s` to `%s`.", each.getFrom(), each.getTo())));
- }
- }
-
- private static Set<String> createPublicIdentifiers(final MCPDescriptorCatalog catalog) {
- Set<String> result = new HashSet<>();
- catalog.getAllResourceDescriptors().stream().map(MCPResourceDescriptor::getUriTemplate).forEach(result::add);
- catalog.getToolDescriptors().stream().map(MCPToolDescriptor::getName).forEach(result::add);
- catalog.getPromptDescriptors().stream().map(MCPPromptDescriptor::getName).forEach(result::add);
- return result;
- }
-
- private static void validateDestructiveToolDescriptor(final MCPToolDescriptor descriptor, final MCPToolRuntimeDescriptor runtimeDescriptor) {
- if (!descriptor.getAnnotations().isDestructiveHint()) {
- return;
- }
- Map<?, ?> executionMode = MCPToolDescriptorValidationUtils.findToolInputProperty(descriptor, MCPPayloadFieldNames.EXECUTION_MODE).orElseThrow(
- () -> new IllegalStateException(String.format("Destructive tool `%s` must declare execution_mode.", descriptor.getName())));
- ShardingSpherePreconditions.checkState(MCPToolDescriptorValidationUtils.isRequiredToolInput(descriptor, MCPPayloadFieldNames.EXECUTION_MODE),
- () -> new IllegalStateException(String.format("Destructive tool `%s` execution_mode must be required.", descriptor.getName())));
- Collection<?> executionModes = executionMode.get("enum") instanceof Collection ? (Collection<?>) executionMode.get("enum") : List.of();
- ShardingSpherePreconditions.checkState(executionModes.contains("preview"),
- () -> new IllegalStateException(String.format("Destructive tool `%s` execution_mode must allow preview.", descriptor.getName())));
- ShardingSpherePreconditions.checkState(!executionModes.contains("auto-execute"),
- () -> new IllegalStateException(String.format("Destructive tool `%s` execution_mode must not expose auto-execute.", descriptor.getName())));
- ShardingSpherePreconditions.checkState(null != runtimeDescriptor && !runtimeDescriptor.getSideEffectScope().isEmpty(),
- () -> new IllegalStateException(String.format("Destructive tool `%s` must declare sideEffectScope in internal runtime.", descriptor.getName())));
- }
-
- private static void validatePlanningExecutionMode(final MCPToolDescriptor descriptor) {
- if (descriptor.getAnnotations().isDestructiveHint()) {
- return;
- }
- Optional<Map<?, ?>> executionMode = MCPToolDescriptorValidationUtils.findToolInputProperty(descriptor, MCPPayloadFieldNames.EXECUTION_MODE);
- if (executionMode.isEmpty()) {
- return;
- }
- Collection<?> executionModes = executionMode.get().get("enum") instanceof Collection ? (Collection<?>) executionMode.get().get("enum") : List.of();
- ShardingSpherePreconditions.checkState(!executionModes.contains("preview"),
- () -> new IllegalStateException(String.format("Planning tool `%s` execution_mode must not expose preview.", descriptor.getName())));
- ShardingSpherePreconditions.checkState(!executionModes.contains("auto-execute"),
- () -> new IllegalStateException(String.format("Planning tool `%s` execution_mode must not expose auto-execute.", descriptor.getName())));
- }
-
- private static boolean isNonEmptyCollection(final Object value) {
- return value instanceof Collection && !((Collection<?>) value).isEmpty();
- }
-
- private static void checkDescription(final String value, final String label) {
- checkNotBlank(value, label);
- ShardingSpherePreconditions.checkState(!value.startsWith(createPlaceholderPrefix("resource:")),
- () -> new IllegalStateException(String.format("%s must not be a placeholder description.", label)));
- ShardingSpherePreconditions.checkState(!value.startsWith(createPlaceholderPrefix("resource template:")),
- () -> new IllegalStateException(String.format("%s must not be a placeholder description.", label)));
- }
-
- private static String createPlaceholderPrefix(final String suffix) {
- return "ShardingSphere MCP " + suffix;
- }
-
- private static void checkNotBlank(final String value, final String label) {
- ShardingSpherePreconditions.checkState(null != value && !value.isBlank(), () -> new IllegalStateException(String.format("%s is required.", label)));
+ MCPResourceDescriptorValidator.validate(catalog);
+ MCPToolDescriptorCatalogValidator.validate(catalog);
+ MCPPromptDescriptorValidator.validate(catalog.getPromptDescriptors(), catalog.getPromptTemplateBindings());
+ MCPCompletionTargetDescriptorValidator.validate(catalog.getCompletionTargetDescriptors(), catalog.getPromptDescriptors(), catalog.getAllResourceDescriptors());
+ MCPResourceNavigationDescriptorValidator.validate(catalog.getResourceNavigationDescriptors(), catalog);
}
}
diff --git a/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPPromptDescriptorValidator.java b/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPPromptDescriptorValidator.java
new file mode 100644
index 00000000000..2b6c771663e
--- /dev/null
+++ b/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPPromptDescriptorValidator.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.mcp.support.descriptor;
+
+import org.apache.shardingsphere.infra.exception.ShardingSpherePreconditions;
+import org.apache.shardingsphere.mcp.api.prompt.descriptor.MCPPromptArgumentDescriptor;
+import org.apache.shardingsphere.mcp.api.prompt.descriptor.MCPPromptDescriptor;
+
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * Validator for MCP prompt descriptors and their internal templates.
+ */
+public final class MCPPromptDescriptorValidator {
+
+ private static final String CLIENT_FORM_ONLY_ARGUMENTS = "org.apache.shardingsphere/client-form-only-arguments";
+
+ private static final Pattern SINGLE_BRACE_PLACEHOLDER_PATTERN = Pattern.compile("(?<!\\{)\\{\\s*([a-zA-Z0-9_.-]+)\\s*}(?!})");
+
+ private MCPPromptDescriptorValidator() {
+ }
+
+ /**
+ * Validate prompt descriptors against their internal templates.
+ *
+ * @param descriptors prompt descriptors
+ * @param templateBindings prompt template bindings
+ */
+ public static void validate(final Collection<MCPPromptDescriptor> descriptors, final Collection<MCPPromptTemplateBinding> templateBindings) {
+ Map<String, MCPPromptDescriptor> registered = new LinkedHashMap<>(descriptors.size(), 1F);
+ Map<String, MCPPromptTemplateBinding> bindings = templateBindings.stream()
+ .collect(Collectors.toMap(MCPPromptTemplateBinding::getPromptName, each -> each));
+ for (MCPPromptDescriptor each : descriptors) {
+ MCPPromptTemplateBinding binding = bindings.get(each.getName());
+ ShardingSpherePreconditions.checkState(null != binding, () -> new IllegalStateException(String.format("Prompt `%s` must declare an internal template binding.", each.getName())));
+ ShardingSpherePreconditions.checkState(null == registered.putIfAbsent(each.getName(), each),
+ () -> new IllegalStateException(String.format("Duplicate MCP prompt descriptor `%s`.", each.getName())));
+ validatePromptTemplate(each, binding);
+ }
+ }
+
+ private static void validatePromptTemplate(final MCPPromptDescriptor descriptor, final MCPPromptTemplateBinding binding) {
+ String template = MCPPromptTemplateLoader.load(binding.getTemplateResource());
+ validateNoUnsupportedModelFacingPlaceholders(binding, template);
+ Set<String> declaredArguments = descriptor.getArguments().stream().map(MCPPromptArgumentDescriptor::getName).collect(Collectors.toSet());
+ Set<String> renderedArguments = MCPPromptTemplateLoader.extractPlaceholders(template);
+ for (String each : renderedArguments) {
+ ShardingSpherePreconditions.checkState(declaredArguments.contains(each),
+ () -> new IllegalStateException(String.format("Prompt template `%s` has undeclared placeholder `%s`.", binding.getTemplateResource(), each)));
+ }
+ validateDeclaredPromptArgumentsRendered(descriptor, binding, declaredArguments, renderedArguments);
+ }
+
+ private static void validateNoUnsupportedModelFacingPlaceholders(final MCPPromptTemplateBinding binding, final String template) {
+ for (String each : template.lines().toList()) {
+ validateNoUnsupportedModelFacingPlaceholderInLine(binding, each);
+ }
+ }
+
+ private static void validateNoUnsupportedModelFacingPlaceholderInLine(final MCPPromptTemplateBinding binding, final String line) {
+ Matcher matcher = SINGLE_BRACE_PLACEHOLDER_PATTERN.matcher(line);
+ while (matcher.find()) {
+ if (!isResourceUriTemplateVariable(line, matcher.start())) {
+ throw new IllegalStateException(String.format("Prompt template `%s` contains unsupported model-facing placeholder `{%s}`.",
+ binding.getTemplateResource(), matcher.group(1)));
+ }
+ }
+ }
+
+ private static boolean isResourceUriTemplateVariable(final String line, final int placeholderStartIndex) {
+ int tokenStartIndex = findTokenStartIndex(line, placeholderStartIndex);
+ int resourceUriStartIndex = line.lastIndexOf("shardingsphere://", placeholderStartIndex);
+ return resourceUriStartIndex >= tokenStartIndex;
+ }
+
+ private static int findTokenStartIndex(final String line, final int index) {
+ for (int i = index - 1; i >= 0; i--) {
+ if (Character.isWhitespace(line.charAt(i))) {
+ return i + 1;
+ }
+ }
+ return 0;
+ }
+
+ private static void validateDeclaredPromptArgumentsRendered(final MCPPromptDescriptor descriptor, final MCPPromptTemplateBinding binding,
+ final Set<String> declaredArguments, final Set<String> renderedArguments) {
+ Set<String> clientFormOnlyArguments = getClientFormOnlyArguments(descriptor);
+ for (String each : declaredArguments) {
+ ShardingSpherePreconditions.checkState(renderedArguments.contains(each) || clientFormOnlyArguments.contains(each),
+ () -> new IllegalStateException(String.format("Prompt `%s` declares argument `%s` but template `%s` does not render it.",
+ descriptor.getName(), each, binding.getTemplateResource())));
+ }
+ }
+
+ private static Set<String> getClientFormOnlyArguments(final MCPPromptDescriptor descriptor) {
+ Object value = descriptor.getMeta().get(CLIENT_FORM_ONLY_ARGUMENTS);
+ if (null == value) {
+ return Set.of();
+ }
+ ShardingSpherePreconditions.checkState(value instanceof Collection,
+ () -> new IllegalStateException(String.format("Prompt `%s` metadata `%s` must be a list.", descriptor.getName(), CLIENT_FORM_ONLY_ARGUMENTS)));
+ return ((Collection<?>) value).stream().map(String::valueOf).collect(Collectors.toSet());
+ }
+}
diff --git a/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPResourceDescriptorValidator.java b/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPResourceDescriptorValidator.java
new file mode 100644
index 00000000000..4a9bc28fcb5
--- /dev/null
+++ b/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPResourceDescriptorValidator.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.mcp.support.descriptor;
+
+import org.apache.shardingsphere.infra.exception.ShardingSpherePreconditions;
+import org.apache.shardingsphere.mcp.api.resource.descriptor.MCPResourceDescriptor;
+import org.apache.shardingsphere.mcp.support.resource.MCPUriTemplate;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Validator for MCP resource descriptors.
+ */
+public final class MCPResourceDescriptorValidator {
+
+ private MCPResourceDescriptorValidator() {
+ }
+
+ /**
+ * Validate fixed and templated resources in one descriptor catalog.
+ *
+ * @param catalog descriptor catalog
+ */
+ public static void validate(final MCPDescriptorCatalog catalog) {
+ Map<String, MCPResourceDescriptor> registered = new LinkedHashMap<>(catalog.getResourceDescriptors().size() + catalog.getResourceTemplateDescriptors().size(), 1F);
+ for (MCPResourceDescriptor each : catalog.getResourceDescriptors()) {
+ checkNotBlank(each.getUriTemplate(), "Resource URI");
+ ShardingSpherePreconditions.checkState(!each.isTemplated(),
+ () -> new IllegalStateException(String.format("Fixed resource `%s` must not contain template variables.", each.getUriTemplate())));
+ validateResourceDescriptor(each, registered);
+ }
+ for (MCPResourceDescriptor each : catalog.getResourceTemplateDescriptors()) {
+ checkNotBlank(each.getUriTemplate(), "Resource template URI template");
+ ShardingSpherePreconditions.checkState(each.isTemplated(),
+ () -> new IllegalStateException(String.format("Resource template `%s` must contain template variables.", each.getUriTemplate())));
+ validateResourceDescriptor(each, registered);
+ validateResourceVariables(each, findShardingSphereResourceMetadata(catalog, each.getUriTemplate()).map(ShardingSphereMCPResourceMetadata::getUriVariables).orElse(List.of()));
+ }
+ }
+
+ private static void validateResourceDescriptor(final MCPResourceDescriptor descriptor, final Map<String, MCPResourceDescriptor> registered) {
+ ShardingSpherePreconditions.checkState(null == registered.putIfAbsent(descriptor.getUriTemplate(), descriptor),
+ () -> new IllegalStateException(String.format("Duplicate MCP resource descriptor `%s`.", descriptor.getUriTemplate())));
+ }
+
+ private static Optional<ShardingSphereMCPResourceMetadata> findShardingSphereResourceMetadata(final MCPDescriptorCatalog catalog, final String uriOrTemplate) {
+ return catalog.getShardingSphereResourceMetadata().stream().filter(each -> uriOrTemplate.equals(each.getUriOrTemplate())).findFirst();
+ }
+
+ private static void validateResourceVariables(final MCPResourceDescriptor descriptor, final Collection<MCPUriVariableDescriptor> uriVariables) {
+ List<String> templateVariables = new MCPUriTemplate(descriptor.getUriTemplate()).getVariableNames();
+ Set<String> registeredTemplateVariables = new HashSet<>();
+ for (String each : templateVariables) {
+ ShardingSpherePreconditions.checkState(registeredTemplateVariables.add(each),
+ () -> new IllegalStateException(String.format("Duplicate URI template variable `%s` in resource descriptor `%s`.", each, descriptor.getUriTemplate())));
+ }
+ Map<String, MCPUriVariableDescriptor> declaredParameters = new LinkedHashMap<>(uriVariables.size(), 1F);
+ for (MCPUriVariableDescriptor each : uriVariables) {
+ ShardingSpherePreconditions.checkState(each.isRequired(), () -> new IllegalStateException(
+ String.format("Resource parameter `%s.%s` must be required because URI template variables are required.", descriptor.getUriTemplate(), each.getName())));
+ ShardingSpherePreconditions.checkState(registeredTemplateVariables.contains(each.getName()),
+ () -> new IllegalStateException(String.format("Resource descriptor `%s` declares non-template parameter `%s`.", descriptor.getUriTemplate(), each.getName())));
+ ShardingSpherePreconditions.checkState(null == declaredParameters.putIfAbsent(each.getName(), each),
+ () -> new IllegalStateException(String.format("Duplicate MCP resource parameter `%s.%s`.", descriptor.getUriTemplate(), each.getName())));
+ }
+ for (String variableName : templateVariables) {
+ ShardingSpherePreconditions.checkState(declaredParameters.containsKey(variableName),
+ () -> new IllegalStateException(String.format("Resource descriptor `%s` must describe URI template variable `%s`.", descriptor.getUriTemplate(), variableName)));
+ }
+ }
+
+ private static void checkNotBlank(final String value, final String label) {
+ ShardingSpherePreconditions.checkState(null != value && !value.isBlank(), () -> new IllegalStateException(String.format("%s is required.", label)));
+ }
+}
diff --git a/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPResourceNavigationDescriptorValidator.java b/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPResourceNavigationDescriptorValidator.java
new file mode 100644
index 00000000000..f10a3535cd4
--- /dev/null
+++ b/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPResourceNavigationDescriptorValidator.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.mcp.support.descriptor;
+
+import org.apache.shardingsphere.infra.exception.ShardingSpherePreconditions;
+import org.apache.shardingsphere.mcp.api.prompt.descriptor.MCPPromptDescriptor;
+import org.apache.shardingsphere.mcp.api.resource.descriptor.MCPResourceDescriptor;
+import org.apache.shardingsphere.mcp.api.tool.descriptor.MCPToolDescriptor;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Validator for MCP resource navigation descriptors.
+ */
+public final class MCPResourceNavigationDescriptorValidator {
+
+ private MCPResourceNavigationDescriptorValidator() {
+ }
+
+ /**
+ * Validate resource navigation endpoints against public descriptor identifiers.
+ *
+ * @param descriptors resource navigation descriptors
+ * @param catalog descriptor catalog
+ */
+ public static void validate(final Collection<MCPResourceNavigationDescriptor> descriptors, final MCPDescriptorCatalog catalog) {
+ Set<String> publicIdentifiers = createPublicIdentifiers(catalog);
+ Set<String> registered = new HashSet<>();
+ for (MCPResourceNavigationDescriptor each : descriptors) {
+ ShardingSpherePreconditions.checkState(publicIdentifiers.contains(each.getFrom()),
+ () -> new IllegalStateException(String.format("Resource navigation references unknown source `%s`.", each.getFrom())));
+ ShardingSpherePreconditions.checkState(publicIdentifiers.contains(each.getTo()),
+ () -> new IllegalStateException(String.format("Resource navigation references unknown target `%s`.", each.getTo())));
+ ShardingSpherePreconditions.checkState(registered.add(each.getFrom() + "->" + each.getTo()),
+ () -> new IllegalStateException(String.format("Duplicate MCP resource navigation `%s` to `%s`.", each.getFrom(), each.getTo())));
+ }
+ }
+
+ private static Set<String> createPublicIdentifiers(final MCPDescriptorCatalog catalog) {
+ Set<String> result = new HashSet<>();
+ catalog.getAllResourceDescriptors().stream().map(MCPResourceDescriptor::getUriTemplate).forEach(result::add);
+ catalog.getToolDescriptors().stream().map(MCPToolDescriptor::getName).forEach(result::add);
+ catalog.getPromptDescriptors().stream().map(MCPPromptDescriptor::getName).forEach(result::add);
+ return result;
+ }
+}
diff --git a/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolDescriptorCatalogValidator.java b/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolDescriptorCatalogValidator.java
new file mode 100644
index 00000000000..48cc6955399
--- /dev/null
+++ b/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolDescriptorCatalogValidator.java
@@ -0,0 +1,159 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.mcp.support.descriptor;
+
+import org.apache.shardingsphere.infra.exception.ShardingSpherePreconditions;
+import org.apache.shardingsphere.mcp.api.resource.descriptor.MCPResourceDescriptor;
+import org.apache.shardingsphere.mcp.api.tool.descriptor.MCPToolDescriptor;
+import org.apache.shardingsphere.mcp.support.protocol.MCPPayloadFieldNames;
+
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Validator for MCP tool descriptors and their runtime bindings.
+ */
+public final class MCPToolDescriptorCatalogValidator {
+
+ private static final Collection<String> SUPPORTED_INPUT_SCHEMA_TOP_LEVEL_FIELDS = Set.of("type", "properties", "required", "additionalProperties");
+
+ private MCPToolDescriptorCatalogValidator() {
+ }
+
+ /**
+ * Validate tool descriptors in one descriptor catalog.
+ *
+ * @param catalog descriptor catalog
+ */
+ public static void validate(final MCPDescriptorCatalog catalog) {
+ Collection<MCPToolDescriptor> descriptors = catalog.getToolDescriptors();
+ Collection<MCPToolRuntimeDescriptor> runtimeDescriptors = catalog.getToolRuntimeDescriptors();
+ Collection<MCPToolDescriptorValidator> descriptorValidators = MCPToolDescriptorValidatorLoader.load();
+ Map<String, MCPToolDescriptor> registered = new LinkedHashMap<>(descriptors.size(), 1F);
+ Map<String, MCPToolRuntimeDescriptor> runtimes = runtimeDescriptors.stream()
+ .collect(Collectors.toMap(MCPToolRuntimeDescriptor::getToolName, each -> each));
+ Set<String> resourceIdentifiers = catalog.getAllResourceDescriptors().stream().map(MCPResourceDescriptor::getUriTemplate).collect(Collectors.toSet());
+ Set<String> shardingSphereResourceIdentifiers = catalog.getShardingSphereResourceMetadata().stream()
+ .map(ShardingSphereMCPResourceMetadata::getUriOrTemplate).collect(Collectors.toSet());
+ for (MCPToolDescriptor each : descriptors) {
+ ShardingSpherePreconditions.checkState(null == registered.putIfAbsent(each.getName(), each),
+ () -> new IllegalStateException(String.format("Duplicate MCP tool descriptor `%s`.", each.getName())));
+ validateToolInputSchema(each);
+ MCPToolOutputSchemaValidator.validate(each);
+ validateToolDescriptorExtensions(each, descriptorValidators);
+ validateDestructiveToolDescriptor(each, runtimes.get(each.getName()));
+ validatePlanningExecutionMode(each);
+ validateRelatedResourceUris(each, resourceIdentifiers, shardingSphereResourceIdentifiers);
+ }
+ validatePlanningToolRuntimeDescriptors(registered, runtimeDescriptors);
+ }
+
+ private static void validateToolInputSchema(final MCPToolDescriptor descriptor) {
+ Map<String, Object> inputSchema = descriptor.getInputSchema();
+ for (String each : inputSchema.keySet()) {
+ ShardingSpherePreconditions.checkState(SUPPORTED_INPUT_SCHEMA_TOP_LEVEL_FIELDS.contains(each),
+ () -> new IllegalStateException(String.format("Tool `%s` inputSchema contains unsupported top-level field `%s`.", descriptor.getName(), each)));
+ }
+ ShardingSpherePreconditions.checkState("object".equals(inputSchema.get("type")),
+ () -> new IllegalStateException(String.format("Tool `%s` inputSchema must be an object.", descriptor.getName())));
+ Object properties = inputSchema.get("properties");
+ ShardingSpherePreconditions.checkState(properties instanceof Map, () -> new IllegalStateException(String.format("Tool `%s` inputSchema must declare properties.", descriptor.getName())));
+ MCPToolOutputSchemaValidator.validateInputSchemaFields(descriptor);
+ }
+
+ private static void validateRelatedResourceUris(final MCPToolDescriptor descriptor, final Set<String> resourceIdentifiers, final Set<String> shardingSphereResourceIdentifiers) {
+ Object value = descriptor.getMeta().get(MCPShardingSphereMetadataKeys.RELATED_RESOURCE_URIS);
+ if (null == value) {
+ return;
+ }
+ ShardingSpherePreconditions.checkState(value instanceof Collection,
+ () -> new IllegalStateException(String.format("Tool `%s` metadata `%s` must be a list.", descriptor.getName(), MCPShardingSphereMetadataKeys.RELATED_RESOURCE_URIS)));
+ for (Object each : (Collection<?>) value) {
+ String uri = String.valueOf(each);
+ ShardingSpherePreconditions.checkState(resourceIdentifiers.contains(uri),
+ () -> new IllegalStateException(String.format("Tool `%s` metadata `%s` references unknown resource `%s`.",
+ descriptor.getName(), MCPShardingSphereMetadataKeys.RELATED_RESOURCE_URIS, uri)));
+ ShardingSpherePreconditions.checkState(shardingSphereResourceIdentifiers.contains(uri),
+ () -> new IllegalStateException(String.format("Tool `%s` metadata `%s` references resource `%s` without ShardingSphere metadata.",
+ descriptor.getName(), MCPShardingSphereMetadataKeys.RELATED_RESOURCE_URIS, uri)));
+ }
+ }
+
+ private static void validatePlanningToolRuntimeDescriptors(final Map<String, MCPToolDescriptor> descriptors, final Collection<MCPToolRuntimeDescriptor> runtimeDescriptors) {
+ Map<String, String> workflowKinds = new LinkedHashMap<>(runtimeDescriptors.size(), 1F);
+ for (MCPToolRuntimeDescriptor each : runtimeDescriptors) {
+ if (!"plan".equals(each.getWorkflowRole())) {
+ continue;
+ }
+ MCPToolDescriptor descriptor = descriptors.get(each.getToolName());
+ ShardingSpherePreconditions.checkState(null != descriptor,
+ () -> new IllegalStateException(String.format("Planning runtime tool `%s` must reference a registered tool descriptor.", each.getToolName())));
+ Object workflowKind = descriptor.getMeta().get(MCPShardingSphereMetadataKeys.WORKFLOW_KIND);
+ ShardingSpherePreconditions.checkState(null != workflowKind && !workflowKind.toString().isBlank(),
+ () -> new IllegalStateException(String.format("Planning tool `%s` metadata must declare `%s`.", each.getToolName(), MCPShardingSphereMetadataKeys.WORKFLOW_KIND)));
+ String previousToolName = workflowKinds.putIfAbsent(workflowKind.toString(), each.getToolName());
+ ShardingSpherePreconditions.checkState(null == previousToolName, () -> new IllegalStateException(
+ String.format("Planning workflow kind `%s` is used by both `%s` and `%s`.", workflowKind, previousToolName, each.getToolName())));
+ }
+ }
+
+ private static void validateDestructiveToolDescriptor(final MCPToolDescriptor descriptor, final MCPToolRuntimeDescriptor runtimeDescriptor) {
+ if (!descriptor.getAnnotations().isDestructiveHint()) {
+ return;
+ }
+ Map<?, ?> executionMode = MCPToolDescriptorValidationUtils.findToolInputProperty(descriptor, MCPPayloadFieldNames.EXECUTION_MODE).orElseThrow(
+ () -> new IllegalStateException(String.format("Destructive tool `%s` must declare execution_mode.", descriptor.getName())));
+ ShardingSpherePreconditions.checkState(MCPToolDescriptorValidationUtils.isRequiredToolInput(descriptor, MCPPayloadFieldNames.EXECUTION_MODE),
+ () -> new IllegalStateException(String.format("Destructive tool `%s` execution_mode must be required.", descriptor.getName())));
+ Collection<?> executionModes = executionMode.get("enum") instanceof Collection ? (Collection<?>) executionMode.get("enum") : List.of();
+ ShardingSpherePreconditions.checkState(executionModes.contains("preview"),
+ () -> new IllegalStateException(String.format("Destructive tool `%s` execution_mode must allow preview.", descriptor.getName())));
+ ShardingSpherePreconditions.checkState(!executionModes.contains("auto-execute"),
+ () -> new IllegalStateException(String.format("Destructive tool `%s` execution_mode must not expose auto-execute.", descriptor.getName())));
+ ShardingSpherePreconditions.checkState(null != runtimeDescriptor && !runtimeDescriptor.getSideEffectScope().isEmpty(),
+ () -> new IllegalStateException(String.format("Destructive tool `%s` must declare sideEffectScope in internal runtime.", descriptor.getName())));
+ }
+
+ private static void validatePlanningExecutionMode(final MCPToolDescriptor descriptor) {
+ if (descriptor.getAnnotations().isDestructiveHint()) {
+ return;
+ }
+ Optional<Map<?, ?>> executionMode = MCPToolDescriptorValidationUtils.findToolInputProperty(descriptor, MCPPayloadFieldNames.EXECUTION_MODE);
+ if (executionMode.isEmpty()) {
+ return;
+ }
+ Collection<?> executionModes = executionMode.get().get("enum") instanceof Collection ? (Collection<?>) executionMode.get().get("enum") : List.of();
+ ShardingSpherePreconditions.checkState(!executionModes.contains("preview"),
+ () -> new IllegalStateException(String.format("Planning tool `%s` execution_mode must not expose preview.", descriptor.getName())));
+ ShardingSpherePreconditions.checkState(!executionModes.contains("auto-execute"),
+ () -> new IllegalStateException(String.format("Planning tool `%s` execution_mode must not expose auto-execute.", descriptor.getName())));
+ }
+
+ private static void validateToolDescriptorExtensions(final MCPToolDescriptor descriptor, final Collection<MCPToolDescriptorValidator> descriptorValidators) {
+ for (MCPToolDescriptorValidator each : descriptorValidators) {
+ if (each.supports(descriptor)) {
+ each.validate(descriptor);
+ }
+ }
+ }
+}
diff --git a/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolOutputSchemaValidator.java b/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolOutputSchemaValidator.java
new file mode 100644
index 00000000000..f70177f19e6
--- /dev/null
+++ b/mcp/support/src/main/java/org/apache/shardingsphere/mcp/support/descriptor/MCPToolOutputSchemaValidator.java
@@ -0,0 +1,285 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.mcp.support.descriptor;
+
+import org.apache.shardingsphere.infra.exception.ShardingSpherePreconditions;
+import org.apache.shardingsphere.mcp.api.tool.descriptor.MCPToolDescriptor;
+import org.apache.shardingsphere.mcp.support.protocol.MCPPayloadFieldNames;
+import org.apache.shardingsphere.mcp.support.protocol.MCPResponseMode;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+
+/**
+ * Validator for model-facing MCP tool output schema contracts.
+ */
+public final class MCPToolOutputSchemaValidator {
+
+ private static final Collection<String> REMOVED_MODEL_FACING_FIELDS = Set.of(
+ "target_tool", "target_resource", "required_arguments", "action_kind", "suggested_next_tool", "suggested_next_tools", "recommended_next_tool",
+ "recommended_recovery", "suggested_next_action", "approved_by_user", "requires_user_approval", "approval_required", "user_overrides");
+
+ private static final Map<String, Collection<String>> NEXT_ACTION_ALLOWED_FIELDS = createNextActionAllowedFields();
+
+ private static final Collection<String> NEXT_ACTION_SCHEMA_ALLOWED_FIELDS = createNextActionSchemaAllowedFields();
+
+ private static final Collection<String> MODEL_CRITICAL_HINT_FIELDS = List.of(
+ MCPPayloadFieldNames.NEXT_ACTIONS, MCPPayloadFieldNames.RESOURCES_TO_READ, MCPPayloadFieldNames.RESOURCE, MCPPayloadFieldNames.PARENT_RESOURCE,
+ MCPPayloadFieldNames.NEXT_RESOURCES, "manual_artifact_summary", "manual_follow_up", "empty_state", "ambiguity_state", MCPPayloadFieldNames.RECOVERY, "recovery_guidance",
+ "remediation");
+
+ private static final Collection<String> CONTINUATION_MODES = List.of("none", "pagination", "metadata_search");
+
+ private static final Collection<String> RECOVERY_CATEGORIES = List.of("not_found", "ambiguous", "empty_scope", "missing_context", "validation", "terminal",
+ "unsupported_target", "invalid_enum", "unsafe_sql", "stale_workflow", "unavailable_runtime", "terminal_operator_action");
+
+ private MCPToolOutputSchemaValidator() {
+ }
+
+ private static Map<String, Collection<String>> createNextActionAllowedFields() {
+ return Map.of(
+ "resource_read", Set.of("order", "type", "title", "resource_uri", "reason", "depends_on"),
+ "tool_call", Set.of("order", "type", "title", "tool_name", "arguments", "reason", "depends_on"),
+ "completion", Set.of("order", "type", "title", "ref", "argument", "context", "missing_context_arguments", "resume_ref", "resume_arguments", "reason", "depends_on"),
+ "ask_user", Set.of("order", "type", "title", "question", "required_inputs", "reason", "depends_on"),
+ "terminal", Set.of("order", "type", "title", "reason", "depends_on"));
+ }
+
+ private static Collection<String> createNextActionSchemaAllowedFields() {
+ Set<String> result = new HashSet<>();
+ for (Collection<String> each : NEXT_ACTION_ALLOWED_FIELDS.values()) {
+ result.addAll(each);
+ }
+ return result;
+ }
+
+ /**
+ * Validate one tool output schema.
+ *
+ * @param descriptor tool descriptor
+ */
+ public static void validate(final MCPToolDescriptor descriptor) {
+ Map<String, Object> outputSchema = descriptor.getOutputSchema();
+ ShardingSpherePreconditions.checkState("object".equals(outputSchema.get("type")),
+ () -> new IllegalStateException(String.format("Tool `%s` outputSchema must be an object.", descriptor.getName())));
+ Object properties = outputSchema.get("properties");
+ ShardingSpherePreconditions.checkState(properties instanceof Map && !((Map<?, ?>) properties).isEmpty(),
+ () -> new IllegalStateException(String.format("Tool `%s` outputSchema must declare properties.", descriptor.getName())));
+ validateNoRemovedModelFacingFields(descriptor, outputSchema);
+ validateOutputExamples(descriptor, outputSchema);
+ validateOutputExampleContractValues(descriptor, outputSchema);
+ validateModelCriticalOutputHints(descriptor, (Map<?, ?>) properties);
+ }
+
+ /**
+ * Validate that model-facing input schema fields do not use removed aliases.
+ *
+ * @param descriptor tool descriptor
+ */
+ public static void validateInputSchemaFields(final MCPToolDescriptor descriptor) {
+ validateNoRemovedModelFacingFields(descriptor, descriptor.getInputSchema());
+ }
+
+ private static void validateOutputExamples(final MCPToolDescriptor descriptor, final Map<String, Object> outputSchema) {
+ ShardingSpherePreconditions.checkState(isNonEmptyCollection(outputSchema.get("examples")),
+ () -> new IllegalStateException(String.format("Tool `%s` outputSchema must declare examples.", descriptor.getName())));
+ }
+
+ private static void validateOutputExampleContractValues(final MCPToolDescriptor descriptor, final Map<String, Object> outputSchema) {
+ Object examples = outputSchema.get("examples");
+ if (!(examples instanceof Collection)) {
+ return;
+ }
+ for (Object each : (Collection<?>) examples) {
+ validateExampleContractValue(descriptor, each);
+ }
+ }
+
+ private static void validateExampleContractValue(final MCPToolDescriptor descriptor, final Object value) {
+ if (value instanceof Map) {
+ validateExampleContractMap(descriptor, (Map<?, ?>) value);
+ } else if (value instanceof Collection) {
+ for (Object each : (Collection<?>) value) {
+ validateExampleContractValue(descriptor, each);
+ }
+ }
+ }
+
+ private static void validateExampleContractMap(final MCPToolDescriptor descriptor, final Map<?, ?> value) {
+ Object responseMode = value.get("response_mode");
+ if (null != responseMode) {
+ ShardingSpherePreconditions.checkState(MCPResponseMode.isAllowed(responseMode.toString()),
+ () -> new IllegalStateException(String.format("Tool `%s` output example uses unknown response_mode `%s`.", descriptor.getName(), responseMode)));
+ }
+ Object continuationMode = value.get("continuation_mode");
+ if (null != continuationMode) {
+ ShardingSpherePreconditions.checkState(CONTINUATION_MODES.contains(continuationMode.toString()),
+ () -> new IllegalStateException(String.format("Tool `%s` output example uses unknown continuation_mode `%s`.", descriptor.getName(), continuationMode)));
+ }
+ Object recoveryCategory = value.get("recovery_category");
+ if (null != recoveryCategory) {
+ ShardingSpherePreconditions.checkState(RECOVERY_CATEGORIES.contains(recoveryCategory.toString()),
+ () -> new IllegalStateException(String.format("Tool `%s` output example uses unknown recovery_category `%s`.", descriptor.getName(), recoveryCategory)));
+ }
+ for (Object each : value.values()) {
+ validateExampleContractValue(descriptor, each);
+ }
+ Object nextActions = value.get(MCPPayloadFieldNames.NEXT_ACTIONS);
+ if (null != nextActions) {
+ validateConcreteNextActions(descriptor, nextActions);
+ }
+ }
+
+ private static void validateNoRemovedModelFacingFields(final MCPToolDescriptor descriptor, final Object value) {
+ if (value instanceof Map) {
+ validateNoRemovedModelFacingFieldMap(descriptor, (Map<?, ?>) value);
+ } else if (value instanceof Collection) {
+ for (Object each : (Collection<?>) value) {
+ validateNoRemovedModelFacingFields(descriptor, each);
+ }
+ }
+ }
+
+ private static void validateNoRemovedModelFacingFieldMap(final MCPToolDescriptor descriptor, final Map<?, ?> value) {
+ for (Entry<?, ?> entry : value.entrySet()) {
+ String key = String.valueOf(entry.getKey());
+ validateNoRemovedModelFacingField(descriptor, key);
+ if ("required".equals(key)) {
+ validateNoRemovedModelFacingRequiredFields(descriptor, entry.getValue());
+ }
+ validateNoRemovedModelFacingFields(descriptor, entry.getValue());
+ }
+ }
+
+ private static void validateNoRemovedModelFacingRequiredFields(final MCPToolDescriptor descriptor, final Object value) {
+ if (!(value instanceof Collection)) {
+ return;
+ }
+ for (Object each : (Collection<?>) value) {
+ validateNoRemovedModelFacingField(descriptor, String.valueOf(each));
+ }
+ }
+
+ private static void validateNoRemovedModelFacingField(final MCPToolDescriptor descriptor, final String fieldName) {
+ ShardingSpherePreconditions.checkState(!REMOVED_MODEL_FACING_FIELDS.contains(fieldName),
+ () -> new IllegalStateException(String.format("Tool `%s` model-facing contract must use canonical fields instead of removed `%s`.", descriptor.getName(), fieldName)));
+ }
+
+ private static void validateConcreteNextActions(final MCPToolDescriptor descriptor, final Object value) {
+ ShardingSpherePreconditions.checkState(value instanceof Collection,
+ () -> new IllegalStateException(String.format("Tool `%s` next_actions example must be an array.", descriptor.getName())));
+ for (Object each : (Collection<?>) value) {
+ ShardingSpherePreconditions.checkState(each instanceof Map,
+ () -> new IllegalStateException(String.format("Tool `%s` next_actions example item must be an object.", descriptor.getName())));
+ validateConcreteNextAction(descriptor, (Map<?, ?>) each);
+ }
+ }
+
+ private static void validateConcreteNextAction(final MCPToolDescriptor descriptor, final Map<?, ?> action) {
+ String type = String.valueOf(action.get("type"));
+ Collection<String> allowedFields = NEXT_ACTION_ALLOWED_FIELDS.get(type);
+ ShardingSpherePreconditions.checkState(null != allowedFields,
+ () -> new IllegalStateException(String.format("Tool `%s` next_actions example uses unknown type `%s`.", descriptor.getName(), type)));
+ for (Object each : action.keySet()) {
+ String fieldName = String.valueOf(each);
+ ShardingSpherePreconditions.checkState(allowedFields.contains(fieldName),
+ () -> new IllegalStateException(String.format("Tool `%s` next_actions example `%s` contains unsupported field `%s`.", descriptor.getName(), type, fieldName)));
+ }
+ }
+
+ private static void validateModelCriticalOutputHints(final MCPToolDescriptor descriptor, final Map<?, ?> properties) {
+ for (Entry<?, ?> entry : properties.entrySet()) {
+ String fieldName = String.valueOf(entry.getKey());
+ if (MODEL_CRITICAL_HINT_FIELDS.contains(fieldName)) {
+ validateModelCriticalOutputHint(descriptor, fieldName, entry.getValue());
+ }
+ validateNestedModelCriticalOutputHints(descriptor, entry.getValue());
+ }
+ }
+
+ private static void validateNestedModelCriticalOutputHints(final MCPToolDescriptor descriptor, final Object value) {
+ if (!(value instanceof Map)) {
+ return;
+ }
+ Object properties = ((Map<?, ?>) value).get("properties");
+ if (properties instanceof Map) {
+ validateModelCriticalOutputHints(descriptor, (Map<?, ?>) properties);
+ }
+ Object items = ((Map<?, ?>) value).get("items");
+ if (items instanceof Map) {
+ validateNestedModelCriticalOutputHints(descriptor, items);
+ }
+ }
+
+ private static void validateModelCriticalOutputHint(final MCPToolDescriptor descriptor, final String fieldName, final Object property) {
+ ShardingSpherePreconditions.checkState(property instanceof Map,
+ () -> new IllegalStateException(String.format("Tool `%s` model-critical output field `%s` must be an object.", descriptor.getName(), fieldName)));
+ Object description = ((Map<?, ?>) property).get("description");
+ checkDescription(null == description ? "" : description.toString(), String.format("Tool model-critical output field `%s.%s` description", descriptor.getName(), fieldName));
+ if (MCPPayloadFieldNames.NEXT_ACTIONS.equals(fieldName)) {
+ validateNextActionsSchema(descriptor, (Map<?, ?>) property);
+ }
+ }
+
+ private static void validateNextActionsSchema(final MCPToolDescriptor descriptor, final Map<?, ?> property) {
+ ShardingSpherePreconditions.checkState("array".equals(property.get("type")), () -> new IllegalStateException(String.format("Tool `%s` next_actions must be an array.", descriptor.getName())));
+ Object items = property.get("items");
+ ShardingSpherePreconditions.checkState(items instanceof Map, () -> new IllegalStateException(String.format("Tool `%s` next_actions items must be an object.", descriptor.getName())));
+ Object properties = ((Map<?, ?>) items).get("properties");
+ ShardingSpherePreconditions.checkState(properties instanceof Map,
+ () -> new IllegalStateException(String.format("Tool `%s` next_actions items must declare properties.", descriptor.getName())));
+ for (String each : List.of("order", "type", "title")) {
+ ShardingSpherePreconditions.checkState(((Map<?, ?>) properties).containsKey(each),
+ () -> new IllegalStateException(String.format("Tool `%s` next_actions item must declare `%s`.", descriptor.getName(), each)));
+ Object field = ((Map<?, ?>) properties).get(each);
+ ShardingSpherePreconditions.checkState(field instanceof Map,
+ () -> new IllegalStateException(String.format("Tool `%s` next_actions item field `%s` must be an object.", descriptor.getName(), each)));
+ Object description = ((Map<?, ?>) field).get("description");
+ checkDescription(null == description ? "" : description.toString(), String.format("Tool next_actions item field `%s.%s` description", descriptor.getName(), each));
+ }
+ for (Object each : ((Map<?, ?>) properties).keySet()) {
+ String fieldName = String.valueOf(each);
+ ShardingSpherePreconditions.checkState(NEXT_ACTION_SCHEMA_ALLOWED_FIELDS.contains(fieldName),
+ () -> new IllegalStateException(String.format("Tool `%s` next_actions item contains unsupported field `%s`.", descriptor.getName(), fieldName)));
+ }
+ }
+
+ private static boolean isNonEmptyCollection(final Object value) {
+ return value instanceof Collection && !((Collection<?>) value).isEmpty();
+ }
+
+ private static void checkDescription(final String value, final String label) {
+ checkNotBlank(value, label);
+ ShardingSpherePreconditions.checkState(!value.startsWith(createPlaceholderPrefix("resource:")),
+ () -> new IllegalStateException(String.format("%s must not be a placeholder description.", label)));
+ ShardingSpherePreconditions.checkState(!value.startsWith(createPlaceholderPrefix("resource template:")),
+ () -> new IllegalStateException(String.format("%s must not be a placeholder description.", label)));
+ }
+
+ private static String createPlaceholderPrefix(final String suffix) {
+ return "ShardingSphere MCP " + suffix;
+ }
+
+ private static void checkNotBlank(final String value, final String label) {
+ ShardingSpherePreconditions.checkState(null != value && !value.isBlank(), () -> new IllegalStateException(String.format("%s is required.", label)));
+ }
+}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/AbstractMCPJdbcMetadataLoaderTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/AbstractMCPJdbcMetadataLoaderTest.java
new file mode 100644
index 00000000000..42bd90f7466
--- /dev/null
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/AbstractMCPJdbcMetadataLoaderTest.java
@@ -0,0 +1,454 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.mcp.support.database.metadata.jdbc;
+
+import lombok.AccessLevel;
+import lombok.RequiredArgsConstructor;
+import org.apache.shardingsphere.database.connector.core.type.DatabaseTypeFactory;
+import org.apache.shardingsphere.mcp.support.database.capability.SupportedMCPMetadataObjectType;
+import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPColumnMetadata;
+import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPDatabaseMetadata;
+import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPIndexMetadata;
+import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPSchemaMetadata;
+import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPSequenceMetadata;
+import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPTableMetadata;
+import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPViewMetadata;
+import org.apache.shardingsphere.mcp.support.fixture.SupportDatabaseTypeFactoryMocker;
+import org.junit.jupiter.params.provider.Arguments;
+import org.mockito.MockedStatic;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.Driver;
+import java.sql.DriverManager;
+import java.sql.DriverPropertyInfo;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import java.sql.Statement;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.logging.Logger;
+import java.util.stream.Stream;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.ArgumentMatchers.nullable;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+abstract class AbstractMCPJdbcMetadataLoaderTest {
+
+ protected static final Map<String, String> METADATA_JDBC_URLS = Map.of(
+ "MySQL", "jdbc:mysql://metadata-loader/test",
+ "PostgreSQL", "jdbc:postgresql://metadata-loader/test",
+ "openGauss", "jdbc:opengauss://metadata-loader/test",
+ "SQLServer", "jdbc:sqlserver://metadata-loader",
+ "Oracle", "jdbc:oracle:thin:@metadata-loader",
+ "MariaDB", "jdbc:mariadb://metadata-loader/test",
+ "Firebird", "jdbc:firebirdsql://metadata-loader/test");
+
+ protected LoadedMetadataCatalog load(final Map<String, RuntimeDatabaseConfiguration> runtimeDatabases) {
+ try (MockedStatic<DatabaseTypeFactory> ignored = SupportDatabaseTypeFactoryMocker.mockByConnectionMetadata()) {
+ MCPJdbcDatabaseProfileLoader databaseProfileLoader = new MCPJdbcDatabaseProfileLoader();
+ MCPJdbcMetadataLoader metadataLoader = new MCPJdbcMetadataLoader();
+ Map<String, RuntimeDatabaseProfile> databaseProfiles = databaseProfileLoader.load(runtimeDatabases);
+ Map<String, MCPDatabaseMetadata> result = new LinkedHashMap<>(runtimeDatabases.size(), 1F);
+ for (Entry<String, RuntimeDatabaseConfiguration> entry : runtimeDatabases.entrySet()) {
+ result.put(entry.getKey(), metadataLoader.load(entry.getKey(), entry.getValue(), databaseProfiles.get(entry.getKey())));
+ }
+ return new LoadedMetadataCatalog(result);
+ }
+ }
+
+ protected static Stream<Arguments> loadTypedMetadataArguments() {
+ return Stream.of(
+ Arguments.of("table orders", SupportedMCPMetadataObjectType.TABLE, "orders"),
+ Arguments.of("table order_items", SupportedMCPMetadataObjectType.TABLE, "order_items"),
+ Arguments.of("view active_orders", SupportedMCPMetadataObjectType.VIEW, "active_orders"),
+ Arguments.of("column status", SupportedMCPMetadataObjectType.COLUMN, "status"),
+ Arguments.of("index idx_orders_status", SupportedMCPMetadataObjectType.INDEX, "idx_orders_status"),
+ Arguments.of("sequence order_seq", SupportedMCPMetadataObjectType.SEQUENCE, "order_seq"));
+ }
+
+ protected static Stream<Arguments> loadSequenceDialectArguments() {
+ return Stream.of(
+ Arguments.of("postgresql", "PostgreSQL", "public", "order_seq",
+ "SELECT sequence_schema AS SEQUENCE_SCHEMA, sequence_name AS SEQUENCE_NAME FROM information_schema.sequences"),
+ Arguments.of("open gauss", "openGauss", "public", "order_seq",
+ "SELECT sequence_schema AS SEQUENCE_SCHEMA, sequence_name AS SEQUENCE_NAME FROM information_schema.sequences"),
+ Arguments.of("sql server", "SQLServer", "dbo", "order_seq",
+ "SELECT schemas.name AS SEQUENCE_SCHEMA, seq.name AS SEQUENCE_NAME FROM sys.sequences seq INNER JOIN sys.schemas schemas ON seq.schema_id = schemas.schema_id"),
+ Arguments.of("oracle", "Oracle", "APP", "ORDER_SEQ",
+ "SELECT USER AS SEQUENCE_SCHEMA, sequence_name AS SEQUENCE_NAME FROM USER_SEQUENCES"),
+ Arguments.of("mariadb", "MariaDB", "logic_db", "order_seq",
+ "SELECT TABLE_SCHEMA AS SEQUENCE_SCHEMA, TABLE_NAME AS SEQUENCE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'SEQUENCE'"),
+ Arguments.of("firebird", "Firebird", "", "ORDER_SEQ",
+ "SELECT '' AS SEQUENCE_SCHEMA, TRIM(RDB$GENERATOR_NAME) AS SEQUENCE_NAME FROM RDB$GENERATORS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0"));
+ }
+
+ protected static Stream<Arguments> loadWithoutSequenceQueryArguments() {
+ return Stream.of(Arguments.of("unsupported database type", "MySQL"));
+ }
+
+ protected Connection createConnectionWithoutSchema(final String databaseType) throws SQLException {
+ return createConnectionWithoutSchemaByURL(getMetadataJdbcUrl(databaseType));
+ }
+
+ protected Connection createConnectionWithoutSchemaByURL(final String jdbcUrl) throws SQLException {
+ Connection result = mock(Connection.class);
+ DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class);
+ Statement statement = mock(Statement.class);
+ ResultSet tableResultSet = mockResultSet("TABLE_NAME", "orders");
+ ResultSet viewResultSet = mockResultSet("TABLE_NAME");
+ ResultSet columnResultSet = mockResultSet("COLUMN_NAME", "order_id");
+ ResultSet indexResultSet = mockResultSet("INDEX_NAME");
+ ResultSet emptyQueryResultSet = mockMultiRowResultSet(List.of());
+ when(result.getMetaData()).thenReturn(databaseMetaData);
+ when(result.createStatement()).thenReturn(statement);
+ when(databaseMetaData.getURL()).thenReturn(jdbcUrl);
+ when(statement.executeQuery(anyString())).thenReturn(emptyQueryResultSet);
+ when(databaseMetaData.getTables(isNull(), isNull(), eq("%"), any(String[].class))).thenAnswer(invocation -> {
+ String[] tableTypes = invocation.getArgument(3);
+ return "TABLE".equals(tableTypes[0]) ? tableResultSet : viewResultSet;
+ });
+ when(databaseMetaData.getColumns(isNull(), isNull(), eq("orders"), eq("%"))).thenReturn(columnResultSet);
+ when(databaseMetaData.getIndexInfo(isNull(), isNull(), eq("orders"), eq(false), eq(false))).thenReturn(indexResultSet);
+ return result;
+ }
+
+ protected Connection createConnectionWithSequenceMetadata(final String databaseType, final String sequenceSchema,
+ final String sequenceName, final String sequenceQuery) throws SQLException {
+ Connection result = mock(Connection.class);
+ DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class);
+ Statement statement = mock(Statement.class);
+ ResultSet tableResultSet = mockResultSet("TABLE_NAME");
+ ResultSet viewResultSet = mockResultSet("TABLE_NAME");
+ ResultSet sequenceResultSet = mockSingleRowResultSet(Map.of("SEQUENCE_SCHEMA", sequenceSchema, "SEQUENCE_NAME", sequenceName));
+ ResultSet emptyQueryResultSet = mockMultiRowResultSet(List.of());
+ when(result.getMetaData()).thenReturn(databaseMetaData);
+ when(result.createStatement()).thenReturn(statement);
+ when(databaseMetaData.getDatabaseProductVersion()).thenReturn("");
+ when(databaseMetaData.getURL()).thenReturn(getMetadataJdbcUrl(databaseType));
+ when(statement.executeQuery(anyString())).thenReturn(emptyQueryResultSet);
+ when(databaseMetaData.getTables(isNull(), isNull(), eq("%"), any(String[].class))).thenAnswer(invocation -> {
+ String[] tableTypes = invocation.getArgument(3);
+ return "TABLE".equals(tableTypes[0]) ? tableResultSet : viewResultSet;
+ });
+ when(statement.executeQuery(sequenceQuery)).thenReturn(sequenceResultSet);
+ return result;
+ }
+
+ protected Connection createConnectionWithFailedSequenceMetadataQuery() throws SQLException {
+ Connection result = mock(Connection.class);
+ DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class);
+ Statement statement = mock(Statement.class);
+ ResultSet tableResultSet = mockResultSet("TABLE_NAME");
+ ResultSet viewResultSet = mockResultSet("TABLE_NAME");
+ when(result.getMetaData()).thenReturn(databaseMetaData);
+ when(result.createStatement()).thenReturn(statement);
+ when(databaseMetaData.getDatabaseProductVersion()).thenReturn("");
+ when(databaseMetaData.getURL()).thenReturn(getMetadataJdbcUrl("PostgreSQL"));
+ when(databaseMetaData.getTables(isNull(), isNull(), eq("%"), any(String[].class))).thenAnswer(invocation -> {
+ String[] tableTypes = invocation.getArgument(3);
+ return "TABLE".equals(tableTypes[0]) ? tableResultSet : viewResultSet;
+ });
+ when(statement.executeQuery("SELECT sequence_schema AS SEQUENCE_SCHEMA, sequence_name AS SEQUENCE_NAME FROM information_schema.sequences"))
+ .thenThrow(new SQLException("sequence metadata query failed"));
+ return result;
+ }
+
+ protected Connection createStandardPostgreSQLMetadataConnection() throws SQLException {
+ Connection result = createConnectionWithMetadata(
+ List.of(
+ Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "orders"),
+ Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "order_items")),
+ List.of(Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "active_orders")),
+ Map.of(
+ "orders", List.of("order_id", "status", "amount"),
+ "order_items", List.of("item_id", "order_id", "sku"),
+ "active_orders", List.of("order_id", "status")),
+ Map.of(
+ "orders", List.of("PRIMARY_KEY_C", "idx_orders_status"),
+ "order_items", List.of("PRIMARY_KEY_C")),
+ List.of(Map.of("SEQUENCE_SCHEMA", "PUBLIC", "SEQUENCE_NAME", "order_seq")));
+ DatabaseMetaData databaseMetaData = result.getMetaData();
+ when(databaseMetaData.getDatabaseProductVersion()).thenReturn("16.2");
+ when(databaseMetaData.getURL()).thenReturn("jdbc:postgresql://metadata-loader/test");
+ return result;
+ }
+
+ protected RuntimeDatabaseConfiguration createMockRuntimeDatabaseConfiguration(final Connection connection) throws SQLException {
+ RuntimeDatabaseConfiguration result = mock(RuntimeDatabaseConfiguration.class);
+ when(result.openConnection(anyString())).thenReturn(connection);
+ return result;
+ }
+
+ protected Connection createConnectionWithMetadata(final List<Map<String, String>> tableRows, final List<Map<String, String>> viewRows, final Map<String, List<String>> columns,
+ final Map<String, List<String>> indexes, final List<Map<String, String>> sequenceRows) throws SQLException {
+ return createConnectionWithMetadata("PostgreSQL", tableRows, viewRows, columns, indexes, sequenceRows);
+ }
+
+ protected Connection createConnectionWithMetadata(final String databaseType, final List<Map<String, String>> tableRows, final List<Map<String, String>> viewRows,
+ final Map<String, List<String>> columns, final Map<String, List<String>> indexes,
+ final List<Map<String, String>> sequenceRows) throws SQLException {
+ Connection result = mock(Connection.class);
+ DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class);
+ Statement statement = mock(Statement.class);
+ ResultSet sequenceResultSet = mockMultiRowResultSet(sequenceRows);
+ ResultSet emptyQueryResultSet = mockMultiRowResultSet(List.of());
+ when(result.getMetaData()).thenReturn(databaseMetaData);
+ when(result.createStatement()).thenReturn(statement);
+ when(databaseMetaData.getDatabaseProductVersion()).thenReturn("");
+ when(databaseMetaData.getURL()).thenReturn(getMetadataJdbcUrl(databaseType));
+ when(statement.executeQuery(anyString())).thenReturn(emptyQueryResultSet);
+ when(databaseMetaData.getTables(isNull(), isNull(), eq("%"), any(String[].class))).thenAnswer(invocation -> {
+ String[] tableTypes = invocation.getArgument(3);
+ return mockMultiRowResultSet("TABLE".equals(tableTypes[0]) ? tableRows : viewRows);
+ });
+ when(databaseMetaData.getColumns(isNull(), nullable(String.class), anyString(), eq("%"))).thenAnswer(invocation -> {
+ String objectName = invocation.getArgument(2);
+ return mockResultSet("COLUMN_NAME", columns.getOrDefault(objectName, List.of()).toArray(new String[0]));
+ });
+ when(databaseMetaData.getIndexInfo(isNull(), nullable(String.class), anyString(), eq(false), eq(false))).thenAnswer(invocation -> {
+ String tableName = invocation.getArgument(2);
+ return mockResultSet("INDEX_NAME", indexes.getOrDefault(tableName, List.of()).toArray(new String[0]));
+ });
+ when(statement.executeQuery(getSequenceQuery(databaseType))).thenReturn(sequenceResultSet);
+ return result;
+ }
+
+ protected void mockEmptyScalarQueries(final Connection connection) throws SQLException {
+ Statement statement = mock(Statement.class);
+ ResultSet resultSet = mockMultiRowResultSet(List.of());
+ when(connection.createStatement()).thenReturn(statement);
+ when(statement.executeQuery(anyString())).thenReturn(resultSet);
+ }
+
+ protected String getSequenceQuery(final String databaseType) {
+ switch (databaseType) {
+ case "PostgreSQL":
+ case "openGauss":
+ return "SELECT sequence_schema AS SEQUENCE_SCHEMA, sequence_name AS SEQUENCE_NAME FROM information_schema.sequences";
+ case "SQLServer":
+ return "SELECT schemas.name AS SEQUENCE_SCHEMA, seq.name AS SEQUENCE_NAME FROM sys.sequences seq INNER JOIN sys.schemas schemas ON seq.schema_id = schemas.schema_id";
+ case "Oracle":
+ return "SELECT USER AS SEQUENCE_SCHEMA, sequence_name AS SEQUENCE_NAME FROM USER_SEQUENCES";
+ case "MariaDB":
+ return "SELECT TABLE_SCHEMA AS SEQUENCE_SCHEMA, TABLE_NAME AS SEQUENCE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'SEQUENCE'";
+ case "Firebird":
+ return "SELECT '' AS SEQUENCE_SCHEMA, TRIM(RDB$GENERATOR_NAME) AS SEQUENCE_NAME FROM RDB$GENERATORS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0";
+ default:
+ return "";
+ }
+ }
+
+ protected ResultSet mockResultSet(final String columnName, final String... values) throws SQLException {
+ ResultSet result = mock(ResultSet.class);
+ AtomicInteger nextIndex = new AtomicInteger();
+ AtomicInteger valueIndex = new AtomicInteger();
+ when(result.next()).thenAnswer(invocation -> nextIndex.getAndIncrement() < values.length);
+ when(result.getString(columnName)).thenAnswer(invocation -> values[valueIndex.getAndIncrement()]);
+ return result;
+ }
+
+ protected ResultSet mockSingleRowResultSet(final Map<String, String> values) throws SQLException {
+ ResultSet result = mock(ResultSet.class);
+ when(result.next()).thenReturn(true, false);
+ for (Entry<String, String> entry : values.entrySet()) {
+ when(result.getString(entry.getKey())).thenReturn(entry.getValue());
+ }
+ return result;
+ }
+
+ protected ResultSet mockMultiRowResultSet(final List<Map<String, String>> values) throws SQLException {
+ ResultSet result = mock(ResultSet.class);
+ AtomicInteger rowIndex = new AtomicInteger(-1);
+ when(result.next()).thenAnswer(invocation -> rowIndex.incrementAndGet() < values.size());
+ when(result.getString(anyString())).thenAnswer(invocation -> {
+ int currentRowIndex = rowIndex.get();
+ return 0 <= currentRowIndex && currentRowIndex < values.size() ? values.get(currentRowIndex).get(invocation.getArgument(0)) : null;
+ });
+ return result;
+ }
+
+ protected String getMetadataJdbcUrl(final String databaseType) {
+ return METADATA_JDBC_URLS.getOrDefault(databaseType, METADATA_JDBC_URLS.get("PostgreSQL"));
+ }
+
+ protected int countMetadata(final MCPDatabaseMetadata databaseMetadata, final SupportedMCPMetadataObjectType objectType, final String objectName) {
+ int result = 0;
+ for (MCPSchemaMetadata each : databaseMetadata.getSchemas()) {
+ result += countSchemaMetadata(each, objectType, objectName);
+ }
+ return result;
+ }
+
+ private int countSchemaMetadata(final MCPSchemaMetadata schemaMetadata, final SupportedMCPMetadataObjectType objectType, final String objectName) {
+ int result = SupportedMCPMetadataObjectType.SCHEMA == objectType && objectName.equals(schemaMetadata.getSchema()) ? 1 : 0;
+ result += countTableMetadata(schemaMetadata.getTables(), objectType, objectName);
+ result += countViewMetadata(schemaMetadata.getViews(), objectType, objectName);
+ result += countSequenceMetadata(schemaMetadata.getSequences(), objectType, objectName);
+ return result;
+ }
+
+ private int countTableMetadata(final Collection<MCPTableMetadata> tables, final SupportedMCPMetadataObjectType objectType, final String objectName) {
+ int result = 0;
+ for (MCPTableMetadata each : tables) {
+ if (SupportedMCPMetadataObjectType.TABLE == objectType && objectName.equals(each.getTable())) {
+ result++;
+ }
+ result += countColumnMetadata(each.getColumns(), objectType, objectName);
+ result += countIndexMetadata(each.getIndexes(), objectType, objectName);
+ }
+ return result;
+ }
+
+ private int countViewMetadata(final Collection<MCPViewMetadata> views, final SupportedMCPMetadataObjectType objectType, final String objectName) {
+ int result = 0;
+ for (MCPViewMetadata each : views) {
+ if (SupportedMCPMetadataObjectType.VIEW == objectType && objectName.equals(each.getView())) {
+ result++;
+ }
+ result += countColumnMetadata(each.getColumns(), objectType, objectName);
+ }
+ return result;
+ }
+
+ private int countColumnMetadata(final Collection<MCPColumnMetadata> columns, final SupportedMCPMetadataObjectType objectType, final String objectName) {
+ int result = 0;
+ for (MCPColumnMetadata each : columns) {
+ if (SupportedMCPMetadataObjectType.COLUMN == objectType && objectName.equals(each.getColumn())) {
+ result++;
+ }
+ }
+ return result;
+ }
+
+ private int countIndexMetadata(final Collection<MCPIndexMetadata> indexes, final SupportedMCPMetadataObjectType objectType, final String objectName) {
+ int result = 0;
+ for (MCPIndexMetadata each : indexes) {
+ if (SupportedMCPMetadataObjectType.INDEX == objectType && objectName.equals(each.getIndex())) {
+ result++;
+ }
+ }
+ return result;
+ }
+
+ private int countSequenceMetadata(final Collection<MCPSequenceMetadata> sequences, final SupportedMCPMetadataObjectType objectType, final String objectName) {
+ int result = 0;
+ for (MCPSequenceMetadata each : sequences) {
+ if (SupportedMCPMetadataObjectType.SEQUENCE == objectType && objectName.equals(each.getSequence())) {
+ result++;
+ }
+ }
+ return result;
+ }
+
+ protected boolean containsMetadata(final MCPDatabaseMetadata databaseMetadata, final SupportedMCPMetadataObjectType objectType, final String objectName) {
+ return 0 < countMetadata(databaseMetadata, objectType, objectName);
+ }
+
+ @RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+ protected static final class LoadedMetadataCatalog {
+
+ private final Map<String, MCPDatabaseMetadata> databaseMetadataMap;
+
+ protected Optional<MCPDatabaseMetadata> findMetadata(final String databaseName) {
+ return Optional.ofNullable(databaseMetadataMap.get(databaseName));
+ }
+
+ protected Map<String, MCPDatabaseMetadata> getDatabaseMetadataMap() {
+ return databaseMetadataMap;
+ }
+ }
+
+ protected static final class MockDriverRegistration implements AutoCloseable {
+
+ private final Driver driver;
+
+ private MockDriverRegistration(final Driver driver) throws SQLException {
+ this.driver = driver;
+ DriverManager.registerDriver(driver);
+ }
+
+ protected static MockDriverRegistration register(final Driver driver) throws SQLException {
+ return new MockDriverRegistration(driver);
+ }
+
+ @Override
+ public void close() throws SQLException {
+ DriverManager.deregisterDriver(driver);
+ }
+ }
+
+ protected static final class MockDriver implements Driver {
+
+ private final String jdbcUrl;
+
+ private final Connection connection;
+
+ protected MockDriver(final String jdbcUrl, final Connection connection) {
+ this.jdbcUrl = jdbcUrl;
+ this.connection = connection;
+ }
+
+ @Override
+ public Connection connect(final String url, final Properties info) {
+ return acceptsURL(url) ? connection : null;
+ }
+
+ @Override
+ public boolean acceptsURL(final String url) {
+ return jdbcUrl.equals(url);
+ }
+
+ @Override
+ public DriverPropertyInfo[] getPropertyInfo(final String url, final Properties info) {
+ return new DriverPropertyInfo[0];
+ }
+
+ @Override
+ public int getMajorVersion() {
+ return 1;
+ }
+
+ @Override
+ public int getMinorVersion() {
+ return 0;
+ }
+
+ @Override
+ public boolean jdbcCompliant() {
+ return false;
+ }
+
+ @Override
+ public Logger getParentLogger() throws SQLFeatureNotSupportedException {
+ throw new SQLFeatureNotSupportedException("Mock driver does not expose a parent logger.");
+ }
+ }
+}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcMetadataLoaderFailureTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcMetadataLoaderFailureTest.java
new file mode 100644
index 00000000000..1290a2e096a
--- /dev/null
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcMetadataLoaderFailureTest.java
@@ -0,0 +1,43 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.mcp.support.database.metadata.jdbc;
+
+import org.junit.jupiter.api.Test;
+
+import java.sql.SQLException;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class MCPJdbcMetadataLoaderFailureTest {
+
+ @Test
+ void assertLoadWithFailedConnection() throws SQLException {
+ RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = mock(RuntimeDatabaseConfiguration.class);
+ SQLException expected = new SQLException("connection failed");
+ when(runtimeDatabaseConfiguration.openConnection("logic_db")).thenThrow(expected);
+ MCPJdbcMetadataLoader metadataLoader = new MCPJdbcMetadataLoader();
+ RuntimeDatabaseConnectionException actual = assertThrows(RuntimeDatabaseConnectionException.class,
+ () -> metadataLoader.load("logic_db", runtimeDatabaseConfiguration, new RuntimeDatabaseProfile("logic_db", "PostgreSQL", "")));
+ assertThat(actual.getMessage(), is("Runtime database `logic_db` connection failed: connection_failed."));
+ assertThat(actual.getCause(), is(expected));
+ }
+}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcMetadataLoaderSchemaFilteringTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcMetadataLoaderSchemaFilteringTest.java
new file mode 100644
index 00000000000..e457fe88eee
--- /dev/null
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcMetadataLoaderSchemaFilteringTest.java
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.mcp.support.database.metadata.jdbc;
+
+import org.apache.shardingsphere.mcp.support.database.capability.SupportedMCPMetadataObjectType;
+import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPDatabaseMetadata;
+import org.junit.jupiter.api.Test;
+
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.Driver;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class MCPJdbcMetadataLoaderSchemaFilteringTest extends AbstractMCPJdbcMetadataLoaderTest {
+
+ @Test
+ void assertLoadWithoutSchemaObjects() throws SQLException {
+ Driver mockDriver = new MockDriver("jdbc:mock:no-schema", createConnectionWithoutSchema("MySQL"));
+ try (MockDriverRegistration registration = MockDriverRegistration.register(mockDriver)) {
+ LoadedMetadataCatalog actual = load(Map.of("logic_db", new RuntimeDatabaseConfiguration("jdbc:mock:no-schema", "", "", MockDriver.class.getName())));
+ MCPDatabaseMetadata databaseMetadata = actual.findMetadata("logic_db").orElseThrow();
+ assertThat(databaseMetadata.getSchemas().size(), is(1));
+ assertThat(databaseMetadata.getSchemas().get(0).getSchema(), is("logic_db"));
+ assertTrue(containsMetadata(databaseMetadata, SupportedMCPMetadataObjectType.TABLE, "orders"));
+ assertTrue(containsMetadata(databaseMetadata, SupportedMCPMetadataObjectType.COLUMN, "order_id"));
+ assertThat(databaseMetadata.getDatabaseVersion(), is(""));
+ }
+ }
+
+ @Test
+ void assertLoadWithoutSystemCatalogForDatabaseAsSchema() throws SQLException {
+ Connection connection = mock(Connection.class);
+ DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class);
+ when(connection.getMetaData()).thenReturn(databaseMetaData);
+ when(databaseMetaData.getDatabaseProductVersion()).thenReturn("");
+ when(databaseMetaData.getURL()).thenReturn(getMetadataJdbcUrl("MySQL"));
+ mockEmptyScalarQueries(connection);
+ when(databaseMetaData.getTables(isNull(), isNull(), eq("%"), any(String[].class))).thenAnswer(invocation -> {
+ String[] tableTypes = invocation.getArgument(3);
+ return mockMultiRowResultSet("TABLE".equals(tableTypes[0])
+ ? List.of(
+ Map.of("TABLE_CAT", "information_schema", "TABLE_SCHEM", "", "TABLE_NAME", "GLOBAL_STATUS"),
+ Map.of("TABLE_CAT", "logic_db", "TABLE_SCHEM", "", "TABLE_NAME", "orders"))
+ : List.of());
+ });
+ ResultSet orderColumns = mockResultSet("COLUMN_NAME", "order_id");
+ when(databaseMetaData.getColumns(eq("logic_db"), isNull(), eq("orders"), eq("%"))).thenReturn(orderColumns);
+ ResultSet orderIndexes = mockResultSet("INDEX_NAME", "idx_orders_status");
+ when(databaseMetaData.getIndexInfo(eq("logic_db"), isNull(), eq("orders"), eq(false), eq(false))).thenReturn(orderIndexes);
+ when(databaseMetaData.getColumns(eq("information_schema"), isNull(), eq("GLOBAL_STATUS"), eq("%")))
+ .thenThrow(new SQLException("system catalog should be skipped"));
+ RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
+ MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
+ assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.TABLE, "orders"));
+ assertFalse(containsMetadata(actual, SupportedMCPMetadataObjectType.TABLE, "GLOBAL_STATUS"));
+ assertThat(actual.getSchemas().get(0).getSchema(), is("logic_db"));
+ }
+
+ @Test
+ void assertLoadWithCatalogBackedMetadataUsingLogicalSchemaName() throws SQLException {
+ Connection connection = mock(Connection.class);
+ DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class);
+ when(connection.getMetaData()).thenReturn(databaseMetaData);
+ when(databaseMetaData.getDatabaseProductVersion()).thenReturn("");
+ when(databaseMetaData.getURL()).thenReturn(getMetadataJdbcUrl("MySQL"));
+ mockEmptyScalarQueries(connection);
+ when(databaseMetaData.getTables(isNull(), isNull(), eq("%"), any(String[].class))).thenAnswer(invocation -> {
+ String[] tableTypes = invocation.getArgument(3);
+ return mockMultiRowResultSet("TABLE".equals(tableTypes[0])
+ ? List.of(Map.of("TABLE_CAT", "orders", "TABLE_SCHEM", "", "TABLE_NAME", "orders"))
+ : List.of());
+ });
+ ResultSet orderColumns = mockResultSet("COLUMN_NAME", "order_id");
+ when(databaseMetaData.getColumns(eq("orders"), isNull(), eq("orders"), eq("%"))).thenReturn(orderColumns);
+ ResultSet orderIndexes = mockResultSet("INDEX_NAME", "idx_orders_status");
+ when(databaseMetaData.getIndexInfo(eq("orders"), isNull(), eq("orders"), eq(false), eq(false))).thenReturn(orderIndexes);
+ RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
+ MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
+ assertThat(actual.getSchemas().get(0).getSchema(), is("logic_db"));
+ assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.TABLE, "orders"));
+ assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.INDEX, "idx_orders_status"));
+ }
+
+ @Test
+ void assertLoadWithoutEmptyTableName() throws SQLException {
+ Connection connection = createConnectionWithMetadata(
+ List.of(Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", ""), Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "orders")),
+ List.of(), Map.of("orders", List.of("order_id")), Map.of(), List.of());
+ RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
+ MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
+ assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.TABLE, "orders"));
+ assertFalse(containsMetadata(actual, SupportedMCPMetadataObjectType.TABLE, ""));
+ }
+
+ @Test
+ void assertLoadWithoutEmptyViewName() throws SQLException {
+ Connection connection = createConnectionWithMetadata(
+ List.of(), List.of(Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", ""), Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "active_orders")),
+ Map.of("active_orders", List.of("order_id")), Map.of(), List.of());
+ RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
+ MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
+ assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.VIEW, "active_orders"));
+ assertFalse(containsMetadata(actual, SupportedMCPMetadataObjectType.VIEW, ""));
+ }
+
+ @Test
+ void assertLoadWithoutEmptyColumnName() throws SQLException {
+ Connection connection = createConnectionWithMetadata(
+ List.of(Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "orders")), List.of(), Map.of("orders", List.of("", "order_id")), Map.of(), List.of());
+ RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
+ MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
+ assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.COLUMN, "order_id"));
+ assertFalse(containsMetadata(actual, SupportedMCPMetadataObjectType.COLUMN, ""));
+ }
+
+ @Test
+ void assertLoadWithoutEmptyOrDuplicateIndexName() throws SQLException {
+ Connection connection = createConnectionWithMetadata(
+ List.of(Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "orders")), List.of(), Map.of("orders", List.of("order_id")),
+ Map.of("orders", List.of("", "idx_orders_status", "idx_orders_status")), List.of());
+ RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
+ MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
+ assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.INDEX, "idx_orders_status"));
+ assertThat(countMetadata(actual, SupportedMCPMetadataObjectType.INDEX, "idx_orders_status"), is(1));
+ }
+
+ @Test
+ void assertLoadWithDuplicateTableRows() throws SQLException {
+ Connection connection = createConnectionWithMetadata(
+ List.of(Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "orders"), Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "orders")),
+ List.of(), Map.of("orders", List.of("order_id")), Map.of(), List.of());
+ RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
+ MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
+ assertThat(countMetadata(actual, SupportedMCPMetadataObjectType.TABLE, "orders"), is(1));
+ }
+
+ @Test
+ void assertLoadWithDuplicateViewRows() throws SQLException {
+ Connection connection = createConnectionWithMetadata(
+ List.of(), List.of(Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "active_orders"), Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "active_orders")),
+ Map.of("active_orders", List.of("order_id")), Map.of(), List.of());
+ RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
+ MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
+ assertThat(countMetadata(actual, SupportedMCPMetadataObjectType.VIEW, "active_orders"), is(1));
+ }
+}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcMetadataLoaderSequenceTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcMetadataLoaderSequenceTest.java
new file mode 100644
index 00000000000..fc9782c95c4
--- /dev/null
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcMetadataLoaderSequenceTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.mcp.support.database.metadata.jdbc;
+
+import org.apache.shardingsphere.mcp.support.database.capability.SupportedMCPMetadataObjectType;
+import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPDatabaseMetadata;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.sql.Connection;
+import java.sql.Driver;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MCPJdbcMetadataLoaderSequenceTest extends AbstractMCPJdbcMetadataLoaderTest {
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("loadWithoutSequenceQueryArguments")
+ void assertLoadWithoutSequenceQuery(final String name, final String databaseType) throws SQLException {
+ RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(createConnectionWithoutSchema(databaseType));
+ MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
+ assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.TABLE, "orders"));
+ assertFalse(containsMetadata(actual, SupportedMCPMetadataObjectType.SEQUENCE, "order_seq"));
+ }
+
+ @Test
+ void assertLoadWithoutSystemSchemaSequence() throws SQLException {
+ Connection connection = createConnectionWithMetadata("PostgreSQL", List.of(), List.of(), Map.of(), Map.of(),
+ List.of(Map.of("SEQUENCE_SCHEMA", "PG_CATALOG", "SEQUENCE_NAME", "order_seq")));
+ RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
+ MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
+ assertFalse(containsMetadata(actual, SupportedMCPMetadataObjectType.SEQUENCE, "order_seq"));
+ }
+
+ @Test
+ void assertLoadWithoutEmptySequenceName() throws SQLException {
+ Connection connection = createConnectionWithMetadata("PostgreSQL", List.of(), List.of(), Map.of(), Map.of(),
+ List.of(Map.of("SEQUENCE_SCHEMA", "public", "SEQUENCE_NAME", "")));
+ RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
+ MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
+ assertFalse(containsMetadata(actual, SupportedMCPMetadataObjectType.SEQUENCE, ""));
+ }
+
+ @Test
+ void assertLoadWithFailedSequenceMetadataQuery() throws SQLException {
+ Driver mockDriver = new MockDriver("jdbc:mock:failed-sequence-query", createConnectionWithFailedSequenceMetadataQuery());
+ try (MockDriverRegistration ignored = MockDriverRegistration.register(mockDriver)) {
+ RuntimeDatabaseConnectionException actual = assertThrows(RuntimeDatabaseConnectionException.class,
+ () -> load(Map.of("logic_db", new RuntimeDatabaseConfiguration("jdbc:mock:failed-sequence-query", "", "", MockDriver.class.getName()))));
+ assertThat(actual.getMessage(), is("Runtime database `logic_db` connection failed: connection_failed."));
+ assertThat(actual.getCause().getMessage(), is("sequence metadata query failed"));
+ }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("loadSequenceDialectArguments")
+ void assertLoadWithDialectSequenceMetadata(final String name, final String databaseType, final String sequenceSchema,
+ final String sequenceName, final String sequenceQuery) throws SQLException {
+ String jdbcUrl = "jdbc:mock:sequence:" + databaseType.toLowerCase(Locale.ENGLISH);
+ Driver mockDriver = new MockDriver(jdbcUrl, createConnectionWithSequenceMetadata(databaseType, sequenceSchema, sequenceName, sequenceQuery));
+ try (MockDriverRegistration ignored = MockDriverRegistration.register(mockDriver)) {
+ LoadedMetadataCatalog actual = load(Map.of("logic_db", new RuntimeDatabaseConfiguration(jdbcUrl, "", "", MockDriver.class.getName())));
+ assertTrue(containsMetadata(actual.findMetadata("logic_db").orElseThrow(), SupportedMCPMetadataObjectType.SEQUENCE, sequenceName));
+ }
+ }
+}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcMetadataLoaderTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcMetadataLoaderTest.java
index 86302879859..054b3152a3e 100644
--- a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcMetadataLoaderTest.java
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/database/metadata/jdbc/MCPJdbcMetadataLoaderTest.java
@@ -17,67 +17,20 @@
package org.apache.shardingsphere.mcp.support.database.metadata.jdbc;
-import lombok.AccessLevel;
-import lombok.RequiredArgsConstructor;
-import org.apache.shardingsphere.database.connector.core.type.DatabaseTypeFactory;
-import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPColumnMetadata;
-import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPDatabaseMetadata;
-import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPIndexMetadata;
-import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPSequenceMetadata;
-import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPSchemaMetadata;
-import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPTableMetadata;
-import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPViewMetadata;
import org.apache.shardingsphere.mcp.support.database.capability.SupportedMCPMetadataObjectType;
-import org.apache.shardingsphere.mcp.support.fixture.SupportDatabaseTypeFactoryMocker;
+import org.apache.shardingsphere.mcp.support.database.metadata.model.MCPDatabaseMetadata;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
-import org.mockito.MockedStatic;
-import java.sql.Connection;
-import java.sql.DatabaseMetaData;
-import java.sql.Driver;
-import java.sql.DriverManager;
-import java.sql.DriverPropertyInfo;
-import java.sql.ResultSet;
import java.sql.SQLException;
-import java.sql.SQLFeatureNotSupportedException;
-import java.sql.Statement;
-import java.util.LinkedHashMap;
-import java.util.Locale;
-import java.util.List;
import java.util.Map;
-import java.util.Map.Entry;
-import java.util.Optional;
-import java.util.Properties;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.logging.Logger;
-import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.ArgumentMatchers.isNull;
-import static org.mockito.ArgumentMatchers.nullable;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
-class MCPJdbcMetadataLoaderTest {
-
- private static final Map<String, String> METADATA_JDBC_URLS = Map.of(
- "MySQL", "jdbc:mysql://metadata-loader/test",
- "PostgreSQL", "jdbc:postgresql://metadata-loader/test",
- "openGauss", "jdbc:opengauss://metadata-loader/test",
- "SQLServer", "jdbc:sqlserver://metadata-loader",
- "Oracle", "jdbc:oracle:thin:@metadata-loader",
- "MariaDB", "jdbc:mariadb://metadata-loader/test",
- "Firebird", "jdbc:firebirdsql://metadata-loader/test");
+class MCPJdbcMetadataLoaderTest extends AbstractMCPJdbcMetadataLoaderTest {
@Test
void assertLoad() throws SQLException {
@@ -103,74 +56,6 @@ class MCPJdbcMetadataLoaderTest {
assertTrue(actual.findMetadata("analytics_db").isPresent());
}
- @Test
- void assertLoadWithoutSchemaObjects() throws SQLException {
- Driver mockDriver = new MockDriver("jdbc:mock:no-schema", createConnectionWithoutSchema("MySQL"));
- try (MockDriverRegistration registration = MockDriverRegistration.register(mockDriver)) {
- LoadedMetadataCatalog actual = load(Map.of("logic_db", new RuntimeDatabaseConfiguration("jdbc:mock:no-schema", "", "", MockDriver.class.getName())));
- MCPDatabaseMetadata databaseMetadata = actual.findMetadata("logic_db").orElseThrow();
- assertThat(databaseMetadata.getSchemas().size(), is(1));
- assertThat(databaseMetadata.getSchemas().get(0).getSchema(), is("logic_db"));
- assertTrue(containsMetadata(databaseMetadata, SupportedMCPMetadataObjectType.TABLE, "orders"));
- assertTrue(containsMetadata(databaseMetadata, SupportedMCPMetadataObjectType.COLUMN, "order_id"));
- assertThat(databaseMetadata.getDatabaseVersion(), is(""));
- }
- }
-
- @Test
- void assertLoadWithoutSystemCatalogForDatabaseAsSchema() throws SQLException {
- Connection connection = mock(Connection.class);
- DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class);
- when(connection.getMetaData()).thenReturn(databaseMetaData);
- when(databaseMetaData.getDatabaseProductVersion()).thenReturn("");
- when(databaseMetaData.getURL()).thenReturn(getMetadataJdbcUrl("MySQL"));
- mockEmptyScalarQueries(connection);
- when(databaseMetaData.getTables(isNull(), isNull(), eq("%"), any(String[].class))).thenAnswer(invocation -> {
- String[] tableTypes = invocation.getArgument(3);
- return mockMultiRowResultSet("TABLE".equals(tableTypes[0])
- ? List.of(
- Map.of("TABLE_CAT", "information_schema", "TABLE_SCHEM", "", "TABLE_NAME", "GLOBAL_STATUS"),
- Map.of("TABLE_CAT", "logic_db", "TABLE_SCHEM", "", "TABLE_NAME", "orders"))
- : List.of());
- });
- ResultSet orderColumns = mockResultSet("COLUMN_NAME", "order_id");
- when(databaseMetaData.getColumns(eq("logic_db"), isNull(), eq("orders"), eq("%"))).thenReturn(orderColumns);
- ResultSet orderIndexes = mockResultSet("INDEX_NAME", "idx_orders_status");
- when(databaseMetaData.getIndexInfo(eq("logic_db"), isNull(), eq("orders"), eq(false), eq(false))).thenReturn(orderIndexes);
- when(databaseMetaData.getColumns(eq("information_schema"), isNull(), eq("GLOBAL_STATUS"), eq("%")))
- .thenThrow(new SQLException("system catalog should be skipped"));
- RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
- MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
- assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.TABLE, "orders"));
- assertFalse(containsMetadata(actual, SupportedMCPMetadataObjectType.TABLE, "GLOBAL_STATUS"));
- assertThat(actual.getSchemas().get(0).getSchema(), is("logic_db"));
- }
-
- @Test
- void assertLoadWithCatalogBackedMetadataUsingLogicalSchemaName() throws SQLException {
- Connection connection = mock(Connection.class);
- DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class);
- when(connection.getMetaData()).thenReturn(databaseMetaData);
- when(databaseMetaData.getDatabaseProductVersion()).thenReturn("");
- when(databaseMetaData.getURL()).thenReturn(getMetadataJdbcUrl("MySQL"));
- mockEmptyScalarQueries(connection);
- when(databaseMetaData.getTables(isNull(), isNull(), eq("%"), any(String[].class))).thenAnswer(invocation -> {
- String[] tableTypes = invocation.getArgument(3);
- return mockMultiRowResultSet("TABLE".equals(tableTypes[0])
- ? List.of(Map.of("TABLE_CAT", "orders", "TABLE_SCHEM", "", "TABLE_NAME", "orders"))
- : List.of());
- });
- ResultSet orderColumns = mockResultSet("COLUMN_NAME", "order_id");
- when(databaseMetaData.getColumns(eq("orders"), isNull(), eq("orders"), eq("%"))).thenReturn(orderColumns);
- ResultSet orderIndexes = mockResultSet("INDEX_NAME", "idx_orders_status");
- when(databaseMetaData.getIndexInfo(eq("orders"), isNull(), eq("orders"), eq(false), eq(false))).thenReturn(orderIndexes);
- RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
- MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
- assertThat(actual.getSchemas().get(0).getSchema(), is("logic_db"));
- assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.TABLE, "orders"));
- assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.INDEX, "idx_orders_status"));
- }
-
@Test
void assertLoadWithSchemaRegisteredOnce() throws SQLException {
LoadedMetadataCatalog actual = load(Map.of("logic_db", createMockRuntimeDatabaseConfiguration(createStandardPostgreSQLMetadataConnection())));
@@ -179,505 +64,4 @@ class MCPJdbcMetadataLoaderTest {
assertTrue(containsMetadata(databaseMetadata, SupportedMCPMetadataObjectType.VIEW, "active_orders"));
assertThat(databaseMetadata.getSchemas().size(), is(1));
}
-
- @ParameterizedTest(name = "{0}")
- @MethodSource("loadWithoutSequenceQueryArguments")
- void assertLoadWithoutSequenceQuery(final String name, final String databaseType) throws SQLException {
- RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(createConnectionWithoutSchema(databaseType));
- MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
- assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.TABLE, "orders"));
- assertFalse(containsMetadata(actual, SupportedMCPMetadataObjectType.SEQUENCE, "order_seq"));
- }
-
- @Test
- void assertLoadWithFailedConnection() throws SQLException {
- RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = mock(RuntimeDatabaseConfiguration.class);
- SQLException expected = new SQLException("connection failed");
- when(runtimeDatabaseConfiguration.openConnection("logic_db")).thenThrow(expected);
- MCPJdbcMetadataLoader metadataLoader = new MCPJdbcMetadataLoader();
- RuntimeDatabaseConnectionException actual = assertThrows(RuntimeDatabaseConnectionException.class,
- () -> metadataLoader.load("logic_db", runtimeDatabaseConfiguration, new RuntimeDatabaseProfile("logic_db", "PostgreSQL", "")));
- assertThat(actual.getMessage(), is("Runtime database `logic_db` connection failed: connection_failed."));
- assertThat(actual.getCause(), is(expected));
- }
-
- @Test
- void assertLoadWithoutEmptyTableName() throws SQLException {
- Connection connection = createConnectionWithMetadata(
- List.of(Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", ""), Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "orders")),
- List.of(),
- Map.of("orders", List.of("order_id")),
- Map.of(),
- List.of());
- RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
- MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
- assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.TABLE, "orders"));
- assertFalse(containsMetadata(actual, SupportedMCPMetadataObjectType.TABLE, ""));
- }
-
- @Test
- void assertLoadWithoutEmptyViewName() throws SQLException {
- Connection connection = createConnectionWithMetadata(
- List.of(),
- List.of(Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", ""), Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "active_orders")),
- Map.of("active_orders", List.of("order_id")),
- Map.of(),
- List.of());
- RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
- MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
- assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.VIEW, "active_orders"));
- assertFalse(containsMetadata(actual, SupportedMCPMetadataObjectType.VIEW, ""));
- }
-
- @Test
- void assertLoadWithoutEmptyColumnName() throws SQLException {
- Connection connection = createConnectionWithMetadata(
- List.of(Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "orders")),
- List.of(),
- Map.of("orders", List.of("", "order_id")),
- Map.of(),
- List.of());
- RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
- MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
- assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.COLUMN, "order_id"));
- assertFalse(containsMetadata(actual, SupportedMCPMetadataObjectType.COLUMN, ""));
- }
-
- @Test
- void assertLoadWithoutEmptyOrDuplicateIndexName() throws SQLException {
- Connection connection = createConnectionWithMetadata(
- List.of(Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "orders")),
- List.of(),
- Map.of("orders", List.of("order_id")),
- Map.of("orders", List.of("", "idx_orders_status", "idx_orders_status")),
- List.of());
- RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
- MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
- assertTrue(containsMetadata(actual, SupportedMCPMetadataObjectType.INDEX, "idx_orders_status"));
- assertThat(countMetadata(actual, SupportedMCPMetadataObjectType.INDEX, "idx_orders_status"), is(1));
- }
-
- @Test
- void assertLoadWithoutSystemSchemaSequence() throws SQLException {
- Connection connection = createConnectionWithMetadata("PostgreSQL",
- List.of(),
- List.of(),
- Map.of(),
- Map.of(),
- List.of(Map.of("SEQUENCE_SCHEMA", "PG_CATALOG", "SEQUENCE_NAME", "order_seq")));
- RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
- MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
- assertFalse(containsMetadata(actual, SupportedMCPMetadataObjectType.SEQUENCE, "order_seq"));
- }
-
- @Test
- void assertLoadWithoutEmptySequenceName() throws SQLException {
- Connection connection = createConnectionWithMetadata("PostgreSQL",
- List.of(),
- List.of(),
- Map.of(),
- Map.of(),
- List.of(Map.of("SEQUENCE_SCHEMA", "public", "SEQUENCE_NAME", "")));
- RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
- MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
- assertFalse(containsMetadata(actual, SupportedMCPMetadataObjectType.SEQUENCE, ""));
- }
-
- @Test
- void assertLoadWithDuplicateTableRows() throws SQLException {
- Connection connection = createConnectionWithMetadata(
- List.of(Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "orders"), Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "orders")),
- List.of(),
- Map.of("orders", List.of("order_id")),
- Map.of(),
- List.of());
- RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
- MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
- assertThat(countMetadata(actual, SupportedMCPMetadataObjectType.TABLE, "orders"), is(1));
- }
-
- @Test
- void assertLoadWithDuplicateViewRows() throws SQLException {
- Connection connection = createConnectionWithMetadata(
- List.of(),
- List.of(Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "active_orders"), Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "active_orders")),
- Map.of("active_orders", List.of("order_id")),
- Map.of(),
- List.of());
- RuntimeDatabaseConfiguration runtimeDatabaseConfiguration = createMockRuntimeDatabaseConfiguration(connection);
- MCPDatabaseMetadata actual = load(Map.of("logic_db", runtimeDatabaseConfiguration)).findMetadata("logic_db").orElseThrow();
- assertThat(countMetadata(actual, SupportedMCPMetadataObjectType.VIEW, "active_orders"), is(1));
- }
-
- @Test
- void assertLoadWithFailedSequenceMetadataQuery() throws SQLException {
- Driver mockDriver = new MockDriver("jdbc:mock:failed-sequence-query", createConnectionWithFailedSequenceMetadataQuery());
- try (MockDriverRegistration registration = MockDriverRegistration.register(mockDriver)) {
- RuntimeDatabaseConnectionException actual = assertThrows(RuntimeDatabaseConnectionException.class,
- () -> load(Map.of("logic_db", new RuntimeDatabaseConfiguration("jdbc:mock:failed-sequence-query", "", "", MockDriver.class.getName()))));
- assertThat(actual.getMessage(), is("Runtime database `logic_db` connection failed: connection_failed."));
- assertThat(actual.getCause().getMessage(), is("sequence metadata query failed"));
- }
- }
-
- @ParameterizedTest(name = "{0}")
- @MethodSource("loadSequenceDialectArguments")
- void assertLoadWithDialectSequenceMetadata(final String name, final String databaseType, final String sequenceSchema,
- final String sequenceName, final String sequenceQuery) throws SQLException {
- String jdbcUrl = "jdbc:mock:sequence:" + databaseType.toLowerCase(Locale.ENGLISH);
- Driver mockDriver = new MockDriver(jdbcUrl, createConnectionWithSequenceMetadata(databaseType, sequenceSchema, sequenceName, sequenceQuery));
- try (MockDriverRegistration registration = MockDriverRegistration.register(mockDriver)) {
- LoadedMetadataCatalog actual = load(Map.of("logic_db", new RuntimeDatabaseConfiguration(jdbcUrl, "", "", MockDriver.class.getName())));
- assertTrue(containsMetadata(actual.findMetadata("logic_db").orElseThrow(), SupportedMCPMetadataObjectType.SEQUENCE, sequenceName));
- }
- }
-
- private LoadedMetadataCatalog load(final Map<String, RuntimeDatabaseConfiguration> runtimeDatabases) {
- try (MockedStatic<DatabaseTypeFactory> ignored = SupportDatabaseTypeFactoryMocker.mockByConnectionMetadata()) {
- MCPJdbcDatabaseProfileLoader databaseProfileLoader = new MCPJdbcDatabaseProfileLoader();
- MCPJdbcMetadataLoader metadataLoader = new MCPJdbcMetadataLoader();
- Map<String, RuntimeDatabaseProfile> databaseProfiles = databaseProfileLoader.load(runtimeDatabases);
- Map<String, MCPDatabaseMetadata> result = new LinkedHashMap<>(runtimeDatabases.size(), 1F);
- for (Entry<String, RuntimeDatabaseConfiguration> entry : runtimeDatabases.entrySet()) {
- result.put(entry.getKey(), metadataLoader.load(entry.getKey(), entry.getValue(), databaseProfiles.get(entry.getKey())));
- }
- return new LoadedMetadataCatalog(result);
- }
- }
-
- private static Stream<Arguments> loadTypedMetadataArguments() {
- return Stream.of(
- Arguments.of("table orders", SupportedMCPMetadataObjectType.TABLE, "orders"),
- Arguments.of("table order_items", SupportedMCPMetadataObjectType.TABLE, "order_items"),
- Arguments.of("view active_orders", SupportedMCPMetadataObjectType.VIEW, "active_orders"),
- Arguments.of("column status", SupportedMCPMetadataObjectType.COLUMN, "status"),
- Arguments.of("index idx_orders_status", SupportedMCPMetadataObjectType.INDEX, "idx_orders_status"),
- Arguments.of("sequence order_seq", SupportedMCPMetadataObjectType.SEQUENCE, "order_seq"));
- }
-
- private static Stream<Arguments> loadSequenceDialectArguments() {
- return Stream.of(
- Arguments.of("postgresql", "PostgreSQL", "public", "order_seq",
- "SELECT sequence_schema AS SEQUENCE_SCHEMA, sequence_name AS SEQUENCE_NAME FROM information_schema.sequences"),
- Arguments.of("open gauss", "openGauss", "public", "order_seq",
- "SELECT sequence_schema AS SEQUENCE_SCHEMA, sequence_name AS SEQUENCE_NAME FROM information_schema.sequences"),
- Arguments.of("sql server", "SQLServer", "dbo", "order_seq",
- "SELECT schemas.name AS SEQUENCE_SCHEMA, seq.name AS SEQUENCE_NAME FROM sys.sequences seq INNER JOIN sys.schemas schemas ON seq.schema_id = schemas.schema_id"),
- Arguments.of("oracle", "Oracle", "APP", "ORDER_SEQ",
- "SELECT USER AS SEQUENCE_SCHEMA, sequence_name AS SEQUENCE_NAME FROM USER_SEQUENCES"),
- Arguments.of("mariadb", "MariaDB", "logic_db", "order_seq",
- "SELECT TABLE_SCHEMA AS SEQUENCE_SCHEMA, TABLE_NAME AS SEQUENCE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'SEQUENCE'"),
- Arguments.of("firebird", "Firebird", "", "ORDER_SEQ",
- "SELECT '' AS SEQUENCE_SCHEMA, TRIM(RDB$GENERATOR_NAME) AS SEQUENCE_NAME FROM RDB$GENERATORS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0"));
- }
-
- private static Stream<Arguments> loadWithoutSequenceQueryArguments() {
- return Stream.of(
- Arguments.of("unsupported database type", "MySQL"));
- }
-
- private Connection createConnectionWithoutSchema(final String databaseType) throws SQLException {
- return createConnectionWithoutSchemaByURL(getMetadataJdbcUrl(databaseType));
- }
-
- private Connection createConnectionWithoutSchemaByURL(final String jdbcUrl) throws SQLException {
- Connection result = mock(Connection.class);
- DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class);
- Statement statement = mock(Statement.class);
- ResultSet tableResultSet = mockResultSet("TABLE_NAME", "orders");
- ResultSet viewResultSet = mockResultSet("TABLE_NAME");
- ResultSet columnResultSet = mockResultSet("COLUMN_NAME", "order_id");
- ResultSet indexResultSet = mockResultSet("INDEX_NAME");
- ResultSet emptyQueryResultSet = mockMultiRowResultSet(List.of());
- when(result.getMetaData()).thenReturn(databaseMetaData);
- when(result.createStatement()).thenReturn(statement);
- when(databaseMetaData.getURL()).thenReturn(jdbcUrl);
- when(statement.executeQuery(anyString())).thenReturn(emptyQueryResultSet);
- when(databaseMetaData.getTables(isNull(), isNull(), eq("%"), any(String[].class))).thenAnswer(invocation -> {
- String[] tableTypes = invocation.getArgument(3);
- return "TABLE".equals(tableTypes[0]) ? tableResultSet : viewResultSet;
- });
- when(databaseMetaData.getColumns(isNull(), isNull(), eq("orders"), eq("%"))).thenReturn(columnResultSet);
- when(databaseMetaData.getIndexInfo(isNull(), isNull(), eq("orders"), eq(false), eq(false))).thenReturn(indexResultSet);
- return result;
- }
-
- private Connection createConnectionWithSequenceMetadata(final String databaseType, final String sequenceSchema,
- final String sequenceName, final String sequenceQuery) throws SQLException {
- Connection result = mock(Connection.class);
- DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class);
- Statement statement = mock(Statement.class);
- ResultSet tableResultSet = mockResultSet("TABLE_NAME");
- ResultSet viewResultSet = mockResultSet("TABLE_NAME");
- ResultSet sequenceResultSet = mockSingleRowResultSet(Map.of("SEQUENCE_SCHEMA", sequenceSchema, "SEQUENCE_NAME", sequenceName));
- ResultSet emptyQueryResultSet = mockMultiRowResultSet(List.of());
- when(result.getMetaData()).thenReturn(databaseMetaData);
- when(result.createStatement()).thenReturn(statement);
- when(databaseMetaData.getDatabaseProductVersion()).thenReturn("");
- when(databaseMetaData.getURL()).thenReturn(getMetadataJdbcUrl(databaseType));
- when(statement.executeQuery(anyString())).thenReturn(emptyQueryResultSet);
- when(databaseMetaData.getTables(isNull(), isNull(), eq("%"), any(String[].class))).thenAnswer(invocation -> {
- String[] tableTypes = invocation.getArgument(3);
- return "TABLE".equals(tableTypes[0]) ? tableResultSet : viewResultSet;
- });
- when(statement.executeQuery(sequenceQuery)).thenReturn(sequenceResultSet);
- return result;
- }
-
- private Connection createConnectionWithFailedSequenceMetadataQuery() throws SQLException {
- Connection result = mock(Connection.class);
- DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class);
- Statement statement = mock(Statement.class);
- ResultSet tableResultSet = mockResultSet("TABLE_NAME");
- ResultSet viewResultSet = mockResultSet("TABLE_NAME");
- when(result.getMetaData()).thenReturn(databaseMetaData);
- when(result.createStatement()).thenReturn(statement);
- when(databaseMetaData.getDatabaseProductVersion()).thenReturn("");
- when(databaseMetaData.getURL()).thenReturn(getMetadataJdbcUrl("PostgreSQL"));
- when(databaseMetaData.getTables(isNull(), isNull(), eq("%"), any(String[].class))).thenAnswer(invocation -> {
- String[] tableTypes = invocation.getArgument(3);
- return "TABLE".equals(tableTypes[0]) ? tableResultSet : viewResultSet;
- });
- when(statement.executeQuery("SELECT sequence_schema AS SEQUENCE_SCHEMA, sequence_name AS SEQUENCE_NAME FROM information_schema.sequences"))
- .thenThrow(new SQLException("sequence metadata query failed"));
- return result;
- }
-
- private Connection createStandardPostgreSQLMetadataConnection() throws SQLException {
- Connection result = createConnectionWithMetadata(
- List.of(
- Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "orders"),
- Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "order_items")),
- List.of(Map.of("TABLE_SCHEM", "PUBLIC", "TABLE_NAME", "active_orders")),
- Map.of(
- "orders", List.of("order_id", "status", "amount"),
- "order_items", List.of("item_id", "order_id", "sku"),
- "active_orders", List.of("order_id", "status")),
- Map.of(
- "orders", List.of("PRIMARY_KEY_C", "idx_orders_status"),
- "order_items", List.of("PRIMARY_KEY_C")),
- List.of(Map.of("SEQUENCE_SCHEMA", "PUBLIC", "SEQUENCE_NAME", "order_seq")));
- DatabaseMetaData databaseMetaData = result.getMetaData();
- when(databaseMetaData.getDatabaseProductVersion()).thenReturn("16.2");
- when(databaseMetaData.getURL()).thenReturn("jdbc:postgresql://metadata-loader/test");
- return result;
- }
-
- private RuntimeDatabaseConfiguration createMockRuntimeDatabaseConfiguration(final Connection connection) throws SQLException {
- RuntimeDatabaseConfiguration result = mock(RuntimeDatabaseConfiguration.class);
- when(result.openConnection(anyString())).thenReturn(connection);
- return result;
- }
-
- private Connection createConnectionWithMetadata(final List<Map<String, String>> tableRows, final List<Map<String, String>> viewRows, final Map<String, List<String>> columns,
- final Map<String, List<String>> indexes, final List<Map<String, String>> sequenceRows) throws SQLException {
- return createConnectionWithMetadata("PostgreSQL", tableRows, viewRows, columns, indexes, sequenceRows);
- }
-
- private Connection createConnectionWithMetadata(final String databaseType, final List<Map<String, String>> tableRows, final List<Map<String, String>> viewRows,
- final Map<String, List<String>> columns, final Map<String, List<String>> indexes,
- final List<Map<String, String>> sequenceRows) throws SQLException {
- Connection result = mock(Connection.class);
- DatabaseMetaData databaseMetaData = mock(DatabaseMetaData.class);
- Statement statement = mock(Statement.class);
- ResultSet sequenceResultSet = mockMultiRowResultSet(sequenceRows);
- ResultSet emptyQueryResultSet = mockMultiRowResultSet(List.of());
- when(result.getMetaData()).thenReturn(databaseMetaData);
- when(result.createStatement()).thenReturn(statement);
- when(databaseMetaData.getDatabaseProductVersion()).thenReturn("");
- when(databaseMetaData.getURL()).thenReturn(getMetadataJdbcUrl(databaseType));
- when(statement.executeQuery(anyString())).thenReturn(emptyQueryResultSet);
- when(databaseMetaData.getTables(isNull(), isNull(), eq("%"), any(String[].class))).thenAnswer(invocation -> {
- String[] tableTypes = invocation.getArgument(3);
- return mockMultiRowResultSet("TABLE".equals(tableTypes[0]) ? tableRows : viewRows);
- });
- when(databaseMetaData.getColumns(isNull(), nullable(String.class), anyString(), eq("%"))).thenAnswer(invocation -> {
- String objectName = invocation.getArgument(2);
- return mockResultSet("COLUMN_NAME", columns.getOrDefault(objectName, List.of()).toArray(new String[0]));
- });
- when(databaseMetaData.getIndexInfo(isNull(), nullable(String.class), anyString(), eq(false), eq(false))).thenAnswer(invocation -> {
- String tableName = invocation.getArgument(2);
- return mockResultSet("INDEX_NAME", indexes.getOrDefault(tableName, List.of()).toArray(new String[0]));
- });
- when(statement.executeQuery(getSequenceQuery(databaseType))).thenReturn(sequenceResultSet);
- return result;
- }
-
- private void mockEmptyScalarQueries(final Connection connection) throws SQLException {
- Statement statement = mock(Statement.class);
- ResultSet resultSet = mockMultiRowResultSet(List.of());
- when(connection.createStatement()).thenReturn(statement);
- when(statement.executeQuery(anyString())).thenReturn(resultSet);
- }
-
- private String getSequenceQuery(final String databaseType) {
- switch (databaseType) {
- case "PostgreSQL":
- case "openGauss":
- return "SELECT sequence_schema AS SEQUENCE_SCHEMA, sequence_name AS SEQUENCE_NAME FROM information_schema.sequences";
- case "SQLServer":
- return "SELECT schemas.name AS SEQUENCE_SCHEMA, seq.name AS SEQUENCE_NAME FROM sys.sequences seq INNER JOIN sys.schemas schemas ON seq.schema_id = schemas.schema_id";
- case "Oracle":
- return "SELECT USER AS SEQUENCE_SCHEMA, sequence_name AS SEQUENCE_NAME FROM USER_SEQUENCES";
- case "MariaDB":
- return "SELECT TABLE_SCHEMA AS SEQUENCE_SCHEMA, TABLE_NAME AS SEQUENCE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'SEQUENCE'";
- case "Firebird":
- return "SELECT '' AS SEQUENCE_SCHEMA, TRIM(RDB$GENERATOR_NAME) AS SEQUENCE_NAME FROM RDB$GENERATORS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0";
- default:
- return "";
- }
- }
-
- private ResultSet mockResultSet(final String columnName, final String... values) throws SQLException {
- ResultSet result = mock(ResultSet.class);
- AtomicInteger nextIndex = new AtomicInteger();
- AtomicInteger valueIndex = new AtomicInteger();
- when(result.next()).thenAnswer(invocation -> nextIndex.getAndIncrement() < values.length);
- when(result.getString(columnName)).thenAnswer(invocation -> values[valueIndex.getAndIncrement()]);
- return result;
- }
-
- private ResultSet mockSingleRowResultSet(final Map<String, String> values) throws SQLException {
- ResultSet result = mock(ResultSet.class);
- when(result.next()).thenReturn(true, false);
- for (Entry<String, String> entry : values.entrySet()) {
- when(result.getString(entry.getKey())).thenReturn(entry.getValue());
- }
- return result;
- }
-
- private ResultSet mockMultiRowResultSet(final List<Map<String, String>> values) throws SQLException {
- ResultSet result = mock(ResultSet.class);
- AtomicInteger rowIndex = new AtomicInteger(-1);
- when(result.next()).thenAnswer(invocation -> rowIndex.incrementAndGet() < values.size());
- when(result.getString(anyString())).thenAnswer(invocation -> {
- int currentRowIndex = rowIndex.get();
- return 0 <= currentRowIndex && currentRowIndex < values.size() ? values.get(currentRowIndex).get(invocation.getArgument(0)) : null;
- });
- return result;
- }
-
- private String getMetadataJdbcUrl(final String databaseType) {
- return METADATA_JDBC_URLS.getOrDefault(databaseType, METADATA_JDBC_URLS.get("PostgreSQL"));
- }
-
- private int countMetadata(final MCPDatabaseMetadata databaseMetadata, final SupportedMCPMetadataObjectType objectType, final String objectName) {
- int result = 0;
- for (MCPSchemaMetadata each : databaseMetadata.getSchemas()) {
- if (SupportedMCPMetadataObjectType.SCHEMA == objectType && objectName.equals(each.getSchema())) {
- result++;
- }
- for (MCPTableMetadata table : each.getTables()) {
- if (SupportedMCPMetadataObjectType.TABLE == objectType && objectName.equals(table.getTable())) {
- result++;
- }
- for (MCPColumnMetadata column : table.getColumns()) {
- if (SupportedMCPMetadataObjectType.COLUMN == objectType && objectName.equals(column.getColumn())) {
- result++;
- }
- }
- for (MCPIndexMetadata index : table.getIndexes()) {
- if (SupportedMCPMetadataObjectType.INDEX == objectType && objectName.equals(index.getIndex())) {
- result++;
- }
- }
- }
- for (MCPViewMetadata view : each.getViews()) {
- if (SupportedMCPMetadataObjectType.VIEW == objectType && objectName.equals(view.getView())) {
- result++;
- }
- for (MCPColumnMetadata column : view.getColumns()) {
- if (SupportedMCPMetadataObjectType.COLUMN == objectType && objectName.equals(column.getColumn())) {
- result++;
- }
- }
- }
- for (MCPSequenceMetadata sequence : each.getSequences()) {
- if (SupportedMCPMetadataObjectType.SEQUENCE == objectType && objectName.equals(sequence.getSequence())) {
- result++;
- }
- }
- }
- return result;
- }
-
- private boolean containsMetadata(final MCPDatabaseMetadata databaseMetadata, final SupportedMCPMetadataObjectType objectType, final String objectName) {
- return 0 < countMetadata(databaseMetadata, objectType, objectName);
- }
-
- @RequiredArgsConstructor(access = AccessLevel.PRIVATE)
- private static final class LoadedMetadataCatalog {
-
- private final Map<String, MCPDatabaseMetadata> databaseMetadataMap;
-
- private Optional<MCPDatabaseMetadata> findMetadata(final String databaseName) {
- return Optional.ofNullable(databaseMetadataMap.get(databaseName));
- }
-
- private Map<String, MCPDatabaseMetadata> getDatabaseMetadataMap() {
- return databaseMetadataMap;
- }
- }
-
- private static final class MockDriverRegistration implements AutoCloseable {
-
- private final Driver driver;
-
- private MockDriverRegistration(final Driver driver) throws SQLException {
- this.driver = driver;
- DriverManager.registerDriver(driver);
- }
-
- private static MockDriverRegistration register(final Driver driver) throws SQLException {
- return new MockDriverRegistration(driver);
- }
-
- @Override
- public void close() throws SQLException {
- DriverManager.deregisterDriver(driver);
- }
- }
-
- @RequiredArgsConstructor(access = AccessLevel.PRIVATE)
- private static final class MockDriver implements Driver {
-
- private final String jdbcUrl;
-
- private final Connection connection;
-
- @Override
- public Connection connect(final String url, final Properties info) {
- return acceptsURL(url) ? connection : null;
- }
-
- @Override
- public boolean acceptsURL(final String url) {
- return jdbcUrl.equals(url);
- }
-
- @Override
- public DriverPropertyInfo[] getPropertyInfo(final String url, final Properties info) {
- return new DriverPropertyInfo[0];
- }
-
- @Override
- public int getMajorVersion() {
- return 1;
- }
-
- @Override
- public int getMinorVersion() {
- return 0;
- }
-
- @Override
- public boolean jdbcCompliant() {
- return false;
- }
-
- @Override
- public Logger getParentLogger() throws SQLFeatureNotSupportedException {
- throw new SQLFeatureNotSupportedException("Mock driver does not expose a parent logger.");
- }
- }
}
diff --git a/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorComponentValidatorsTest.java b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorComponentValidatorsTest.java
new file mode 100644
index 00000000000..7eb5818d8fa
--- /dev/null
+++ b/mcp/support/src/test/java/org/apache/shardingsphere/mcp/support/descriptor/MCPDescriptorComponentValidatorsTest.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.mcp.support.descriptor;
+
+import org.apache.shardingsphere.mcp.api.prompt.descriptor.MCPPromptArgumentDescriptor;
+import org.apache.shardingsphere.mcp.api.prompt.descriptor.MCPPromptDescriptor;
+import org.apache.shardingsphere.mcp.api.resource.descriptor.MCPResourceAnnotations;
+import org.apache.shardingsphere.mcp.api.resource.descriptor.MCPResourceDescriptor;
+import org.apache.shardingsphere.mcp.api.tool.descriptor.MCPToolAnnotations;
+import org.apache.shardingsphere.mcp.api.tool.descriptor.MCPToolDescriptor;
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class MCPDescriptorComponentValidatorsTest {
+
+ @Test
+ void assertResourceDescriptorValidator() {
+ MCPDescriptorCatalog catalog =
+ new MCPDescriptorCatalog(List.of(createResourceDescriptor()), List.of(createResourceDescriptor()), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of());
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPResourceDescriptorValidator.validate(catalog));
+ assertThat(actual.getMessage(), is("Resource template `shardingsphere://capabilities` must contain template variables."));
+ }
+
+ @Test
+ void assertToolDescriptorCatalogValidator() {
+ Map<String, Object> inputSchema = new LinkedHashMap<>(createInputSchema());
+ inputSchema.put("oneOf", List.of(Map.of("required", List.of("query"))));
+ MCPDescriptorCatalog catalog = new MCPDescriptorCatalog(List.of(), List.of(), List.of(), List.of(new MCPToolDescriptor(
+ "database_gateway_test_tool", "Test Tool", "Run a test tool.", inputSchema, createOutputSchema(), createAnnotations(), Map.of())), List.of(), List.of(), List.of(), List.of(),
+ List.of());
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPToolDescriptorCatalogValidator.validate(catalog));
+ assertThat(actual.getMessage(), is("Tool `database_gateway_test_tool` inputSchema contains unsupported top-level field `oneOf`."));
+ }
+
+ @Test
+ void assertToolOutputSchemaValidator() {
+ MCPToolDescriptor descriptor = new MCPToolDescriptor("database_gateway_test_tool", "Test Tool", "Run a test tool.",
+ createInputSchema(), createOutputSchema(Map.of("recommended_next_tool", Map.of("type", "string", "description", "Removed alias."))), createAnnotations(), Map.of());
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPToolOutputSchemaValidator.validate(descriptor));
+ assertThat(actual.getMessage(), is("Tool `database_gateway_test_tool` model-facing contract must use canonical fields instead of removed `recommended_next_tool`."));
+ }
+
+ @Test
+ void assertPromptDescriptorValidator() {
+ MCPPromptDescriptor prompt = new MCPPromptDescriptor("test_prompt", "Test Prompt", "Guide the model through a test prompt.", List.of(), Map.of());
+ MCPPromptTemplateBinding binding = new MCPPromptTemplateBinding("test_prompt", "META-INF/shardingsphere-mcp/prompts/fixture-single-brace-placeholder.md");
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPPromptDescriptorValidator.validate(List.of(prompt), List.of(binding)));
+ assertThat(actual.getMessage(), is("Prompt template `META-INF/shardingsphere-mcp/prompts/fixture-single-brace-placeholder.md` contains unsupported model-facing placeholder `{database}`."));
+ }
+
+ @Test
+ void assertCompletionTargetDescriptorValidator() {
+ MCPPromptDescriptor prompt = new MCPPromptDescriptor("test_prompt", "Test Prompt", "Guide the model through a test prompt.",
+ List.of(new MCPPromptArgumentDescriptor("database", "Database", "Logical database.", false)), Map.of());
+ MCPCompletionTargetDescriptor completion = new MCPCompletionTargetDescriptor("prompt", "test_prompt", List.of("database"), 50,
+ Map.of(MCPShardingSphereMetadataKeys.REQUIRED_CONTEXT_ARGUMENTS, Map.of("database", List.of("tenant"))));
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPCompletionTargetDescriptorValidator.validate(List.of(completion), List.of(prompt), List.of()));
+ assertThat(actual.getMessage(), is("Completion target `prompt:test_prompt` context argument `tenant` for `database` is not declared by the target."));
+ }
+
+ @Test
+ void assertResourceNavigationDescriptorValidator() {
+ MCPResourceNavigationDescriptor navigation = new MCPResourceNavigationDescriptor("missing", "database_gateway_test_tool", List.of(), List.of(), "Read test tool.");
+ MCPDescriptorCatalog catalog = new MCPDescriptorCatalog(List.of(), List.of(), List.of(), List.of(createToolDescriptor()), List.of(), List.of(), List.of(), List.of(), List.of());
+ IllegalStateException actual = assertThrows(IllegalStateException.class, () -> MCPResourceNavigationDescriptorValidator.validate(List.of(navigation), catalog));
+ assertThat(actual.getMessage(), is("Resource navigation references unknown source `missing`."));
+ }
+
+ private MCPResourceDescriptor createResourceDescriptor() {
+ return new MCPResourceDescriptor("shardingsphere://capabilities", "server-capability-catalog", "Server Capability Catalog",
+ "Read the model-facing capability catalog.", "application/json", MCPResourceAnnotations.EMPTY, Map.of());
+ }
+
+ private MCPToolDescriptor createToolDescriptor() {
+ return new MCPToolDescriptor("database_gateway_test_tool", "Test Tool", "Run a test tool.", createInputSchema(), createOutputSchema(), createAnnotations(), Map.of());
+ }
+
+ private MCPToolAnnotations createAnnotations() {
+ return new MCPToolAnnotations("Test Tool", true, false, true, true);
+ }
+
+ private Map<String, Object> createInputSchema() {
+ return Map.of("type", "object", "properties", Map.of("query", Map.of("type", "string", "description", "Query.")), "required", List.of("query"), "additionalProperties", false);
+ }
+
+ private Map<String, Object> createOutputSchema() {
+ return createOutputSchema(Map.of("status", Map.of("type", "string", "description", "Status.")));
+ }
+
+ private Map<String, Object> createOutputSchema(final Map<String, Object> properties) {
+ return Map.of("type", "object", "properties", properties, "examples", List.of(Map.of("status", "ok")));
+ }
+}
diff --git a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationRunner.java b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationRunner.java
index 3a04401696c..7ffd5dce9b7 100644
--- a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationRunner.java
+++ b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationRunner.java
@@ -26,7 +26,6 @@ import org.apache.shardingsphere.test.e2e.mcp.llm.conversation.client.LLMChatMod
import org.apache.shardingsphere.test.e2e.mcp.llm.conversation.client.LLMToolCall;
import org.apache.shardingsphere.test.e2e.mcp.llm.scenario.LLME2EScenario;
import org.apache.shardingsphere.test.e2e.mcp.llm.scenario.LLMStructuredAnswer;
-import org.apache.shardingsphere.test.e2e.mcp.support.transport.MCPInteractionActionNames;
import org.apache.shardingsphere.test.e2e.mcp.support.transport.MCPInteractionTraceRecord;
import org.apache.shardingsphere.test.e2e.mcp.support.transport.client.MCPInteractionClient;
@@ -58,6 +57,8 @@ public final class LLMMCPConversationRunner {
private final LLMMCPSafetyValidator safetyValidator = new LLMMCPSafetyValidator();
+ private final LLMMCPTraceRecordFactory traceRecordFactory = new LLMMCPTraceRecordFactory();
+
private final String modelProvider;
private final String modelName;
@@ -189,9 +190,10 @@ public final class LLMMCPConversationRunner {
}
messages.add(LLMChatMessage.assistant(completion.getContent(), completion.getToolCalls()));
for (LLMToolCall each : completion.getToolCalls()) {
- List<String> availableToolNames = instructionFactory.hasSideEffectExecutionNextAction(artifacts.getInteractionTrace())
+ List<String> availableToolNames = turnPlanner.createImmediateNextActionToolNames(artifacts.getInteractionTrace());
+ availableToolNames = availableToolNames.isEmpty() && instructionFactory.hasSideEffectExecutionNextAction(artifacts.getInteractionTrace())
? turnPlanner.createTurnToolNames(scenario, artifacts.getInteractionTrace())
- : List.of();
+ : availableToolNames;
if (!availableToolNames.isEmpty() && !availableToolNames.contains(each.getName())) {
Map<String, Object> toolResponse = new LinkedHashMap<>(4, 1F);
toolResponse.put("response_mode", "tool_call_rejected");
@@ -212,7 +214,7 @@ public final class LLMMCPConversationRunner {
each.getName(), String.join(", ", availableToolNames), expectedQueryInstruction)));
return Optional.empty();
}
- Optional<LLME2EArtifactBundle> result = processToolCall(scenario, each, messages, artifacts);
+ Optional<LLME2EArtifactBundle> result = processToolCall(scenario, each, messages, artifacts, availableToolNames);
if (result.isPresent()) {
return result;
}
@@ -225,8 +227,8 @@ public final class LLMMCPConversationRunner {
}
private Optional<LLME2EArtifactBundle> processToolCall(final LLME2EScenario scenario, final LLMToolCall toolCall, final List<LLMChatMessage> messages,
- final LLMMCPConversationArtifacts artifacts) throws InterruptedException {
- if (!scenario.getAllowedToolNames().contains(toolCall.getName())) {
+ final LLMMCPConversationArtifacts artifacts, final List<String> extraAllowedToolNames) throws InterruptedException {
+ if (!scenario.getAllowedToolNames().contains(toolCall.getName()) && !extraAllowedToolNames.contains(toolCall.getName())) {
artifacts.addInteractionTrace(MCPInteractionTraceRecord.createInvalidAction(artifacts.nextSequence(), "tool_call", toolCall.getName(),
Map.of("rawArgumentsJson", toolCall.getArgumentsJson()), "unexpected_tool_requested"));
return Optional.of(createFailureBundle(scenario, artifacts, "unexpected_tool_requested", "Model requested an unsupported tool."));
@@ -260,7 +262,7 @@ public final class LLMMCPConversationRunner {
artifacts.addRuntimeLogLine("action=" + toolName + " args=" + JsonUtils.toJsonString(arguments));
artifacts.addRuntimeLogLine("response=" + JsonUtils.toJsonString(response));
String actionOrigin = MCPInteractionTraceRecord.MODEL_TOOL_CALL_ORIGIN;
- artifacts.addInteractionTrace(createTraceRecord(artifacts.nextSequence(), toolName, actionOrigin, arguments, response, latencyMillis));
+ artifacts.addInteractionTrace(traceRecordFactory.createTraceRecord(artifacts.nextSequence(), toolName, actionOrigin, arguments, response, latencyMillis));
messages.add(LLMChatMessage.tool(toolCall.getId(), LLMMCPModelFacingToolResponseFormatter.format(response)));
return Optional.empty();
}
@@ -306,31 +308,4 @@ public final class LLMMCPConversationRunner {
}
}
- private MCPInteractionTraceRecord createTraceRecord(final int sequence, final String actionName, final String actionOrigin, final Map<String, Object> arguments,
- final Map<String, Object> structuredContent, final long latencyMillis) {
- String bridgeActionOrigin = MCPInteractionTraceRecord.PROTOCOL_BRIDGE_ORIGIN;
- if (MCPInteractionActionNames.LIST_RESOURCES.equals(actionName)) {
- return new MCPInteractionTraceRecord(sequence, MCPInteractionActionNames.RESOURCE_LIST_KIND, bridgeActionOrigin, MCPInteractionActionNames.LIST_RESOURCES,
- Map.of(), structuredContent, true, latencyMillis);
- }
- if (MCPInteractionActionNames.READ_RESOURCE.equals(actionName)) {
- return new MCPInteractionTraceRecord(sequence, MCPInteractionActionNames.RESOURCE_READ_KIND, bridgeActionOrigin, MCPInteractionActionNames.READ_RESOURCE,
- Map.of("uri", Objects.toString(arguments.get("uri"), "").trim()), structuredContent, true, latencyMillis);
- }
- if (MCPInteractionActionNames.LIST_PROMPTS.equals(actionName)) {
- return new MCPInteractionTraceRecord(sequence, MCPInteractionActionNames.PROMPT_LIST_KIND, bridgeActionOrigin, MCPInteractionActionNames.LIST_PROMPTS,
- Map.of(), structuredContent, true, latencyMillis);
- }
- if (MCPInteractionActionNames.GET_PROMPT.equals(actionName)) {
- return new MCPInteractionTraceRecord(sequence, MCPInteractionActionNames.PROMPT_GET_KIND, bridgeActionOrigin, MCPInteractionActionNames.GET_PROMPT,
- Map.of("name", Objects.toString(arguments.get("name"), "").trim(), "arguments", LLMMCPJsonValues.castToMap(arguments.getOrDefault("arguments", Map.of()))),
- structuredContent, true, latencyMillis);
- }
- if (MCPInteractionActionNames.COMPLETE.equals(actionName)) {
- return new MCPInteractionTraceRecord(sequence, MCPInteractionActionNames.COMPLETION_KIND, bridgeActionOrigin, MCPInteractionActionNames.COMPLETE,
- arguments, structuredContent, true, latencyMillis);
- }
- return new MCPInteractionTraceRecord(sequence, "tool_call", actionOrigin, actionName, arguments, structuredContent, true, latencyMillis);
- }
-
}
diff --git a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationRunnerNextActionTest.java b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationRunnerNextActionTest.java
index aacc7594f03..4e9a6a4c8a2 100644
--- a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationRunnerNextActionTest.java
+++ b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationRunnerNextActionTest.java
@@ -373,6 +373,36 @@ class LLMMCPConversationRunnerNextActionTest extends AbstractLLMMCPConversationR
verify(getMCPInteractionClient(), never()).call("database_gateway_execute_update", misroutedQueryArguments);
}
+ @Test
+ void assertRunFollowsImmediateCompletionRecoveryAction() throws IOException, InterruptedException {
+ List<String> toolNames = List.of("database_gateway_plan_mask_rule", "database_gateway_execute_query");
+ LLME2EScenario actualScenario = createScenario(toolNames);
+ LLMMCPConversationRunner actualRunner = createRunner(4);
+ Map<String, Object> planArguments = Map.of("plan_id", "plan_id", "database", DATABASE_NAME, "schema", SCHEMA_NAME, "table", TABLE_NAME);
+ Map<String, Object> reference = Map.of("type", "ref/resource", "uri", "shardingsphere://workflows/{plan_id}");
+ Map<String, Object> argument = Map.of("name", "plan_id", "value", "");
+ Map<String, Object> completionArguments = Map.of("ref", reference, "argument", argument);
+ Map<String, Object> executeQueryArguments = createExecuteQueryArguments(QUERY);
+ when(getLLMChatClient().complete(anyList(), anyList(), eq("required"), eq(false))).thenReturn(
+ createToolCallCompletion("tool-1", "database_gateway_plan_mask_rule", planArguments, "plan-response"),
+ createToolCallCompletion("tool-2", MCPInteractionActionNames.COMPLETE, completionArguments, "completion-response"),
+ createToolCallCompletion("tool-3", "database_gateway_execute_query", executeQueryArguments, "query-response"));
+ when(getMCPInteractionClient().call("database_gateway_plan_mask_rule", planArguments)).thenReturn(Map.of("response_mode", "recovery", "recovery", Map.of(
+ "next_actions", List.of(Map.of("type", "completion", "ref", reference, "argument", argument)))));
+ when(getMCPInteractionClient().complete(reference, "plan_id", "", Map.of())).thenReturn(Map.of("completion", Map.of("values", List.of())));
+ when(getMCPInteractionClient().call("database_gateway_execute_query", executeQueryArguments)).thenReturn(createResultSetPayload(2));
+ when(getLLMChatClient().complete(anyList(), eq(List.of()), eq("none"), eq(true))).thenReturn(
+ createFinalAnswerCompletion(List.of("database_gateway_plan_mask_rule", MCPInteractionActionNames.COMPLETE, "database_gateway_execute_query"), 2, "final-answer-response"));
+
+ LLME2EArtifactBundle actual = actualRunner.run(actualScenario);
+
+ assertTrue(actual.getAssertionReport().isSuccess());
+ List<List<Map<String, Object>>> actualTools = captureRequiredToolDefinitions(3);
+ assertThat(getToolNames(actualTools.get(1)), is(List.of(MCPInteractionActionNames.COMPLETE)));
+ assertThat(actual.getInteractionTrace().get(1).getActionKind(), is(MCPInteractionActionNames.COMPLETION_KIND));
+ verify(getMCPInteractionClient()).complete(reference, "plan_id", "", Map.of());
+ }
+
@Test
void assertRunCompactsManualArtifactsBeforeReadOnlyVerification() throws IOException, InterruptedException {
List<String> toolNames = List.of("database_gateway_apply_workflow", "database_gateway_execute_query");
diff --git a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationTurnPlanner.java b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationTurnPlanner.java
index 0e397ec021b..ef07ee89fe8 100644
--- a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationTurnPlanner.java
+++ b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationTurnPlanner.java
@@ -34,11 +34,9 @@ final class LLMMCPConversationTurnPlanner {
}
List<String> createTurnToolNames(final LLME2EScenario scenario, final List<MCPInteractionTraceRecord> interactionTrace) {
- if (!interactionTrace.isEmpty()) {
- String immediateActionName = instructionFactory.findImmediateNextActionName(interactionTrace.getLast());
- if (!immediateActionName.isEmpty() && scenario.getAllowedToolNames().contains(immediateActionName)) {
- return List.of(immediateActionName);
- }
+ List<String> immediateActionToolNames = createImmediateNextActionToolNames(interactionTrace);
+ if (!immediateActionToolNames.isEmpty()) {
+ return immediateActionToolNames;
}
if (instructionFactory.hasSideEffectExecutionNextAction(interactionTrace)) {
List<String> readOnlyToolNames = findMissingReadOnlyToolNames(scenario, interactionTrace);
@@ -50,6 +48,14 @@ final class LLMMCPConversationTurnPlanner {
return missingToolNames.isEmpty() ? scenario.getAllowedToolNames() : List.of(missingToolNames.getFirst());
}
+ List<String> createImmediateNextActionToolNames(final List<MCPInteractionTraceRecord> interactionTrace) {
+ if (interactionTrace.isEmpty()) {
+ return List.of();
+ }
+ String immediateActionName = instructionFactory.findImmediateNextActionName(interactionTrace.getLast());
+ return immediateActionName.isEmpty() ? List.of() : List.of(immediateActionName);
+ }
+
String createToolChoice(final LLME2EScenario scenario, final List<MCPInteractionTraceRecord> interactionTrace, final boolean finalAnswerRequested) {
if (finalAnswerRequested) {
return "none";
diff --git a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationTurnPlannerTest.java b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationTurnPlannerTest.java
index b69c87d947f..c779c0656ad 100644
--- a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationTurnPlannerTest.java
+++ b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPConversationTurnPlannerTest.java
@@ -42,6 +42,17 @@ class LLMMCPConversationTurnPlannerTest {
assertThat(actual, is(List.of(MCPInteractionActionNames.READ_RESOURCE)));
}
+ @Test
+ void assertCreateTurnToolNamesWithImmediateCompletionAction() {
+ LLMMCPConversationInstructionFactory instructionFactory = new LLMMCPConversationInstructionFactory();
+ LLMMCPConversationTurnPlanner planner = new LLMMCPConversationTurnPlanner(instructionFactory);
+ List<String> actual = planner.createTurnToolNames(createScenario(List.of("database_gateway_plan_mask_rule", "database_gateway_execute_query"),
+ List.of("database_gateway_plan_mask_rule", "database_gateway_execute_query")),
+ List.of(createTraceRecord("database_gateway_plan_mask_rule",
+ Map.of("recovery", Map.of("next_actions", List.of(Map.of("type", "completion")))))));
+ assertThat(actual, is(List.of(MCPInteractionActionNames.COMPLETE)));
+ }
+
@Test
void assertCreateTurnToolNamesPrefersReadOnlyAfterSideEffectNextAction() {
LLMMCPConversationInstructionFactory instructionFactory = new LLMMCPConversationInstructionFactory();
diff --git a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPTraceRecordFactory.java b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPTraceRecordFactory.java
new file mode 100644
index 00000000000..1c168ff70bd
--- /dev/null
+++ b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPTraceRecordFactory.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.test.e2e.mcp.llm.conversation;
+
+import org.apache.shardingsphere.test.e2e.mcp.support.transport.MCPInteractionActionNames;
+import org.apache.shardingsphere.test.e2e.mcp.support.transport.MCPInteractionTraceRecord;
+
+import java.util.Map;
+import java.util.Objects;
+
+final class LLMMCPTraceRecordFactory {
+
+ MCPInteractionTraceRecord createTraceRecord(final int sequence, final String actionName, final String actionOrigin, final Map<String, Object> args,
+ final Map<String, Object> structuredContent, final long latencyMillis) {
+ String bridgeActionOrigin = MCPInteractionTraceRecord.PROTOCOL_BRIDGE_ORIGIN;
+ if (MCPInteractionActionNames.LIST_RESOURCES.equals(actionName)) {
+ return new MCPInteractionTraceRecord(sequence, MCPInteractionActionNames.RESOURCE_LIST_KIND, bridgeActionOrigin, MCPInteractionActionNames.LIST_RESOURCES,
+ Map.of(), structuredContent, true, latencyMillis);
+ }
+ if (MCPInteractionActionNames.READ_RESOURCE.equals(actionName)) {
+ return new MCPInteractionTraceRecord(sequence, MCPInteractionActionNames.RESOURCE_READ_KIND, bridgeActionOrigin, MCPInteractionActionNames.READ_RESOURCE,
+ Map.of("uri", Objects.toString(args.get("uri"), "").trim()), structuredContent, true, latencyMillis);
+ }
+ if (MCPInteractionActionNames.LIST_PROMPTS.equals(actionName)) {
+ return new MCPInteractionTraceRecord(sequence, MCPInteractionActionNames.PROMPT_LIST_KIND, bridgeActionOrigin, MCPInteractionActionNames.LIST_PROMPTS,
+ Map.of(), structuredContent, true, latencyMillis);
+ }
+ if (MCPInteractionActionNames.GET_PROMPT.equals(actionName)) {
+ return new MCPInteractionTraceRecord(sequence, MCPInteractionActionNames.PROMPT_GET_KIND, bridgeActionOrigin, MCPInteractionActionNames.GET_PROMPT,
+ Map.of("name", Objects.toString(args.get("name"), "").trim(), "arguments", LLMMCPJsonValues.castToMap(args.getOrDefault("arguments", Map.of()))),
+ structuredContent, true, latencyMillis);
+ }
+ if (MCPInteractionActionNames.COMPLETE.equals(actionName)) {
+ return new MCPInteractionTraceRecord(sequence, MCPInteractionActionNames.COMPLETION_KIND, bridgeActionOrigin, MCPInteractionActionNames.COMPLETE,
+ args, structuredContent, true, latencyMillis);
+ }
+ return new MCPInteractionTraceRecord(sequence, "tool_call", actionOrigin, actionName, args, structuredContent, true, latencyMillis);
+ }
+}
diff --git a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/client/LLMChatModelClient.java b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/client/LLMChatModelClient.java
index 6a3fd6bd7e7..901edd68d57 100644
--- a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/client/LLMChatModelClient.java
+++ b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/client/LLMChatModelClient.java
@@ -22,8 +22,6 @@ import com.google.common.base.Preconditions;
import lombok.RequiredArgsConstructor;
import org.apache.shardingsphere.infra.util.json.JsonUtils;
import org.apache.shardingsphere.test.e2e.mcp.llm.config.LLME2EConfiguration;
-import org.apache.shardingsphere.test.e2e.mcp.support.runtime.ReadinessProbe;
-import org.apache.shardingsphere.test.e2e.mcp.support.runtime.ReadinessProbe.ReadinessResult;
import java.io.IOException;
import java.net.URI;
@@ -44,10 +42,6 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public final class LLMChatModelClient {
- private static final long INITIAL_READINESS_INTERVAL_MILLIS = 250L;
-
- private static final long MAX_READINESS_INTERVAL_MILLIS = 2000L;
-
private static final int COMPLETION_MAX_TOKENS = 512;
private static final int READINESS_MAX_TOKENS = 64;
@@ -63,88 +57,10 @@ public final class LLMChatModelClient {
* @throws IllegalStateException model service is not ready
*/
public void waitUntilReady() throws InterruptedException {
- new ReadinessProbe(config.getReadyTimeoutSeconds() * 1000L, INITIAL_READINESS_INTERVAL_MILLIS, MAX_READINESS_INTERVAL_MILLIS)
- .waitUntilReady(this::checkReadiness, this::createReadinessException);
- }
-
- private ReadinessResult<Boolean> checkReadiness() throws InterruptedException {
- try {
- return isReadinessContractReady() ? ReadinessResult.ready(Boolean.TRUE) : ReadinessResult.retry(null);
- } catch (final IOException ex) {
- return ReadinessResult.retry(ex);
- } catch (final IllegalStateException ex) {
- return isNonRetryableReadinessFailure(ex) ? ReadinessResult.failed(ex) : ReadinessResult.retry(ex);
- }
- }
-
- private boolean isNonRetryableReadinessFailure(final Exception cause) {
- String message = Objects.toString(cause.getMessage(), "");
- return message.contains("HTTP 400") || message.contains("HTTP 401");
- }
-
- private IllegalStateException createReadinessException(final Exception cause, final int attemptCount, final long elapsedMillis) {
- IllegalStateException result = new IllegalStateException(createReadinessFailureMessage(cause, attemptCount, elapsedMillis));
- if (null != cause) {
- result.initCause(cause);
- }
- return result;
- }
-
- private String createReadinessFailureMessage(final Exception cause, final int attemptCount, final long elapsedMillis) {
- String result = String.format("Model service is not ready for `%s` after %d readiness attempt(s), elapsedMillis=%d, timeoutSeconds=%d.",
- config.getModelName(), attemptCount, elapsedMillis, config.getReadyTimeoutSeconds());
- return null == cause || null == cause.getMessage() || cause.getMessage().isBlank()
- ? result
- : result + " Last readiness failure: " + cause.getMessage();
- }
-
- private boolean isModelListReady() throws IOException, InterruptedException {
- HttpResponse<String> response = sendModelListRequest();
- if (200 != response.statusCode()) {
- throw new IllegalStateException(createHttpReadinessFailure("model-list", response));
- }
- return containsModel(response.body());
- }
-
- private boolean isReadinessContractReady() throws IOException, InterruptedException {
- return isModelListReady() && isCompletionProbeReady() && isRequiredToolProbeReady() && isAutoToolProbeReady() && isFinalAnswerProbeReady();
- }
-
- private boolean isCompletionProbeReady() throws IOException, InterruptedException {
- HttpResponse<String> response = sendReadinessCompletionRequest(List.of(LLMChatMessage.user("Return ok.")), List.of(), "", false);
- if (200 != response.statusCode()) {
- throw new IllegalStateException(createHttpReadinessFailure("completion", response));
- }
- return hasCompletionChoice(response.body());
- }
-
- private boolean isRequiredToolProbeReady() throws IOException, InterruptedException {
- HttpResponse<String> response = sendReadinessCompletionRequest(List.of(LLMChatMessage.user("Call mcp_read_resource with uri mcp://readiness.")),
- createReadinessTools(), "required", false);
- if (200 != response.statusCode()) {
- throw new IllegalStateException(createHttpReadinessFailure("tool-choice-required", response));
- }
- return hasToolCallChoice(response.body());
- }
-
- private boolean isAutoToolProbeReady() throws IOException, InterruptedException {
- HttpResponse<String> response = sendReadinessCompletionRequest(List.of(LLMChatMessage.user("Return ok without calling tools.")), createReadinessTools(), "auto", false);
- if (200 != response.statusCode()) {
- throw new IllegalStateException(createHttpReadinessFailure("tool-choice-auto", response));
- }
- return hasCompletionChoice(response.body());
- }
-
- private boolean isFinalAnswerProbeReady() throws IOException, InterruptedException {
- HttpResponse<String> response = sendReadinessCompletionRequest(List.of(LLMChatMessage.user(
- "Respond with exactly {\"status\":\"ok\"} and no Markdown fences.")), List.of(), "none", true);
- if (200 != response.statusCode()) {
- throw new IllegalStateException(createHttpReadinessFailure("tool-choice-none-json", response));
- }
- return hasJsonCompletionChoice(response.body());
+ new LLMChatReadinessProbe(config, this).waitUntilReady();
}
- private String createHttpReadinessFailure(final String requestKind, final HttpResponse<String> response) {
+ String createHttpReadinessFailure(final String requestKind, final HttpResponse<String> response) {
return String.format("%s readiness request returned HTTP %d%s.", requestKind, response.statusCode(), createErrorCodeSuffix(response.body()));
}
@@ -216,7 +132,7 @@ public final class LLMChatModelClient {
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
}
- private HttpResponse<String> sendModelListRequest() throws IOException, InterruptedException {
+ HttpResponse<String> sendModelListRequest() throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(config.getModelsUrl()))
.timeout(Duration.ofSeconds(Math.min(config.getRequestTimeoutSeconds(), 30)))
.header("Authorization", "Bearer " + config.getApiKey())
@@ -225,25 +141,25 @@ public final class LLMChatModelClient {
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
}
- private HttpResponse<String> sendReadinessCompletionRequest(final List<LLMChatMessage> messages, final List<Map<String, Object>> tools,
- final String toolChoice, final boolean jsonResponse) throws IOException, InterruptedException {
+ HttpResponse<String> sendReadinessCompletionRequest(final List<LLMChatMessage> messages, final List<Map<String, Object>> tools,
+ final String toolChoice, final boolean jsonResponse) throws IOException, InterruptedException {
return sendCompletionRequest(createCompletionRequestPayload(messages, tools, toolChoice, jsonResponse, READINESS_MAX_TOKENS), Math.min(config.getRequestTimeoutSeconds(), 30));
}
- private boolean containsModel(final String responseBody) {
+ boolean containsModel(final String responseBody) {
Map<String, Object> payload = parseJsonObject(responseBody, "Failed to parse model-list response.");
return castToList(payload.get("data")).stream().anyMatch(each -> config.getModelName().equals(Objects.toString(each.get("id"), "").trim()));
}
- private boolean hasCompletionChoice(final String responseBody) {
+ boolean hasCompletionChoice(final String responseBody) {
return !castToList(parseJsonObject(responseBody, "Failed to parse readiness completion response.").get("choices")).isEmpty();
}
- private boolean hasToolCallChoice(final String responseBody) {
+ boolean hasToolCallChoice(final String responseBody) {
return !createToolCalls(getFirstAssistantMessage(responseBody, "Failed to parse readiness tool-call response.").get("tool_calls")).isEmpty();
}
- private boolean hasJsonCompletionChoice(final String responseBody) {
+ boolean hasJsonCompletionChoice(final String responseBody) {
String content = Objects.toString(getFirstAssistantMessage(responseBody, "Failed to parse readiness JSON response.").get("content"), "").trim();
if (content.isEmpty()) {
return false;
@@ -257,7 +173,7 @@ public final class LLMChatModelClient {
return choices.isEmpty() ? Map.of() : castToMap(choices.get(0).get("message"));
}
- private List<Map<String, Object>> createReadinessTools() {
+ List<Map<String, Object>> createReadinessTools() {
return List.of(Map.of(
"type", "function",
"function", Map.of(
diff --git a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/client/LLMChatReadinessProbe.java b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/client/LLMChatReadinessProbe.java
new file mode 100644
index 00000000000..66a2e9a7c22
--- /dev/null
+++ b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/client/LLMChatReadinessProbe.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.test.e2e.mcp.llm.conversation.client;
+
+import org.apache.shardingsphere.test.e2e.mcp.llm.config.LLME2EConfiguration;
+import org.apache.shardingsphere.test.e2e.mcp.support.runtime.ReadinessProbe;
+import org.apache.shardingsphere.test.e2e.mcp.support.runtime.ReadinessProbe.ReadinessResult;
+
+import java.io.IOException;
+import java.net.http.HttpResponse;
+import java.util.List;
+import java.util.Objects;
+
+final class LLMChatReadinessProbe {
+
+ private static final long INITIAL_READINESS_INTERVAL_MILLIS = 250L;
+
+ private static final long MAX_READINESS_INTERVAL_MILLIS = 2000L;
+
+ private final LLME2EConfiguration config;
+
+ private final LLMChatModelClient client;
+
+ LLMChatReadinessProbe(final LLME2EConfiguration config, final LLMChatModelClient client) {
+ this.config = config;
+ this.client = client;
+ }
+
+ void waitUntilReady() throws InterruptedException {
+ new ReadinessProbe(config.getReadyTimeoutSeconds() * 1000L, INITIAL_READINESS_INTERVAL_MILLIS, MAX_READINESS_INTERVAL_MILLIS)
+ .waitUntilReady(this::checkReadiness, this::createReadinessException);
+ }
+
+ private ReadinessResult<Boolean> checkReadiness() throws InterruptedException {
+ try {
+ return isReadinessContractReady() ? ReadinessResult.ready(Boolean.TRUE) : ReadinessResult.retry(null);
+ } catch (final IOException ex) {
+ return ReadinessResult.retry(ex);
+ } catch (final IllegalStateException ex) {
+ return isNonRetryableReadinessFailure(ex) ? ReadinessResult.failed(ex) : ReadinessResult.retry(ex);
+ }
+ }
+
+ private boolean isReadinessContractReady() throws IOException, InterruptedException {
+ return isModelListReady() && isCompletionProbeReady() && isRequiredToolProbeReady() && isAutoToolProbeReady() && isFinalAnswerProbeReady();
+ }
+
+ private boolean isModelListReady() throws IOException, InterruptedException {
+ HttpResponse<String> response = client.sendModelListRequest();
+ if (200 != response.statusCode()) {
+ throw new IllegalStateException(client.createHttpReadinessFailure("model-list", response));
+ }
+ return client.containsModel(response.body());
+ }
+
+ private boolean isCompletionProbeReady() throws IOException, InterruptedException {
+ HttpResponse<String> response = client.sendReadinessCompletionRequest(List.of(LLMChatMessage.user("Return ok.")), List.of(), "", false);
+ if (200 != response.statusCode()) {
+ throw new IllegalStateException(client.createHttpReadinessFailure("completion", response));
+ }
+ return client.hasCompletionChoice(response.body());
+ }
+
+ private boolean isRequiredToolProbeReady() throws IOException, InterruptedException {
+ HttpResponse<String> response = client.sendReadinessCompletionRequest(List.of(LLMChatMessage.user("Call mcp_read_resource with uri mcp://readiness.")),
+ client.createReadinessTools(), "required", false);
+ if (200 != response.statusCode()) {
+ throw new IllegalStateException(client.createHttpReadinessFailure("tool-choice-required", response));
+ }
+ return client.hasToolCallChoice(response.body());
+ }
+
+ private boolean isAutoToolProbeReady() throws IOException, InterruptedException {
+ HttpResponse<String> response = client.sendReadinessCompletionRequest(List.of(LLMChatMessage.user("Return ok without calling tools.")), client.createReadinessTools(), "auto", false);
+ if (200 != response.statusCode()) {
+ throw new IllegalStateException(client.createHttpReadinessFailure("tool-choice-auto", response));
+ }
+ return client.hasCompletionChoice(response.body());
+ }
+
+ private boolean isFinalAnswerProbeReady() throws IOException, InterruptedException {
+ HttpResponse<String> response = client.sendReadinessCompletionRequest(List.of(LLMChatMessage.user(
+ "Respond with exactly {\"status\":\"ok\"} and no Markdown fences.")), List.of(), "none", true);
+ if (200 != response.statusCode()) {
+ throw new IllegalStateException(client.createHttpReadinessFailure("tool-choice-none-json", response));
+ }
+ return client.hasJsonCompletionChoice(response.body());
+ }
+
+ private boolean isNonRetryableReadinessFailure(final Exception cause) {
+ String message = Objects.toString(cause.getMessage(), "");
+ return message.contains("HTTP 400") || message.contains("HTTP 401");
+ }
+
+ private IllegalStateException createReadinessException(final Exception cause, final int attemptCount, final long elapsedMillis) {
+ IllegalStateException result = new IllegalStateException(createReadinessFailureMessage(cause, attemptCount, elapsedMillis));
+ if (null != cause) {
+ result.initCause(cause);
+ }
+ return result;
+ }
+
+ private String createReadinessFailureMessage(final Exception cause, final int attemptCount, final long elapsedMillis) {
+ String result = String.format("Model service is not ready for `%s` after %d readiness attempt(s), elapsedMillis=%d, timeoutSeconds=%d.",
+ config.getModelName(), attemptCount, elapsedMillis, config.getReadyTimeoutSeconds());
+ return null == cause || null == cause.getMessage() || cause.getMessage().isBlank()
+ ? result
+ : result + " Last readiness failure: " + cause.getMessage();
+ }
+}
diff --git a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/production/AbstractProductionMySQLRuntimeE2ETest.java b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/production/AbstractProductionMySQLRuntimeE2ETest.java
index ace56833999..4956c78d63d 100644
--- a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/production/AbstractProductionMySQLRuntimeE2ETest.java
+++ b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/production/AbstractProductionMySQLRuntimeE2ETest.java
@@ -17,18 +17,10 @@
package org.apache.shardingsphere.test.e2e.mcp.runtime.production;
-import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
-import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
-import io.modelcontextprotocol.client.transport.ServerParameters;
-import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
-import io.modelcontextprotocol.spec.ProtocolVersions;
-import org.apache.shardingsphere.mcp.bootstrap.MCPBootstrap;
-import org.apache.shardingsphere.mcp.bootstrap.transport.MCPTransportJsonMapperFactory;
import org.apache.shardingsphere.mcp.support.database.metadata.jdbc.RuntimeDatabaseConfiguration;
-import org.apache.shardingsphere.mcp.support.descriptor.MCPShardingSphereMetadataKeys;
import org.apache.shardingsphere.test.e2e.mcp.env.MCPE2ECondition;
import org.apache.shardingsphere.test.e2e.mcp.support.OfficialMCPToolNames;
import org.apache.shardingsphere.test.e2e.mcp.support.runtime.MySQLRuntimeTestSupport;
@@ -36,7 +28,6 @@ import org.apache.shardingsphere.test.e2e.mcp.support.runtime.RuntimeTransport;
import org.apache.shardingsphere.test.e2e.mcp.support.transport.MCPInteractionPayloads;
import org.apache.shardingsphere.test.e2e.mcp.support.transport.MCPPayloadAssertions;
import org.apache.shardingsphere.test.e2e.mcp.support.transport.client.MCPInteractionClient;
-import org.apache.shardingsphere.test.e2e.mcp.support.transport.client.MCPStdioLogbackConfiguration;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assumptions;
@@ -46,18 +37,13 @@ import org.junit.jupiter.params.provider.Arguments;
import org.testcontainers.containers.GenericContainer;
import java.io.IOException;
-import java.net.URI;
-import java.nio.file.Path;
-import java.nio.file.Paths;
import java.sql.SQLException;
-import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
-import static org.hamcrest.Matchers.hasItems;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.isA;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -73,8 +59,6 @@ abstract class AbstractProductionMySQLRuntimeE2ETest extends AbstractTransportPa
protected static final String MASK_PLAN_TOOL_NAME = "database_gateway_plan_mask_rule";
- protected static final String STDIO_LOGBACK_CONFIG_FILE_NAME = "mcp-e2e-sdk-stdio-logback.xml";
-
private GenericContainer<?> container;
private String physicalSchemaName;
@@ -174,7 +158,7 @@ abstract class AbstractProductionMySQLRuntimeE2ETest extends AbstractTransportPa
}
protected static Stream<Arguments> transports() {
- return runtimeTransports().map(each -> Arguments.of(getTransportName(each), each));
+ return ProductionRuntimeTransportCases.transports();
}
protected static Stream<Arguments> dualTransports() {
@@ -182,43 +166,15 @@ abstract class AbstractProductionMySQLRuntimeE2ETest extends AbstractTransportPa
}
protected static Stream<Arguments> semanticPrimaryTransport() {
- return semanticPrimaryRuntimeTransports().map(each -> Arguments.of(getTransportName(each), each));
+ return ProductionRuntimeTransportCases.semanticPrimaryTransport();
}
protected static Stream<Arguments> assertReadSingleMetadataResourceCases() {
- return semanticPrimaryRuntimeTransports().flatMap(each -> Stream.of(
- Arguments.of(getTransportName(each) + " database detail", each, "shardingsphere://databases/logic_db", "database", LOGICAL_DATABASE_NAME),
- Arguments.of(getTransportName(each) + " schema detail", each, "shardingsphere://databases/logic_db/schemas/logic_db", "schema", LOGICAL_DATABASE_NAME),
- Arguments.of(getTransportName(each) + " table column detail", each,
- "shardingsphere://databases/logic_db/schemas/logic_db/tables/orders/columns/status", "column", "status"),
- Arguments.of(getTransportName(each) + " view detail", each, "shardingsphere://databases/logic_db/schemas/logic_db/views/active_orders", "view", "active_orders"),
- Arguments.of(getTransportName(each) + " view column detail", each,
- "shardingsphere://databases/logic_db/schemas/logic_db/views/active_orders/columns/status", "column", "status"),
- Arguments.of(getTransportName(each) + " index detail", each,
- "shardingsphere://databases/logic_db/schemas/logic_db/tables/orders/indexes/idx_orders_status", "index", "idx_orders_status")));
+ return ProductionRuntimeTransportCases.assertReadSingleMetadataResourceCases(LOGICAL_DATABASE_NAME);
}
protected static Stream<Arguments> assertReadCollectionMetadataResourceCases() {
- return semanticPrimaryRuntimeTransports().flatMap(each -> Stream.of(
- Arguments.of(getTransportName(each) + " schemas list", each, "shardingsphere://databases/logic_db/schemas", "schema", List.of(LOGICAL_DATABASE_NAME)),
- Arguments.of(getTransportName(each) + " tables list", each,
- "shardingsphere://databases/logic_db/schemas/logic_db/tables", "table", List.of("order_items", "orders")),
- Arguments.of(getTransportName(each) + " table columns list", each,
- "shardingsphere://databases/logic_db/schemas/logic_db/tables/orders/columns", "column", List.of("amount", "order_id", "status")),
- Arguments.of(getTransportName(each) + " view columns list", each,
- "shardingsphere://databases/logic_db/schemas/logic_db/views/active_orders/columns", "column", List.of("order_id", "status"))));
- }
-
- protected static Stream<RuntimeTransport> runtimeTransports() {
- return Stream.of(RuntimeTransport.HTTP, RuntimeTransport.STDIO);
- }
-
- private static Stream<RuntimeTransport> semanticPrimaryRuntimeTransports() {
- return Stream.of(RuntimeTransport.HTTP);
- }
-
- protected static String getTransportName(final RuntimeTransport transport) {
- return RuntimeTransport.HTTP == transport ? "http" : "stdio";
+ return ProductionRuntimeTransportCases.assertReadCollectionMetadataResourceCases(LOGICAL_DATABASE_NAME);
}
protected Map<String, RuntimeDatabaseConfiguration> createPreparedProgrammaticRuntimeDatabases() throws IOException {
@@ -338,33 +294,13 @@ abstract class AbstractProductionMySQLRuntimeE2ETest extends AbstractTransportPa
}
protected McpSyncClient createElicitationClient(final RuntimeTransport transport, final List<McpSchema.ElicitRequest> elicitationRequests) throws IOException {
- return McpClient.sync(createClientTransport(transport))
- .clientInfo(new McpSchema.Implementation("mcp-e2e-elicitation", "MCP E2E Elicitation", "1.0.0"))
- .capabilities(McpSchema.ClientCapabilities.builder().elicitation().build())
- .requestTimeout(Duration.ofSeconds(30L))
- .initializationTimeout(Duration.ofSeconds(30L))
- .elicitation(request -> createElicitationResult(elicitationRequests, request))
- .build();
+ return ProductionMCPClientTransportFactory.createElicitationClient(createClientTransport(transport), elicitationRequests, this::createElicitationResult);
}
- protected McpClientTransport createClientTransport(final RuntimeTransport transport) throws IOException {
+ private McpClientTransport createClientTransport(final RuntimeTransport transport) throws IOException {
return RuntimeTransport.HTTP == transport
- ? createHttpClientTransport()
- : createStdioClientTransport();
- }
-
- protected McpClientTransport createHttpClientTransport() throws IOException {
- URI endpointUri = getHttpEndpointUri();
- return HttpClientStreamableHttpTransport.builder(String.format("%s://%s:%d", endpointUri.getScheme(), endpointUri.getHost(), endpointUri.getPort()))
- .endpoint(endpointUri.getPath()).build();
- }
-
- protected StdioClientTransport createStdioClientTransport() throws IOException {
- Path configFile = getConfigFile();
- return new ProtocolAwareStdioClientTransport(ServerParameters.builder(Paths.get(System.getProperty("java.home"), "bin", "java").toString())
- .args("-Dlogback.configurationFile=" + MCPStdioLogbackConfiguration.createForConfig(configFile, STDIO_LOGBACK_CONFIG_FILE_NAME),
- "-cp", System.getProperty("java.class.path"), MCPBootstrap.class.getName(), configFile.toString())
- .build());
+ ? ProductionMCPClientTransportFactory.createHttpClientTransport(getHttpEndpointUri())
+ : ProductionMCPClientTransportFactory.createStdioClientTransport(getConfigFile());
}
protected McpSchema.ElicitResult createElicitationResult(final List<McpSchema.ElicitRequest> elicitationRequests,
@@ -377,20 +313,7 @@ abstract class AbstractProductionMySQLRuntimeE2ETest extends AbstractTransportPa
}
protected void assertElicitationRequest(final List<McpSchema.ElicitRequest> actualRequests) {
- assertThat(actualRequests.size(), is(1));
- McpSchema.ElicitRequest actual = actualRequests.getFirst();
- assertThat(actual.meta().get(MCPShardingSphereMetadataKeys.TOOL), is(MASK_PLAN_TOOL_NAME));
- assertFalse(String.valueOf(actual.meta().get(MCPShardingSphereMetadataKeys.PLAN_ID)).isBlank());
- Map<String, Object> actualRequestedSchema = actual.requestedSchema();
- assertThat(actualRequestedSchema.get("type"), is("object"));
- assertFalse((Boolean) actualRequestedSchema.get("additionalProperties"));
- Map<String, Object> actualProperties = castToMap(actualRequestedSchema.get("properties"));
- assertTrue(actualProperties.containsKey("field_1"));
- assertTrue(actualProperties.containsKey("field_2"));
- assertThat(String.valueOf(castToMap(actualProperties.get("field_1")).get("description")), is("Please provide property `from-x`."));
- assertThat(String.valueOf(castToMap(actualProperties.get("field_2")).get("description")), is("Please provide property `to-y`."));
- assertFalse(actualProperties.keySet().stream().map(String::valueOf).anyMatch(each -> each.contains("secret") || each.contains("password") || each.contains("token")));
- assertThat(getStringList(actualRequestedSchema.get("required")), hasItems("field_1", "field_2"));
+ ProductionMCPClientTransportFactory.assertElicitationRequest(actualRequests);
}
@SuppressWarnings("unchecked")
@@ -415,18 +338,6 @@ abstract class AbstractProductionMySQLRuntimeE2ETest extends AbstractTransportPa
return ((List<?>) value).stream().map(String::valueOf).toList();
}
- protected static final class ProtocolAwareStdioClientTransport extends StdioClientTransport {
-
- protected ProtocolAwareStdioClientTransport(final ServerParameters params) {
- super(params, MCPTransportJsonMapperFactory.create());
- }
-
- @Override
- public List<String> protocolVersions() {
- return List.of(ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
- }
- }
-
private static final class MySQLRuntimeFixture implements AutoCloseable {
private final GenericContainer<?> container;
diff --git a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/production/ProductionMCPClientTransportFactory.java b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/production/ProductionMCPClientTransportFactory.java
new file mode 100644
index 00000000000..3a55210f428
--- /dev/null
+++ b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/production/ProductionMCPClientTransportFactory.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.test.e2e.mcp.runtime.production;
+
+import io.modelcontextprotocol.client.McpClient;
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
+import io.modelcontextprotocol.client.transport.ServerParameters;
+import io.modelcontextprotocol.client.transport.StdioClientTransport;
+import io.modelcontextprotocol.spec.McpClientTransport;
+import io.modelcontextprotocol.spec.McpSchema;
+import io.modelcontextprotocol.spec.ProtocolVersions;
+import org.apache.shardingsphere.mcp.bootstrap.MCPBootstrap;
+import org.apache.shardingsphere.mcp.bootstrap.transport.MCPTransportJsonMapperFactory;
+import org.apache.shardingsphere.mcp.support.descriptor.MCPShardingSphereMetadataKeys;
+import org.apache.shardingsphere.test.e2e.mcp.support.transport.client.MCPStdioLogbackConfiguration;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiFunction;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.hasItems;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+final class ProductionMCPClientTransportFactory {
+
+ private static final String STDIO_LOGBACK_CONFIG_FILE_NAME = "mcp-e2e-sdk-stdio-logback.xml";
+
+ private static final String MASK_PLAN_TOOL_NAME = "database_gateway_plan_mask_rule";
+
+ private ProductionMCPClientTransportFactory() {
+ }
+
+ static McpSyncClient createElicitationClient(final McpClientTransport clientTransport, final List<McpSchema.ElicitRequest> elicitationRequests,
+ final BiFunction<List<McpSchema.ElicitRequest>, McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler) {
+ return McpClient.sync(clientTransport)
+ .clientInfo(new McpSchema.Implementation("mcp-e2e-elicitation", "MCP E2E Elicitation", "1.0.0"))
+ .capabilities(McpSchema.ClientCapabilities.builder().elicitation().build())
+ .requestTimeout(Duration.ofSeconds(30L))
+ .initializationTimeout(Duration.ofSeconds(30L))
+ .elicitation(request -> elicitationHandler.apply(elicitationRequests, request))
+ .build();
+ }
+
+ static McpClientTransport createHttpClientTransport(final URI endpointUri) {
+ return HttpClientStreamableHttpTransport.builder(String.format("%s://%s:%d", endpointUri.getScheme(), endpointUri.getHost(), endpointUri.getPort()))
+ .endpoint(endpointUri.getPath()).build();
+ }
+
+ static StdioClientTransport createStdioClientTransport(final Path configFile) throws IOException {
+ return new ProtocolAwareStdioClientTransport(ServerParameters.builder(Paths.get(System.getProperty("java.home"), "bin", "java").toString())
+ .args("-Dlogback.configurationFile=" + MCPStdioLogbackConfiguration.createForConfig(configFile, STDIO_LOGBACK_CONFIG_FILE_NAME),
+ "-cp", System.getProperty("java.class.path"), MCPBootstrap.class.getName(), configFile.toString())
+ .build());
+ }
+
+ static void assertElicitationRequest(final List<McpSchema.ElicitRequest> actualRequests) {
+ assertThat(actualRequests.size(), is(1));
+ McpSchema.ElicitRequest actual = actualRequests.getFirst();
+ assertThat(actual.meta().get(MCPShardingSphereMetadataKeys.TOOL), is(MASK_PLAN_TOOL_NAME));
+ assertFalse(String.valueOf(actual.meta().get(MCPShardingSphereMetadataKeys.PLAN_ID)).isBlank());
+ Map<String, Object> actualRequestedSchema = actual.requestedSchema();
+ assertThat(actualRequestedSchema.get("type"), is("object"));
+ assertFalse((Boolean) actualRequestedSchema.get("additionalProperties"));
+ Map<String, Object> actualProperties = castToMap(actualRequestedSchema.get("properties"));
+ assertTrue(actualProperties.containsKey("field_1"));
+ assertTrue(actualProperties.containsKey("field_2"));
+ assertThat(String.valueOf(castToMap(actualProperties.get("field_1")).get("description")), is("Please provide property `from-x`."));
+ assertThat(String.valueOf(castToMap(actualProperties.get("field_2")).get("description")), is("Please provide property `to-y`."));
+ assertFalse(actualProperties.keySet().stream().map(String::valueOf).anyMatch(each -> each.contains("secret") || each.contains("password") || each.contains("token")));
+ assertThat(getStringList(actualRequestedSchema.get("required")), hasItems("field_1", "field_2"));
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map<String, Object> castToMap(final Object value) {
+ return (Map<String, Object>) value;
+ }
+
+ private static List<String> getStringList(final Object value) {
+ return ((List<?>) value).stream().map(String::valueOf).toList();
+ }
+
+ private static final class ProtocolAwareStdioClientTransport extends StdioClientTransport {
+
+ private ProtocolAwareStdioClientTransport(final ServerParameters params) {
+ super(params, MCPTransportJsonMapperFactory.create());
+ }
+
+ @Override
+ public List<String> protocolVersions() {
+ return List.of(ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
+ }
+ }
+}
diff --git a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/production/ProductionRuntimeTransportCases.java b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/production/ProductionRuntimeTransportCases.java
new file mode 100644
index 00000000000..aa145d11d30
--- /dev/null
+++ b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/production/ProductionRuntimeTransportCases.java
@@ -0,0 +1,80 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.test.e2e.mcp.runtime.production;
+
+import org.apache.shardingsphere.test.e2e.mcp.support.runtime.RuntimeTransport;
+import org.junit.jupiter.params.provider.Arguments;
+
+import java.util.List;
+import java.util.stream.Stream;
+
+final class ProductionRuntimeTransportCases {
+
+ private ProductionRuntimeTransportCases() {
+ }
+
+ static Stream<Arguments> transports() {
+ return runtimeTransports().map(each -> Arguments.of(getTransportName(each), each));
+ }
+
+ static Stream<Arguments> semanticPrimaryTransport() {
+ return semanticPrimaryRuntimeTransports().map(each -> Arguments.of(getTransportName(each), each));
+ }
+
+ static Stream<Arguments> assertReadSingleMetadataResourceCases(final String logicalDatabaseName) {
+ return semanticPrimaryRuntimeTransports().flatMap(each -> createSingleMetadataResourceCases(logicalDatabaseName, each));
+ }
+
+ static Stream<Arguments> assertReadCollectionMetadataResourceCases(final String logicalDatabaseName) {
+ return semanticPrimaryRuntimeTransports().flatMap(each -> Stream.of(
+ Arguments.of(getTransportName(each) + " schemas list", each, "shardingsphere://databases/" + logicalDatabaseName + "/schemas", "schema", List.of(logicalDatabaseName)),
+ Arguments.of(getTransportName(each) + " tables list", each,
+ "shardingsphere://databases/" + logicalDatabaseName + "/schemas/" + logicalDatabaseName + "/tables", "table", List.of("order_items", "orders")),
+ Arguments.of(getTransportName(each) + " table columns list", each,
+ "shardingsphere://databases/" + logicalDatabaseName + "/schemas/" + logicalDatabaseName + "/tables/orders/columns", "column", List.of("amount", "order_id", "status")),
+ Arguments.of(getTransportName(each) + " view columns list", each,
+ "shardingsphere://databases/" + logicalDatabaseName + "/schemas/" + logicalDatabaseName + "/views/active_orders/columns", "column", List.of("order_id", "status"))));
+ }
+
+ static Stream<RuntimeTransport> runtimeTransports() {
+ return Stream.of(RuntimeTransport.HTTP, RuntimeTransport.STDIO);
+ }
+
+ static String getTransportName(final RuntimeTransport transport) {
+ return RuntimeTransport.HTTP == transport ? "http" : "stdio";
+ }
+
+ private static Stream<RuntimeTransport> semanticPrimaryRuntimeTransports() {
+ return Stream.of(RuntimeTransport.HTTP);
+ }
+
+ private static Stream<Arguments> createSingleMetadataResourceCases(final String logicalDatabaseName, final RuntimeTransport transport) {
+ return Stream.of(
+ Arguments.of(getTransportName(transport) + " database detail", transport, "shardingsphere://databases/" + logicalDatabaseName, "database", logicalDatabaseName),
+ Arguments.of(getTransportName(transport) + " schema detail", transport, "shardingsphere://databases/" + logicalDatabaseName + "/schemas/" + logicalDatabaseName, "schema",
+ logicalDatabaseName),
+ Arguments.of(getTransportName(transport) + " table column detail", transport,
+ "shardingsphere://databases/" + logicalDatabaseName + "/schemas/" + logicalDatabaseName + "/tables/orders/columns/status", "column", "status"),
+ Arguments.of(getTransportName(transport) + " view detail", transport,
+ "shardingsphere://databases/" + logicalDatabaseName + "/schemas/" + logicalDatabaseName + "/views/active_orders", "view", "active_orders"),
+ Arguments.of(getTransportName(transport) + " view column detail", transport,
+ "shardingsphere://databases/" + logicalDatabaseName + "/schemas/" + logicalDatabaseName + "/views/active_orders/columns/status", "column", "status"),
+ Arguments.of(getTransportName(transport) + " index detail", transport,
+ "shardingsphere://databases/" + logicalDatabaseName + "/schemas/" + logicalDatabaseName + "/tables/orders/indexes/idx_orders_status", "index", "idx_orders_status"));
+ }
+}
diff --git a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/support/runtime/MySQLRuntimeDockerSupport.java b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/support/runtime/MySQLRuntimeDockerSupport.java
new file mode 100644
index 00000000000..7260416075e
--- /dev/null
+++ b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/support/runtime/MySQLRuntimeDockerSupport.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shardingsphere.test.e2e.mcp.support.runtime;
+
+import org.apache.shardingsphere.test.e2e.env.runtime.EnvironmentPropertiesLoader;
+import org.testcontainers.DockerClientFactory;
+
+import java.time.Duration;
+import java.util.Optional;
+import java.util.Properties;
+
+final class MySQLRuntimeDockerSupport {
+
+ private MySQLRuntimeDockerSupport() {
+ }
+
+ static String getMySQLImage() {
+ return getMySQLImage(EnvironmentPropertiesLoader.loadProperties());
+ }
+
+ static String getMySQLImage(final Properties props) {
+ String result = props.getProperty("mcp.e2e.mysql.image", "").trim();
+ if (result.isEmpty()) {
+ throw new IllegalStateException("MCP E2E MySQL image property `mcp.e2e.mysql.image` is required.");
+ }
+ return result;
+ }
+
+ static boolean isDockerAvailable() {
+ return getDockerUnavailableReason().isEmpty();
+ }
+
+ static Optional<String> getDockerUnavailableReason() {
+ try {
+ return DockerClientFactory.instance().isDockerAvailable()
+ ? Optional.empty()
+ : Optional.of("Testcontainers Docker client reported Docker unavailable.");
+ } catch (final IllegalStateException ex) {
+ return Optional.of(createDockerUnavailableReason(ex));
+ }
+ }
+
+ static String createDockerRequiredMessage(final String scenarioMessage) {
+ return createDockerRequiredMessage(scenarioMessage, getDockerUnavailableReason().orElse(""));
+ }
+
+ static String createDockerRequiredMessage(final String scenarioMessage, final String unavailableReason) {
+ return unavailableReason.isEmpty() ? scenarioMessage : scenarioMessage + " Docker readiness diagnostic: " + unavailableReason;
+ }
+
+ static long getJdbcReadyTimeoutMillis(final Duration defaultReadyTimeout) {
+ String configuredJdbcReadyTimeoutSeconds = EnvironmentPropertiesLoader.loadProperties().getProperty("mcp.e2e.mysql.ready-timeout-seconds", "").trim();
+ try {
+ if (configuredJdbcReadyTimeoutSeconds.isEmpty()) {
+ return defaultReadyTimeout.toMillis();
+ }
+ int parsedJdbcReadyTimeoutSeconds = Integer.parseInt(configuredJdbcReadyTimeoutSeconds);
+ if (0 >= parsedJdbcReadyTimeoutSeconds) {
+ throw new IllegalArgumentException("MCP E2E MySQL JDBC readiness timeout must be positive.");
+ }
+ return Duration.ofSeconds(parsedJdbcReadyTimeoutSeconds).toMillis();
+ } catch (final NumberFormatException ex) {
+ throw new IllegalArgumentException("MCP E2E MySQL JDBC readiness timeout must be an integer.", ex);
+ }
+ }
+
+ private static String createDockerUnavailableReason(final IllegalStateException ex) {
+ return null == ex.getMessage() || ex.getMessage().isBlank()
+ ? "Testcontainers Docker availability check failed without a message."
+ : ex.getMessage();
+ }
+}
diff --git a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/support/runtime/MySQLRuntimeTestSupport.java b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/support/runtime/MySQLRuntimeTestSupport.java
index 1ad17219004..2edf51b44fa 100644
--- a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/support/runtime/MySQLRuntimeTestSupport.java
+++ b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/support/runtime/MySQLRuntimeTestSupport.java
@@ -22,8 +22,6 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.apache.shardingsphere.mcp.support.database.metadata.jdbc.RuntimeDatabaseConfiguration;
-import org.apache.shardingsphere.test.e2e.env.runtime.EnvironmentPropertiesLoader;
-import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.utility.DockerImageName;
@@ -75,7 +73,7 @@ public final class MySQLRuntimeTestSupport {
* @return MySQL runtime container
*/
public static GenericContainer<?> createContainer() {
- return new GenericContainer<>(DockerImageName.parse(getMySQLImage()))
+ return new GenericContainer<>(DockerImageName.parse(MySQLRuntimeDockerSupport.getMySQLImage()))
.withEnv("MYSQL_ROOT_PASSWORD", ROOT_PASSWORD)
.withEnv("MYSQL_DATABASE", DATABASE_NAME)
.withEnv("MYSQL_USER", USERNAME)
@@ -85,16 +83,8 @@ public final class MySQLRuntimeTestSupport {
.withStartupTimeout(Duration.ofMinutes(2));
}
- private static String getMySQLImage() {
- return getMySQLImage(EnvironmentPropertiesLoader.loadProperties());
- }
-
static String getMySQLImage(final Properties props) {
- String result = props.getProperty("mcp.e2e.mysql.image", "").trim();
- if (result.isEmpty()) {
- throw new IllegalStateException("MCP E2E MySQL image property `mcp.e2e.mysql.image` is required.");
- }
- return result;
+ return MySQLRuntimeDockerSupport.getMySQLImage(props);
}
/**
@@ -103,7 +93,7 @@ public final class MySQLRuntimeTestSupport {
* @return whether Docker is available
*/
public static boolean isDockerAvailable() {
- return getDockerUnavailableReason().isEmpty();
+ return MySQLRuntimeDockerSupport.isDockerAvailable();
}
/**
@@ -112,13 +102,7 @@ public final class MySQLRuntimeTestSupport {
* @return Docker unavailable reason
*/
public static Optional<String> getDockerUnavailableReason() {
- try {
- return DockerClientFactory.instance().isDockerAvailable()
- ? Optional.empty()
- : Optional.of("Testcontainers Docker client reported Docker unavailable.");
- } catch (final IllegalStateException ex) {
- return Optional.of(createDockerUnavailableReason(ex));
- }
+ return MySQLRuntimeDockerSupport.getDockerUnavailableReason();
}
/**
@@ -128,17 +112,11 @@ public final class MySQLRuntimeTestSupport {
* @return Docker-required message
*/
public static String createDockerRequiredMessage(final String scenarioMessage) {
- return createDockerRequiredMessage(scenarioMessage, getDockerUnavailableReason().orElse(""));
+ return MySQLRuntimeDockerSupport.createDockerRequiredMessage(scenarioMessage);
}
static String createDockerRequiredMessage(final String scenarioMessage, final String unavailableReason) {
- return unavailableReason.isEmpty() ? scenarioMessage : scenarioMessage + " Docker readiness diagnostic: " + unavailableReason;
- }
-
- private static String createDockerUnavailableReason(final IllegalStateException ex) {
- return null == ex.getMessage() || ex.getMessage().isBlank()
- ? "Testcontainers Docker availability check failed without a message."
- : ex.getMessage();
+ return MySQLRuntimeDockerSupport.createDockerRequiredMessage(scenarioMessage, unavailableReason);
}
/**
@@ -324,21 +302,7 @@ public final class MySQLRuntimeTestSupport {
private static Connection getConnection(final GenericContainer<?> container, final String databaseName) throws SQLException {
String jdbcUrl = createJdbcUrl(container.getHost(), container.getMappedPort(3306), databaseName);
- String configuredJdbcReadyTimeoutSeconds = EnvironmentPropertiesLoader.loadProperties().getProperty("mcp.e2e.mysql.ready-timeout-seconds", "").trim();
- long jdbcReadyTimeoutMillis;
- try {
- if (configuredJdbcReadyTimeoutSeconds.isEmpty()) {
- jdbcReadyTimeoutMillis = DEFAULT_JDBC_READY_TIMEOUT.toMillis();
- } else {
- int parsedJdbcReadyTimeoutSeconds = Integer.parseInt(configuredJdbcReadyTimeoutSeconds);
- if (0 >= parsedJdbcReadyTimeoutSeconds) {
- throw new IllegalArgumentException("MCP E2E MySQL JDBC readiness timeout must be positive.");
- }
- jdbcReadyTimeoutMillis = Duration.ofSeconds(parsedJdbcReadyTimeoutSeconds).toMillis();
- }
- } catch (final NumberFormatException ex) {
- throw new IllegalArgumentException("MCP E2E MySQL JDBC readiness timeout must be an integer.", ex);
- }
+ long jdbcReadyTimeoutMillis = MySQLRuntimeDockerSupport.getJdbcReadyTimeoutMillis(DEFAULT_JDBC_READY_TIMEOUT);
try {
return new ReadinessProbe(jdbcReadyTimeoutMillis, JDBC_READY_INITIAL_INTERVAL_MILLIS, JDBC_READY_MAX_INTERVAL_MILLIS)
.waitUntilReady(() -> getConnectionIfReady(jdbcUrl),