Skip to content

Commit b89b2a3

Browse files
C3 MCP Resources endpoint — generateMcpResourcesJson()
Add resources/list support per MCP 2025-03-26 spec: - generateMcpResourcesJson(HttpServletRequest) iterates all deployed services, skips system services (AdminService, etc.), and emits one resource entry per service with: uri: axis2://services/<name> name: service display name description: mcpDescription param if set, else empty string mimeType: application/json metadata: { wsdlUrl, operations[], requiresAuth } - wsdlUrl is "GET /services/<name>?wsdl" (relative, server-agnostic) - requiresAuth mirrors the existing mcpRequiresAuth parameter check - error path returns {"resources":[],"_error":"<message>"} (never throws) Tests (8 new): deployed service listed, system service excluded, operations list populated, mcpDescription used, requiresAuth true/false, empty-service list, wsdlUrl format verified. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 83500ec commit b89b2a3

File tree

2 files changed

+198
-0
lines changed

2 files changed

+198
-0
lines changed

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

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,114 @@ public String generateMcpCatalogJson(HttpServletRequest request) {
902902
}
903903
}
904904

905+
/**
906+
* Generate an MCP Resources listing (C3).
907+
*
908+
* <p>MCP Resources are read-only, browseable data items — conceptually the
909+
* complement of Tools (which take actions). Here each deployed Axis2 service
910+
* becomes a resource so that an AI client can discover what services exist and
911+
* fetch their WSDL or metadata without executing an operation.
912+
*
913+
* <p>Output shape (MCP 2025-03-26 {@code resources/list} response):
914+
* <pre>
915+
* {
916+
* "resources": [
917+
* {
918+
* "uri": "axis2://services/PortfolioService",
919+
* "name": "PortfolioService",
920+
* "description": "...", // mcpDescription service param or auto-generated
921+
* "mimeType": "application/json",
922+
* "metadata": {
923+
* "wsdlUrl": "POST /services/PortfolioService?wsdl",
924+
* "operations": ["getPortfolio", "updateWeights", ...],
925+
* "requiresAuth": true
926+
* }
927+
* }
928+
* ]
929+
* }
930+
* </pre>
931+
*
932+
* <p>System services ("Version", "AdminService", names starting with "__") are
933+
* excluded, matching the tool catalog filter.
934+
*
935+
* @param request the incoming HTTP request (used only to determine the base URL)
936+
* @return JSON string; never null. On error returns
937+
* {@code {"resources":[],"_error":"..."}} so callers can distinguish
938+
* failure from an empty deployment.
939+
*/
940+
public String generateMcpResourcesJson(HttpServletRequest request) {
941+
try {
942+
AxisConfiguration axisConfig = configurationContext.getAxisConfiguration();
943+
com.fasterxml.jackson.databind.ObjectMapper jackson = io.swagger.v3.core.util.Json.mapper();
944+
com.fasterxml.jackson.databind.node.ObjectNode root = jackson.createObjectNode();
945+
com.fasterxml.jackson.databind.node.ArrayNode resources = root.putArray("resources");
946+
947+
java.util.Map<String, AxisService> services = axisConfig.getServices();
948+
if (services != null) {
949+
for (AxisService service : services.values()) {
950+
String svcName = service.getName();
951+
if (isSystemService(svcName)) continue;
952+
953+
// URI: logical identifier for the resource in the MCP protocol.
954+
// Uses the "axis2://" scheme so clients can distinguish these
955+
// resources from generic HTTP URLs.
956+
String uri = "axis2://services/" + svcName;
957+
958+
// Human-readable description: service-level mcpDescription param
959+
// or auto-generated fallback.
960+
String description = getMcpStringParam(null, service, "mcpDescription",
961+
"Axis2 service: " + svcName);
962+
963+
com.fasterxml.jackson.databind.node.ObjectNode resource =
964+
resources.addObject();
965+
resource.put("uri", uri);
966+
resource.put("name", svcName);
967+
resource.put("description", description);
968+
resource.put("mimeType", "application/json");
969+
970+
// metadata sub-object: service-specific details for MCP clients
971+
// that want to introspect available operations before calling.
972+
com.fasterxml.jackson.databind.node.ObjectNode metadata =
973+
resource.putObject("metadata");
974+
metadata.put("wsdlUrl", "GET /services/" + svcName + "?wsdl");
975+
976+
// List all non-system operation names.
977+
com.fasterxml.jackson.databind.node.ArrayNode ops = metadata.putArray("operations");
978+
java.util.Iterator<AxisOperation> opIter = service.getOperations();
979+
while (opIter != null && opIter.hasNext()) {
980+
AxisOperation op = opIter.next();
981+
if (op != null && op.getName() != null) {
982+
String opName = op.getName().getLocalPart();
983+
if (opName != null && !opName.startsWith("__")) {
984+
ops.add(opName);
985+
}
986+
}
987+
}
988+
989+
// Auth requirement mirrors the tool catalog heuristic.
990+
String svcLower = svcName.toLowerCase(java.util.Locale.ROOT);
991+
boolean requiresAuth;
992+
String mcpRequiresAuthParam = getMcpStringParam(null, service,
993+
"mcpRequiresAuth", null);
994+
if (mcpRequiresAuthParam != null) {
995+
requiresAuth = !"false".equalsIgnoreCase(mcpRequiresAuthParam);
996+
} else {
997+
requiresAuth = !svcLower.equals("loginservice")
998+
&& !svcLower.equals("adminconsole");
999+
}
1000+
metadata.put("requiresAuth", requiresAuth);
1001+
}
1002+
}
1003+
1004+
log.debug("Generated MCP resources JSON ({} services)", resources.size());
1005+
return jackson.writeValueAsString(root);
1006+
1007+
} catch (Exception e) {
1008+
log.error("Failed to generate MCP resources JSON", e);
1009+
return "{\"resources\":[],\"_error\":\"resources generation failed — see server log\"}";
1010+
}
1011+
}
1012+
9051013
/**
9061014
* Get OpenAPI JSON processing performance statistics using moshih2 metrics.
9071015
*/

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,96 @@ public void testMcpOpenWorldParamSetsOpenWorldHint() throws Exception {
958958
annotations.path("openWorldHint").asBoolean());
959959
}
960960

