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 8513c1d580f Add Mask planning scenario to MCP LLM E2E (#39289)
8513c1d580f is described below
commit 8513c1d580f7fc6a543ee631a53ee2df0030c522
Author: Liang Zhang <[email protected]>
AuthorDate: Fri Jul 31 19:41:06 2026 +0800
Add Mask planning scenario to MCP LLM E2E (#39289)
* Add Mask planning scenario to MCP LLM E2E
* Add Mask planning scenario to MCP LLM E2E
---
.github/workflows/e2e-mcp.yml | 2 +
.../test/e2e/mcp/llm/LLMHttpE2ETest.java | 95 +++++++++++++++++++---
.../llm/conversation/LLMConversationRunner.java | 11 +--
.../conversation/LLMMCPToolDefinitionFactory.java | 32 +++-----
4 files changed, 103 insertions(+), 37 deletions(-)
diff --git a/.github/workflows/e2e-mcp.yml b/.github/workflows/e2e-mcp.yml
index 9c4b750cdad..ebf6e3e4c27 100644
--- a/.github/workflows/e2e-mcp.yml
+++ b/.github/workflows/e2e-mcp.yml
@@ -269,6 +269,8 @@ jobs:
test: LLMHttpE2ETest#assertReadOnlyQuery
- id: http-metadata-discovery
test: LLMHttpE2ETest#assertMetadataDiscovery
+ - id: http-mask-planning
+ test: LLMHttpE2ETest#assertMaskPlanning
- id: http-side-effect-preview
test: LLMHttpE2ETest#assertSideEffectPreview
- id: http-invalid-resource-recovery
diff --git
a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/LLMHttpE2ETest.java
b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/LLMHttpE2ETest.java
index c3f9e358c9b..3d9433e9042 100644
---
a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/LLMHttpE2ETest.java
+++
b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/LLMHttpE2ETest.java
@@ -48,6 +48,7 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collection;
+import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
@@ -74,14 +75,26 @@ class LLMHttpE2ETest extends
AbstractConfigBackedRuntimeE2ETest {
private static final String STALE_TABLE_RESOURCE_URI =
"shardingsphere://databases/logic_db/schemas/logic_db/tables/missing_orders";
+ private static final String READ_RESOURCE_TOOL_NAME = "mcp_read_resource";
+
+ private static final String SEARCH_METADATA_TOOL_NAME =
"database_gateway_search_metadata";
+
+ private static final String EXECUTE_QUERY_TOOL_NAME =
"database_gateway_execute_query";
+
+ private static final String EXECUTE_UPDATE_TOOL_NAME =
"database_gateway_execute_update";
+
+ private static final String PLAN_MASK_RULE_TOOL_NAME =
"database_gateway_plan_mask_rule";
+
private static final Set<String> EXPECTED_METADATA_NAMES =
Set.of("active_orders", "order_items", "orders");
private static final List<String> ARTIFACT_FILES = List.of(
"run-context.json", "system-prompt.md", "question.txt",
"answer.txt", "raw-model-output.txt", "available-tools.json",
"interaction-trace.json", "assertion-report.json");
+ // JSON boolean and null literals are metadata rather than credential
values.
private static final Pattern UNREDACTED_SECRET_PATTERN = Pattern.compile(
-
"(?i)(?<![a-z0-9_])\"?(?:api[_-]?key|access[_-]?token|token|authorization|password|passwd|pwd|secret)\"?\\s*[:=]\\s*[\"']?(?!<redacted>)[^\\s,\"'}]+"
+
"(?i)(?<![a-z0-9_])\"?(?:api[_-]?key|access[_-]?token|token|authorization|password|passwd|pwd|secret)\"?\\s*[:=]\\s*"
+ +
"(?!(?:true|false|null)(?:\\s*[,}\\]]|\\s*$))[\"']?(?!<redacted>)[^\\s,\"'}]+"
+ "|(Bearer\\s+)(?!<redacted>)[A-Za-z0-9._~+/=-]+|jdbc:");
private static LLMRuntimeSupport.ModelRuntime llmRuntime;
@@ -116,7 +129,7 @@ class LLMHttpE2ETest extends
AbstractConfigBackedRuntimeE2ETest {
runScenario(new Scenario(
"read-only-query",
"How many rows are currently in the orders table of the
logic_db runtime database? Inspect the live MCP server and answer concisely.",
- false,
+ Set.of(EXECUTE_QUERY_TOOL_NAME),
this::evaluateReadOnlyQuery));
}
@@ -125,10 +138,22 @@ class LLMHttpE2ETest extends
AbstractConfigBackedRuntimeE2ETest {
runScenario(new Scenario(
"metadata-discovery",
"List every table or view currently visible through the live
MCP server. The user does not know the database or schema names, so discover
the required scope first.",
- false,
+ Set.of(SEARCH_METADATA_TOOL_NAME),
this::evaluateMetadataDiscovery));
}
+ @Test
+ void assertMaskPlanning() throws IOException {
+ runScenario(new Scenario(
+ "mask-planning",
+ "The target metadata has already been verified, so do not run
separate metadata discovery. Create a reviewable Mask rule plan, without
applying it, "
+ + "for the status column of the orders table in the
logic_db database and logic_db schema. "
+ + "Use the create operation, KEEP_FIRST_N_LAST_M
algorithm, and primary properties first-n=1, last-m=1, replace-char=*. "
+ + "Report the plan ID and explicitly confirm that
nothing was applied.",
+ Set.of(PLAN_MASK_RULE_TOOL_NAME),
+ this::evaluateMaskPlanning));
+ }
+
@Test
void assertSideEffectPreview() throws IOException, SQLException {
prepareRuntimeFixture();
@@ -137,7 +162,7 @@ class LLMHttpE2ETest extends
AbstractConfigBackedRuntimeE2ETest {
"side-effect-preview",
"A user wants to change the status of order 1 in the orders
table to REVIEW_PENDING. Preview the proposed change without executing it, then
report whether it "
+ "was only previewed and whether any data changed.
Inspect the live MCP server to discover any required runtime scope.",
- true,
+ Set.of(SEARCH_METADATA_TOOL_NAME, EXECUTE_UPDATE_TOOL_NAME),
(answer, trace) -> evaluateSideEffectPreview(answer, trace,
statusBefore)));
}
@@ -147,7 +172,7 @@ class LLMHttpE2ETest extends
AbstractConfigBackedRuntimeE2ETest {
"invalid-resource-recovery",
"A user pasted stale resource `" + STALE_TABLE_RESOURCE_URI +
"`. Inspect that resource, then follow the first safe read-only action in its
top-level "
+ "`next_actions` by reading its `resource_uri`
exactly. Do not guess another URI. Then report how many rows are currently in
the orders table.",
- false,
+ Set.of(READ_RESOURCE_TOOL_NAME, EXECUTE_QUERY_TOOL_NAME),
this::evaluateInvalidResourceRecovery));
}
@@ -189,7 +214,7 @@ class LLMHttpE2ETest extends
AbstractConfigBackedRuntimeE2ETest {
Set<String> actualMetadataNames = new LinkedHashSet<>();
for (int index = unscopedSearchIndex; index < trace.size(); index++) {
MCPInteractionTraceRecord each = trace.get(index);
- if (isValidModelAction(each, "database_gateway_search_metadata")
&& (unscopedSearchIndex == index || each.getModelTurn() > discoveryTurn)) {
+ if (isValidModelAction(each, SEARCH_METADATA_TOOL_NAME) &&
(unscopedSearchIndex == index || each.getModelTurn() > discoveryTurn)) {
actualMetadataNames.addAll(getMetadataNames(each.getStructuredContent()));
}
}
@@ -202,9 +227,57 @@ class LLMHttpE2ETest extends
AbstractConfigBackedRuntimeE2ETest {
: LLME2EAssertionReport.failure("answer_mismatch", "The answer
omitted an object returned by the live MCP metadata searches.");
}
+ private LLME2EAssertionReport evaluateMaskPlanning(final String answer,
final List<MCPInteractionTraceRecord> trace) {
+ Optional<MCPInteractionTraceRecord> plannedAction = trace.stream()
+ .filter(each -> isValidModelAction(each,
PLAN_MASK_RULE_TOOL_NAME))
+ .filter(each ->
"planned".equals(each.getStructuredContent().get("status")))
+ .findFirst();
+ if (plannedAction.isEmpty()) {
+ return LLME2EAssertionReport.failure("missing_mask_plan", "The
model did not produce a planned Mask workflow.");
+ }
+ if (!hasExpectedMaskPlanArguments(trace)) {
+ return
LLME2EAssertionReport.failure("mask_plan_arguments_mismatch", "The model did
not send the requested Mask rule inputs.");
+ }
+ Map<String, Object> response =
plannedAction.get().getStructuredContent();
+ String planId = Objects.toString(response.get("plan_id"), "").trim();
+ List<Map<String, Object>> distSQLArtifacts =
getObjectList(response.get("distsql_artifacts"));
+ Map<String, Object> primaryProperties =
getObjectMap(getObjectMap(response.get("masked_property_preview")).get("primary"));
+ if (planId.isEmpty() ||
!"mask.rule".equals(response.get("workflow_kind")) ||
!"review".equals(response.get("current_step"))
+ || !getList(response.get("missing_required_inputs")).isEmpty()
|| distSQLArtifacts.isEmpty()
+ || !Objects.toString(distSQLArtifacts.getFirst().get("sql"),
"").startsWith("CREATE MASK RULE `orders`")
+ ||
!"1".equals(Objects.toString(primaryProperties.get("first-n"), ""))
+ ||
!"1".equals(Objects.toString(primaryProperties.get("last-m"), ""))
+ || !"*".equals(primaryProperties.get("replace-char"))) {
+ return
LLME2EAssertionReport.failure("mask_plan_evidence_mismatch", "The MCP response
did not contain the requested reviewable Mask plan.");
+ }
+ String normalizedAnswer = answer.toLowerCase(Locale.ENGLISH);
+ boolean answerReportsNoApplication = normalizedAnswer.contains("not
applied") || normalizedAnswer.contains("not been applied")
+ || normalizedAnswer.contains("nothing was applied") ||
normalizedAnswer.contains("without applying")
+ || normalizedAnswer.contains("not executed") ||
normalizedAnswer.contains("no changes were applied");
+ return answer.contains(planId) && answerReportsNoApplication
+ ? LLME2EAssertionReport.success("The answer reported the live
Mask plan ID and confirmed that it was not applied.")
+ : LLME2EAssertionReport.failure("answer_mismatch", "The answer
did not report the Mask plan ID and confirm that it was not applied.");
+ }
+
+ private boolean hasExpectedMaskPlanArguments(final
List<MCPInteractionTraceRecord> trace) {
+ Map<String, Object> arguments = new LinkedHashMap<>();
+ Map<String, Object> primaryProperties = new LinkedHashMap<>();
+ for (MCPInteractionTraceRecord each : trace) {
+ if (isValidModelAction(each, PLAN_MASK_RULE_TOOL_NAME)) {
+ arguments.putAll(each.getArguments());
+
primaryProperties.putAll(getObjectMap(each.getArguments().get("primary_algorithm_properties")));
+ }
+ }
+ return DATABASE_NAME.equals(arguments.get("database")) &&
TABLE_NAME.equals(arguments.get("table")) &&
"status".equals(arguments.get("column"))
+ && "create".equals(arguments.get("operation_type")) &&
"KEEP_FIRST_N_LAST_M".equals(arguments.get("algorithm_type"))
+ &&
"1".equals(Objects.toString(primaryProperties.get("first-n"), ""))
+ &&
"1".equals(Objects.toString(primaryProperties.get("last-m"), ""))
+ && "*".equals(primaryProperties.get("replace-char"));
+ }
+
private LLME2EAssertionReport evaluateSideEffectPreview(final String
answer, final List<MCPInteractionTraceRecord> trace, final String statusBefore)
{
Optional<MCPInteractionTraceRecord> preview = trace.stream()
- .filter(each -> isValidModelAction(each,
"database_gateway_execute_update"))
+ .filter(each -> isValidModelAction(each,
EXECUTE_UPDATE_TOOL_NAME))
.filter(each ->
"preview".equals(each.getArguments().get("execution_mode")))
.filter(this::isSentinelPreview)
.findFirst();
@@ -258,7 +331,7 @@ class LLMHttpE2ETest extends
AbstractConfigBackedRuntimeE2ETest {
private int findStaleResourceIndex(final List<MCPInteractionTraceRecord>
trace) {
for (int index = 0; index < trace.size(); index++) {
MCPInteractionTraceRecord each = trace.get(index);
- if (isValidModelAction(each, "mcp_read_resource")
+ if (isValidModelAction(each, READ_RESOURCE_TOOL_NAME)
&&
STALE_TABLE_RESOURCE_URI.equals(each.getArguments().get("uri"))
&& hasRecoveryCategory(each.getStructuredContent(),
"object_not_visible")) {
return index;
@@ -280,7 +353,7 @@ class LLMHttpE2ETest extends
AbstractConfigBackedRuntimeE2ETest {
for (int index = staleResourceIndex + 1; index < trace.size();
index++) {
MCPInteractionTraceRecord each = trace.get(index);
if (each.getModelTurn() > staleResource.getModelTurn()
- && isValidModelAction(each, "mcp_read_resource")
+ && isValidModelAction(each, READ_RESOURCE_TOOL_NAME)
&& Objects.equals(recoveryAction.get("resource_uri"),
each.getArguments().get("uri"))
&& containsOrdersTable(each.getStructuredContent())) {
return index;
@@ -302,7 +375,7 @@ class LLMHttpE2ETest extends
AbstractConfigBackedRuntimeE2ETest {
private Optional<Integer> findQueryCount(final
List<MCPInteractionTraceRecord> trace, final int previousModelTurn) {
for (MCPInteractionTraceRecord each : trace) {
- if (each.getModelTurn() <= previousModelTurn ||
!isValidModelAction(each, "database_gateway_execute_query") ||
!isOrdersCountQuery(each.getArguments())) {
+ if (each.getModelTurn() <= previousModelTurn ||
!isValidModelAction(each, EXECUTE_QUERY_TOOL_NAME) ||
!isOrdersCountQuery(each.getArguments())) {
continue;
}
for (Map<String, Object> row :
getObjectList(each.getStructuredContent().get("row_objects"))) {
@@ -341,7 +414,7 @@ class LLMHttpE2ETest extends
AbstractConfigBackedRuntimeE2ETest {
private int findUnscopedMetadataSearchIndex(final
List<MCPInteractionTraceRecord> trace) {
for (int index = 0; index < trace.size(); index++) {
MCPInteractionTraceRecord each = trace.get(index);
- if (isValidModelAction(each, "database_gateway_search_metadata")
+ if (isValidModelAction(each, SEARCH_METADATA_TOOL_NAME)
&& !each.getArguments().containsKey("database") &&
!each.getArguments().containsKey("schema")
&&
getObjectList(each.getStructuredContent().get("items")).stream().anyMatch(item
-> DATABASE_NAME.equals(item.get("database")))) {
return index;
diff --git
a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMConversationRunner.java
b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMConversationRunner.java
index 668aead231d..d880d695749 100644
---
a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMConversationRunner.java
+++
b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMConversationRunner.java
@@ -50,8 +50,9 @@ public final class LLMConversationRunner {
private static final String SYSTEM_PROMPT = """
You are evaluating a live ShardingSphere MCP server. Use the
available MCP functions to inspect current state and complete the user's task.
Choose tools from their advertised names, descriptions, and input
schemas. Pass resource URIs only to mcp_read_resource, never as SQL.
- Never guess or stop before retrieving the requested evidence.
Preview side effects without executing them. Use function calls for every
- tool invocation rather than printing a tool-call object as the
answer. When you have enough evidence, answer the user's question concisely.
+ Never guess or stop before retrieving the requested evidence. When
target metadata is explicitly marked as already verified, use the requested
+ planning response as evidence instead of repeating metadata
discovery. Preview side effects without executing them. Use function calls for
+ every tool invocation rather than printing a tool-call object as
the answer. When you have enough evidence, answer the user's question concisely.
""";
private final int maxTurns;
@@ -88,7 +89,7 @@ public final class LLMConversationRunner {
try {
mcpInteractionClient.open();
List<Map<String, Object>> advertisedTools =
mcpInteractionClient.listTools();
- List<Map<String, Object>> toolDefinitions =
toolDefinitionFactory.createFromRemote(advertisedTools,
scenario.allowsSideEffectPreview());
+ List<Map<String, Object>> toolDefinitions =
toolDefinitionFactory.createFromRemote(advertisedTools,
scenario.allowedToolNames());
artifacts.setToolDefinitions(toolDefinitions);
return runTurns(scenario, artifacts, toolDefinitions);
} catch (final IOException ex) {
@@ -224,10 +225,10 @@ public final class LLMConversationRunner {
*
* @param id scenario ID
* @param question question
- * @param allowsSideEffectPreview whether the scenario may expose update
preview
+ * @param allowedToolNames tools exposed only to this scenario
* @param evaluator scenario evidence evaluator
*/
- public record Scenario(String id, String question, boolean
allowsSideEffectPreview,
+ public record Scenario(String id, String question, Set<String>
allowedToolNames,
BiFunction<String, List<MCPInteractionTraceRecord>,
LLME2EAssertionReport> evaluator) {
}
diff --git
a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPToolDefinitionFactory.java
b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPToolDefinitionFactory.java
index fe2c12b3ea1..0e1e55d979a 100644
---
a/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPToolDefinitionFactory.java
+++
b/test/e2e/mcp/src/test/java/org/apache/shardingsphere/test/e2e/mcp/llm/conversation/LLMMCPToolDefinitionFactory.java
@@ -17,45 +17,35 @@
package org.apache.shardingsphere.test.e2e.mcp.llm.conversation;
+import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
final class LLMMCPToolDefinitionFactory {
- private static final String EXECUTE_UPDATE_TOOL_NAME =
"database_gateway_execute_update";
-
- List<Map<String, Object>> createFromRemote(final List<Map<String, Object>>
advertisedTools, final boolean includeUpdatePreview) {
+ List<Map<String, Object>> createFromRemote(final List<Map<String, Object>>
advertisedTools, final Set<String> allowedToolNames) {
List<Map<String, Object>> result = new LinkedList<>();
- result.add(createReadResourceToolDefinition());
- boolean readOnlyToolFound = false;
- boolean updatePreviewToolFound = false;
+ Set<String> missingAllowedToolNames = new
LinkedHashSet<>(allowedToolNames);
+ if
(missingAllowedToolNames.remove(LLMConversationRunner.READ_RESOURCE_TOOL_NAME))
{
+ result.add(createReadResourceToolDefinition());
+ }
for (Map<String, Object> each : advertisedTools) {
String toolName = Objects.toString(each.get("name"), "").trim();
- boolean readOnly = isReadOnly(each);
- boolean updatePreview = includeUpdatePreview &&
EXECUTE_UPDATE_TOOL_NAME.equals(toolName);
- if (!readOnly && !updatePreview) {
+ if (!missingAllowedToolNames.contains(toolName)) {
continue;
}
result.add(createRemoteToolDefinition(each, toolName));
- readOnlyToolFound |= readOnly;
- updatePreviewToolFound |= updatePreview;
- }
- if (!readOnlyToolFound) {
- throw new IllegalStateException("MCP runtime did not advertise any
read-only tools.");
+ missingAllowedToolNames.remove(toolName);
}
- if (includeUpdatePreview && !updatePreviewToolFound) {
- throw new IllegalStateException("MCP runtime did not advertise
required preview tool: " + EXECUTE_UPDATE_TOOL_NAME);
+ if (!missingAllowedToolNames.isEmpty()) {
+ throw new IllegalStateException("MCP runtime did not advertise
required scenario tools: " + missingAllowedToolNames);
}
return result;
}
- private boolean isReadOnly(final Map<String, Object> advertisedTool) {
- Object annotations = advertisedTool.get("annotations");
- return annotations instanceof Map && Boolean.TRUE.equals(((Map<?, ?>)
annotations).get("readOnlyHint"));
- }
-
private Map<String, Object> createRemoteToolDefinition(final Map<String,
Object> advertisedTool, final String toolName) {
Object inputSchema = advertisedTool.get("inputSchema");
if (toolName.isEmpty() || !(inputSchema instanceof Map)) {