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 3c5c1d85b39 Improve MCP tool argument validation and HTTP contract
coverage (#39020)
3c5c1d85b39 is described below
commit 3c5c1d85b3902ac36d86dd6704d36f6c1e1e9002
Author: Liang Zhang <[email protected]>
AuthorDate: Mon Jul 6 17:25:13 2026 +0800
Improve MCP tool argument validation and HTTP contract coverage (#39020)
* Improve ShardingSphere MCP prompt completions
- add supported prompt completion targets for planning prompts
- support storage unit completion aliases used by feature prompts
- clarify execute_update as preview-or-execute side-effecting SQL
* Improve MCP tool argument validation and HTTP contract coverage
- Validate integer bounds and schema-valued additionalProperties in MCP
tool arguments
- Apply execution argument validation to execute_update preview mode
- Return explicit 405 for unsupported Streamable HTTP GET event streams
- Add focused unit and E2E coverage for tool errors, rate limits, and HTTP
transport contracts
---
.../server/http/StreamableHttpMCPServlet.java | 23 +++++-
.../server/http/StreamableHttpMCPServletTest.java | 20 ++----
.../core/tool/handler/MCPToolArgumentContract.java | 75 +++++++++++++++----
.../handler/execute/ExecuteUpdateToolHandler.java | 1 +
.../tool/handler/ToolDefinitionRegistryTest.java | 34 +++++++++
.../execute/ExecuteUpdateToolHandlerTest.java | 14 ++++
.../AbstractHttpProtocolOnlyE2ETest.java | 11 +++
.../HttpTransportProtocolContractE2ETest.java | 84 ++++++++++++++++++++++
.../client/MCPHttpTransportTestSupport.java | 18 +++++
9 files changed, 251 insertions(+), 29 deletions(-)
diff --git
a/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/http/StreamableHttpMCPServlet.java
b/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/http/StreamableHttpMCPServlet.java
index 21c7d125e02..fc849f2c811 100644
---
a/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/http/StreamableHttpMCPServlet.java
+++
b/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/http/StreamableHttpMCPServlet.java
@@ -61,6 +61,8 @@ final class StreamableHttpMCPServlet extends HttpServlet
implements McpStreamabl
private static final String JSON_CONTENT_TYPE = "application/json";
+ private static final String EVENT_STREAM_CONTENT_TYPE =
"text/event-stream";
+
private final HttpServletStreamableServerTransportProvider delegate;
private final McpJsonMapper jsonMapper;
@@ -135,8 +137,25 @@ final class StreamableHttpMCPServlet extends HttpServlet
implements McpStreamabl
}
@Override
- protected void doGet(final HttpServletRequest request, final
HttpServletResponse response) throws IOException, ServletException {
- serviceRequest(request, response);
+ protected void doGet(final HttpServletRequest request, final
HttpServletResponse response) throws IOException {
+ setUtf8Encoding(request, response);
+ if (!isEventStreamAccepted(request)) {
+ response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Accept
must include text/event-stream.");
+ return;
+ }
+ if (validateTransportSecurity(request, response)) {
+ response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED,
"HTTP GET event streams are not supported.");
+ }
+ }
+
+ private boolean isEventStreamAccepted(final HttpServletRequest request) {
+ String acceptHeader =
Objects.toString(request.getHeader(HttpHeaders.ACCEPT), "");
+ for (String each : acceptHeader.split(",")) {
+ if (EVENT_STREAM_CONTENT_TYPE.equalsIgnoreCase(each.split(";",
2)[0].trim())) {
+ return true;
+ }
+ }
+ return false;
}
private void serviceWithApplicationClassLoader(final HttpServletRequest
request, final HttpServletResponse response) throws IOException,
ServletException {
diff --git
a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/http/StreamableHttpMCPServletTest.java
b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/http/StreamableHttpMCPServletTest.java
index 0d09037d737..1c8b82859a7 100644
---
a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/http/StreamableHttpMCPServletTest.java
+++
b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/server/http/StreamableHttpMCPServletTest.java
@@ -182,22 +182,15 @@ class StreamableHttpMCPServletTest {
HttpServletResponse response = mock(HttpServletResponse.class);
when(delegate.closeGracefully()).thenReturn(Mono.empty());
StreamableHttpMCPServlet actual = createServlet(delegate,
mock(MCPSessionManager.class), mock(MCPSessionExecutionCoordinator.class));
- doAnswer(invocation ->
assertDelegatedRequest(invocation.getArgument(0), invocation.getArgument(1),
request, response))
- .when(delegate).service(any(HttpServletRequest.class),
any(HttpServletResponse.class));
actual.service(request, response);
verify(request).setCharacterEncoding("UTF-8");
verify(response).setCharacterEncoding("UTF-8");
- }
-
- private Object assertDelegatedRequest(final HttpServletRequest
actualRequest, final HttpServletResponse actualResponse, final
HttpServletRequest request, final HttpServletResponse response) {
- assertThat(actualRequest, is(request));
- assertThat(actualResponse, is(response));
- assertThat(Thread.currentThread().getContextClassLoader(),
is(StreamableHttpMCPServlet.class.getClassLoader()));
- return null;
+ verify(response).sendError(HttpServletResponse.SC_BAD_REQUEST, "Accept
must include text/event-stream.");
+ verify(delegate, never()).service(any(HttpServletRequest.class),
any(HttpServletResponse.class));
}
@Test
- void assertServiceGetWithExistingAcceptHeader() throws ServletException,
IOException, ReflectiveOperationException {
+ void assertRejectUnsupportedEventStreamGet() throws ServletException,
IOException, ReflectiveOperationException {
HttpServletStreamableServerTransportProvider delegate =
mock(HttpServletStreamableServerTransportProvider.class);
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getMethod()).thenReturn("GET");
@@ -205,14 +198,11 @@ class StreamableHttpMCPServletTest {
HttpServletResponse response = mock(HttpServletResponse.class);
when(delegate.closeGracefully()).thenReturn(Mono.empty());
StreamableHttpMCPServlet actual = createServlet(delegate,
mock(MCPSessionManager.class), mock(MCPSessionExecutionCoordinator.class));
- doAnswer(invocation -> {
- assertThat(invocation.getArgument(0), is(request));
- assertThat(((HttpServletRequest)
invocation.getArgument(0)).getHeader(HttpHeaders.ACCEPT),
is("text/event-stream"));
- return null;
- }).when(delegate).service(any(HttpServletRequest.class),
any(HttpServletResponse.class));
actual.service(request, response);
verify(request).setCharacterEncoding("UTF-8");
verify(response).setCharacterEncoding("UTF-8");
+ verify(response).sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED,
"HTTP GET event streams are not supported.");
+ verify(delegate, never()).service(any(HttpServletRequest.class),
any(HttpServletResponse.class));
}
@Test
diff --git
a/mcp/core/src/main/java/org/apache/shardingsphere/mcp/core/tool/handler/MCPToolArgumentContract.java
b/mcp/core/src/main/java/org/apache/shardingsphere/mcp/core/tool/handler/MCPToolArgumentContract.java
index c3477c43e7a..2f15c959d03 100644
---
a/mcp/core/src/main/java/org/apache/shardingsphere/mcp/core/tool/handler/MCPToolArgumentContract.java
+++
b/mcp/core/src/main/java/org/apache/shardingsphere/mcp/core/tool/handler/MCPToolArgumentContract.java
@@ -20,9 +20,11 @@ package org.apache.shardingsphere.mcp.core.tool.handler;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import org.apache.shardingsphere.infra.exception.ShardingSpherePreconditions;
+import
org.apache.shardingsphere.mcp.api.protocol.exception.MCPInvalidRequestException;
import
org.apache.shardingsphere.mcp.core.protocol.exception.MCPExecutionModeRequiredException;
import
org.apache.shardingsphere.mcp.core.protocol.exception.MCPInvalidApprovedStepsException;
import
org.apache.shardingsphere.mcp.core.protocol.exception.MCPInvalidExecutionModeException;
+import
org.apache.shardingsphere.mcp.core.protocol.exception.MCPInvalidToolArgumentException;
import
org.apache.shardingsphere.mcp.core.protocol.exception.MCPMissingToolArgumentException;
import
org.apache.shardingsphere.mcp.core.protocol.exception.MCPToolArgumentContractViolationException;
import org.apache.shardingsphere.mcp.support.protocol.MCPPayloadFieldNames;
@@ -52,6 +54,12 @@ final class MCPToolArgumentContract {
private static final String ENUM = "enum";
+ private static final String MINIMUM = "minimum";
+
+ private static final String MAXIMUM = "maximum";
+
+ private static final String DEFAULT_VALUE = "default";
+
private static final String STRING = "string";
private static final String INTEGER = "integer";
@@ -74,13 +82,9 @@ final class MCPToolArgumentContract {
private void validateObject(final Map<?, ?> arguments, final Map<?, ?>
schema, final String path, final Map<String, Object> rootArguments) {
validateRequiredArguments(arguments, schema, path, rootArguments);
- validateUnknownArguments(arguments, schema, path, rootArguments);
for (Entry<?, ?> entry : arguments.entrySet()) {
String argumentName = Objects.toString(entry.getKey(), "");
- Map<?, ?> property = findProperty(schema, argumentName);
- if (!property.isEmpty()) {
- validateArgument(entry.getValue(), property, appendPath(path,
argumentName), rootArguments);
- }
+ validatePropertyArgument(entry.getValue(), schema,
appendPath(path, argumentName), rootArguments, argumentName);
}
}
@@ -107,15 +111,18 @@ final class MCPToolArgumentContract {
ShardingSpherePreconditions.checkState(!actualValue.isEmpty(), () ->
createMissingArgumentException(rootArguments, path));
}
- private void validateUnknownArguments(final Map<?, ?> arguments, final
Map<?, ?> schema, final String path, final Map<String, Object> rootArguments) {
- if (!Boolean.FALSE.equals(schema.get(ADDITIONAL_PROPERTIES))) {
+ private void validatePropertyArgument(final Object value, final Map<?, ?>
schema, final String argumentPath, final Map<String, Object> rootArguments,
final String argumentName) {
+ Map<?, ?> property = findProperty(schema, argumentName);
+ if (!property.isEmpty()) {
+ validateArgument(value, property, argumentPath, rootArguments);
return;
}
- for (Object each : arguments.keySet()) {
- String argumentName = Objects.toString(each, "");
- if (findProperty(schema, argumentName).isEmpty()) {
- throw createContractViolationException(rootArguments,
appendPath(path, argumentName), "unknown_argument", "", List.of());
- }
+ Object additionalProperties = schema.get(ADDITIONAL_PROPERTIES);
+ if (Boolean.FALSE.equals(additionalProperties)) {
+ throw createContractViolationException(rootArguments,
argumentPath, "unknown_argument", "", List.of());
+ }
+ if (additionalProperties instanceof Map<?, ?>) {
+ validateArgument(value, (Map<?, ?>) additionalProperties,
argumentPath, rootArguments);
}
}
@@ -125,6 +132,9 @@ final class MCPToolArgumentContract {
ShardingSpherePreconditions.checkState(isValidType(value,
expectedType),
() -> createContractViolationException(rootArguments,
path, "invalid_argument_type", expectedType, List.of()));
}
+ if (INTEGER.equals(expectedType)) {
+ validateIntegerRange(value, schema, path);
+ }
validateEnumValue(value, schema, path, rootArguments);
if (ARRAY.equals(expectedType)) {
validateArrayArgument((Collection<?>) value, schema, path,
rootArguments);
@@ -153,6 +163,47 @@ final class MCPToolArgumentContract {
return !OBJECT.equals(expectedType) || value instanceof Map<?, ?>;
}
+ private void validateIntegerRange(final Object value, final Map<?, ?>
schema, final String path) {
+ if (!(schema.get(MINIMUM) instanceof Number) || !(schema.get(MAXIMUM)
instanceof Number)) {
+ return;
+ }
+ BigInteger actualValue = toBigInteger(value);
+ BigInteger minimumValue = toBigInteger(schema.get(MINIMUM));
+ BigInteger maximumValue = toBigInteger(schema.get(MAXIMUM));
+
ShardingSpherePreconditions.checkState(isIntegerWithinRange(actualValue,
minimumValue, maximumValue), () -> createInvalidIntegerArgumentException(value,
schema, path));
+ }
+
+ private RuntimeException createInvalidIntegerArgumentException(final
Object value, final Map<?, ?> schema, final String path) {
+ int minimum = getIntegerSchemaValue(schema, MINIMUM);
+ int maximum = getIntegerSchemaValue(schema, MAXIMUM);
+ return new MCPInvalidToolArgumentException(toolName, toolName, path,
minimum, maximum, getSuggestedIntegerValue(value, schema, minimum, maximum),
+ new MCPInvalidRequestException(String.format("%s is out of
range.", path)));
+ }
+
+ private BigInteger toBigInteger(final Object value) {
+ return value instanceof BigInteger ? (BigInteger) value :
BigInteger.valueOf(((Number) value).longValue());
+ }
+
+ private int getIntegerSchemaValue(final Map<?, ?> schema, final String
key) {
+ return ((Number) schema.get(key)).intValue();
+ }
+
+ private int getSuggestedIntegerValue(final Object value, final Map<?, ?>
schema, final int minimumValue, final int maximumValue) {
+ Object defaultValue = schema.get(DEFAULT_VALUE);
+ if (isValidType(defaultValue, INTEGER) &&
isIntegerWithinRange(defaultValue, minimumValue, maximumValue)) {
+ return ((Number) defaultValue).intValue();
+ }
+ return toBigInteger(value).compareTo(BigInteger.valueOf(minimumValue))
< 0 ? minimumValue : maximumValue;
+ }
+
+ private boolean isIntegerWithinRange(final Object value, final int
minimumValue, final int maximumValue) {
+ return isIntegerWithinRange(toBigInteger(value),
BigInteger.valueOf(minimumValue), BigInteger.valueOf(maximumValue));
+ }
+
+ private boolean isIntegerWithinRange(final BigInteger value, final
BigInteger minimumValue, final BigInteger maximumValue) {
+ return value.compareTo(minimumValue) >= 0 &&
value.compareTo(maximumValue) <= 0;
+ }
+
private void validateEnumValue(final Object value, final Map<?, ?> schema,
final String path, final Map<String, Object> rootArguments) {
Object enumValues = schema.get(ENUM);
if (enumValues instanceof Collection<?> && !((Collection<?>)
enumValues).contains(value)) {
diff --git
a/mcp/core/src/main/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/ExecuteUpdateToolHandler.java
b/mcp/core/src/main/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/ExecuteUpdateToolHandler.java
index 6c287fa9cd4..eacaa424c14 100644
---
a/mcp/core/src/main/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/ExecuteUpdateToolHandler.java
+++
b/mcp/core/src/main/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/ExecuteUpdateToolHandler.java
@@ -72,6 +72,7 @@ public final class ExecuteUpdateToolHandler implements
MCPToolHandler<MCPDatabas
public MCPResponse handle(final MCPDatabaseHandlerContext databaseContext,
final MCPToolCall toolCall) {
MCPToolArguments toolArguments = new
MCPToolArguments(toolCall.getArguments());
String executionMode = resolveExecutionMode(toolArguments);
+ SQLExecutionToolHandlerSupport.checkExecutionArguments(toolArguments,
TOOL_NAME);
String sql = toolArguments.getStringArgument("sql");
ClassificationResult classificationResult =
checkUpdateStatement(toolArguments, sql);
if (EXECUTION_MODE_PREVIEW.equals(executionMode)) {
diff --git
a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/ToolDefinitionRegistryTest.java
b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/ToolDefinitionRegistryTest.java
index 5be43b2e496..ec8fd4649d2 100644
---
a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/ToolDefinitionRegistryTest.java
+++
b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/ToolDefinitionRegistryTest.java
@@ -29,6 +29,7 @@ import
org.apache.shardingsphere.mcp.core.context.MCPRuntimeContext;
import
org.apache.shardingsphere.mcp.core.protocol.exception.MCPExecutionModeRequiredException;
import
org.apache.shardingsphere.mcp.core.protocol.exception.MCPInvalidApprovedStepsException;
import
org.apache.shardingsphere.mcp.core.protocol.exception.MCPInvalidExecutionModeException;
+import
org.apache.shardingsphere.mcp.core.protocol.exception.MCPInvalidToolArgumentException;
import
org.apache.shardingsphere.mcp.core.protocol.exception.MCPToolArgumentContractViolationException;
import
org.apache.shardingsphere.mcp.core.protocol.exception.UnsupportedToolException;
import org.apache.shardingsphere.mcp.core.resource.ResourceTestDataFactory;
@@ -139,6 +140,20 @@ class ToolDefinitionRegistryTest {
assertThat(actual.getSuggestedArguments(), is(Map.of("query",
"order")));
}
+ @Test
+ void assertDispatchWithIntegerAboveMaximum() {
+ MCPInvalidToolArgumentException actual =
assertThrows(MCPInvalidToolArgumentException.class,
+ () -> dispatch("database_gateway_execute_update",
Map.of("database", "logic_db", "sql", "UPDATE orders SET status = 'PAID' WHERE
order_id = 1",
+ "execution_mode", "preview", "max_rows", 5001)));
+ assertThat(actual.getMessage(), is("max_rows must be an integer
between 0 and 5000."));
+ assertThat(actual.getSourceTool(),
is("database_gateway_execute_update"));
+ assertThat(actual.getTargetTool(),
is("database_gateway_execute_update"));
+ assertThat(actual.getArgumentPath(), is("max_rows"));
+ assertThat(actual.getMinimumValue(), is(0));
+ assertThat(actual.getMaximumValue(), is(5000));
+ assertThat(actual.getSuggestedValue(), is(100));
+ }
+
@Test
void assertDispatchWithInvalidEnumArgument() {
MCPToolArgumentContractViolationException actual =
assertThrows(MCPToolArgumentContractViolationException.class,
@@ -200,6 +215,19 @@ class ToolDefinitionRegistryTest {
assertThat(actual.getSuggestedArguments(), is(Map.of()));
}
+ @Test
+ void assertValidateWithSchemaAdditionalProperties() {
+ MCPToolDescriptor descriptor =
createAdditionalPropertiesFixtureToolDescriptor();
+ MCPToolArgumentContractViolationException actual =
assertThrows(MCPToolArgumentContractViolationException.class,
+ () -> new MCPToolArgumentContract(descriptor.getName(),
descriptor.getInputSchema()).validate(Map.of("options", Map.of("mode", 1))));
+ assertThat(actual.getMessage(), is("options.mode must be a string."));
+ assertThat(actual.getToolName(), is("fixture_tool"));
+ assertThat(actual.getArgumentPath(), is("options.mode"));
+ assertThat(actual.getCategory(), is("invalid_argument_type"));
+ assertThat(actual.getExpectedType(), is("string"));
+ assertThat(actual.getSuggestedArguments(), is(Map.of()));
+ }
+
@Test
void assertGetSupportedToolsWithNoToolHandlers() {
try (MockedStatic<ShardingSphereServiceLoader> mocked =
mockStatic(ShardingSphereServiceLoader.class)) {
@@ -224,6 +252,12 @@ class ToolDefinitionRegistryTest {
"additionalProperties", false), Collections.emptyMap(), new
MCPToolAnnotations("Fixture Tool", true, false, true, true),
Collections.emptyMap());
}
+ private static MCPToolDescriptor
createAdditionalPropertiesFixtureToolDescriptor() {
+ Map<String, Object> optionSchema = Map.of("type", "object",
"additionalProperties", Map.of("type", "string"));
+ return new MCPToolDescriptor("fixture_tool", "Fixture Tool", "Fixture
tool.", Map.of("type", "object", "properties", Map.of("options", optionSchema),
"required", List.of(),
+ "additionalProperties", false), Collections.emptyMap(), new
MCPToolAnnotations("Fixture Tool", true, false, true, true),
Collections.emptyMap());
+ }
+
private static void assertToolFields(final MCPToolDescriptor descriptor,
final List<String> expectedFieldNames) {
assertThat(getInputProperties(descriptor).keySet().stream().map(Object::toString).toList(),
is(expectedFieldNames));
}
diff --git
a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/ExecuteUpdateToolHandlerTest.java
b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/ExecuteUpdateToolHandlerTest.java
index cf636605810..64abba2e0b5 100644
---
a/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/ExecuteUpdateToolHandlerTest.java
+++
b/mcp/core/src/test/java/org/apache/shardingsphere/mcp/core/tool/handler/execute/ExecuteUpdateToolHandlerTest.java
@@ -21,6 +21,7 @@ import
org.apache.shardingsphere.mcp.api.protocol.exception.MCPInvalidRequestExc
import
org.apache.shardingsphere.mcp.api.protocol.exception.MCPUnsupportedException;
import org.apache.shardingsphere.mcp.api.protocol.response.MCPResponse;
import org.apache.shardingsphere.mcp.api.tool.MCPToolCall;
+import
org.apache.shardingsphere.mcp.core.protocol.exception.MCPInvalidToolArgumentException;
import
org.apache.shardingsphere.mcp.support.database.MCPDatabaseHandlerContext;
import
org.apache.shardingsphere.mcp.support.database.capability.SupportedMCPStatement;
import
org.apache.shardingsphere.mcp.support.database.spi.MCPFeatureExecutionFacade;
@@ -140,6 +141,19 @@ class ExecuteUpdateToolHandlerTest {
verifyNoInteractions(executionFacade);
}
+ @Test
+ void assertRejectPreviewWithInvalidTimeout() {
+ MCPFeatureExecutionFacade executionFacade =
mock(MCPFeatureExecutionFacade.class);
+ MCPDatabaseHandlerContext databaseContext =
mock(MCPDatabaseHandlerContext.class);
+ when(databaseContext.getExecutionFacade()).thenReturn(executionFacade);
+ MCPInvalidToolArgumentException actual =
assertThrows(MCPInvalidToolArgumentException.class, () -> new
ExecuteUpdateToolHandler().handle(databaseContext, new MCPToolCall("session-1",
+ Map.of("database", "logic_db", "schema", "public", "sql",
"update orders set status = 'PAID'", "execution_mode", "preview", "timeout_ms",
300001))));
+ assertThat(actual.getMessage(), is("timeout_ms must be an integer
between 0 and 300000."));
+ assertThat(actual.getArgumentPath(), is("timeout_ms"));
+ assertThat(actual.getSuggestedValue(), is(0));
+ verifyNoInteractions(executionFacade);
+ }
+
@Test
void assertRejectUnknownExecutionMode() {
MCPFeatureExecutionFacade executionFacade =
mock(MCPFeatureExecutionFacade.class);
diff --git
a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/programmatic/AbstractHttpProtocolOnlyE2ETest.java
b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/programmatic/AbstractHttpProtocolOnlyE2ETest.java
index 261e3bd5354..5fc539d0eb8 100644
---
a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/programmatic/AbstractHttpProtocolOnlyE2ETest.java
+++
b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/programmatic/AbstractHttpProtocolOnlyE2ETest.java
@@ -27,6 +27,7 @@ import
org.apache.shardingsphere.test.e2e.mcp.support.transport.client.MCPHttpTr
import org.junit.jupiter.api.AfterEach;
import java.io.IOException;
+import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpResponse;
@@ -92,6 +93,12 @@ abstract class AbstractHttpProtocolOnlyE2ETest {
return MCPHttpTransportTestSupport.sendJsonRpcRequest(httpClient,
getEndpointUri(), headers, "resource-1", "resources/read", Map.of("uri",
"shardingsphere://capabilities"));
}
+ protected final HttpResponse<String> sendToolCallRequest(final HttpClient
httpClient, final String sessionId,
+ final String
toolName, final Map<String, Object> arguments) throws IOException,
InterruptedException {
+ return MCPHttpTransportTestSupport.sendJsonRpcRequest(httpClient,
getEndpointUri(), createSessionHeaders(sessionId), toolName + "-1",
"tools/call",
+ Map.of("name", toolName, "arguments", arguments));
+ }
+
protected final HttpResponse<String> sendDeleteRequest(final HttpClient
httpClient, final Map<String, String> headers) throws IOException,
InterruptedException {
return MCPHttpTransportTestSupport.sendDeleteRequest(httpClient,
getEndpointUri(), headers);
}
@@ -105,6 +112,10 @@ abstract class AbstractHttpProtocolOnlyE2ETest {
return MCPHttpTransportTestSupport.openEventStream(httpClient,
getEndpointUri(), headers);
}
+ protected final HttpResponse<InputStream> openEventStreamInputStream(final
HttpClient httpClient, final Map<String, String> headers) throws IOException,
InterruptedException {
+ return
MCPHttpTransportTestSupport.openEventStreamInputStream(httpClient,
getEndpointUri(), headers);
+ }
+
protected final Map<String, Object> parseJsonBody(final String
responseBody) {
return MCPInteractionPayloads.parseJsonPayload(responseBody);
}
diff --git
a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/programmatic/HttpTransportProtocolContractE2ETest.java
b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/programmatic/HttpTransportProtocolContractE2ETest.java
index 05db95835de..e50fe15b5f7 100644
---
a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/programmatic/HttpTransportProtocolContractE2ETest.java
+++
b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/runtime/programmatic/HttpTransportProtocolContractE2ETest.java
@@ -17,10 +17,12 @@
package org.apache.shardingsphere.test.e2e.mcp.runtime.programmatic;
+import org.apache.shardingsphere.mcp.support.security.MCPClientSafetyPolicy;
import
org.apache.shardingsphere.test.e2e.mcp.support.transport.client.MCPHttpTransportTestSupport;
import org.junit.jupiter.api.Test;
import java.io.IOException;
+import java.io.InputStream;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@@ -84,6 +86,65 @@ class HttpTransportProtocolContractE2ETest extends
AbstractHttpProtocolOnlyE2ETe
assertThat(actual.statusCode(), is(400));
}
+ @Test
+ void assertRejectUnsupportedActiveEventStream() throws IOException,
InterruptedException {
+ launchHttpTransport();
+ HttpClient httpClient = HttpClient.newHttpClient();
+ String sessionId = initializeSession(httpClient);
+ Map<String, String> headers = new
LinkedHashMap<>(createSessionHeaders(sessionId));
+ headers.put("Accept", "text/event-stream");
+ HttpResponse<InputStream> actual =
openEventStreamInputStream(httpClient, headers);
+ try (InputStream ignored = actual.body()) {
+ assertThat(actual.statusCode(), is(405));
+ }
+ }
+
+ @Test
+ void assertReturnToolErrorForInvalidIntegerArgument() throws IOException,
InterruptedException {
+ launchHttpTransport();
+ HttpClient httpClient = HttpClient.newHttpClient();
+ String sessionId = initializeSession(httpClient);
+ HttpResponse<String> actual = sendToolCallRequest(httpClient,
sessionId, "database_gateway_execute_update", createInvalidUpdateArguments());
+ Map<String, Object> actualRecovery = assertToolErrorRecovery(actual,
"invalid_integer_argument");
+ assertThat(actualRecovery.get("argument_path"), is("max_rows"));
+ assertThat(actualRecovery.get("minimum_value"), is(0));
+ assertThat(actualRecovery.get("maximum_value"), is(5000));
+ assertThat(actualRecovery.get("suggested_value"), is(100));
+ }
+
+ @Test
+ void assertReturnToolErrorForSchemaAdditionalProperties() throws
IOException, InterruptedException {
+ launchHttpTransport();
+ HttpClient httpClient = HttpClient.newHttpClient();
+ String sessionId = initializeSession(httpClient);
+ HttpResponse<String> actual = sendToolCallRequest(httpClient,
sessionId, "database_gateway_plan_readwrite_splitting_rule",
+ Map.of("load_balancer_properties", Map.of("weight", 1)));
+ Map<String, Object> actualRecovery = assertToolErrorRecovery(actual,
"invalid_argument_type");
+ assertThat(actualRecovery.get("argument_path"),
is("load_balancer_properties.weight"));
+ assertThat(actualRecovery.get("expected_type"), is("string"));
+ }
+
+ @Test
+ void assertEnforceToolCallLimitPerSession() throws IOException,
InterruptedException {
+ String propertyName =
MCPClientSafetyPolicy.MAX_TOOL_CALLS_PER_SESSION_PROPERTY;
+ String originalValue = System.getProperty(propertyName);
+ System.setProperty(propertyName, "1");
+ try {
+ launchHttpTransport();
+ HttpClient httpClient = HttpClient.newHttpClient();
+ String sessionId = initializeSession(httpClient);
+ assertToolErrorRecovery(sendToolCallRequest(httpClient, sessionId,
"database_gateway_execute_update", createInvalidUpdateArguments()),
"invalid_integer_argument");
+ Map<String, Object> actualRecovery = assertToolErrorRecovery(
+ sendToolCallRequest(httpClient, sessionId,
"database_gateway_execute_update", createInvalidUpdateArguments()),
"tool_call_limit_exceeded");
+ assertThat(actualRecovery.get("session_id"), is(sessionId));
+ assertThat(actualRecovery.get("max_tool_calls_per_session"),
is(1));
+ String otherSessionId = initializeSession(httpClient);
+ assertToolErrorRecovery(sendToolCallRequest(httpClient,
otherSessionId, "database_gateway_execute_update",
createInvalidUpdateArguments()), "invalid_integer_argument");
+ } finally {
+ restoreProperty(propertyName, originalValue);
+ }
+ }
+
@Test
void assertAcceptInitializeWithUnsupportedProtocolVersion() throws
IOException, InterruptedException {
launchHttpTransport();
@@ -138,4 +199,27 @@ class HttpTransportProtocolContractE2ETest extends
AbstractHttpProtocolOnlyE2ETe
assertThat(actual.statusCode(), is(404));
assertThat(String.valueOf(parseJsonBody(actual.body()).get("message")),
is("Session not found: " + sessionId));
}
+
+ private Map<String, Object> assertToolErrorRecovery(final
HttpResponse<String> response, final String expectedCategory) {
+ assertThat(response.statusCode(), is(200));
+ Map<String, Object> result =
castToMap(parseJsonBody(response.body()).get("result"));
+ assertTrue((boolean) result.get("isError"));
+ Map<String, Object> structuredContent =
castToMap(result.get("structuredContent"));
+ assertThat(structuredContent.get("response_mode"), is("recovery"));
+ Map<String, Object> actualRecovery =
castToMap(structuredContent.get("recovery"));
+ assertThat(actualRecovery.get("category"), is(expectedCategory));
+ return actualRecovery;
+ }
+
+ private Map<String, Object> createInvalidUpdateArguments() {
+ return Map.of("database", "logic_db", "sql", "UPDATE orders SET status
= 'PAID' WHERE order_id = 1", "execution_mode", "preview", "max_rows", 5001);
+ }
+
+ private void restoreProperty(final String propertyName, final String
originalValue) {
+ if (null == originalValue) {
+ System.clearProperty(propertyName);
+ } else {
+ System.setProperty(propertyName, originalValue);
+ }
+ }
}
diff --git
a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/support/transport/client/MCPHttpTransportTestSupport.java
b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/support/transport/client/MCPHttpTransportTestSupport.java
index 9de3c8c2b43..3b94679f68a 100644
---
a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/support/transport/client/MCPHttpTransportTestSupport.java
+++
b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/support/transport/client/MCPHttpTransportTestSupport.java
@@ -22,6 +22,7 @@ import lombok.NoArgsConstructor;
import
org.apache.shardingsphere.test.e2e.mcp.support.transport.MCPInteractionProtocolSupport;
import java.io.IOException;
+import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
@@ -131,6 +132,23 @@ public final class MCPHttpTransportTestSupport {
return httpClient.send(requestBuilder.build(),
HttpResponse.BodyHandlers.ofString());
}
+ /**
+ * Open an event stream without waiting for the whole response body.
+ *
+ * @param httpClient HTTP client
+ * @param endpointUri MCP endpoint URI
+ * @param headers request headers
+ * @return HTTP response
+ * @throws IOException I/O exception
+ * @throws InterruptedException interrupted exception
+ */
+ public static HttpResponse<InputStream> openEventStreamInputStream(final
HttpClient httpClient, final URI endpointUri,
+ final
Map<String, String> headers) throws IOException, InterruptedException {
+ HttpRequest.Builder requestBuilder =
HttpRequest.newBuilder(endpointUri).GET();
+ applyHeaders(requestBuilder, headers);
+ return httpClient.send(requestBuilder.build(),
HttpResponse.BodyHandlers.ofInputStream());
+ }
+
/**
* Send a JSON-RPC request.
*