Skip to content

Commit 2c0577f

Browse files
mcp: apply Gemini 2.5 Pro code review fixes
Four issues found and fixed: CRITICAL — McpStdioServer: tools/call errors now always produce a JSON-RPC response. Previously, IllegalArgumentException (unknown tool, missing param) propagated to run() where it was only logged to stderr, leaving the client waiting forever. Added try-catch inside processLine() with correct error codes: -32602 for invalid params, -32000 for I/O/interrupt, -32603 for internal errors. HIGH — OpenApiSpecGenerator.generateMcpCatalogJson(): replaced StringBuilder + custom escapeJson() with Jackson ObjectMapper. The old escapeJson() only handled backslash and double-quote, silently corrupting any operation name containing \n, \t, \r, or other control characters. Jackson guarantees RFC 8259-correct escaping. MEDIUM — X509AuthenticationFilter.extractCN(): replaced comma-split with LdapName + Rdn iteration. RFC 2253 allows escaped commas inside RDN values (e.g. O="Example, Inc."); a plain split(",") would mis-parse such DNs. LdapName handles escaping correctly, iterates in reverse since CN is typically the most-specific (last) RDN. LOW — McpStdioServerTest: renamed testToolsCallUnknownToolProducesNo- StdoutResponse and updated assertion. The old test validated the buggy behavior (empty stdout). The new test asserts a -32602 error response with the echoed id and the unknown tool name in the message. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 70fcdd2 commit 2c0577f

File tree

5 files changed

+109
-60
lines changed

5 files changed

+109
-60
lines changed

