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 5c5270057ca Simplify tool elicitation handler (#38740)
5c5270057ca is described below
commit 5c5270057cad39b8c252a1199b3f5959c3063bd2
Author: Liang Zhang <[email protected]>
AuthorDate: Thu May 28 12:51:48 2026 +0800
Simplify tool elicitation handler (#38740)
Refactor MCP tool elicitation handling by extracting client capability
detection and fallback response construction into dedicated public types.
Restore ArgumentBinding semantic helper usage, merge elicitation workflow
coverage into the real specification factory test, and add focused tests for
new public contracts.
Also document helper extraction boundaries in AGENTS.md.
---
AGENTS.md | 20 +-
.../tool/MCPClientElicitationCapabilities.java | 51 +++
.../tool/MCPToolClarificationPolicy.java | 7 +-
.../tool/MCPToolElicitationFallbackReason.java | 63 ++++
.../MCPToolElicitationFallbackResponseFactory.java | 106 ++++++
.../capability/tool/MCPToolElicitationHandler.java | 164 ++-------
.../tool/MCPClientElicitationCapabilitiesTest.java | 85 +++++
.../tool/MCPToolClarificationPolicyTest.java | 35 ++
.../tool/MCPToolElicitationFallbackReasonTest.java | 88 +++++
...ToolElicitationFallbackResponseFactoryTest.java | 78 ++++
...MCPToolSpecificationElicitationFactoryTest.java | 397 ---------------------
.../tool/MCPToolSpecificationFactoryTest.java | 364 +++++++++++++++++++
12 files changed, 921 insertions(+), 537 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 77a31843edc..2c4197a4f84 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -18,6 +18,15 @@ This guide is written **for AI coding agents only**. Follow
it literally; improv
- **Architecture**: follow SOLID, DRY, separation of concerns, and YAGNI
(build only what you need).
- **Code Quality**:
- Use clear naming and reasonable abstractions.
+ - Do not introduce package-private top-level helper types by default.
+ Keep very small, single-owner state or continuation helpers as private
nested types, but avoid accumulating multiple nested collaborators inside one
class.
+ When a helper has cohesive behavior, multiple callers, direct test
value, or enough logic to distract from the owner class, split it into a public
top-level type with a clear contract and direct tests.
+ If neither private nor public fits, pause before coding and explain why.
+ - Every new public production type must have direct, focused tests.
+ Broad workflow tests do not replace public contract tests unless they
explicitly exercise that public type's behavior.
+ - New internal abstractions must reduce cognitive complexity instead of
merely wrapping branches in more types.
+ For simple internal two-path flows, avoid marker interfaces, multi-type
result hierarchies, or extra DTO-style helpers.
+ Add them only when they define a stable boundary, keep owner classes
readable, or remove meaningful duplicated logic.
- Delete unused code; when changing functionality, remove legacy
compatibility shims.
- Keep variable declarations adjacent to first use; if a value must be
retained, declare it `final` to satisfy Checkstyle
VariableDeclarationUsageDistance.
- Single-use local variables must be inlined by default; keep a local
variable only when it is reused (for stubbing/verification/assertions) or
materially improves readability.
@@ -43,7 +52,9 @@ This guide is written **for AI coding agents only**. Follow
it literally; improv
- **Test-Driven**: design for testability, ensure unit-test coverage, and keep
background unit tests under 60s to avoid job stalls.
- **Quality Assurance**: run static checks, formatting, and code reviews.
- **Checkstyle Gate**: do not hand off code with Checkstyle/Spotless
failures—run the relevant module check locally and fix before completion.
-- **Formatting Gate**: after code changes, format only with `./mvnw
spotless:apply -Pcheck -T1C`, then check style with `./mvnw checkstyle:check
-Pcheck -T1C`; do not use any other formatting method.
+- **Formatting Gate**: after code or documentation changes, format only with
`./mvnw spotless:apply -Pcheck -T1C`, then check style with `./mvnw
checkstyle:check -Pcheck -T1C`; do not use any other formatting method.
+ Spotless must run after the last file-changing action and before
Checkstyle/tests in the final handoff sequence.
+ If any file is edited, generated, moved, or manually whitespace-cleaned
after Spotless, rerun Spotless before replying.
- **Continuous Verification**: rely on automated tests and integration
validation.
- **Test Naming Simplicity**: keep test names concise and scenario-focused
(avoid “ReturnsXXX”/overly wordy or AI-like phrasing); describe the scenario
directly.
- **Coverage Discipline**: follow the dedicated coverage & branch checklist
before coding when coverage targets are stated.
@@ -120,6 +131,8 @@ Dangerous operation detected! Operation type: [specific
action] Scope of impact:
- **Execution discipline:** inspect existing code before edits; keep changes
minimal; default to mocks and SPI loaders; keep variable declarations near
first use and mark retained values `final`; inline single-use locals by default
unless reuse/readability justifies retention; delete dead code and avoid
placeholders/TODOs.
- **AGENTS.md maintenance:** do not add or update a `Session Notes` section in
`AGENTS.md`. Keep task-specific notes in the active conversation, issue, or PR;
only stable project-level rules may be generalized into this file.
- **Post-task self-check (before replying):** confirm all instructions were
honored; verify no placeholders/unused code; ensure Checkstyle/Spotless gates
for touched modules are satisfied or explain why not run and what to run; list
commands with exit codes; call out risks and follow-ups; complete all
applicable checks before replying and do not rely on users to find missed rule
violations.
+- **End-of-task format/style gate:** for any task that edits files, run
`./mvnw spotless:apply -Pcheck -T1C` after the final edit, then run `./mvnw
checkstyle:check -Pcheck -T1C` when production, test, or project-rule files are
touched.
+ Do not perform manual formatting or whitespace cleanup after the final
Spotless run; if a later cleanup is required, repeat Spotless and then
Checkstyle before the final response.
- **Final response template:** include intent/why, changed files with paths,
rationale per file/section, commands run (with exit codes), verification
status, and remaining risks/next actions (if tests skipped, state reason and
the exact command to run); include a concise self-check result statement
confirming final clean status after fixes.
## Final Self-Iteration Gate
@@ -228,8 +241,9 @@ Always state which topology, registry, and engine versions
(e.g., MySQL 5.7 vs 8
## Verification & Commands
- Core commands: `./mvnw clean install -B -T1C -Pcheck` (full build), `./mvnw
test -pl <module>[-am]` (scoped unit tests), `./mvnw -pl <module> -DskipITs
-Dspotless.skip=true -Dtest=ClassName test` (fast verification), `./mvnw -pl
proxy -am -DskipTests package` (proxy packaging/perf smoke).
- Coverage: when tests change or targets demand it, run `./mvnw test
jacoco:check@jacoco-check -Pcoverage-check` or scoped `-pl <module> -am
-Djacoco.skip=false test jacoco:report`; pair with the Coverage & Branch
Checklist.
-- Format: after code changes, run `./mvnw spotless:apply -Pcheck -T1C`; do not
use any other formatting method.
-- Style: after code changes and formatting, run `./mvnw checkstyle:check
-Pcheck -T1C`.
+- Format: after code or documentation changes, run `./mvnw spotless:apply
-Pcheck -T1C`; do not use any other formatting method.
+ This must be repeated after the last file-changing action before handoff.
+- Style: after formatting, run `./mvnw checkstyle:check -Pcheck -T1C` when
production, test, or project-rule files are touched.
- Scoped defaults: prefer module-scoped runs over whole-repo builds; include
`-Dsurefire.failIfNoSpecifiedTests=false` when targeting specific tests.
- Testing ground rules: JUnit 5 + Mockito, `ClassNameTest` naming,
Arrange–Act–Assert, mock external systems/time/network, reset static caches,
and reuse swappers/helpers for complex configs.
- API bans: if a user forbids a tool/assertion, add it to the plan, avoid it
during implementation, and cite verification searches (e.g., `rg assertEquals`)
in the final report.
diff --git
a/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPClientElicitationCapabilities.java
b/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPClientElicitationCapabilities.java
new file mode 100644
index 00000000000..eebdc80d6b3
--- /dev/null
+++
b/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPClientElicitationCapabilities.java
@@ -0,0 +1,51 @@
+/*
+ * 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.server.McpSyncServerExchange;
+import io.modelcontextprotocol.spec.McpSchema;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * MCP client elicitation capabilities.
+ */
+@Getter
+@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
+public final class MCPClientElicitationCapabilities {
+
+ private final boolean formModeSupported;
+
+ private final boolean urlModeSupported;
+
+ /**
+ * Create client elicitation capabilities from server exchange.
+ *
+ * @param exchange MCP sync server exchange
+ * @return client elicitation capabilities
+ */
+ public static MCPClientElicitationCapabilities from(final
McpSyncServerExchange exchange) {
+ McpSchema.ClientCapabilities clientCapabilities =
exchange.getClientCapabilities();
+ if (null == clientCapabilities || null ==
clientCapabilities.elicitation()) {
+ return new MCPClientElicitationCapabilities(false, false);
+ }
+ McpSchema.ClientCapabilities.Elicitation elicitation =
clientCapabilities.elicitation();
+ return new MCPClientElicitationCapabilities(null != elicitation.form()
|| null == elicitation.url(), null != elicitation.url());
+ }
+}
diff --git
a/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolClarificationPolicy.java
b/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolClarificationPolicy.java
index d730e6cab4a..87d2b68c93a 100644
---
a/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolClarificationPolicy.java
+++
b/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolClarificationPolicy.java
@@ -81,15 +81,12 @@ final class MCPToolClarificationPolicy {
return Optional.empty();
}
ArgumentBinding argumentBinding = binding.get();
- if (fieldBindings.containsKey(argumentBinding.formPropertyName()))
{
- return Optional.empty();
- }
properties.put(argumentBinding.formPropertyName(),
createRequestedPropertySchema(question, argumentBinding));
required.add(argumentBinding.formPropertyName());
fieldBindings.put(argumentBinding.formPropertyName(),
argumentBinding);
questionIndex++;
}
- return properties.isEmpty() ? Optional.empty() : Optional.of(new
ClarificationForm(createObjectSchema(properties, required), fieldBindings,
planId));
+ return Optional.of(new
ClarificationForm(createObjectSchema(properties, required), fieldBindings,
planId));
}
boolean hasSensitiveClarificationQuestions(final Map<String, Object>
payload) {
@@ -140,7 +137,7 @@ final class MCPToolClarificationPolicy {
return value.replaceAll(CAMEL_CASE_SEPARATOR_PATTERN, "$1
$2").toLowerCase(Locale.ENGLISH).replaceAll(NON_ALPHANUMERIC_PATTERN, "");
}
- String getPlanId(final Map<String, Object> payload) {
+ private String getPlanId(final Map<String, Object> payload) {
return Objects.toString(payload.get(WorkflowFieldNames.PLAN_ID), "");
}
diff --git
a/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFallbackReason.java
b/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFallbackReason.java
new file mode 100644
index 00000000000..7a80a06a190
--- /dev/null
+++
b/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFallbackReason.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.bootstrap.transport.capability.tool;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * MCP tool elicitation fallback reason.
+ */
+@Getter
+@RequiredArgsConstructor
+public enum MCPToolElicitationFallbackReason {
+
+ CLIENT_UNSUPPORTED("client_unsupported", "structured_fallback"),
+
+ REMOTE_IDENTITY_REQUIRED("remote_identity_required",
"structured_fallback"),
+
+ MISSING_PLAN_ID("missing_plan_id", "structured_fallback"),
+
+ SENSITIVE_FORM_BLOCKED("sensitive_form_blocked", "url_fallback"),
+
+ URL_MODE_NOT_IMPLEMENTED("url_mode_not_implemented", "url_fallback"),
+
+ AMBIGUOUS_FIELD_BINDING("ambiguous_field_binding", "structured_fallback"),
+
+ ELICITATION_FAILED("elicitation_failed", "structured_fallback"),
+
+ MALFORMED_ELICITATION_RESULT("malformed_elicitation_result",
"structured_fallback"),
+
+ INVALID_ELICITED_CONTENT("invalid_elicited_content",
"structured_fallback"),
+
+ STALE_ELICITATION("stale_elicitation", "structured_fallback");
+
+ private final String value;
+
+ private final String selectedInteraction;
+
+ /**
+ * Adjust fallback reason according to client capabilities.
+ *
+ * @param clientCapabilities client elicitation capabilities
+ * @return fallback reason
+ */
+ public MCPToolElicitationFallbackReason withClientCapabilities(final
MCPClientElicitationCapabilities clientCapabilities) {
+ return SENSITIVE_FORM_BLOCKED == this &&
clientCapabilities.isUrlModeSupported() ? URL_MODE_NOT_IMPLEMENTED : this;
+ }
+}
diff --git
a/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFallbackResponseFactory.java
b/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFallbackResponseFactory.java
new file mode 100644
index 00000000000..46b8d3f0412
--- /dev/null
+++
b/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFallbackResponseFactory.java
@@ -0,0 +1,106 @@
+/*
+ * 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 org.apache.shardingsphere.mcp.api.protocol.response.MCPResponse;
+import org.apache.shardingsphere.mcp.support.protocol.MCPPayloadFieldNames;
+import org.apache.shardingsphere.mcp.support.protocol.response.MCPMapResponse;
+
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * MCP tool elicitation fallback response factory.
+ */
+public final class MCPToolElicitationFallbackResponseFactory {
+
+ private static final String ELICITATION_SUPPORT_FIELD =
"elicitation_support";
+
+ private static final String FALLBACK_REASON_FIELD = "fallback_reason";
+
+ private static final String FORM_MODE_FIELD = "form_mode";
+
+ private static final String URL_MODE_FIELD = "url_mode";
+
+ private static final String SELECTED_INTERACTION_FIELD =
"selected_interaction";
+
+ private final MCPToolClarificationPolicy clarificationPolicy = new
MCPToolClarificationPolicy();
+
+ /**
+ * Create fallback response.
+ *
+ * @param payload original payload
+ * @param fallbackReason fallback reason
+ * @param clientCapabilities client elicitation capabilities
+ * @return MCP response
+ */
+ public MCPResponse create(final Map<String, Object> payload, final
MCPToolElicitationFallbackReason fallbackReason,
+ final MCPClientElicitationCapabilities
clientCapabilities) {
+ Map<String, Object> result = new LinkedHashMap<>(payload);
+ if (clarificationPolicy.hasSensitiveClarificationQuestions(payload)) {
+ result.put(MCPPayloadFieldNames.CLARIFICATION_QUESTIONS,
createSanitizedClarificationQuestions(payload));
+ result.put(MCPPayloadFieldNames.NEXT_ACTIONS,
createSensitiveNextActions());
+ }
+ result.put(ELICITATION_SUPPORT_FIELD,
createElicitationSupportPayload(clientCapabilities,
fallbackReason.getSelectedInteraction()));
+ result.put(FALLBACK_REASON_FIELD, fallbackReason.getValue());
+ return new MCPMapResponse(result);
+ }
+
+ private List<Map<String, Object>>
createSanitizedClarificationQuestions(final Map<String, Object> payload) {
+ Object clarificationQuestions =
payload.get(MCPPayloadFieldNames.CLARIFICATION_QUESTIONS);
+ if (!(clarificationQuestions instanceof List<?> questions)) {
+ return List.of();
+ }
+ List<Map<String, Object>> result = new LinkedList<>();
+ for (Object each : questions) {
+ if (each instanceof Map<?, ?> question) {
+ result.add(createSanitizedClarificationQuestion(question));
+ }
+ }
+ return result;
+ }
+
+ private Map<String, Object> createSanitizedClarificationQuestion(final
Map<?, ?> question) {
+ Map<String, Object> result = new LinkedHashMap<>(4, 1F);
+ result.put(MCPPayloadFieldNames.FIELD,
Objects.toString(question.get(MCPPayloadFieldNames.FIELD), ""));
+ result.put(MCPPayloadFieldNames.INPUT_TYPE, "secret");
+ result.put(MCPPayloadFieldNames.SECRET, true);
+ result.put(MCPPayloadFieldNames.MESSAGE, "Sensitive input must be
provided through configured secure channels before continuing the same
planner.");
+ return result;
+ }
+
+ private List<Map<String, Object>> createSensitiveNextActions() {
+ Map<String, Object> result = new LinkedHashMap<>(4, 1F);
+ result.put("order", 1);
+ result.put("type", "terminal");
+ result.put("title", "Collect sensitive inputs through configured
secure channels.");
+ result.put(MCPPayloadFieldNames.REASON, "MCP form elicitation is
limited to non-sensitive STDIO continuations; URL mode is not implemented in
this release.");
+ return List.of(result);
+ }
+
+ private Map<String, Object> createElicitationSupportPayload(final
MCPClientElicitationCapabilities clientCapabilities, final String
selectedInteraction) {
+ Map<String, Object> result = new LinkedHashMap<>(3, 1F);
+ result.put(FORM_MODE_FIELD, clientCapabilities.isFormModeSupported());
+ result.put(URL_MODE_FIELD, clientCapabilities.isUrlModeSupported());
+ result.put(SELECTED_INTERACTION_FIELD, selectedInteraction);
+ return result;
+ }
+}
diff --git
a/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationHandler.java
b/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationHandler.java
index 55ce709b117..14d6c031041 100644
---
a/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationHandler.java
+++
b/mcp/bootstrap/src/main/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationHandler.java
@@ -26,15 +26,11 @@ import
org.apache.shardingsphere.mcp.api.tool.descriptor.MCPToolDescriptor;
import org.apache.shardingsphere.mcp.core.tool.MCPToolController;
import org.apache.shardingsphere.mcp.core.tool.handler.MCPToolDefinition;
import
org.apache.shardingsphere.mcp.support.descriptor.MCPShardingSphereMetadataKeys;
-import org.apache.shardingsphere.mcp.support.protocol.MCPPayloadFieldNames;
-import org.apache.shardingsphere.mcp.support.protocol.response.MCPMapResponse;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowFieldNames;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
-import java.util.LinkedHashMap;
-import java.util.LinkedList;
-import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -50,40 +46,6 @@ final class MCPToolElicitationHandler {
private static final Duration FORM_CONTINUATION_TTL =
Duration.ofMinutes(10L);
- private static final String ELICITATION_SUPPORT_FIELD =
"elicitation_support";
-
- private static final String FALLBACK_REASON_FIELD = "fallback_reason";
-
- private static final String FORM_MODE_FIELD = "form_mode";
-
- private static final String URL_MODE_FIELD = "url_mode";
-
- private static final String SELECTED_INTERACTION_FIELD =
"selected_interaction";
-
- private static final String STRUCTURED_FALLBACK_INTERACTION =
"structured_fallback";
-
- private static final String URL_FALLBACK_INTERACTION = "url_fallback";
-
- private static final String CLIENT_UNSUPPORTED_REASON =
"client_unsupported";
-
- private static final String REMOTE_IDENTITY_REQUIRED_REASON =
"remote_identity_required";
-
- private static final String MISSING_PLAN_ID_REASON = "missing_plan_id";
-
- private static final String SENSITIVE_FORM_BLOCKED_REASON =
"sensitive_form_blocked";
-
- private static final String URL_MODE_NOT_IMPLEMENTED_REASON =
"url_mode_not_implemented";
-
- private static final String AMBIGUOUS_FIELD_BINDING_REASON =
"ambiguous_field_binding";
-
- private static final String ELICITATION_FAILED_REASON =
"elicitation_failed";
-
- private static final String MALFORMED_ELICITATION_RESULT_REASON =
"malformed_elicitation_result";
-
- private static final String INVALID_ELICITED_CONTENT_REASON =
"invalid_elicited_content";
-
- private static final String STALE_ELICITATION_REASON = "stale_elicitation";
-
private final MCPToolController toolController;
private final String activeTransport;
@@ -92,6 +54,8 @@ final class MCPToolElicitationHandler {
private final MCPToolClarificationPolicy clarificationPolicy = new
MCPToolClarificationPolicy();
+ private final MCPToolElicitationFallbackResponseFactory
fallbackResponseFactory = new MCPToolElicitationFallbackResponseFactory();
+
boolean shouldHandle(final MCPToolDescriptor toolDescriptor, final
Map<String, Object> payload) {
return
clarificationPolicy.requiresPlanningClarification(toolDescriptor, payload);
}
@@ -99,122 +63,62 @@ final class MCPToolElicitationHandler {
MCPResponse handle(final McpSyncServerExchange exchange, final
MCPToolDefinition toolDefinition, final Map<String, Object> arguments,
final MCPResponse fallbackResponse, final Map<String,
Object> payload) {
MCPToolDescriptor toolDescriptor = toolDefinition.getDescriptor();
- ClientElicitationSupport clientSupport =
getClientElicitationSupport(exchange);
+ MCPClientElicitationCapabilities clientCapabilities =
MCPClientElicitationCapabilities.from(exchange);
Optional<MCPToolClarificationPolicy.ClarificationForm>
clarificationForm = clarificationPolicy.createClarificationForm(payload,
toolDescriptor);
if (clarificationForm.isEmpty()) {
- return createFallbackResponse(payload,
determineUnavailableFormReason(payload, clientSupport), clientSupport);
+ return createFallbackResponse(payload,
getUnavailableFormFallbackReason(payload, clientCapabilities),
clientCapabilities);
}
- if (!clientSupport.supportsFormMode()) {
- return createFallbackResponse(payload, CLIENT_UNSUPPORTED_REASON,
clientSupport);
+ if (!clientCapabilities.isFormModeSupported()) {
+ return createFallbackResponse(payload,
MCPToolElicitationFallbackReason.CLIENT_UNSUPPORTED, clientCapabilities);
}
if (!STDIO_TRANSPORT.equals(activeTransport)) {
- return createFallbackResponse(payload,
REMOTE_IDENTITY_REQUIRED_REASON, clientSupport);
+ return createFallbackResponse(payload,
MCPToolElicitationFallbackReason.REMOTE_IDENTITY_REQUIRED, clientCapabilities);
}
FormContinuationContext continuationContext =
createContinuationContext(exchange, toolDescriptor, arguments,
clarificationForm.get());
McpSchema.ElicitResult elicitedResult;
try {
elicitedResult =
exchange.createElicitation(createElicitRequest(toolDescriptor.getName(),
clarificationForm.get(), continuationContext.formRequestId()));
} catch (final McpError | IllegalStateException |
UnsupportedOperationException ignored) {
- return createFallbackResponse(payload, ELICITATION_FAILED_REASON,
clientSupport);
+ return createFallbackResponse(payload,
MCPToolElicitationFallbackReason.ELICITATION_FAILED, clientCapabilities);
}
- return continueOrFallback(exchange, toolDefinition, arguments,
fallbackResponse, payload, clarificationForm.get(), continuationContext,
elicitedResult, clientSupport);
+ return continueOrFallback(exchange, toolDefinition, arguments,
fallbackResponse, payload, clarificationForm.get(), continuationContext,
elicitedResult, clientCapabilities);
}
private MCPResponse continueOrFallback(final McpSyncServerExchange
exchange, final MCPToolDefinition toolDefinition, final Map<String, Object>
arguments,
final MCPResponse fallbackResponse,
final Map<String, Object> payload,
final
MCPToolClarificationPolicy.ClarificationForm clarificationForm,
final FormContinuationContext
continuationContext, final McpSchema.ElicitResult elicitedResult,
- final ClientElicitationSupport
clientSupport) {
+ final
MCPClientElicitationCapabilities clientCapabilities) {
if (null == elicitedResult || null == elicitedResult.action()) {
- return createFallbackResponse(payload,
MALFORMED_ELICITATION_RESULT_REASON, clientSupport);
+ return createFallbackResponse(payload,
MCPToolElicitationFallbackReason.MALFORMED_ELICITATION_RESULT,
clientCapabilities);
}
if (McpSchema.ElicitResult.Action.ACCEPT != elicitedResult.action()) {
return fallbackResponse;
}
if (null == elicitedResult.content()) {
- return createFallbackResponse(payload,
MALFORMED_ELICITATION_RESULT_REASON, clientSupport);
+ return createFallbackResponse(payload,
MCPToolElicitationFallbackReason.MALFORMED_ELICITATION_RESULT,
clientCapabilities);
}
- if (!isActiveContinuation(exchange, toolDefinition.getDescriptor(),
arguments, clarificationForm, continuationContext)) {
- return createFallbackResponse(payload, STALE_ELICITATION_REASON,
clientSupport);
+ if (!continuationContext.isActive(activeTransport, clock, exchange,
toolDefinition.getDescriptor(), arguments, clarificationForm)) {
+ return createFallbackResponse(payload,
MCPToolElicitationFallbackReason.STALE_ELICITATION, clientCapabilities);
}
if (!clarificationPolicy.isValidElicitedContent(clarificationForm,
elicitedResult.content())) {
- return createFallbackResponse(payload,
INVALID_ELICITED_CONTENT_REASON, clientSupport);
+ return createFallbackResponse(payload,
MCPToolElicitationFallbackReason.INVALID_ELICITED_CONTENT, clientCapabilities);
}
return toolController.handle(exchange.sessionId(), toolDefinition,
clarificationPolicy.mergeArguments(arguments, clarificationForm,
elicitedResult.content()));
}
- private ClientElicitationSupport getClientElicitationSupport(final
McpSyncServerExchange exchange) {
- McpSchema.ClientCapabilities clientCapabilities =
exchange.getClientCapabilities();
- if (null == clientCapabilities || null ==
clientCapabilities.elicitation()) {
- return new ClientElicitationSupport(false, false);
- }
- McpSchema.ClientCapabilities.Elicitation elicitation =
clientCapabilities.elicitation();
- return new ClientElicitationSupport(null != elicitation.form() || null
== elicitation.url(), null != elicitation.url());
+ private MCPResponse createFallbackResponse(final Map<String, Object>
payload, final MCPToolElicitationFallbackReason fallbackReason,
+ final
MCPClientElicitationCapabilities clientCapabilities) {
+ return fallbackResponseFactory.create(payload, fallbackReason,
clientCapabilities);
}
- private String determineUnavailableFormReason(final Map<String, Object>
payload, final ClientElicitationSupport clientSupport) {
- if (clarificationPolicy.getPlanId(payload).trim().isEmpty()) {
- return MISSING_PLAN_ID_REASON;
+ private MCPToolElicitationFallbackReason
getUnavailableFormFallbackReason(final Map<String, Object> payload, final
MCPClientElicitationCapabilities clientCapabilities) {
+ if (Objects.toString(payload.get(WorkflowFieldNames.PLAN_ID),
"").trim().isEmpty()) {
+ return MCPToolElicitationFallbackReason.MISSING_PLAN_ID;
}
- if (!clarificationPolicy.hasSensitiveClarificationQuestions(payload)) {
- return AMBIGUOUS_FIELD_BINDING_REASON;
- }
- return clientSupport.supportsUrlMode() ?
URL_MODE_NOT_IMPLEMENTED_REASON : SENSITIVE_FORM_BLOCKED_REASON;
- }
-
- private MCPResponse createFallbackResponse(final Map<String, Object>
payload, final String fallbackReason, final ClientElicitationSupport
clientSupport) {
- Map<String, Object> result = new LinkedHashMap<>(payload);
- if (clarificationPolicy.hasSensitiveClarificationQuestions(payload)) {
- result.put(MCPPayloadFieldNames.CLARIFICATION_QUESTIONS,
createSanitizedClarificationQuestions(payload));
- result.put(MCPPayloadFieldNames.NEXT_ACTIONS,
createSensitiveNextActions());
- }
- result.put(ELICITATION_SUPPORT_FIELD,
createElicitationSupportPayload(clientSupport,
selectFallbackInteraction(fallbackReason)));
- result.put(FALLBACK_REASON_FIELD, fallbackReason);
- return new MCPMapResponse(result);
- }
-
- private List<Map<String, Object>>
createSanitizedClarificationQuestions(final Map<String, Object> payload) {
- Object clarificationQuestions =
payload.get(MCPPayloadFieldNames.CLARIFICATION_QUESTIONS);
- if (!(clarificationQuestions instanceof List<?> questions)) {
- return List.of();
- }
- List<Map<String, Object>> result = new LinkedList<>();
- for (Object each : questions) {
- if (each instanceof Map<?, ?> question) {
- result.add(createSanitizedClarificationQuestion(question));
- }
- }
- return result;
- }
-
- private Map<String, Object> createSanitizedClarificationQuestion(final
Map<?, ?> question) {
- Map<String, Object> result = new LinkedHashMap<>(4, 1F);
- result.put(MCPPayloadFieldNames.FIELD,
Objects.toString(question.get(MCPPayloadFieldNames.FIELD), ""));
- result.put(MCPPayloadFieldNames.INPUT_TYPE, "secret");
- result.put(MCPPayloadFieldNames.SECRET, true);
- result.put(MCPPayloadFieldNames.MESSAGE, "Sensitive input must be
provided through configured secure channels before continuing the same
planner.");
- return result;
- }
-
- private List<Map<String, Object>> createSensitiveNextActions() {
- Map<String, Object> result = new LinkedHashMap<>(4, 1F);
- result.put("order", 1);
- result.put("type", "terminal");
- result.put("title", "Collect sensitive inputs through configured
secure channels.");
- result.put(MCPPayloadFieldNames.REASON, "MCP form elicitation is
limited to non-sensitive STDIO continuations; URL mode is not implemented in
this release.");
- return List.of(result);
- }
-
- private Map<String, Object> createElicitationSupportPayload(final
ClientElicitationSupport clientSupport, final String selectedInteraction) {
- Map<String, Object> result = new LinkedHashMap<>(3, 1F);
- result.put(FORM_MODE_FIELD, clientSupport.supportsFormMode());
- result.put(URL_MODE_FIELD, clientSupport.supportsUrlMode());
- result.put(SELECTED_INTERACTION_FIELD, selectedInteraction);
- return result;
- }
-
- private String selectFallbackInteraction(final String fallbackReason) {
- return URL_MODE_NOT_IMPLEMENTED_REASON.equals(fallbackReason) ||
SENSITIVE_FORM_BLOCKED_REASON.equals(fallbackReason) ? URL_FALLBACK_INTERACTION
: STRUCTURED_FALLBACK_INTERACTION;
+ return clarificationPolicy.hasSensitiveClarificationQuestions(payload)
+ ?
MCPToolElicitationFallbackReason.SENSITIVE_FORM_BLOCKED.withClientCapabilities(clientCapabilities)
+ : MCPToolElicitationFallbackReason.AMBIGUOUS_FIELD_BINDING;
}
private FormContinuationContext createContinuationContext(final
McpSyncServerExchange exchange, final MCPToolDescriptor toolDescriptor, final
Map<String, Object> arguments,
@@ -223,13 +127,6 @@ final class MCPToolElicitationHandler {
UUID.randomUUID().toString());
}
- private boolean isActiveContinuation(final McpSyncServerExchange exchange,
final MCPToolDescriptor toolDescriptor, final Map<String, Object> arguments,
- final
MCPToolClarificationPolicy.ClarificationForm clarificationForm, final
FormContinuationContext continuationContext) {
- return STDIO_TRANSPORT.equals(activeTransport) &&
continuationContext.sessionId().equals(exchange.sessionId())
- &&
continuationContext.toolName().equals(toolDescriptor.getName()) &&
continuationContext.planId().equals(clarificationForm.planId())
- && continuationContext.argumentsHashCode() ==
arguments.hashCode() &&
clock.instant().isBefore(continuationContext.expiresAt());
- }
-
private McpSchema.ElicitRequest createElicitRequest(final String toolName,
final MCPToolClarificationPolicy.ClarificationForm clarificationForm, final
String formRequestId) {
return McpSchema.ElicitRequest.builder()
.message(String.format("Provide missing ShardingSphere
workflow inputs for `%s`.", toolName))
@@ -245,10 +142,13 @@ final class MCPToolElicitationHandler {
MCPShardingSphereMetadataKeys.FORM_REQUEST_ID, formRequestId);
}
- private record ClientElicitationSupport(boolean supportsFormMode, boolean
supportsUrlMode) {
-
- }
-
private record FormContinuationContext(String toolName, String sessionId,
String planId, int argumentsHashCode, Instant expiresAt, String formRequestId) {
+
+ private boolean isActive(final String activeTransport, final Clock
clock, final McpSyncServerExchange exchange, final MCPToolDescriptor
toolDescriptor,
+ final Map<String, Object> arguments, final
MCPToolClarificationPolicy.ClarificationForm clarificationForm) {
+ return STDIO_TRANSPORT.equals(activeTransport) &&
sessionId.equals(exchange.sessionId())
+ && toolName.equals(toolDescriptor.getName()) &&
planId.equals(clarificationForm.planId())
+ && argumentsHashCode == arguments.hashCode() &&
clock.instant().isBefore(expiresAt);
+ }
}
}
diff --git
a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPClientElicitationCapabilitiesTest.java
b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPClientElicitationCapabilitiesTest.java
new file mode 100644
index 00000000000..81316dbe256
--- /dev/null
+++
b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPClientElicitationCapabilitiesTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.server.McpSyncServerExchange;
+import io.modelcontextprotocol.spec.McpSchema;
+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 java.util.Collections;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class MCPClientElicitationCapabilitiesTest {
+
+ @Test
+ void assertFromWithoutClientCapabilities() {
+ McpSyncServerExchange exchange = mock(McpSyncServerExchange.class);
+ MCPClientElicitationCapabilities actual =
MCPClientElicitationCapabilities.from(exchange);
+ assertFalse(actual.isFormModeSupported());
+ assertFalse(actual.isUrlModeSupported());
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("getClientCapabilities")
+ void assertFrom(final String name, final McpSchema.ClientCapabilities
clientCapabilities, final boolean expectedFormModeSupported, final boolean
expectedUrlModeSupported) {
+ McpSyncServerExchange exchange = mock(McpSyncServerExchange.class);
+ when(exchange.getClientCapabilities()).thenReturn(clientCapabilities);
+ MCPClientElicitationCapabilities actual =
MCPClientElicitationCapabilities.from(exchange);
+ assertFormModeSupported(actual, expectedFormModeSupported);
+ assertUrlModeSupported(actual, expectedUrlModeSupported);
+ }
+
+ private void assertFormModeSupported(final
MCPClientElicitationCapabilities actual, final boolean
expectedFormModeSupported) {
+ if (expectedFormModeSupported) {
+ assertTrue(actual.isFormModeSupported());
+ } else {
+ assertFalse(actual.isFormModeSupported());
+ }
+ }
+
+ private void assertUrlModeSupported(final MCPClientElicitationCapabilities
actual, final boolean expectedUrlModeSupported) {
+ if (expectedUrlModeSupported) {
+ assertTrue(actual.isUrlModeSupported());
+ } else {
+ assertFalse(actual.isUrlModeSupported());
+ }
+ }
+
+ private static Stream<Arguments> getClientCapabilities() {
+ return Stream.of(
+ Arguments.of("without elicitation capabilities", new
McpSchema.ClientCapabilities(Collections.emptyMap(), null, null, null), false,
false),
+ Arguments.of("with default elicitation capabilities",
McpSchema.ClientCapabilities.builder().elicitation().build(), true, false),
+ Arguments.of("with form elicitation capabilities", new
McpSchema.ClientCapabilities(
+ Collections.emptyMap(), null, null,
+ new McpSchema.ClientCapabilities.Elicitation(new
McpSchema.ClientCapabilities.Elicitation.Form(), null)), true, false),
+ Arguments.of("with form and url elicitation capabilities", new
McpSchema.ClientCapabilities(
+ Collections.emptyMap(), null, null,
+ new McpSchema.ClientCapabilities.Elicitation(new
McpSchema.ClientCapabilities.Elicitation.Form(), new
McpSchema.ClientCapabilities.Elicitation.Url())), true, true),
+ Arguments.of("with url elicitation capabilities", new
McpSchema.ClientCapabilities(
+ Collections.emptyMap(), null, null,
+ new McpSchema.ClientCapabilities.Elicitation(null, new
McpSchema.ClientCapabilities.Elicitation.Url())), false, true));
+ }
+}
diff --git
a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolClarificationPolicyTest.java
b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolClarificationPolicyTest.java
index 1add55192bf..9821d95aef3 100644
---
a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolClarificationPolicyTest.java
+++
b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolClarificationPolicyTest.java
@@ -23,7 +23,10 @@ import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
+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;
@@ -59,4 +62,36 @@ class MCPToolClarificationPolicyTest extends
AbstractMCPToolSpecificationFactory
"status", "clarifying",
MCPPayloadFieldNames.CLARIFICATION_QUESTIONS, List.of())));
}
+
+ @Test
+ void assertCreateClarificationForm() {
+ Optional<MCPToolClarificationPolicy.ClarificationForm> actual =
policy.createClarificationForm(createClarifyingPayload(),
+
createPlanningToolDescriptor("database_gateway_plan_encrypt_rule"));
+ assertTrue(actual.isPresent());
+ assertThat(actual.get().planId(), is("plan-1"));
+ assertThat(actual.get().requestedSchema(),
is(createExpectedElicitRequestedSchema()));
+ }
+
+ @Test
+ void assertCreateClarificationFormWithoutPlanId() {
+ Optional<MCPToolClarificationPolicy.ClarificationForm> actual =
policy.createClarificationForm(createClarifyingPayloadWithoutPlanId(),
+
createPlanningToolDescriptor("database_gateway_plan_encrypt_rule"));
+ assertTrue(actual.isEmpty());
+ }
+
+ @Test
+ void assertCreateClarificationFormWithSensitiveQuestion() {
+ Map<String, Object> payload =
createClarifyingPayload(createClarifyingQuestion("custom_properties.access-token",
"string", false, "Provide access token."));
+ Optional<MCPToolClarificationPolicy.ClarificationForm> actual =
policy.createClarificationForm(payload,
+
createPlanningToolDescriptor("database_gateway_plan_encrypt_rule"));
+ assertTrue(actual.isEmpty());
+ }
+
+ @Test
+ void assertCreateClarificationFormWithAmbiguousFieldBinding() {
+ Map<String, Object> payload =
createClarifyingPayload(createClarifyingQuestion("requires_review", "boolean",
false, "Require review?"));
+ Optional<MCPToolClarificationPolicy.ClarificationForm> actual =
policy.createClarificationForm(payload,
+
createAmbiguousPlanningToolDescriptor("database_gateway_plan_encrypt_rule"));
+ assertTrue(actual.isEmpty());
+ }
}
diff --git
a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFallbackReasonTest.java
b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFallbackReasonTest.java
new file mode 100644
index 00000000000..4d5f3e750d1
--- /dev/null
+++
b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFallbackReasonTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.server.McpSyncServerExchange;
+import io.modelcontextprotocol.spec.McpSchema;
+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 java.util.Collections;
+import java.util.stream.Stream;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class MCPToolElicitationFallbackReasonTest {
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("getFallbackReasons")
+ void assertGetters(final MCPToolElicitationFallbackReason fallbackReason,
final String expectedValue, final String expectedSelectedInteraction) {
+ assertThat(fallbackReason.getValue(), is(expectedValue));
+ assertThat(fallbackReason.getSelectedInteraction(),
is(expectedSelectedInteraction));
+ }
+
+ @Test
+ void assertWithClientCapabilitiesUseUrlFallback() {
+ MCPToolElicitationFallbackReason actual =
MCPToolElicitationFallbackReason.SENSITIVE_FORM_BLOCKED.withClientCapabilities(createClientCapabilities(createFormAndUrlClientCapabilities()));
+ assertThat(actual,
is(MCPToolElicitationFallbackReason.URL_MODE_NOT_IMPLEMENTED));
+ }
+
+ @Test
+ void assertWithClientCapabilitiesKeepSensitiveFallback() {
+ MCPToolElicitationFallbackReason actual =
+
MCPToolElicitationFallbackReason.SENSITIVE_FORM_BLOCKED.withClientCapabilities(createClientCapabilities(McpSchema.ClientCapabilities.builder().elicitation().build()));
+ assertThat(actual,
is(MCPToolElicitationFallbackReason.SENSITIVE_FORM_BLOCKED));
+ }
+
+ @Test
+ void assertWithClientCapabilitiesKeepStructuredFallback() {
+ MCPToolElicitationFallbackReason actual =
MCPToolElicitationFallbackReason.AMBIGUOUS_FIELD_BINDING.withClientCapabilities(createClientCapabilities(createFormAndUrlClientCapabilities()));
+ assertThat(actual,
is(MCPToolElicitationFallbackReason.AMBIGUOUS_FIELD_BINDING));
+ }
+
+ private static Stream<Arguments> getFallbackReasons() {
+ return Stream.of(
+
Arguments.of(MCPToolElicitationFallbackReason.CLIENT_UNSUPPORTED,
"client_unsupported", "structured_fallback"),
+
Arguments.of(MCPToolElicitationFallbackReason.REMOTE_IDENTITY_REQUIRED,
"remote_identity_required", "structured_fallback"),
+ Arguments.of(MCPToolElicitationFallbackReason.MISSING_PLAN_ID,
"missing_plan_id", "structured_fallback"),
+
Arguments.of(MCPToolElicitationFallbackReason.SENSITIVE_FORM_BLOCKED,
"sensitive_form_blocked", "url_fallback"),
+
Arguments.of(MCPToolElicitationFallbackReason.URL_MODE_NOT_IMPLEMENTED,
"url_mode_not_implemented", "url_fallback"),
+
Arguments.of(MCPToolElicitationFallbackReason.AMBIGUOUS_FIELD_BINDING,
"ambiguous_field_binding", "structured_fallback"),
+
Arguments.of(MCPToolElicitationFallbackReason.ELICITATION_FAILED,
"elicitation_failed", "structured_fallback"),
+
Arguments.of(MCPToolElicitationFallbackReason.MALFORMED_ELICITATION_RESULT,
"malformed_elicitation_result", "structured_fallback"),
+
Arguments.of(MCPToolElicitationFallbackReason.INVALID_ELICITED_CONTENT,
"invalid_elicited_content", "structured_fallback"),
+
Arguments.of(MCPToolElicitationFallbackReason.STALE_ELICITATION,
"stale_elicitation", "structured_fallback"));
+ }
+
+ private MCPClientElicitationCapabilities createClientCapabilities(final
McpSchema.ClientCapabilities clientCapabilities) {
+ McpSyncServerExchange exchange = mock(McpSyncServerExchange.class);
+ when(exchange.getClientCapabilities()).thenReturn(clientCapabilities);
+ return MCPClientElicitationCapabilities.from(exchange);
+ }
+
+ private McpSchema.ClientCapabilities createFormAndUrlClientCapabilities() {
+ return new McpSchema.ClientCapabilities(
+ Collections.emptyMap(), null, null,
+ new McpSchema.ClientCapabilities.Elicitation(new
McpSchema.ClientCapabilities.Elicitation.Form(), new
McpSchema.ClientCapabilities.Elicitation.Url()));
+ }
+}
diff --git
a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFallbackResponseFactoryTest.java
b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFallbackResponseFactoryTest.java
new file mode 100644
index 00000000000..17c015a79aa
--- /dev/null
+++
b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolElicitationFallbackResponseFactoryTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.server.McpSyncServerExchange;
+import io.modelcontextprotocol.spec.McpSchema;
+import org.apache.shardingsphere.mcp.api.protocol.response.MCPResponse;
+import org.apache.shardingsphere.mcp.support.protocol.MCPPayloadFieldNames;
+import org.apache.shardingsphere.mcp.support.workflow.model.WorkflowFieldNames;
+import org.junit.jupiter.api.Test;
+
+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.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class MCPToolElicitationFallbackResponseFactoryTest extends
AbstractMCPToolSpecificationFactoryTest {
+
+ private final MCPToolElicitationFallbackResponseFactory factory = new
MCPToolElicitationFallbackResponseFactory();
+
+ @Test
+ void assertCreateStructuredFallback() {
+ MCPResponse actual = factory.create(Map.of(WorkflowFieldNames.PLAN_ID,
"plan-1", "status", "clarifying"),
+ MCPToolElicitationFallbackReason.AMBIGUOUS_FIELD_BINDING,
createClientCapabilities(McpSchema.ClientCapabilities.builder().elicitation().build()));
+ Map<String, Object> actualPayload = actual.toPayload();
+ assertThat(actualPayload.get("fallback_reason"),
is("ambiguous_field_binding"));
+ Map<?, ?> actualSupport = (Map<?, ?>)
actualPayload.get("elicitation_support");
+ assertTrue((Boolean) actualSupport.get("form_mode"));
+ assertFalse((Boolean) actualSupport.get("url_mode"));
+ assertThat(actualSupport.get("selected_interaction"),
is("structured_fallback"));
+
assertFalse(actualPayload.containsKey(MCPPayloadFieldNames.NEXT_ACTIONS));
+ }
+
+ @Test
+ void assertCreateSensitiveFallback() {
+ MCPResponse actual =
factory.create(createClarifyingPayload(createClarifyingQuestion("primary_algorithm_properties.access-token",
"string", false, "Provide access token.")),
+ MCPToolElicitationFallbackReason.SENSITIVE_FORM_BLOCKED,
createClientCapabilities(McpSchema.ClientCapabilities.builder().elicitation().build()));
+ Map<String, Object> actualPayload = actual.toPayload();
+ assertThat(actualPayload.get("fallback_reason"),
is("sensitive_form_blocked"));
+ assertSensitiveFallback(actualPayload);
+ }
+
+ private void assertSensitiveFallback(final Map<String, Object>
actualPayload) {
+ Map<?, ?> actualQuestion = (Map<?, ?>) ((List<?>)
actualPayload.get(MCPPayloadFieldNames.CLARIFICATION_QUESTIONS)).get(0);
+ assertThat(actualQuestion.get(MCPPayloadFieldNames.INPUT_TYPE),
is("secret"));
+ assertTrue((boolean) actualQuestion.get(MCPPayloadFieldNames.SECRET));
+ assertThat(actualQuestion.get(MCPPayloadFieldNames.MESSAGE),
is("Sensitive input must be provided through configured secure channels before
continuing the same planner."));
+
assertFalse(actualQuestion.containsKey(MCPPayloadFieldNames.DISPLAY_MESSAGE));
+ assertThat(((Map<?, ?>) ((List<?>)
actualPayload.get(MCPPayloadFieldNames.NEXT_ACTIONS)).get(0)).get("type"),
is("terminal"));
+ assertFalse(String.valueOf(actualPayload).contains("Provide access
token."));
+ }
+
+ private MCPClientElicitationCapabilities createClientCapabilities(final
McpSchema.ClientCapabilities clientCapabilities) {
+ McpSyncServerExchange exchange = mock(McpSyncServerExchange.class);
+ when(exchange.getClientCapabilities()).thenReturn(clientCapabilities);
+ return MCPClientElicitationCapabilities.from(exchange);
+ }
+}
diff --git
a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolSpecificationElicitationFactoryTest.java
b/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolSpecificationElicitationFactoryTest.java
deleted file mode 100644
index 92dd1e233cf..00000000000
---
a/mcp/bootstrap/src/test/java/org/apache/shardingsphere/mcp/bootstrap/transport/capability/tool/MCPToolSpecificationElicitationFactoryTest.java
+++ /dev/null
@@ -1,397 +0,0 @@
-/*
- * 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.server.McpServerFeatures.SyncToolSpecification;
-import io.modelcontextprotocol.server.McpSyncServerExchange;
-import io.modelcontextprotocol.spec.McpSchema;
-import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
-import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
-import org.apache.shardingsphere.mcp.api.protocol.response.MCPResponse;
-import org.apache.shardingsphere.mcp.core.context.MCPRequestScope;
-import org.apache.shardingsphere.mcp.core.context.MCPRuntimeContext;
-import org.apache.shardingsphere.mcp.core.tool.handler.MCPToolDefinition;
-import org.apache.shardingsphere.mcp.core.tool.handler.ToolDefinitionRegistry;
-import
org.apache.shardingsphere.mcp.support.descriptor.MCPShardingSphereMetadataKeys;
-import org.apache.shardingsphere.mcp.support.protocol.response.MCPMapResponse;
-import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentCaptor;
-import org.mockito.MockedStatic;
-
-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.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 MCPToolSpecificationElicitationFactoryTest extends
AbstractMCPToolSpecificationFactoryTest {
-
- @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);
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
- McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT,
- Map.of("field_1", "foo_display", "field_2", true)),
clientCapabilities);
- CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new CallToolRequest(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);
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
- McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of()),
createFormAndUrlClientCapabilities());
- CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new CallToolRequest(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);
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
- McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of("field_1",
true)));
- CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new CallToolRequest(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);
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
- McpSyncServerExchange exchange =
createUrlOnlyElicitationExchange();
- CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new CallToolRequest(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);
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("http")).createToolSpecifications().get(0);
- McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of("field_1",
"foo_display")));
- CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new CallToolRequest(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);
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
- McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT,
Map.of("custom_properties.display-name", "foo_display")));
- CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new CallToolRequest(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);
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
- McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of("field_1",
"foo_display")));
- CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new CallToolRequest(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());
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(runtimeContext).createToolSpecifications().get(0);
- McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of()));
- CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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());
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(runtimeContext).createToolSpecifications().get(0);
- McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of()));
- CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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);
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
- McpSyncServerExchange exchange = mock(McpSyncServerExchange.class);
- when(exchange.sessionId()).thenReturn("session-id");
- CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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);
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
- McpSyncServerExchange exchange =
createThrowingElicitationExchange();
- CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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);
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
- 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 =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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);
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
- McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, elicitedContent));
- CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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);
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
- McpSyncServerExchange exchange =
createElicitationExchange(elicitedResult);
- CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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 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);
- SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
- McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(action, Map.of()));
- CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("database_gateway_plan_encrypt_rule", Map.of()));
- assertThat(actual.structuredContent(), is(expectedPayload));
- verify(exchange).createElicitation(any());
- }
- }
-}
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 fd59a5049c3..3e75d1e7d9d 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
@@ -31,6 +31,7 @@ import io.modelcontextprotocol.spec.McpSchema.TextContent;
import org.apache.shardingsphere.mcp.api.protocol.response.MCPResponse;
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;
@@ -40,8 +41,11 @@ import
org.apache.shardingsphere.mcp.support.descriptor.MCPShardingSphereMetadat
import org.apache.shardingsphere.mcp.support.protocol.MCPResourceHintUtils;
import org.apache.shardingsphere.mcp.support.protocol.response.MCPMapResponse;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
+import java.time.Clock;
+import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -54,9 +58,13 @@ 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 {
@@ -282,6 +290,362 @@ class MCPToolSpecificationFactoryTest extends
AbstractMCPToolSpecificationFactor
}
}
+ @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);
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
+ McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT,
+ Map.of("field_1", "foo_display", "field_2", true)),
clientCapabilities);
+ CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new CallToolRequest(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);
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
+ McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of()),
createFormAndUrlClientCapabilities());
+ CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new CallToolRequest(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);
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
+ McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of("field_1",
true)));
+ CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new CallToolRequest(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);
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
+ McpSyncServerExchange exchange =
createUrlOnlyElicitationExchange();
+ CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new CallToolRequest(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);
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("http")).createToolSpecifications().get(0);
+ McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of("field_1",
"foo_display")));
+ CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new CallToolRequest(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);
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
+ McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT,
Map.of("custom_properties.display-name", "foo_display")));
+ CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new CallToolRequest(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);
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
+ McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of("field_1",
"foo_display")));
+ CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new CallToolRequest(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());
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(runtimeContext).createToolSpecifications().get(0);
+ McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of()));
+ CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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());
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(runtimeContext).createToolSpecifications().get(0);
+ McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, Map.of()));
+ CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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);
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
+ McpSyncServerExchange exchange = mock(McpSyncServerExchange.class);
+ when(exchange.sessionId()).thenReturn("session-id");
+ CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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);
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
+ 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 =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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);
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
+ McpSyncServerExchange exchange =
createThrowingElicitationExchange();
+ CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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);
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
+ 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 =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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);
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
+ McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, elicitedContent));
+ CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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);
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
+ McpSyncServerExchange exchange =
createElicitationExchange(elicitedResult);
+ CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("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 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);
+ SyncToolSpecification actualSpecification = new
MCPToolSpecificationFactory(createRuntimeContext("stdio")).createToolSpecifications().get(0);
+ McpSyncServerExchange exchange = createElicitationExchange(new
McpSchema.ElicitResult(action, Map.of()));
+ CallToolResult actual =
actualSpecification.callHandler().apply(exchange, new
CallToolRequest("database_gateway_plan_encrypt_rule", Map.of()));
+ assertThat(actual.structuredContent(), is(expectedPayload));
+ verify(exchange).createElicitation(any());
+ }
+ }
+
@Test
void assertToolOutputSchemaExamplesMatchSchemas() {
JsonSchemaValidator validator = new DefaultJsonSchemaValidator();