961+
// ── C3: MCP Resources ────────────────────────────────────────────────────
962+
963+
public void testMcpResourcesListsDeployedService() throws Exception {
964+
addService("PortfolioService", "getPortfolio");
965+
966+
String json = generator.generateMcpResourcesJson(mockRequest);
967+
JsonNode resources = MAPPER.readTree(json).path("resources");
968+
assertEquals("one resource per service", 1, resources.size());
969+
970+
JsonNode r = resources.get(0);
971+
assertEquals("axis2://services/PortfolioService", r.path("uri").asText());
972+
assertEquals("PortfolioService", r.path("name").asText());
973+
assertEquals("application/json", r.path("mimeType").asText());
974+
}
975+
976+
public void testMcpResourcesExcludesSystemServices() throws Exception {
977+
addService("PortfolioService", "getPortfolio");
978+
// These must be filtered
979+
axisConfig.addService(new AxisService("Version"));
980+
axisConfig.addService(new AxisService("AdminService"));
981+
AxisService hidden = new AxisService("__internal");
982+
axisConfig.addService(hidden);
983+
984+
String json = generator.generateMcpResourcesJson(mockRequest);
985+
JsonNode resources = MAPPER.readTree(json).path("resources");
986+
assertEquals("only PortfolioService, not system services", 1, resources.size());
987+
assertEquals("PortfolioService", resources.get(0).path("name").asText());
988+
}
989+
990+
public void testMcpResourcesIncludesOperationList() throws Exception {
991+
AxisService svc = new AxisService("PortfolioService");
992+
addOperation(svc, "getPortfolio");
993+
addOperation(svc, "updateWeights");
994+
axisConfig.addService(svc);
995+
996+
String json = generator.generateMcpResourcesJson(mockRequest);
997+
JsonNode ops = MAPPER.readTree(json).path("resources").get(0)
998+
.path("metadata").path("operations");
999+
assertEquals("two operations listed", 2, ops.size());
1000+
}
1001+
1002+
public void testMcpResourcesUsesServiceLevelMcpDescription() throws Exception {
1003+
AxisService svc = new AxisService("PortfolioService");
1004+
svc.addParameter(new org.apache.axis2.description.Parameter(
1005+
"mcpDescription", "Manages investment portfolios."));
1006+
addOperation(svc, "getPortfolio");
1007+
axisConfig.addService(svc);
1008+
1009+
String json = generator.generateMcpResourcesJson(mockRequest);
1010+
JsonNode r = MAPPER.readTree(json).path("resources").get(0);
1011+
assertEquals("Manages investment portfolios.", r.path("description").asText());
1012+
}
1013+
1014+
public void testMcpResourcesRequiresAuthTrueForNormalService() throws Exception {
1015+
addService("PortfolioService", "getPortfolio");
1016+
1017+
String json = generator.generateMcpResourcesJson(mockRequest);
1018+
JsonNode meta = MAPPER.readTree(json).path("resources").get(0).path("metadata");
1019+
assertTrue("requiresAuth must be true for non-login service",
1020+
meta.path("requiresAuth").asBoolean());
1021+
}
1022+
1023+
public void testMcpResourcesRequiresAuthFalseForLoginService() throws Exception {
1024+
addService("loginService", "doLogin");
1025+
1026+
String json = generator.generateMcpResourcesJson(mockRequest);
1027+
JsonNode meta = MAPPER.readTree(json).path("resources").get(0).path("metadata");
1028+
assertFalse("requiresAuth must be false for loginService",
1029+
meta.path("requiresAuth").asBoolean());
1030+
}
1031+
1032+
public void testMcpResourcesEmptyOnNoServices() throws Exception {
1033+
// axisConfig has no user services at this point
1034+
String json = generator.generateMcpResourcesJson(mockRequest);
1035+
JsonNode resources = MAPPER.readTree(json).path("resources");
1036+
assertTrue("resources array must be present and empty", resources.isArray());
1037+
assertEquals(0, resources.size());
1038+
assertTrue("no _error field on success",
1039+
MAPPER.readTree(json).path("_error").isMissingNode());
1040+
}
1041+
1042+
public void testMcpResourcesWsdlUrlInMetadata() throws Exception {
1043+
addService("PortfolioService", "getPortfolio");
1044+
1045+
String json = generator.generateMcpResourcesJson(mockRequest);
1046+
String wsdl = MAPPER.readTree(json).path("resources").get(0)
1047+
.path("metadata").path("wsdlUrl").asText();
1048+
assertEquals("GET /services/PortfolioService?wsdl", wsdl);
1049+
}
1050+
9611051
// ── B2: mcpAuthScope ─────────────────────────────────────────────────────
9621052

9631053
public void testMcpAuthScopeParamAppearsAsXAuthScope() throws Exception {

0 commit comments

Comments
 (0)