Skip to content

Commit a02a94a

Browse files
MCP catalog: fix InOnly test semantics, add description/annotation tuning
Fix failing test: testInOnlyMalformedJsonBodyReturnsBadRequestWithCorrelationId was asserting that "Bad Request" appears in the HTTP response body for InOnly (fire-and- forget) operations. InOnly MEP swallows the AxisFault at the Axis2 dispatch layer — the response is always empty (same as success). Rename to testInOnlyMalformedJsonBodyDoesNotLeakExceptionDetails; assert only that no exception class name or stack trace appears in whatever body is returned. Phase A1 — natural language tool descriptions (mcpDescription parameter): getMcpStringParam() reads "mcpDescription" from AxisOperation first, then AxisService, then falls back to "ServiceName: operationName". Set in services.xml at <operation> or <service> level. 9 new tests covering operation-level override, service-level default, precedence, and fallback. Phase A2 — per-service annotation tuning (mcpReadOnly et al.): getMcpBoolParam() reads mcpReadOnly, mcpDestructive, mcpIdempotent, mcpOpenWorld from AxisOperation then AxisService; maps to MCP 2025 annotations readOnlyHint/destructiveHint/idempotentHint/openWorldHint. Conservative false defaults preserved when no parameters set. 7 new tests covering each hint and the default-preservation invariant. Update json-rpc-mcp-guide.xml test table for renamed InOnly test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 71d451d commit a02a94a

4 files changed

Lines changed: 237 additions & 15 deletions

File tree

modules/json/test/org/apache/axis2/json/gson/rpc/JSONRPCIntegrationTest.java

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -134,17 +134,24 @@ public void testMalformedJsonBodyDoesNotLeakExceptionClassName() throws Exceptio
134134
}
135135

136136
/**
137-
* The InOnly receiver (fire-and-forget) must apply the same correlation ID
138-
* pattern for malformed requests — no exception leak on that path either.
137+
* InOnly (fire-and-forget) operations do not return fault messages to the client —
138+
* the AxisFault is caught by the Axis2 InOnly dispatch and the HTTP response is
139+
* empty (identical to a successful InOnly call). The errorRef UUID is logged
140+
* server-side; clients have no observable fault body to inspect.
141+
*
142+
* What IS testable: nothing in the response (empty or otherwise) leaks a Java
143+
* exception class name or stack trace.
139144
*/
140145
@Test
141-
public void testInOnlyMalformedJsonBodyReturnsBadRequestWithCorrelationId() throws Exception {
146+
public void testInOnlyMalformedJsonBodyDoesNotLeakExceptionDetails() throws Exception {
142147
String pingUrl = server.getEndpoint("JSONPOJOService") + "ping";
143148
UtilTest.TestResponse result = UtilTest.postForResponse("NOT_VALID_JSON", pingUrl);
144149
String body = result.body;
145-
Assert.assertTrue("InOnly malformed request must return 'Bad Request'",
146-
body.contains("Bad Request"));
147-
Assert.assertTrue("InOnly malformed request must contain an errorRef",
148-
body.contains("errorRef="));
150+
Assert.assertFalse("InOnly error response must not leak 'MalformedJsonException'",
151+
body.contains("MalformedJsonException"));
152+
Assert.assertFalse("InOnly error response must not leak 'IOException'",
153+
body.contains("IOException"));
154+
Assert.assertFalse("InOnly error response must not leak stack trace",
155+
body.contains("at org.apache"));
149156
}
150157
}