modules/mcp-bridge/src/main/java/org/apache/axis2/mcp/bridge/McpStdioServer.java

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -119,18 +119,32 @@ private void processLine(String line) throws Exception {
119119
JsonNode params = request.path("params");
120120
System.err.println("[axis2-mcp-bridge] Request id=" + id + " method=" + method);
121121

122-
switch (method) {
123-
case "initialize":
124-
writeSuccess(id, buildInitializeResult(params));
125-
break;
126-
case "tools/list":
127-
writeSuccess(id, buildToolsListResult());
128-
break;
129-
case "tools/call":
130-
writeSuccess(id, buildToolsCallResult(params));
131-
break;
132-
default:
133-
writeError(id, -32601, "Method not found: " + method);
122+
try {
123+
switch (method) {
124+
case "initialize":
125+
writeSuccess(id, buildInitializeResult(params));
126+
break;
127+
case "tools/list":
128+
writeSuccess(id, buildToolsListResult());
129+
break;
130+
case "tools/call":
131+
writeSuccess(id, buildToolsCallResult(params));
132+
break;
133+
default:
134+
writeError(id, -32601, "Method not found: " + method);
135+
}
136+
} catch (IllegalArgumentException e) {
137+
// Invalid params: unknown tool name, missing required param, etc.
138+
writeError(id, -32602, "Invalid params: " + e.getMessage());
139+
} catch (InterruptedException e) {
140+
Thread.currentThread().interrupt();
141+
writeError(id, -32000, "Server error during tool call: " + e.getMessage());
142+
} catch (IOException e) {
143+
writeError(id, -32000, "Server error during tool call: " + e.getMessage());
144+
} catch (Exception e) {
145+
System.err.println("[axis2-mcp-bridge] Internal error id=" + id + ": " + e.getMessage());
146+
e.printStackTrace(System.err);
147+
writeError(id, -32603, "Internal error: " + e.getMessage());
134148
}
135149
}
136150

modules/mcp-bridge/src/test/java/org/apache/axis2/mcp/bridge/McpStdioServerTest.java

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -202,27 +202,35 @@ public void testToolsListReturnsMultipleTools() throws Exception {
202202
// ── tools/call ──────────────────────────────────────────────────────────
203203

204204
/**
205-
* When tools/call names a tool that is not in the registry, the server
206-
* throws IllegalArgumentException, which is caught by the run() loop and
207-
* logged to stderr. No JSON-RPC response is written to stdout.
205+
* When tools/call names a tool not in the registry, the server must return
206+
* a JSON-RPC error with code -32602 (Invalid Params) per the spec. The id
207+
* from the request must be echoed in the error response so the client can
208+
* correlate it.
208209
*/
209-
public void testToolsCallUnknownToolProducesNoStdoutResponse() throws Exception {
210+
public void testToolsCallUnknownToolReturnsInvalidParamsError() throws Exception {
210211
String output = runServer(new StubToolRegistry(),
211212
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\"," +
212213
"\"params\":{\"name\":\"nonexistentTool\",\"arguments\":{}}}");
213214

214-
// Nothing should be written to stdout for the unknown-tool error path
215-
// (exception is caught in run() and logged to stderr only)
216-
assertTrue("No stdout response expected for unknown tool", output.isEmpty());
215+
JsonNode response = parseSingleResponse(output);
216+
assertEquals("id must be echoed", 3, response.path("id").asInt());
217+
assertTrue("Response must contain 'error'", response.has("error"));
218+
assertFalse("Response must not contain 'result'", response.has("result"));
219+
assertEquals(-32602, response.path("error").path("code").asInt());
220+
assertTrue("Error message must mention the tool name",
221+
response.path("error").path("message").asText().contains("nonexistentTool"));
217222
}
218223

219-
public void testToolsCallMissingNameProducesNoStdoutResponse() throws Exception {
224+
public void testToolsCallMissingNameReturnsInvalidParamsError() throws Exception {
220225
// params.name is absent — throws IllegalArgumentException in buildToolsCallResult
221226
String output = runServer(new StubToolRegistry(),
222-
"{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\"," +
227+
"{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\"," +
223228
"\"params\":{\"arguments\":{}}}");
224229

225-
assertTrue("No stdout response expected for missing name", output.isEmpty());
230+
JsonNode response = parseSingleResponse(output);
231+
assertEquals(7, response.path("id").asInt());
232+
assertTrue("Response must contain 'error'", response.has("error"));
233+
assertEquals(-32602, response.path("error").path("code").asInt());
226234
}
227235

228236
// ── error cases ─────────────────────────────────────────────────────────

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

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -633,12 +633,23 @@ 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+
* Generates the MCP tool catalog JSON for the {@code /openapi-mcp.json} endpoint.
638+
*
639+
* <p>Uses Jackson to build the JSON object graph, which guarantees correct
640+
* escaping of all string values including control characters — something a
641+
* hand-rolled {@code StringBuilder} approach cannot safely guarantee.
642+
*
643+
* <p>Uses the same service/operation filtering as {@link #generatePaths()}.
644+
*/
636645
public String generateMcpCatalogJson(HttpServletRequest request) {
637646
try {
638647
AxisConfiguration axisConfig = configurationContext.getAxisConfiguration();
639-
StringBuilder json = new StringBuilder();
640-
json.append("{\n \"tools\": [\n");
641-
boolean firstTool = true;
648+
649+
com.fasterxml.jackson.databind.ObjectMapper jackson =
650+
new com.fasterxml.jackson.databind.ObjectMapper();
651+
com.fasterxml.jackson.databind.node.ObjectNode root = jackson.createObjectNode();
652+
com.fasterxml.jackson.databind.node.ArrayNode toolsArray = root.putArray("tools");
642653

643654
Iterator<AxisService> services = axisConfig.getServices().values().iterator();
644655
while (services.hasNext()) {
@@ -654,44 +665,31 @@ public String generateMcpCatalogJson(HttpServletRequest request) {
654665
String opName = operation.getName().getLocalPart();
655666
String path = "/services/" + service.getName() + "/" + opName;
656667

657-
if (!firstTool) json.append(",\n");
658-
firstTool = false;
659-
660-
json.append(" {\n");
661-
json.append(" \"name\": \"").append(escapeJson(opName)).append("\",\n");
662-
json.append(" \"description\": \"").append(escapeJson(service.getName()))
663-
.append(": ").append(escapeJson(opName)).append("\",\n");
664-
json.append(" \"inputSchema\": {\n");
665-
json.append(" \"type\": \"object\",\n");
666-
json.append(" \"properties\": {},\n");
667-
json.append(" \"required\": []\n");
668-
json.append(" },\n");
669-
json.append(" \"endpoint\": \"POST ").append(escapeJson(path)).append("\"\n");
670-
json.append(" }");
668+
com.fasterxml.jackson.databind.node.ObjectNode toolNode = toolsArray.addObject();
669+
toolNode.put("name", opName);
670+
toolNode.put("description", service.getName() + ": " + opName);
671+
672+
// inputSchema: minimal MCP-compliant structure. Richer schemas are
673+
// produced when services carry @McpTool annotations (future work).
674+
com.fasterxml.jackson.databind.node.ObjectNode schema =
675+
toolNode.putObject("inputSchema");
676+
schema.put("type", "object");
677+
schema.putObject("properties");
678+
schema.putArray("required");
679+
680+
toolNode.put("endpoint", "POST " + path);
671681
}
672682
}
673683

674-
json.append("\n ]\n}");
675684
log.debug("Generated MCP catalog JSON");
676-
return json.toString();
685+
return jackson.writeValueAsString(root);
677686

678687
} catch (Exception e) {
679688
log.error("Failed to generate MCP catalog JSON", e);
680689
return "{\"tools\":[]}";
681690
}
682691
}
683692

684-
/**
685-
* Escape a string for safe inclusion in a JSON string value.
686-
* Axis2 service/operation names follow XML NCName rules and will not
687-
* contain control characters, but backslash and double-quote are escaped
688-
* defensively.
689-
*/
690-
private String escapeJson(String s) {
691-
if (s == null) return "";
692-
return s.replace("\\", "\\\\").replace("\"", "\\\"");
693-
}
694-
695693
/**
696694
* Get OpenAPI JSON processing performance statistics using moshih2 metrics.
697695
*/

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,24 @@ public void testServiceNameWithBackslashIsEscaped() throws Exception {
266266
assertNotNull(root);
267267
}
268268

269+
/**
270+
* Jackson correctly escapes all JSON control characters including tab, newline,
271+
* and carriage return — not just backslash and double-quote.
272+
*/
273+
public void testControlCharactersInOperationNameAreEscaped() throws Exception {
274+
AxisService svc = new AxisService("TestService");
275+
AxisOperation op = new InOutAxisOperation();
276+
op.setName(QName.valueOf("op\twith\ttabs")); // tab chars
277+
svc.addOperation(op);
278+
axisConfig.addService(svc);
279+
280+
String json = generator.generateMcpCatalogJson(mockRequest);
281+
assertFalse("Tab characters must not appear literally in JSON output",
282+
json.contains("\t"));
283+
JsonNode root = MAPPER.readTree(json); // still parseable
284+
assertNotNull(root);
285+
}
286+
269287
// ── catalog request is null-safe ──────────────────────────────────────────
270288

271289
public void testGenerateMcpCatalogWithNullRequestDoesNotThrow() throws Exception {

modules/samples/userguide/src/userguide/springbootdemo-tomcat11/src/main/java/userguide/springboot/security/webservices/X509AuthenticationFilter.java

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
import org.springframework.security.core.context.SecurityContextHolder;
3434
import org.springframework.web.filter.GenericFilterBean;
3535

36+
import javax.naming.InvalidNameException;
37+
import javax.naming.ldap.LdapName;
38+
import javax.naming.ldap.Rdn;
3639
import javax.security.auth.x500.X500Principal;
3740
import java.io.IOException;
3841
import java.security.cert.X509Certificate;
@@ -95,17 +98,25 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo
9598
/**
9699
* Extracts the CN value from an {@link X500Principal}.
97100
*
98-
* <p>Uses RFC 2253 format ({@code CN=value,O=...}) for reliable parsing.
99-
* Returns the full DN string if no CN attribute is present.
101+
* <p>Uses {@link LdapName} to parse the RFC 2253 DN, which correctly
102+
* handles escaped commas in RDN values (e.g. {@code O="Example, Inc."}).
103+
* Falls back to the full DN string if parsing fails or no CN attribute
104+
* is present.
100105
*/
101106
private String extractCN(X500Principal principal) {
102-
String dn = principal.getName(X500Principal.RFC2253);
103-
for (String part : dn.split(",")) {
104-
part = part.trim();
105-
if (part.startsWith("CN=")) {
106-
return part.substring(3);
107+
try {
108+
LdapName ldapDN = new LdapName(principal.getName());
109+
// Iterate in reverse — CN is typically the most-specific (last) RDN
110+
for (int i = ldapDN.size() - 1; i >= 0; i--) {
111+
Rdn rdn = ldapDN.getRdn(i);
112+
if ("CN".equalsIgnoreCase(rdn.getType())) {
113+
return rdn.getValue().toString();
114+
}
107115
}
116+
} catch (InvalidNameException e) {
117+
logger.warn("X509AuthenticationFilter: could not parse DN, using full DN: "
118+
+ principal.getName());
108119
}
109-
return dn;
120+
return principal.getName();
110121
}
111122
}

0 commit comments

Comments
 (0)