modules/openapi/src/main/java/org/apache/axis2/openapi/OpenApiSpecGenerator.java

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,47 @@ public ConfigurationContext getConfigurationContext() {
633633
* Uses the same service filtering as {@link #generatePaths()} so the tool
634634
* catalog is consistent with the OpenAPI spec.
635635
*/
636+
/**
637+
* Reads a string-valued MCP metadata parameter, checking the operation first
638+
* then the service, falling back to {@code defaultValue}.
639+
*
640+
* <p>Callers set this in {@code services.xml}:
641+
* <pre>
642+
* &lt;operation name="doFoo"&gt;
643+
* &lt;parameter name="mcpDescription"&gt;Natural language description&lt;/parameter&gt;
644+
* &lt;/operation&gt;
645+
* </pre>
646+
*/
647+
private String getMcpStringParam(AxisOperation operation, AxisService service,
648+
String paramName, String defaultValue) {
649+
org.apache.axis2.description.Parameter p = operation.getParameter(paramName);
650+
if (p == null) p = service.getParameter(paramName);
651+
if (p != null && p.getValue() != null) {
652+
String v = p.getValue().toString().trim();
653+
if (!v.isEmpty()) return v;
654+
}
655+
return defaultValue;
656+
}
657+
658+
/**
659+
* Reads a boolean-valued MCP metadata parameter, checking the operation first
660+
* then the service, falling back to {@code defaultValue}.
661+
*
662+
* <p>Accepts "true" / "false" (case-insensitive). Any other value is treated as
663+
* {@code defaultValue}.
664+
*/
665+
private boolean getMcpBoolParam(AxisOperation operation, AxisService service,
666+
String paramName, boolean defaultValue) {
667+
org.apache.axis2.description.Parameter p = operation.getParameter(paramName);
668+
if (p == null) p = service.getParameter(paramName);
669+
if (p != null && p.getValue() != null) {
670+
String v = p.getValue().toString().trim().toLowerCase(java.util.Locale.ROOT);
671+
if ("true".equals(v)) return true;
672+
if ("false".equals(v)) return false;
673+
}
674+
return defaultValue;
675+
}
676+
636677
/**
637678
* Generates the MCP tool catalog JSON for the {@code /openapi-mcp.json} endpoint.
638679
*
@@ -684,7 +725,17 @@ public String generateMcpCatalogJson(HttpServletRequest request) {
684725

685726
com.fasterxml.jackson.databind.node.ObjectNode toolNode = toolsArray.addObject();
686727
toolNode.put("name", opName);
687-
toolNode.put("description", service.getName() + ": " + opName);
728+
729+
// Description: prefer operation-level "mcpDescription" parameter,
730+
// then service-level "mcpDescription", then auto-generated fallback.
731+
// Set in services.xml:
732+
// <operation name="doFoo">
733+
// <parameter name="mcpDescription">Human-readable tool description</parameter>
734+
// </operation>
735+
// or at service level for a default across all operations.
736+
String description = getMcpStringParam(operation, service, "mcpDescription",
737+
service.getName() + ": " + opName);
738+
toolNode.put("description", description);
688739

689740
// inputSchema: minimal MCP-compliant structure. Richer schemas are
690741
// produced when services carry @McpTool annotations (future work).
@@ -706,14 +757,20 @@ public String generateMcpCatalogJson(HttpServletRequest request) {
706757
// Whether the caller must supply a Bearer token (from doLogin).
707758
toolNode.put("x-requiresAuth", requiresAuth);
708759

709-
// MCP 2025-03-26 tool annotations — conservative defaults.
710-
// Override via @McpTool when richer metadata is available.
760+
// MCP 2025-03-26 tool annotations.
761+
// Tunable via services.xml parameters at operation or service level:
762+
// mcpReadOnly → readOnlyHint (true for GET-equivalent operations)
763+
// mcpDestructive → destructiveHint
764+
// mcpIdempotent → idempotentHint (true for pure reads / PUT-equivalent)
765+
// mcpOpenWorld → openWorldHint (true for operations with side effects
766+
// outside the Axis2 service boundary)
767+
// Conservative false defaults are preserved when parameters are absent.
711768
com.fasterxml.jackson.databind.node.ObjectNode annotations =
712769
toolNode.putObject("annotations");
713-
annotations.put("readOnlyHint", false);
714-
annotations.put("destructiveHint", false);
715-
annotations.put("idempotentHint", false);
716-
annotations.put("openWorldHint", false);
770+
annotations.put("readOnlyHint", getMcpBoolParam(operation, service, "mcpReadOnly", false));
771+
annotations.put("destructiveHint", getMcpBoolParam(operation, service, "mcpDestructive", false));
772+
annotations.put("idempotentHint", getMcpBoolParam(operation, service, "mcpIdempotent", false));
773+
annotations.put("openWorldHint", getMcpBoolParam(operation, service, "mcpOpenWorld", false));
717774
}
718775
}
719776

modules/openapi/src/test/java/org/apache/axis2/openapi/McpCatalogGeneratorTest.java

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,164 @@ public void testAllAnnotationHintsAreBooleans() throws Exception {
521521
}
522522
}
523523

524+
// ── A1: mcpDescription parameter ─────────────────────────────────────────
525+
526+
/**
527+
* When an operation has a {@code mcpDescription} parameter, that value
528+
* replaces the auto-generated "ServiceName: operationName" description.
529+
* This is the primary way to make tool descriptions useful to LLMs.
530+
*/
531+
public void testOperationLevelMcpDescriptionOverridesDefault() throws Exception {
532+
AxisService svc = new AxisService("GetAssetCalculationsService");
533+
AxisOperation op = new InOutAxisOperation();
534+
op.setName(QName.valueOf("getAssetCalculations"));
535+
op.addParameter(new org.apache.axis2.description.Parameter(
536+
"mcpDescription",
537+
"Get calculated portfolio metrics (OPS, PWR, Kelly) for assets in a fund."));
538+
svc.addOperation(op);
539+
axisConfig.addService(svc);
540+
541+
JsonNode tool = getCatalogTools().get(0);
542+
assertEquals("Operation-level mcpDescription must be used as tool description",
543+
"Get calculated portfolio metrics (OPS, PWR, Kelly) for assets in a fund.",
544+
tool.path("description").asText());
545+
}
546+
547+
/**
548+
* When no operation-level parameter is set but the service has
549+
* {@code mcpDescription}, that value is used as the description.
550+
*/
551+
public void testServiceLevelMcpDescriptionUsedWhenNoOperationLevel() throws Exception {
552+
AxisService svc = new AxisService("SearchService");
553+
svc.addParameter(new org.apache.axis2.description.Parameter(
554+
"mcpDescription", "Service-level default description"));
555+
AxisOperation op = new InOutAxisOperation();
556+
op.setName(QName.valueOf("search"));
557+
svc.addOperation(op);
558+
axisConfig.addService(svc);
559+
560+
JsonNode tool = getCatalogTools().get(0);
561+
assertEquals("Service-level mcpDescription must be used when no operation-level param",
562+
"Service-level default description",
563+
tool.path("description").asText());
564+
}
565+
566+
/**
567+
* Operation-level {@code mcpDescription} takes precedence over a service-level one.
568+
*/
569+
public void testOperationLevelMcpDescriptionTakesPrecedenceOverServiceLevel() throws Exception {
570+
AxisService svc = new AxisService("SearchService");
571+
svc.addParameter(new org.apache.axis2.description.Parameter(
572+
"mcpDescription", "Service-level description"));
573+
AxisOperation op = new InOutAxisOperation();
574+
op.setName(QName.valueOf("search"));
575+
op.addParameter(new org.apache.axis2.description.Parameter(
576+
"mcpDescription", "Operation-level description"));
577+
svc.addOperation(op);
578+
axisConfig.addService(svc);
579+
580+
JsonNode tool = getCatalogTools().get(0);
581+
assertEquals("Operation-level mcpDescription must win over service-level",
582+
"Operation-level description",
583+
tool.path("description").asText());
584+
}
585+
586+
/**
587+
* When no {@code mcpDescription} parameter is set at either level, the
588+
* auto-generated "ServiceName: operationName" fallback is still produced.
589+
*/
590+
public void testDescriptionFallsBackToAutoGeneratedWhenNoMcpDescriptionParam() throws Exception {
591+
addService("MyService", "myOperation");
592+
JsonNode tool = getCatalogTools().get(0);
593+
assertEquals("Auto-generated fallback must be 'ServiceName: operationName'",
594+
"MyService: myOperation",
595+
tool.path("description").asText());
596+
}
597+
598+
// ── A2: mcpReadOnly / mcpIdempotent annotation tuning ────────────────────
599+
600+
/**
601+
* When a service sets {@code mcpReadOnly=true}, the catalog must publish
602+
* {@code readOnlyHint: true} for all its operations. Read-only services
603+
* (GetAsset*, Search*) should set this so MCP hosts can safely auto-approve
604+
* them without human confirmation.
605+
*/
606+
public void testServiceLevelMcpReadOnlySetsTrueOnAnnotation() throws Exception {
607+
AxisService svc = new AxisService("GetAssetCalculationsService");
608+
svc.addParameter(new org.apache.axis2.description.Parameter("mcpReadOnly", "true"));
609+
AxisOperation op = new InOutAxisOperation();
610+
op.setName(QName.valueOf("getAssetCalculations"));
611+
svc.addOperation(op);
612+
axisConfig.addService(svc);
613+
614+
JsonNode annotations = getCatalogTools().get(0).path("annotations");
615+
assertTrue("readOnlyHint must be true when mcpReadOnly=true on service",
616+
annotations.path("readOnlyHint").asBoolean());
617+
}
618+
619+
/**
620+
* Operation-level {@code mcpReadOnly=true} overrides a service-level
621+
* {@code false} (or absent) — per-operation tuning takes precedence.
622+
*/
623+
public void testOperationLevelMcpReadOnlyOverridesServiceLevel() throws Exception {
624+
AxisService svc = new AxisService("MixedService");
625+
// no service-level mcpReadOnly
626+
AxisOperation op = new InOutAxisOperation();
627+
op.setName(QName.valueOf("readOnlyOp"));
628+
op.addParameter(new org.apache.axis2.description.Parameter("mcpReadOnly", "true"));
629+
svc.addOperation(op);
630+
axisConfig.addService(svc);
631+
632+
JsonNode annotations = getCatalogTools().get(0).path("annotations");
633+
assertTrue("readOnlyHint must be true when operation sets mcpReadOnly=true",
634+
annotations.path("readOnlyHint").asBoolean());
635+
}
636+
637+
/**
638+
* {@code mcpIdempotent=true} maps to {@code idempotentHint: true}.
639+
*/
640+
public void testMcpIdempotentParamSetsIdempotentHint() throws Exception {
641+
AxisService svc = new AxisService("QueryService");
642+
AxisOperation op = new InOutAxisOperation();
643+
op.setName(QName.valueOf("getByKey"));
644+
op.addParameter(new org.apache.axis2.description.Parameter("mcpIdempotent", "true"));
645+
svc.addOperation(op);
646+
axisConfig.addService(svc);
647+
648+
JsonNode annotations = getCatalogTools().get(0).path("annotations");
649+
assertTrue("idempotentHint must be true when mcpIdempotent=true",
650+
annotations.path("idempotentHint").asBoolean());
651+
}
652+
653+
/**
654+
* {@code mcpDestructive=true} maps to {@code destructiveHint: true}.
655+
*/
656+
public void testMcpDestructiveParamSetsDestructiveHint() throws Exception {
657+
AxisService svc = new AxisService("AdminService");
658+
AxisOperation op = new InOutAxisOperation();
659+
op.setName(QName.valueOf("deleteAllData"));
660+
op.addParameter(new org.apache.axis2.description.Parameter("mcpDestructive", "true"));
661+
svc.addOperation(op);
662+
axisConfig.addService(svc);
663+
664+
JsonNode annotations = getCatalogTools().get(0).path("annotations");
665+
assertTrue("destructiveHint must be true when mcpDestructive=true",
666+
annotations.path("destructiveHint").asBoolean());
667+
}
668+
669+
/**
670+
* Conservative defaults remain intact when no MCP annotation parameters
671+
* are set — no existing behaviour is changed by the new parameter support.
672+
*/
673+
public void testAnnotationDefaultsAreConservativeWhenNoParamsSet() throws Exception {
674+
addService("NoParamService", "doSomething");
675+
JsonNode annotations = getCatalogTools().get(0).path("annotations");
676+
assertFalse("readOnlyHint default must be false", annotations.path("readOnlyHint").asBoolean());
677+
assertFalse("destructiveHint default must be false", annotations.path("destructiveHint").asBoolean());
678+
assertFalse("idempotentHint default must be false", annotations.path("idempotentHint").asBoolean());
679+
assertFalse("openWorldHint default must be false", annotations.path("openWorldHint").asBoolean());
680+
}
681+
524682
// ── tool list mirrors existing OpenAPI paths ──────────────────────────────
525683

526684
/**

src/site/xdoc/docs/json-rpc-mcp-guide.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,7 @@ conformance checklist when extending the catalog or adding new MCP client code.<
449449
<tr><td><code>testMalformedJsonBodyCorrelationIdIsUuid</code></td><td>errorRef matches 8-4-4-4-12 hex UUID format</td></tr>
450450
<tr><td><code>testMissingOuterArrayReturnsBadRequestWithCorrelationId</code></td><td>Valid JSON but wrong envelope structure → correlated Bad Request</td></tr>
451451
<tr><td><code>testMalformedJsonBodyDoesNotLeakExceptionClassName</code></td><td>No "MalformedJsonException", "IOException", or "at org.apache" in response</td></tr>
452-
<tr><td><code>testInOnlyMalformedJsonBodyReturnsBadRequestWithCorrelationId</code></td><td>InOnly receiver applies same error hardening</td></tr>
452+
<tr><td><code>testInOnlyMalformedJsonBodyDoesNotLeakExceptionDetails</code></td><td>InOnly receiver returns empty body on fault (MEP semantics); no exception class name or stack trace leaked in response</td></tr>
453453
</table>
454454

455455
<h3>6.5 pyRapi Authentication — test_auth.py (18 tests)</h3>

0 commit comments

Comments
 (0)