diff --git a/.github/workflows/test-belyportal.yml b/.github/workflows/test-belyportal.yml new file mode 100644 index 000000000..a52c49450 --- /dev/null +++ b/.github/workflows/test-belyportal.yml @@ -0,0 +1,128 @@ +name: Test BELY Portal + +on: + pull_request: + paths: + - 'src/java/LogrPortal/src/java/**' + - 'src/java/LogrPortal/test/**' + - '.github/workflows/test-belyportal.yml' + workflow_dispatch: + +jobs: + test: + name: Compile and Run BELY Portal Java Tests + runs-on: ubuntu-latest + + defaults: + run: + working-directory: src/java/LogrPortal + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '11' + + - name: Fetch JAXB API (removed from the JDK since Java 11) + run: | + set -euo pipefail + # javax.xml.bind was dropped from the JDK in Java 11 (JEP 320). The + # application normally compiles against Payara's bundled JAXB + # implementation via NetBeans' j2ee.platform.classpath, which this + # CI-only javac/java build path does not use. Fetch a standalone + # JAXB API jar (interfaces only, sufficient for compilation) instead. + mkdir -p build/extra-libs + curl -fsSL -o build/extra-libs/jaxb-api-2.3.1.jar \ + https://repo1.maven.org/maven2/javax/xml/bind/jaxb-api/2.3.1/jaxb-api-2.3.1.jar + + - name: Generate default PluginRegistrar + run: | + set -euo pipefail + # src/java/.../plugins/support/ is gitignored (see .gitignore) and is + # normally populated by tools/developer_tools/logr_plugins at deploy + # time based on which plugins are installed. CdbPluginManager.java + # imports PluginRegistrar unconditionally, so a fresh checkout can't + # compile without it. Generate the no-plugins-installed version. + REGISTRAR_DIR=src/java/gov/anl/aps/logr/portal/plugins/support + REGISTRAR_FILE="$REGISTRAR_DIR/PluginRegistrar.java" + if [ ! -f "$REGISTRAR_FILE" ]; then + mkdir -p "$REGISTRAR_DIR" + cat > "$REGISTRAR_FILE" <<'EOF' + package gov.anl.aps.logr.portal.plugins.support; + + import gov.anl.aps.logr.portal.plugins.CdbPluginManager; + + public abstract class PluginRegistrar { + + public static void registerPlugins(CdbPluginManager cdbPluginManager) { + // No plugins installed for this CI build. + } + } + EOF + fi + + - name: Compile main sources + run: | + set -euo pipefail + mkdir -p build/classes + find src/java -name '*.java' > build/main-sources.txt + javac -proc:none -source 11 -target 11 \ + -cp 'lib/*:build/extra-libs/*' \ + -d build/classes \ + @build/main-sources.txt + + - name: Compile test sources + run: | + set -euo pipefail + mkdir -p build/test-classes + find test -name '*.java' > build/test-sources.txt + javac -proc:none -source 11 -target 11 \ + -cp 'lib/*:build/extra-libs/*:build/classes' \ + -d build/test-classes \ + @build/test-sources.txt + + - name: Discover and run tests + run: | + set -uo pipefail + CP="lib/*:build/extra-libs/*:build/classes:build/test-classes" + + TEST_CLASSES=() + while IFS= read -r class; do + TEST_CLASSES+=("$class") + done < <( + find test -name '*Test.java' \ + | sed -e 's#^test/##' -e 's/\.java$//' -e 's#/#.#g' \ + | sort + ) + + if [ "${#TEST_CLASSES[@]}" -eq 0 ]; then + echo "::error::No test classes discovered under test/ (pattern *Test.java)." + exit 1 + fi + + echo "Discovered ${#TEST_CLASSES[@]} test classes:" + printf ' %s\n' "${TEST_CLASSES[@]}" + + FAILED=0 + for class in "${TEST_CLASSES[@]}"; do + echo "::group::${class}" + java -cp "$CP" "$class" + rc=$? + if [ "$rc" -eq 0 ]; then + echo "${class}: PASS" + else + echo "${class}: FAIL (exit ${rc})" + FAILED=1 + fi + echo "::endgroup::" + done + + if [ "$FAILED" -ne 0 ]; then + echo "::error::One or more test classes failed." + exit 1 + fi + echo "All test classes passed." diff --git a/src/java/LogrPortal/nbproject/project.properties b/src/java/LogrPortal/nbproject/project.properties index 1662297d3..adc8a59d4 100644 --- a/src/java/LogrPortal/nbproject/project.properties +++ b/src/java/LogrPortal/nbproject/project.properties @@ -35,8 +35,7 @@ dist.dir=dist dist.ear.war=${dist.dir}/${war.ear.name} dist.javadoc.dir=${dist.dir}/javadoc dist.war=${dist.dir}/${war.name} -endorsed.classpath=\ - ${libs.javaee-endorsed-api-7.0.classpath} +endorsed.classpath= excludes= file.reference.bcmail-jdk14-1.38.jar=lib/bcmail-jdk14-1.38.jar file.reference.bcprov-jdk14-1.38.jar=lib/bcprov-jdk14-1.38.jar @@ -184,8 +183,8 @@ javac.processorpath=\ ${javac.classpath}:\ ${libs.eclipselink.classpath}:\ ${libs.eclipselinkmodelgen.classpath} -javac.source=1.8 -javac.target=1.8 +javac.source=11 +javac.target=11 javac.test.classpath=\ ${javac.classpath}:\ ${build.classes.dir} diff --git a/src/java/LogrPortal/src/java/cdb.portal.properties.template b/src/java/LogrPortal/src/java/cdb.portal.properties.template index 4a1fe3453..bfd8d0811 100644 --- a/src/java/LogrPortal/src/java/cdb.portal.properties.template +++ b/src/java/LogrPortal/src/java/cdb.portal.properties.template @@ -24,3 +24,8 @@ cdb.portal.handler.http.linkDisplayLength=32 # CDB Web Service cdb.webService.url=http://localhost:10232/cdb cdb.permanentContextRoot.url=http://localhost:8080/cdb + +# Model Context Protocol (MCP) endpoint at /api/mcp +cdb.portal.mcp.enabled=true +cdb.portal.mcp.requireAuth=false +cdb.portal.mcp.allowedOrigins= diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/common/mqtt/constants/CallSource.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/common/mqtt/constants/CallSource.java index a754b1a4b..4e5e81917 100644 --- a/src/java/LogrPortal/src/java/gov/anl/aps/logr/common/mqtt/constants/CallSource.java +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/common/mqtt/constants/CallSource.java @@ -11,7 +11,8 @@ public enum CallSource { API("API"), - Portal("Portal"); + Portal("Portal"), + MCP("MCP"); private final String value; diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/CdbRestService.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/CdbRestService.java index bf11691b7..09a1010fd 100644 --- a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/CdbRestService.java +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/CdbRestService.java @@ -45,6 +45,7 @@ private Set> getRestResourceClasses() { resources.add(gov.anl.aps.logr.rest.routes.DomainRoute.class); resources.add(gov.anl.aps.logr.rest.routes.DownloadRoute.class); resources.add(gov.anl.aps.logr.rest.routes.LogbookRoute.class); + resources.add(gov.anl.aps.logr.rest.mcp.McpRoute.class); resources.add(gov.anl.aps.logr.rest.routes.NotificationConfigurationRoute.class); resources.add(gov.anl.aps.logr.rest.routes.PropertyValueRoute.class); resources.add(gov.anl.aps.logr.rest.routes.SearchRoute.class); diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/McpConstants.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/McpConstants.java new file mode 100644 index 000000000..a4b21d803 --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/McpConstants.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** Shared constants for the MCP endpoint: private ObjectMapper, header names, error codes, server identity. */ +public final class McpConstants { + + private McpConstants() { + } + + public static final ObjectMapper MAPPER = new ObjectMapper(); + + public static final String PROTOCOL_VERSION = "2026-07-28"; + public static final List SUPPORTED_PROTOCOL_VERSIONS = Collections.singletonList(PROTOCOL_VERSION); + + // Legacy (pre-2026-07-28) revisions served via a sessionless "initialize" handshake; see McpRoute#initializeLegacy. + public static final List LEGACY_PROTOCOL_VERSIONS = Arrays.asList("2025-11-25", "2025-06-18", "2025-03-26"); + public static final String LEGACY_PROTOCOL_VERSION_DEFAULT = "2025-11-25"; + + public static final String HEADER_PROTOCOL_VERSION = "MCP-Protocol-Version"; + public static final String HEADER_METHOD = "Mcp-Method"; + public static final String HEADER_NAME = "Mcp-Name"; + public static final String HEADER_ORIGIN = "Origin"; + public static final String HEADER_TOKEN = "token"; + + public static final String META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion"; + public static final String META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo"; + public static final String META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities"; + public static final String META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"; + + public static final int ERR_PARSE_ERROR = -32700; + public static final int ERR_INVALID_REQUEST = -32600; + public static final int ERR_METHOD_NOT_FOUND = -32601; + public static final int ERR_INVALID_PARAMS = -32602; + public static final int ERR_INTERNAL_ERROR = -32603; + public static final int ERR_UNAUTHORIZED = -32001; + public static final int ERR_HEADER_MISMATCH = -32020; + public static final int ERR_UNSUPPORTED_VERSION = -32022; + + public static final String SERVER_NAME = "bely"; + public static final String SERVER_TITLE = "BELY Electronic Logbook"; + public static final String SERVER_VERSION = "1.0.0"; + + public static final String INSTRUCTIONS = + "BELY (Best Electronic Logbook Yet) is an electronic logbook. A \"log document\" " + + "(sometimes just \"document\") is a dated container such as an operations shift log; " + + "each document belongs to a logbook type (e.g. \"Ops Shift\") and a system (e.g. " + + "\"Storage Ring\"). A document may have \"sections\" — sub-documents that group their " + + "own log entries under a heading within the parent document. A \"log entry\" (or " + + "\"log\") is one timestamped note inside a document or section, and may have replies " + + "and reactions. Use bely_list_lookups to discover valid logbook type and system ids " + + "before filtering searches or listings by them. Use bely_search to find documents or " + + "entries by text; use bely_get_log_document / bely_get_log_entry to read one in full " + + "once you know its id. All tools are read-only."; + + public static final String PROP_ENABLED = "cdb.portal.mcp.enabled"; + public static final String PROP_REQUIRE_AUTH = "cdb.portal.mcp.requireAuth"; + public static final String PROP_ALLOWED_ORIGINS = "cdb.portal.mcp.allowedOrigins"; +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/McpRoute.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/McpRoute.java new file mode 100644 index 000000000..4c0388831 --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/McpRoute.java @@ -0,0 +1,364 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gov.anl.aps.logr.portal.model.db.beans.DomainFacade; +import gov.anl.aps.logr.portal.model.db.beans.ItemDomainLogbookFacade; +import gov.anl.aps.logr.portal.model.db.beans.ItemFacade; +import gov.anl.aps.logr.portal.model.db.beans.UserGroupFacade; +import gov.anl.aps.logr.portal.model.db.beans.UserInfoFacade; +import gov.anl.aps.logr.portal.model.db.entities.UserInfo; +import gov.anl.aps.logr.portal.utilities.ConfigurationUtility; +import gov.anl.aps.logr.rest.authentication.User; +import gov.anl.aps.logr.rest.authentication.UserSessionKeeper; +import gov.anl.aps.logr.rest.mcp.protocol.JsonRpcError; +import gov.anl.aps.logr.rest.mcp.protocol.JsonRpcRequest; +import gov.anl.aps.logr.rest.mcp.protocol.JsonRpcResponse; +import gov.anl.aps.logr.rest.mcp.protocol.McpProtocolException; +import gov.anl.aps.logr.rest.mcp.tools.McpArgumentException; +import gov.anl.aps.logr.rest.mcp.tools.McpTool; +import gov.anl.aps.logr.rest.mcp.tools.McpToolContext; +import gov.anl.aps.logr.rest.mcp.tools.McpToolRegistry; +import gov.anl.aps.logr.rest.mcp.tools.McpToolResult; +import gov.anl.aps.logr.rest.mcp.tools.impl.BelyGetLogDocumentTool; +import gov.anl.aps.logr.rest.mcp.tools.impl.BelyGetLogEntryTool; +import gov.anl.aps.logr.rest.mcp.tools.impl.BelyListLogDocumentsTool; +import gov.anl.aps.logr.rest.mcp.tools.impl.BelyListLogEntriesTool; +import gov.anl.aps.logr.rest.mcp.tools.impl.BelyListLookupsTool; +import gov.anl.aps.logr.rest.mcp.tools.impl.BelyListUserGroupsTool; +import gov.anl.aps.logr.rest.mcp.tools.impl.BelyListUsersTool; +import gov.anl.aps.logr.rest.mcp.tools.impl.BelySearchTool; +import io.swagger.v3.oas.annotations.Hidden; +import java.util.Base64; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.ejb.EJB; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Dual-era MCP Streamable HTTP endpoint at a single POST {@code /api/mcp}: modern + * ({@code 2026-07-28}) per-request-metadata clients are served statelessly, and legacy + * ({@code 2025-03-26} through {@code 2025-11-25}) clients are served via a sessionless + * {@code initialize} handshake (no {@code Mcp-Session-Id} is issued or required). + */ +@Hidden +@Path("/mcp") +public class McpRoute { + + private static final Logger LOGGER = LogManager.getLogger(McpRoute.class.getName()); + + private static final Pattern BASE64_SENTINEL = Pattern.compile("^=\\?base64\\?(.*)\\?=$"); + + private static final McpToolRegistry REGISTRY = buildRegistry(); + + @EJB + DomainFacade domainFacade; + @EJB + ItemDomainLogbookFacade itemDomainLogbookFacade; + @EJB + ItemFacade itemFacade; + @EJB + UserInfoFacade userInfoFacade; + @EJB + UserGroupFacade userGroupFacade; + + private static McpToolRegistry buildRegistry() { + McpToolRegistry registry = new McpToolRegistry(); + registry.register(new BelySearchTool()); + registry.register(new BelyListLogDocumentsTool()); + registry.register(new BelyGetLogDocumentTool()); + registry.register(new BelyListLogEntriesTool()); + registry.register(new BelyGetLogEntryTool()); + registry.register(new BelyListLookupsTool()); + registry.register(new BelyListUsersTool()); + registry.register(new BelyListUserGroupsTool()); + return registry; + } + + @POST + @Consumes(MediaType.WILDCARD) + @Produces(MediaType.APPLICATION_JSON) + public Response post(String body, @Context HttpHeaders headers) { + if (!isEnabled()) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + + JsonNode id = null; + try { + String origin = headers.getHeaderString(McpConstants.HEADER_ORIGIN); + if (origin != null && !isOriginAllowed(origin)) { + throw new McpProtocolException(403, McpConstants.ERR_INVALID_REQUEST, "Origin not allowed: " + origin); + } + + JsonNode root; + try { + root = McpConstants.MAPPER.readTree(body); + } catch (JsonProcessingException e) { + throw new McpProtocolException(400, McpConstants.ERR_PARSE_ERROR, "Malformed JSON: " + e.getOriginalMessage()); + } + + JsonRpcRequest request = JsonRpcRequest.parse(root); + id = request.getId(); + + // Notification POSTs (no "id") are not subject to header validation per spec; ack and return. + if (!request.hasId()) { + return Response.status(Response.Status.ACCEPTED).build(); + } + + // Modern (2026-07-28) requests always carry Mcp-Method; legacy requests (including + // "initialize" itself) never do and skip per-request header validation entirely. + if (headers.getHeaderString(McpConstants.HEADER_METHOD) != null) { + validateHeaders(request, headers); + } + + UserInfo currentUser = resolveUser(headers); + + ObjectNode result = dispatch(request, currentUser); + return Response.ok(JsonRpcResponse.success(id, result)).build(); + } catch (McpProtocolException e) { + return Response.status(e.getHttpStatus()) + .entity(JsonRpcResponse.error(id, e.toJsonRpcError())) + .build(); + } catch (Throwable t) { + LOGGER.error("Unhandled error processing MCP request", t); + return Response.ok(JsonRpcResponse.error(id, + new JsonRpcError(McpConstants.ERR_INTERNAL_ERROR, "Internal error"))) + .build(); + } + } + + @GET + public Response getNotAllowed() { + return methodNotAllowed(); + } + + @DELETE + public Response deleteNotAllowed() { + return methodNotAllowed(); + } + + private Response methodNotAllowed() { + return Response.status(Response.Status.METHOD_NOT_ALLOWED).header("Allow", "POST").build(); + } + + private boolean isEnabled() { + return Boolean.parseBoolean(ConfigurationUtility.getPortalProperty(McpConstants.PROP_ENABLED, "true")); + } + + private boolean requiresAuth() { + return Boolean.parseBoolean(ConfigurationUtility.getPortalProperty(McpConstants.PROP_REQUIRE_AUTH, "false")); + } + + private boolean isOriginAllowed(String origin) { + List allowed = ConfigurationUtility.getPortalPropertyList(McpConstants.PROP_ALLOWED_ORIGINS); + return allowed.contains(origin); + } + + // Package-private (not private) so McpRouteHeaderValidationTest can call it directly, without a container. + void validateHeaders(JsonRpcRequest request, HttpHeaders headers) throws McpProtocolException { + String methodHeader = headers.getHeaderString(McpConstants.HEADER_METHOD); + if (methodHeader == null || !methodHeader.equals(request.getMethod())) { + throw new McpProtocolException(400, McpConstants.ERR_HEADER_MISMATCH, + "Mcp-Method header must equal the request \"method\""); + } + + JsonNode params = request.getParams(); + JsonNode meta = params != null ? params.get("_meta") : null; + JsonNode metaVersion = meta != null ? meta.get(McpConstants.META_PROTOCOL_VERSION) : null; + if (metaVersion == null || !metaVersion.isTextual()) { + throw new McpProtocolException(400, McpConstants.ERR_UNSUPPORTED_VERSION, + "params._meta[\"" + McpConstants.META_PROTOCOL_VERSION + "\"] is required; this server supports: " + + McpConstants.SUPPORTED_PROTOCOL_VERSIONS); + } + String requestedVersion = metaVersion.asText(); + + String versionHeader = headers.getHeaderString(McpConstants.HEADER_PROTOCOL_VERSION); + if (versionHeader == null || !versionHeader.equals(requestedVersion)) { + throw new McpProtocolException(400, McpConstants.ERR_HEADER_MISMATCH, + "MCP-Protocol-Version header must equal params._meta[\"" + McpConstants.META_PROTOCOL_VERSION + "\"]"); + } + + if (!McpConstants.SUPPORTED_PROTOCOL_VERSIONS.contains(requestedVersion)) { + throw new McpProtocolException(400, McpConstants.ERR_UNSUPPORTED_VERSION, + "Unsupported protocol version: " + requestedVersion, unsupportedVersionData(requestedVersion)); + } + + if ("tools/call".equals(request.getMethod())) { + String nameHeader = headers.getHeaderString(McpConstants.HEADER_NAME); + String paramsName = params != null && params.has("name") ? params.get("name").asText() : null; + if (nameHeader == null || paramsName == null || !decodeSentinel(nameHeader).equals(paramsName)) { + throw new McpProtocolException(400, McpConstants.ERR_HEADER_MISMATCH, + "Mcp-Name header must equal params.name"); + } + } + } + + private static ObjectNode unsupportedVersionData(String requestedVersion) { + ObjectNode data = McpConstants.MAPPER.createObjectNode(); + ArrayNode supported = data.putArray("supported"); + McpConstants.SUPPORTED_PROTOCOL_VERSIONS.forEach(supported::add); + if (requestedVersion != null) { + data.put("requested", requestedVersion); + } + return data; + } + + // Decodes the =?base64??= sentinel wrapper for non-ASCII header values; non-matching/invalid input passes through unchanged. + static String decodeSentinel(String headerValue) { + if (headerValue == null) { + return null; + } + Matcher matcher = BASE64_SENTINEL.matcher(headerValue); + if (!matcher.matches()) { + return headerValue; + } + try { + byte[] decoded = Base64.getDecoder().decode(matcher.group(1)); + return new String(decoded, java.nio.charset.StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return headerValue; + } + } + + private UserInfo resolveUser(HttpHeaders headers) throws McpProtocolException { + String token = headers.getHeaderString(McpConstants.HEADER_TOKEN); + if (token == null || token.isEmpty()) { + if (requiresAuth()) { + throw new McpProtocolException(401, McpConstants.ERR_UNAUTHORIZED, "Authentication required"); + } + return null; + } + + UserSessionKeeper keeper = UserSessionKeeper.getInstance(); + if (!keeper.validateToken(token)) { + throw new McpProtocolException(401, McpConstants.ERR_UNAUTHORIZED, "Invalid or expired token"); + } + User user = keeper.getUserForToken(token); + return user != null ? user.getUser() : null; + } + + private ObjectNode dispatch(JsonRpcRequest request, UserInfo currentUser) throws McpProtocolException { + switch (request.getMethod()) { + case "initialize": + return initializeLegacy(request); + case "server/discover": + return discover(); + case "tools/list": + return listTools(); + case "tools/call": + return callTool(request, currentUser); + default: + throw new McpProtocolException(404, McpConstants.ERR_METHOD_NOT_FOUND, "Method not found: " + request.getMethod()); + } + } + + // Package-private (not private) so McpInitializeLegacyTest can call it directly, without a container. + // Sessionless: no Mcp-Session-Id is issued, since BELY's tools carry no per-connection state. + ObjectNode initializeLegacy(JsonRpcRequest request) { + JsonNode params = request.getParams(); + String requestedVersion = params != null && params.has("protocolVersion") + ? params.get("protocolVersion").asText() : null; + String negotiatedVersion = McpConstants.LEGACY_PROTOCOL_VERSIONS.contains(requestedVersion) + ? requestedVersion : McpConstants.LEGACY_PROTOCOL_VERSION_DEFAULT; + + ObjectNode result = McpConstants.MAPPER.createObjectNode(); + result.put("protocolVersion", negotiatedVersion); + + ObjectNode capabilities = result.putObject("capabilities"); + ObjectNode toolsCapability = capabilities.putObject("tools"); + toolsCapability.put("listChanged", false); + + ObjectNode serverInfo = result.putObject("serverInfo"); + serverInfo.put("name", McpConstants.SERVER_NAME); + serverInfo.put("title", McpConstants.SERVER_TITLE); + serverInfo.put("version", McpConstants.SERVER_VERSION); + + result.put("instructions", McpConstants.INSTRUCTIONS); + return result; + } + + // Package-private (not private) so McpDiscoverShapeTest can call it directly, without a container. + ObjectNode discover() { + ObjectNode result = McpConstants.MAPPER.createObjectNode(); + result.put("resultType", "complete"); + ArrayNode supported = result.putArray("supportedVersions"); + McpConstants.SUPPORTED_PROTOCOL_VERSIONS.forEach(supported::add); + + ObjectNode serverInfo = result.putObject("serverInfo"); + serverInfo.put("name", McpConstants.SERVER_NAME); + serverInfo.put("title", McpConstants.SERVER_TITLE); + serverInfo.put("version", McpConstants.SERVER_VERSION); + + ObjectNode meta = result.putObject("_meta"); + ObjectNode metaServerInfo = meta.putObject(McpConstants.META_SERVER_INFO); + metaServerInfo.put("name", McpConstants.SERVER_NAME); + metaServerInfo.put("title", McpConstants.SERVER_TITLE); + metaServerInfo.put("version", McpConstants.SERVER_VERSION); + + ObjectNode capabilities = result.putObject("capabilities"); + ObjectNode toolsCapability = capabilities.putObject("tools"); + toolsCapability.put("listChanged", false); + + result.put("instructions", McpConstants.INSTRUCTIONS); + return result; + } + + private ObjectNode listTools() { + ObjectNode result = McpConstants.MAPPER.createObjectNode(); + result.put("resultType", "complete"); + ArrayNode tools = result.putArray("tools"); + for (McpTool tool : REGISTRY.list()) { + ObjectNode toolNode = tools.addObject(); + toolNode.put("name", tool.getName()); + toolNode.put("title", tool.getTitle()); + toolNode.put("description", tool.getDescription()); + toolNode.set("inputSchema", tool.getInputSchema()); + } + return result; + } + + private ObjectNode callTool(JsonRpcRequest request, UserInfo currentUser) throws McpProtocolException { + // -32602 answers with HTTP 200, same as -32603 below: some clients never surface a non-2xx body to the model. + JsonNode params = request.getParams(); + String name = params != null && params.has("name") ? params.get("name").asText() : null; + if (name == null) { + throw new McpProtocolException(200, McpConstants.ERR_INVALID_PARAMS, "Missing params.name"); + } + + McpTool tool = REGISTRY.get(name); + if (tool == null) { + throw new McpProtocolException(200, McpConstants.ERR_INVALID_PARAMS, "Unknown tool: " + name); + } + + JsonNode arguments = params.has("arguments") ? params.get("arguments") : null; + McpToolContext ctx = new McpToolContext( + domainFacade, itemDomainLogbookFacade, itemFacade, userInfoFacade, userGroupFacade, currentUser); + + McpToolResult result; + try { + result = tool.call(arguments, ctx); + } catch (McpArgumentException e) { + result = McpToolResult.error(e.getMessage()); + } + + return result.toJson(); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/protocol/JsonRpcError.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/protocol/JsonRpcError.java new file mode 100644 index 000000000..40e9f2481 --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/protocol/JsonRpcError.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.protocol; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gov.anl.aps.logr.rest.mcp.McpConstants; + +/** JSON-RPC 2.0 error object: {@code { code, message, data? }}. */ +public class JsonRpcError { + + private final int code; + private final String message; + private final JsonNode data; + + public JsonRpcError(int code, String message) { + this(code, message, null); + } + + public JsonRpcError(int code, String message, JsonNode data) { + this.code = code; + this.message = message; + this.data = data; + } + + public int getCode() { + return code; + } + + public ObjectNode toJson() { + ObjectNode node = McpConstants.MAPPER.createObjectNode(); + node.put("code", code); + node.put("message", message); + if (data != null) { + node.set("data", data); + } + return node; + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/protocol/JsonRpcRequest.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/protocol/JsonRpcRequest.java new file mode 100644 index 000000000..8094ffd35 --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/protocol/JsonRpcRequest.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.protocol; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.MissingNode; +import gov.anl.aps.logr.rest.mcp.McpConstants; + +/** Inbound JSON-RPC 2.0 envelope; {@link #parse} assumes malformed-JSON bodies were already rejected by the caller. */ +public class JsonRpcRequest { + + private final JsonNode id; + private final String method; + private final JsonNode params; + + private JsonRpcRequest(JsonNode id, String method, JsonNode params) { + this.id = id; + this.method = method; + this.params = params; + } + + public static JsonRpcRequest parse(JsonNode root) throws McpProtocolException { + if (root == null || root.isMissingNode() || root.isNull()) { + throw new McpProtocolException(400, McpConstants.ERR_INVALID_REQUEST, "Empty request body"); + } + if (root.isArray()) { + throw new McpProtocolException(400, McpConstants.ERR_INVALID_REQUEST, "Batching is not supported"); + } + if (!root.isObject()) { + throw new McpProtocolException(400, McpConstants.ERR_INVALID_REQUEST, "Request must be a JSON object"); + } + + JsonNode jsonrpc = root.get("jsonrpc"); + if (jsonrpc == null || !"2.0".equals(jsonrpc.asText())) { + throw new McpProtocolException(400, McpConstants.ERR_INVALID_REQUEST, "Missing or invalid \"jsonrpc\" version, expected \"2.0\""); + } + + JsonNode methodNode = root.get("method"); + if (methodNode == null || !methodNode.isTextual() || methodNode.asText().isEmpty()) { + throw new McpProtocolException(400, McpConstants.ERR_INVALID_REQUEST, "Missing \"method\""); + } + + JsonNode idNode = root.has("id") ? root.get("id") : MissingNode.getInstance(); + JsonNode paramsNode = root.has("params") ? root.get("params") : MissingNode.getInstance(); + + return new JsonRpcRequest(idNode, methodNode.asText(), paramsNode); + } + + public JsonNode getId() { + return id; + } + + public boolean hasId() { + return id != null && !id.isMissingNode(); + } + + public String getMethod() { + return method; + } + + public JsonNode getParams() { + return params; + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/protocol/JsonRpcResponse.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/protocol/JsonRpcResponse.java new file mode 100644 index 000000000..499e93f7d --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/protocol/JsonRpcResponse.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.protocol; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gov.anl.aps.logr.rest.mcp.McpConstants; + +/** Builds outbound JSON-RPC 2.0 response envelopes as pre-serialized strings, bypassing the app-wide Jackson provider. */ +public final class JsonRpcResponse { + + private JsonRpcResponse() { + } + + public static String success(JsonNode id, JsonNode result) { + ObjectNode node = McpConstants.MAPPER.createObjectNode(); + node.put("jsonrpc", "2.0"); + node.set("id", id); + node.set("result", result); + return node.toString(); + } + + public static String error(JsonNode id, JsonRpcError error) { + ObjectNode node = McpConstants.MAPPER.createObjectNode(); + node.put("jsonrpc", "2.0"); + node.set("id", id == null || id.isMissingNode() ? McpConstants.MAPPER.nullNode() : id); + node.set("error", error.toJson()); + return node.toString(); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/protocol/McpProtocolException.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/protocol/McpProtocolException.java new file mode 100644 index 000000000..a7a54066f --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/protocol/McpProtocolException.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.protocol; + +import com.fasterxml.jackson.databind.JsonNode; + +/** A transport/protocol-level failure: carries the HTTP status plus the JSON-RPC error to embed in the body. */ +public class McpProtocolException extends Exception { + + private final int httpStatus; + private final int jsonRpcCode; + private final JsonNode data; + + public McpProtocolException(int httpStatus, int jsonRpcCode, String message) { + this(httpStatus, jsonRpcCode, message, null); + } + + public McpProtocolException(int httpStatus, int jsonRpcCode, String message, JsonNode data) { + super(message); + this.httpStatus = httpStatus; + this.jsonRpcCode = jsonRpcCode; + this.data = data; + } + + public int getHttpStatus() { + return httpStatus; + } + + public int getJsonRpcCode() { + return jsonRpcCode; + } + + public JsonNode getData() { + return data; + } + + public JsonRpcError toJsonRpcError() { + return new JsonRpcError(jsonRpcCode, getMessage(), data); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/render/McpRenderLimits.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/render/McpRenderLimits.java new file mode 100644 index 000000000..e4811d02c --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/render/McpRenderLimits.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.render; + +/** Every numeric cap applied when rendering MCP tool output, collected in one place for easy tuning. */ +public final class McpRenderLimits { + + private McpRenderLimits() { + } + + public static final int MAX_RESULT_CHARS = 40000; + + public static final int DEFAULT_ROW_LIMIT = 25; + public static final int MAX_ROW_LIMIT = 200; + + public static final int DEFAULT_ENTRY_LIMIT = 20; + public static final int MAX_ENTRY_LIMIT = 100; + + public static final int DEFAULT_ENTRY_BODY_CHARS = 1500; + public static final int MAX_ENTRY_BODY_CHARS = 20000; + + public static final int MAX_SINGLE_ENTRY_CHARS = 50000; + public static final int SEARCH_SNIPPET_CHARS = 300; + + public static final int DEFAULT_USER_LIMIT = 100; + public static final int MAX_USER_LIMIT = 500; +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/render/McpTextRenderer.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/render/McpTextRenderer.java new file mode 100644 index 000000000..0ec22eb85 --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/render/McpTextRenderer.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.render; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; +import java.util.function.Supplier; + +/** Text-shaping helpers shared by every MCP tool renderer: truncation, whitespace collapsing, dates, lazy-load guard. */ +public final class McpTextRenderer { + + private McpTextRenderer() { + } + + public static String nz(String value) { + return value == null ? "" : value; + } + + public static String truncate(String text, int maxChars) { + if (text == null) { + return ""; + } + if (text.length() <= maxChars) { + return text; + } + return text.substring(0, maxChars); + } + + public static String truncateWithMarker(String text, int maxChars, String followUpHint) { + if (text == null) { + return ""; + } + if (text.length() <= maxChars) { + return text; + } + return text.substring(0, maxChars) + + "\n… [truncated " + (text.length() - maxChars) + " of " + text.length() + + " chars; " + followUpHint + "]"; + } + + public static String collapseWhitespace(String text) { + if (text == null) { + return ""; + } + return text.trim().replaceAll("\\s+", " "); + } + + public static String isoDate(Date date) { + if (date == null) { + return "?"; + } + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + format.setTimeZone(TimeZone.getTimeZone("UTC")); + return format.format(date); + } + + public static String safe(Supplier supplier) { + try { + String value = supplier.get(); + return value == null ? "?" : value; + } catch (RuntimeException e) { + return "?"; + } + } + + public static String capResult(String text) { + if (text == null) { + return ""; + } + if (text.length() <= McpRenderLimits.MAX_RESULT_CHARS) { + return text; + } + return text.substring(0, McpRenderLimits.MAX_RESULT_CHARS) + + "\n… [result truncated at " + McpRenderLimits.MAX_RESULT_CHARS + + " chars — narrow your query and try again]"; + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/schema/JsonSchemaBuilder.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/schema/JsonSchemaBuilder.java new file mode 100644 index 000000000..e3305a345 --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/schema/JsonSchemaBuilder.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.schema; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gov.anl.aps.logr.rest.mcp.McpConstants; + +/** Fluent builder for the small subset of JSON Schema needed to describe MCP tool input — not general-purpose. */ +public class JsonSchemaBuilder { + + private final ObjectNode schema; + private final ObjectNode properties; + private final ArrayNode required; + + public JsonSchemaBuilder() { + schema = McpConstants.MAPPER.createObjectNode(); + schema.put("type", "object"); + properties = schema.putObject("properties"); + required = schema.putArray("required"); + } + + public JsonSchemaBuilder stringProp(String name, String description) { + return prop(name, "string", description, false); + } + + public JsonSchemaBuilder requiredStringProp(String name, String description) { + return prop(name, "string", description, true); + } + + public JsonSchemaBuilder integerProp(String name, String description) { + return prop(name, "integer", description, false); + } + + public JsonSchemaBuilder requiredIntegerProp(String name, String description) { + return prop(name, "integer", description, true); + } + + public JsonSchemaBuilder booleanProp(String name, String description) { + return prop(name, "boolean", description, false); + } + + public JsonSchemaBuilder integerArrayProp(String name, String description) { + ObjectNode p = properties.putObject(name); + p.put("type", "array"); + p.putObject("items").put("type", "integer"); + if (description != null) { + p.put("description", description); + } + return this; + } + + public JsonSchemaBuilder enumProp(String name, String description, String... values) { + ObjectNode p = properties.putObject(name); + p.put("type", "string"); + if (description != null) { + p.put("description", description); + } + ArrayNode enumNode = p.putArray("enum"); + for (String value : values) { + enumNode.add(value); + } + return this; + } + + public JsonSchemaBuilder requiredEnumProp(String name, String description, String... values) { + enumProp(name, description, values); + required.add(name); + return this; + } + + private JsonSchemaBuilder prop(String name, String type, String description, boolean isRequired) { + ObjectNode p = properties.putObject(name); + p.put("type", type); + if (description != null) { + p.put("description", description); + } + if (isRequired) { + required.add(name); + } + return this; + } + + public ObjectNode build() { + if (required.isEmpty()) { + schema.remove("required"); + } + return schema; + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/AbstractMcpTool.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/AbstractMcpTool.java new file mode 100644 index 000000000..f08fd2c0d --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/AbstractMcpTool.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.tools; + +import com.fasterxml.jackson.databind.JsonNode; +import gov.anl.aps.logr.portal.model.db.entities.EntityType; +import gov.anl.aps.logr.portal.model.db.entities.ItemType; +import gov.anl.aps.logr.rest.entities.DateParam; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; +import javax.ws.rs.WebApplicationException; + +/** Argument-extraction helpers shared by every tool; required accessors throw {@link McpArgumentException} (-32602). */ +public abstract class AbstractMcpTool implements McpTool { + + protected static JsonNode arg(JsonNode args, String name) { + if (args == null || !args.has(name) || args.get(name).isNull()) { + return null; + } + return args.get(name); + } + + protected static String optString(JsonNode args, String name) { + JsonNode node = arg(args, name); + return node == null ? null : node.asText(); + } + + protected static String reqString(JsonNode args, String name) throws McpArgumentException { + String value = optString(args, name); + if (value == null || value.isEmpty()) { + throw new McpArgumentException("Missing required argument \"" + name + "\""); + } + return value; + } + + protected static Integer optInteger(JsonNode args, String name) { + JsonNode node = arg(args, name); + return node == null ? null : node.asInt(); + } + + protected static int reqInteger(JsonNode args, String name) throws McpArgumentException { + Integer value = optInteger(args, name); + if (value == null) { + throw new McpArgumentException("Missing required argument \"" + name + "\""); + } + return value; + } + + protected static boolean optBoolean(JsonNode args, String name, boolean defaultValue) { + JsonNode node = arg(args, name); + return node == null ? defaultValue : node.asBoolean(defaultValue); + } + + protected static int optIntInRange(JsonNode args, String name, int defaultValue, int min, int max) { + Integer value = optInteger(args, name); + if (value == null) { + return defaultValue; + } + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; + } + + protected static List optIntegerList(JsonNode args, String name) { + List result = new ArrayList<>(); + JsonNode node = arg(args, name); + if (node != null && node.isArray()) { + for (JsonNode item : node) { + result.add(item.asInt()); + } + } + return result; + } + + protected static Date optDate(JsonNode args, String name) throws McpArgumentException { + String value = optString(args, name); + if (value == null || value.isEmpty()) { + return null; + } + try { + return new DateParam(value).getDate(); + } catch (WebApplicationException e) { + throw new McpArgumentException("Invalid date for \"" + name + "\": \"" + value + + "\" (expected ISO 8601, \"yyyy-MM-dd HH:mm:ss\", or \"yyyy-MM-dd\")"); + } + } + + protected static String describeEntityTypes(List types) { + return types.stream() + .map(t -> t.getId() + " (" + t.getName() + ")") + .collect(Collectors.joining(", ")); + } + + protected static String describeItemTypes(List types) { + return types.stream() + .map(t -> t.getId() + " (" + t.getName() + ")") + .collect(Collectors.joining(", ")); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpArgumentException.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpArgumentException.java new file mode 100644 index 000000000..7fded7f90 --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpArgumentException.java @@ -0,0 +1,13 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.tools; + +/** Missing/malformed tool argument; the route maps this to JSON-RPC -32602, distinct from an {@code isError} result. */ +public class McpArgumentException extends Exception { + + public McpArgumentException(String message) { + super(message); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpTool.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpTool.java new file mode 100644 index 000000000..7074360dc --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpTool.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.tools; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** One MCP tool: name/description/schema for {@code tools/list}, plus {@link #call} with a per-request context. */ +public interface McpTool { + + String getName(); + + String getTitle(); + + String getDescription(); + + ObjectNode getInputSchema(); + + McpToolResult call(JsonNode args, McpToolContext ctx) throws McpArgumentException; +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpToolContext.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpToolContext.java new file mode 100644 index 000000000..f7c66cb62 --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpToolContext.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.tools; + +import gov.anl.aps.logr.portal.constants.EntityTypeName; +import gov.anl.aps.logr.portal.constants.ItemDomainName; +import gov.anl.aps.logr.portal.model.db.beans.DomainFacade; +import gov.anl.aps.logr.portal.model.db.beans.ItemDomainLogbookFacade; +import gov.anl.aps.logr.portal.model.db.beans.ItemFacade; +import gov.anl.aps.logr.portal.model.db.beans.UserGroupFacade; +import gov.anl.aps.logr.portal.model.db.beans.UserInfoFacade; +import gov.anl.aps.logr.portal.model.db.entities.Domain; +import gov.anl.aps.logr.portal.model.db.entities.EntityType; +import gov.anl.aps.logr.portal.model.db.entities.ItemType; +import gov.anl.aps.logr.portal.model.db.entities.UserInfo; +import java.util.ArrayList; +import java.util.List; + +/** Facade bundle plus the resolved current user, built once per MCP request and passed to every {@link McpTool#call}. */ +public class McpToolContext { + + private final DomainFacade domainFacade; + private final ItemDomainLogbookFacade itemDomainLogbookFacade; + private final ItemFacade itemFacade; + private final UserInfoFacade userInfoFacade; + private final UserGroupFacade userGroupFacade; + private final UserInfo currentUser; + + public McpToolContext( + DomainFacade domainFacade, + ItemDomainLogbookFacade itemDomainLogbookFacade, + ItemFacade itemFacade, + UserInfoFacade userInfoFacade, + UserGroupFacade userGroupFacade, + UserInfo currentUser) { + this.domainFacade = domainFacade; + this.itemDomainLogbookFacade = itemDomainLogbookFacade; + this.itemFacade = itemFacade; + this.userInfoFacade = userInfoFacade; + this.userGroupFacade = userGroupFacade; + this.currentUser = currentUser; + } + + public ItemDomainLogbookFacade getItemDomainLogbookFacade() { + return itemDomainLogbookFacade; + } + + public ItemFacade getItemFacade() { + return itemFacade; + } + + public UserInfoFacade getUserInfoFacade() { + return userInfoFacade; + } + + public UserGroupFacade getUserGroupFacade() { + return userGroupFacade; + } + + public UserInfo getCurrentUser() { + return currentUser; + } + + private Domain getLogbookDomain() { + return domainFacade.find(ItemDomainName.LOGBOOK_ID); + } + + // Copies before filtering — SearchRoute/LogbookRoute's equivalent helper mutates the shared, JPA-managed list in place. + public List getLogbookTypes() { + Domain domain = getLogbookDomain(); + List logbookTypes = new ArrayList<>(domain.getAllowedEntityTypeList()); + logbookTypes.removeIf(t -> t.getName().equals(EntityTypeName.template.getValue())); + return logbookTypes; + } + + public List getLogbookSystems() { + Domain domain = getLogbookDomain(); + return domain.getItemTypeList(); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpToolRegistry.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpToolRegistry.java new file mode 100644 index 000000000..370ab91bc --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpToolRegistry.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.tools; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Registered MCP tools in a deterministic order — a spec SHOULD for {@code tools/list}. */ +public class McpToolRegistry { + + private final Map tools = new LinkedHashMap<>(); + + public void register(McpTool tool) { + tools.put(tool.getName(), tool); + } + + public McpTool get(String name) { + return tools.get(name); + } + + public Collection list() { + return tools.values(); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpToolResult.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpToolResult.java new file mode 100644 index 000000000..4fc8f6b3b --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/McpToolResult.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.tools; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gov.anl.aps.logr.rest.mcp.McpConstants; +import gov.anl.aps.logr.rest.mcp.render.McpTextRenderer; + +/** The single text content block every MCP tool returns; {@code isError} marks a recoverable, retry-able failure. */ +public class McpToolResult { + + private final String text; + private final boolean isError; + + private McpToolResult(String text, boolean isError) { + this.text = McpTextRenderer.capResult(text); + this.isError = isError; + } + + public static McpToolResult text(String text) { + return new McpToolResult(text, false); + } + + public static McpToolResult error(String message) { + return new McpToolResult(message, true); + } + + public ObjectNode toJson() { + ObjectNode result = McpConstants.MAPPER.createObjectNode(); + ArrayNode content = result.putArray("content"); + ObjectNode block = content.addObject(); + block.put("type", "text"); + block.put("text", text); + result.put("isError", isError); + result.put("resultType", "complete"); + return result; + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyGetLogDocumentTool.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyGetLogDocumentTool.java new file mode 100644 index 000000000..2a91e3121 --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyGetLogDocumentTool.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.tools.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gov.anl.aps.logr.portal.model.db.entities.Item; +import gov.anl.aps.logr.portal.model.db.entities.ItemDomainLogbook; +import gov.anl.aps.logr.portal.model.db.entities.Log; +import gov.anl.aps.logr.rest.mcp.schema.JsonSchemaBuilder; +import gov.anl.aps.logr.rest.mcp.tools.AbstractMcpTool; +import gov.anl.aps.logr.rest.mcp.tools.McpArgumentException; +import gov.anl.aps.logr.rest.mcp.tools.McpToolContext; +import gov.anl.aps.logr.rest.mcp.tools.McpToolResult; +import java.util.List; + +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.isoDate; +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.nz; +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.safe; + +/** Fetches one log document's header plus its section list (each section is itself a full {@code ItemDomainLogbook}). */ +public class BelyGetLogDocumentTool extends AbstractMcpTool { + + @Override + public String getName() { + return "bely_get_log_document"; + } + + @Override + public String getTitle() { + return "Get BELY log document"; + } + + @Override + public String getDescription() { + return "Fetch a log document's header (type, system, dates) plus its list of sections. " + + "Provide either logDocumentId or name. Follow up with bely_list_log_entries on the " + + "document or a section's logDocumentId to read its entries."; + } + + @Override + public ObjectNode getInputSchema() { + return new JsonSchemaBuilder() + .integerProp("logDocumentId", "Log document id") + .stringProp("name", "Log document name (exact match; used only if logDocumentId is omitted)") + .build(); + } + + @Override + public McpToolResult call(JsonNode args, McpToolContext ctx) throws McpArgumentException { + Integer logDocumentId = optInteger(args, "logDocumentId"); + String name = optString(args, "name"); + if (logDocumentId == null && (name == null || name.isEmpty())) { + throw new McpArgumentException("Provide either logDocumentId or name"); + } + + ItemDomainLogbook doc; + if (logDocumentId != null) { + Item item = ctx.getItemFacade().findById(logDocumentId); + if (!(item instanceof ItemDomainLogbook)) { + return McpToolResult.error("No log document found with logDocumentId " + logDocumentId); + } + doc = (ItemDomainLogbook) item; + } else { + List matches = ctx.getItemDomainLogbookFacade().findByName(name); + if (matches == null || matches.isEmpty()) { + return McpToolResult.error("No log document found with name \"" + name + "\""); + } + doc = matches.get(0); + } + + return McpToolResult.text(render(doc)); + } + + private String render(ItemDomainLogbook doc) { + StringBuilder sb = new StringBuilder(); + sb.append("Log document ").append(doc.getId()).append(" \"").append(nz(doc.getName())).append("\"\n"); + sb.append("Type: ").append(safe(doc::getLongEntityTypeString)) + .append(" | System: ").append(safe(doc::getItemTypeString)).append("\n"); + sb.append("Created ").append(safe(() -> isoDate(doc.getEntityInfo().getCreatedOnDateTime()))) + .append(" by ").append(safe(() -> doc.getEntityInfo().getCreatedByUsername())) + .append(" | Last modified ").append(safe(() -> isoDate(doc.getEntityInfo().getLastModifiedOnDateTime()))) + .append("\n\n"); + + List sections = doc.getLogbookSections(); + sb.append("Sections (").append(sections.size()).append(")\n"); + for (ItemDomainLogbook section : sections) { + List logs = section.getLogList(); + int count = logs == null ? 0 : logs.size(); + sb.append("- ").append(nz(section.getName())) + .append(" (logDocumentId=").append(section.getId()) + .append(", ").append(count).append(" entries)\n"); + } + if (sections.isEmpty()) { + sb.append("(no sections)\n"); + } + sb.append("\nCall bely_list_log_entries with a section's logDocumentId to read its entries.\n"); + + return sb.toString(); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyGetLogEntryTool.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyGetLogEntryTool.java new file mode 100644 index 000000000..07a668940 --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyGetLogEntryTool.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.tools.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gov.anl.aps.logr.portal.model.db.entities.Attachment; +import gov.anl.aps.logr.portal.model.db.entities.Item; +import gov.anl.aps.logr.portal.model.db.entities.ItemDomainLogbook; +import gov.anl.aps.logr.portal.model.db.entities.Log; +import gov.anl.aps.logr.portal.model.db.entities.LogReaction; +import gov.anl.aps.logr.rest.mcp.render.McpRenderLimits; +import gov.anl.aps.logr.rest.mcp.schema.JsonSchemaBuilder; +import gov.anl.aps.logr.rest.mcp.tools.AbstractMcpTool; +import gov.anl.aps.logr.rest.mcp.tools.McpArgumentException; +import gov.anl.aps.logr.rest.mcp.tools.McpToolContext; +import gov.anl.aps.logr.rest.mcp.tools.McpToolResult; +import java.util.List; +import java.util.Objects; + +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.isoDate; +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.nz; +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.safe; +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.truncate; +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.truncateWithMarker; + +/** Fetches one log entry in full, walking top-level logs then replies the same way {@code LogbookRoute} does. */ +public class BelyGetLogEntryTool extends AbstractMcpTool { + + @Override + public String getName() { + return "bely_get_log_entry"; + } + + @Override + public String getTitle() { + return "Get BELY log entry"; + } + + @Override + public String getDescription() { + return "Fetch one log entry in full, with its attachments, and optionally its replies and reactions."; + } + + @Override + public ObjectNode getInputSchema() { + return new JsonSchemaBuilder() + .requiredIntegerProp("logDocumentId", "Log document (or section) id containing the entry") + .requiredIntegerProp("logId", "Log entry id") + .booleanProp("includeReplies", "Include full reply text (default true)") + .booleanProp("includeReactions", "Include reactions (default false)") + .booleanProp("includeAttachments", "Include attachment list with download paths (default true)") + .build(); + } + + @Override + public McpToolResult call(JsonNode args, McpToolContext ctx) throws McpArgumentException { + int logDocumentId = reqInteger(args, "logDocumentId"); + int logId = reqInteger(args, "logId"); + boolean includeReplies = optBoolean(args, "includeReplies", true); + boolean includeReactions = optBoolean(args, "includeReactions", false); + boolean includeAttachments = optBoolean(args, "includeAttachments", true); + + Item item = ctx.getItemFacade().findById(logDocumentId); + if (!(item instanceof ItemDomainLogbook)) { + return McpToolResult.error("No log document found with logDocumentId " + logDocumentId); + } + ItemDomainLogbook doc = (ItemDomainLogbook) item; + + Log log = findLogInDocument(doc, logId); + if (log == null) { + return McpToolResult.error("Log id " + logId + " does not exist in log document " + logDocumentId + "."); + } + + return McpToolResult.text(render(doc, log, includeReplies, includeReactions, includeAttachments)); + } + + private Log findLogInDocument(ItemDomainLogbook doc, int logId) { + List logList = doc.getLogList(); + if (logList == null) { + return null; + } + for (Log log : logList) { + if (Objects.equals(log.getId(), logId)) { + return log; + } + List children = log.getChildLogList(); + if (children != null) { + for (Log reply : children) { + if (Objects.equals(reply.getId(), logId)) { + return reply; + } + } + } + } + return null; + } + + private String render(ItemDomainLogbook doc, Log log, boolean includeReplies, boolean includeReactions, boolean includeAttachments) { + StringBuilder sb = new StringBuilder(); + sb.append("logId ").append(log.getId()).append(" in log document ").append(doc.getId()) + .append(" \"").append(nz(doc.getName())).append("\"\n"); + sb.append("Entered ").append(isoDate(log.getEnteredOnDateTime())) + .append(" by ").append(safe(() -> log.getEnteredByUser().getUsername())).append("\n"); + + boolean modified; + try { + modified = log.isModifiedEntry(); + } catch (RuntimeException e) { + modified = false; + } + if (modified) { + sb.append("Last modified ").append(isoDate(log.getLastModifiedOnDateTime())) + .append(" by ").append(safe(() -> log.getLastModifiedByUser().getUsername())).append("\n"); + } + + sb.append("\n").append(truncate(nz(log.getText()), McpRenderLimits.MAX_SINGLE_ENTRY_CHARS)).append("\n"); + + if (includeAttachments) { + List attachments = log.getAttachmentList(); + if (attachments != null && !attachments.isEmpty()) { + sb.append("\nAttachments (").append(attachments.size()).append(")\n"); + for (Attachment a : attachments) { + String filename = a.getOriginalFilename(); + if (filename == null) { + filename = a.getName(); + } + sb.append("- ").append(nz(filename)).append(" | /api/Downloads/Attachments/").append(nz(a.getName())).append("\n"); + } + } + } + + if (includeReactions) { + List reactions = log.getLogReactionList(); + if (reactions != null && !reactions.isEmpty()) { + sb.append("\nReactions (").append(reactions.size()).append(")\n"); + for (LogReaction r : reactions) { + sb.append("- ").append(safe(() -> r.getReaction().getEmoji() + " " + r.getReaction().getName())) + .append(" by ").append(nz(r.getUsername())).append("\n"); + } + } + } + + if (includeReplies) { + List replies = log.getChildLogList(); + if (replies != null && !replies.isEmpty()) { + sb.append("\nReplies (").append(replies.size()).append(")\n"); + for (Log reply : replies) { + sb.append("--- logId ").append(reply.getId()) + .append(" | ").append(safe(() -> reply.getEnteredByUser().getUsername())) + .append(" | ").append(isoDate(reply.getEnteredOnDateTime())).append("\n"); + sb.append(truncateWithMarker(nz(reply.getText()), McpRenderLimits.DEFAULT_ENTRY_BODY_CHARS, + "call bely_get_log_entry logDocumentId=" + doc.getId() + " logId=" + reply.getId() + " for full text")) + .append("\n"); + } + } + } + + return sb.toString(); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListLogDocumentsTool.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListLogDocumentsTool.java new file mode 100644 index 000000000..24c43fb4e --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListLogDocumentsTool.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.tools.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gov.anl.aps.logr.portal.constants.ItemDomainName; +import gov.anl.aps.logr.portal.model.db.entities.EntityType; +import gov.anl.aps.logr.portal.model.db.entities.ItemDomainLogbook; +import gov.anl.aps.logr.rest.mcp.render.McpRenderLimits; +import gov.anl.aps.logr.rest.mcp.schema.JsonSchemaBuilder; +import gov.anl.aps.logr.rest.mcp.tools.AbstractMcpTool; +import gov.anl.aps.logr.rest.mcp.tools.McpArgumentException; +import gov.anl.aps.logr.rest.mcp.tools.McpToolContext; +import gov.anl.aps.logr.rest.mcp.tools.McpToolResult; +import java.util.List; + +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.isoDate; +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.nz; +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.safe; + +/** Lists log documents of a given logbook type, newest last-modified first. */ +public class BelyListLogDocumentsTool extends AbstractMcpTool { + + @Override + public String getName() { + return "bely_list_log_documents"; + } + + @Override + public String getTitle() { + return "List BELY log documents"; + } + + @Override + public String getDescription() { + return "List log documents of a given logbook type, newest last-modified first. " + + "Use bely_list_lookups kind=logbookTypes to find the type id."; + } + + @Override + public ObjectNode getInputSchema() { + return new JsonSchemaBuilder() + .requiredIntegerProp("logbookTypeId", "Logbook type id (see bely_list_lookups kind=logbookTypes)") + .integerProp("limit", "Maximum rows to return (default " + McpRenderLimits.DEFAULT_ROW_LIMIT + + ", max " + McpRenderLimits.MAX_ROW_LIMIT + ")") + .build(); + } + + @Override + public McpToolResult call(JsonNode args, McpToolContext ctx) throws McpArgumentException { + int logbookTypeId = reqInteger(args, "logbookTypeId"); + int limit = optIntInRange(args, "limit", McpRenderLimits.DEFAULT_ROW_LIMIT, 1, McpRenderLimits.MAX_ROW_LIMIT); + + List types = ctx.getLogbookTypes(); + EntityType type = types.stream().filter(t -> t.getId().equals(logbookTypeId)).findFirst().orElse(null); + if (type == null) { + return McpToolResult.error("Unknown logbookTypeId " + logbookTypeId + ". Valid ids: " + describeEntityTypes(types)); + } + + String domainName = ItemDomainName.logbook.getValue(); + List documents = ctx.getItemDomainLogbookFacade() + .findByDomainNameAndEntityTypeOrderByLastModifiedDate(domainName, type.getName(), limit); + + return McpToolResult.text(render(type, documents, limit)); + } + + private String render(EntityType type, List documents, int limit) { + StringBuilder sb = new StringBuilder(); + sb.append("Log documents of type \"").append(type.getName()).append("\" (id=").append(type.getId()).append(")\n"); + sb.append("Showing ").append(documents.size()).append(" (limit=").append(limit).append(")\n\n"); + + for (ItemDomainLogbook doc : documents) { + sb.append("- logDocumentId=").append(doc.getId()) + .append(" | ").append(nz(doc.getName())) + .append(" | system=").append(safe(doc::getItemTypeString)) + .append(" | modified ").append(safe(() -> isoDate(doc.getEntityInfo().getLastModifiedOnDateTime()))) + .append("\n"); + } + + if (documents.size() >= limit) { + sb.append("\n… result count equals the limit; more may exist — increase limit (max ") + .append(McpRenderLimits.MAX_ROW_LIMIT).append(") to check\n"); + } + + return sb.toString(); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListLogEntriesTool.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListLogEntriesTool.java new file mode 100644 index 000000000..54ad285f9 --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListLogEntriesTool.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.tools.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gov.anl.aps.logr.portal.model.db.entities.Item; +import gov.anl.aps.logr.portal.model.db.entities.ItemDomainLogbook; +import gov.anl.aps.logr.rest.entities.LogEntry; +import gov.anl.aps.logr.rest.mcp.render.McpRenderLimits; +import gov.anl.aps.logr.rest.mcp.schema.JsonSchemaBuilder; +import gov.anl.aps.logr.rest.mcp.tools.AbstractMcpTool; +import gov.anl.aps.logr.rest.mcp.tools.McpArgumentException; +import gov.anl.aps.logr.rest.mcp.tools.McpToolContext; +import gov.anl.aps.logr.rest.mcp.tools.McpToolResult; +import java.util.List; + +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.isoDate; +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.nz; +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.truncateWithMarker; + +/** Lists a page of log entries for one document (or section), bodies truncated with a fetch-in-full marker. */ +public class BelyListLogEntriesTool extends AbstractMcpTool { + + @Override + public String getName() { + return "bely_list_log_entries"; + } + + @Override + public String getTitle() { + return "List BELY log entries"; + } + + @Override + public String getDescription() { + return "List log entries in one log document (or section), oldest first, with bodies truncated " + + "to maxBodyChars. Use offset/limit to page through more, and bely_get_log_entry for a " + + "single entry in full (including replies and attachments)."; + } + + @Override + public ObjectNode getInputSchema() { + return new JsonSchemaBuilder() + .requiredIntegerProp("logDocumentId", "Log document (or section) id") + .integerProp("offset", "Number of entries to skip (default 0)") + .integerProp("limit", "Maximum entries to return (default " + McpRenderLimits.DEFAULT_ENTRY_LIMIT + + ", max " + McpRenderLimits.MAX_ENTRY_LIMIT + ")") + .integerProp("maxBodyChars", "Maximum characters of each entry body to include (default " + + McpRenderLimits.DEFAULT_ENTRY_BODY_CHARS + ", max " + McpRenderLimits.MAX_ENTRY_BODY_CHARS + ")") + .booleanProp("includeReplies", "Include each entry's reply count (default false)") + .booleanProp("includeReactions", "Load reactions (not rendered in the list; use bely_get_log_entry to see them) (default false)") + .build(); + } + + @Override + public McpToolResult call(JsonNode args, McpToolContext ctx) throws McpArgumentException { + int logDocumentId = reqInteger(args, "logDocumentId"); + int offset = optIntInRange(args, "offset", 0, 0, Integer.MAX_VALUE); + int limit = optIntInRange(args, "limit", McpRenderLimits.DEFAULT_ENTRY_LIMIT, 1, McpRenderLimits.MAX_ENTRY_LIMIT); + int maxBodyChars = optIntInRange(args, "maxBodyChars", McpRenderLimits.DEFAULT_ENTRY_BODY_CHARS, 1, McpRenderLimits.MAX_ENTRY_BODY_CHARS); + boolean includeReplies = optBoolean(args, "includeReplies", false); + boolean includeReactions = optBoolean(args, "includeReactions", false); + + Item item = ctx.getItemFacade().findById(logDocumentId); + if (!(item instanceof ItemDomainLogbook)) { + return McpToolResult.error("No log document found with logDocumentId " + logDocumentId); + } + ItemDomainLogbook doc = (ItemDomainLogbook) item; + + List entries = LogEntry.createLogEntryList(doc, includeReplies, includeReactions); + int total = entries.size(); + if (offset >= total) { + return McpToolResult.text("Log document " + logDocumentId + " \"" + nz(doc.getName()) + "\"\n" + + "Entries 0 of " + total + " (offset=" + offset + " is past the end)\n"); + } + int end = Math.min(total, offset + limit); + List page = entries.subList(offset, end); + + return McpToolResult.text(render(doc, page, offset, end, total, limit, maxBodyChars, includeReplies)); + } + + private String render(ItemDomainLogbook doc, List page, int offset, int end, int total, + int limit, int maxBodyChars, boolean includeReplies) { + StringBuilder sb = new StringBuilder(); + sb.append("Log document ").append(doc.getId()).append(" \"").append(nz(doc.getName())).append("\"\n"); + sb.append("Entries ").append(offset + 1).append("-").append(end).append(" of ").append(total) + .append(" (offset=").append(offset).append(", limit=").append(limit) + .append(", maxBodyChars=").append(maxBodyChars).append(")\n"); + + for (LogEntry entry : page) { + String body = nz(entry.getLogEntry()); + String rendered = truncateWithMarker(body, maxBodyChars, + "call bely_get_log_entry logDocumentId=" + doc.getId() + " logId=" + entry.getLogId() + " for full text"); + sb.append("\n--- logId ").append(entry.getLogId()) + .append(" | ").append(nz(entry.getEnteredByUsername())) + .append(" | entered ").append(isoDate(entry.getEnteredOnDateTime())); + if (includeReplies) { + List replies = entry.getLogReplies(); + sb.append(" | replies ").append(replies == null ? 0 : replies.size()); + } + sb.append("\n").append(rendered).append("\n"); + } + + if (end < total) { + sb.append("\n… showing ").append(end - offset).append(" of ").append(total) + .append(" entries; call again with offset=").append(end).append(" to continue\n"); + } + + return sb.toString(); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListLookupsTool.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListLookupsTool.java new file mode 100644 index 000000000..f3d6c0360 --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListLookupsTool.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.tools.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gov.anl.aps.logr.portal.constants.EntityTypeName; +import gov.anl.aps.logr.portal.constants.ItemDomainName; +import gov.anl.aps.logr.portal.model.db.entities.EntityType; +import gov.anl.aps.logr.portal.model.db.entities.ItemDomainLogbook; +import gov.anl.aps.logr.portal.model.db.entities.ItemType; +import gov.anl.aps.logr.rest.mcp.schema.JsonSchemaBuilder; +import gov.anl.aps.logr.rest.mcp.tools.AbstractMcpTool; +import gov.anl.aps.logr.rest.mcp.tools.McpArgumentException; +import gov.anl.aps.logr.rest.mcp.tools.McpToolContext; +import gov.anl.aps.logr.rest.mcp.tools.McpToolResult; +import java.util.List; + +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.nz; + +/** Enumerates the small, mostly-static lookup lists other tools reference by id: types, systems, templates. */ +public class BelyListLookupsTool extends AbstractMcpTool { + + private static final String KIND_LOGBOOK_TYPES = "logbookTypes"; + private static final String KIND_SYSTEMS = "systems"; + private static final String KIND_TEMPLATES = "templates"; + + @Override + public String getName() { + return "bely_list_lookups"; + } + + @Override + public String getTitle() { + return "List BELY lookups"; + } + + @Override + public String getDescription() { + return "List logbook types, systems, or document templates, with the ids other tools filter by."; + } + + @Override + public ObjectNode getInputSchema() { + return new JsonSchemaBuilder() + .requiredEnumProp("kind", "Which lookup list to return", KIND_LOGBOOK_TYPES, KIND_SYSTEMS, KIND_TEMPLATES) + .build(); + } + + @Override + public McpToolResult call(JsonNode args, McpToolContext ctx) throws McpArgumentException { + String kind = reqString(args, "kind"); + switch (kind) { + case KIND_LOGBOOK_TYPES: + return McpToolResult.text(renderEntityTypes(ctx.getLogbookTypes())); + case KIND_SYSTEMS: + return McpToolResult.text(renderItemTypes(ctx.getLogbookSystems())); + case KIND_TEMPLATES: + return McpToolResult.text(renderTemplates(ctx)); + default: + return McpToolResult.error("Unknown kind \"" + kind + "\". Valid values: " + + KIND_LOGBOOK_TYPES + ", " + KIND_SYSTEMS + ", " + KIND_TEMPLATES); + } + } + + private String renderEntityTypes(List types) { + StringBuilder sb = new StringBuilder("Logbook types (").append(types.size()).append(")\n"); + for (EntityType t : types) { + sb.append("- id=").append(t.getId()).append(" | ").append(nz(t.getName())).append("\n"); + } + return sb.toString(); + } + + private String renderItemTypes(List types) { + StringBuilder sb = new StringBuilder("Systems (").append(types.size()).append(")\n"); + for (ItemType t : types) { + sb.append("- id=").append(t.getId()).append(" | ").append(nz(t.getName())).append("\n"); + } + return sb.toString(); + } + + private String renderTemplates(McpToolContext ctx) { + List templates = ctx.getItemDomainLogbookFacade() + .findByDomainAndEntityTypeAndTopLevel(ItemDomainName.logbook.getValue(), EntityTypeName.template.getValue()); + StringBuilder sb = new StringBuilder("Document templates (").append(templates.size()).append(")\n"); + for (ItemDomainLogbook t : templates) { + sb.append("- logDocumentId=").append(t.getId()).append(" | ").append(nz(t.getName())).append("\n"); + } + return sb.toString(); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListUserGroupsTool.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListUserGroupsTool.java new file mode 100644 index 000000000..0232941fa --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListUserGroupsTool.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.tools.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gov.anl.aps.logr.portal.model.db.entities.UserGroup; +import gov.anl.aps.logr.rest.mcp.render.McpRenderLimits; +import gov.anl.aps.logr.rest.mcp.schema.JsonSchemaBuilder; +import gov.anl.aps.logr.rest.mcp.tools.AbstractMcpTool; +import gov.anl.aps.logr.rest.mcp.tools.McpArgumentException; +import gov.anl.aps.logr.rest.mcp.tools.McpToolContext; +import gov.anl.aps.logr.rest.mcp.tools.McpToolResult; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.nz; +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.safe; + +/** Lists user groups, optionally filtered by name substring. */ +public class BelyListUserGroupsTool extends AbstractMcpTool { + + @Override + public String getName() { + return "bely_list_user_groups"; + } + + @Override + public String getTitle() { + return "List BELY user groups"; + } + + @Override + public String getDescription() { + return "List user groups, optionally filtered by a name substring."; + } + + @Override + public ObjectNode getInputSchema() { + return new JsonSchemaBuilder() + .stringProp("name", "Case-insensitive substring to match against the group name") + .integerProp("limit", "Maximum rows to return (default " + McpRenderLimits.DEFAULT_ROW_LIMIT + + ", max " + McpRenderLimits.MAX_ROW_LIMIT + ")") + .build(); + } + + @Override + public McpToolResult call(JsonNode args, McpToolContext ctx) throws McpArgumentException { + String name = optString(args, "name"); + int limit = optIntInRange(args, "limit", McpRenderLimits.DEFAULT_ROW_LIMIT, 1, McpRenderLimits.MAX_ROW_LIMIT); + + List matches; + if (name == null || name.isEmpty()) { + matches = ctx.getUserGroupFacade().findAll(); + } else { + UserGroup exact = ctx.getUserGroupFacade().findByName(name); + if (exact != null) { + matches = Collections.singletonList(exact); + } else { + matches = substringFilter(ctx.getUserGroupFacade().findAll(), name); + } + } + + return McpToolResult.text(render(name, matches, limit)); + } + + private List substringFilter(List all, String name) { + String needle = name.toLowerCase(Locale.ROOT); + List result = new ArrayList<>(); + for (UserGroup g : all) { + if (g.getName() != null && g.getName().toLowerCase(Locale.ROOT).contains(needle)) { + result.add(g); + } + } + return result; + } + + private String render(String name, List matches, int limit) { + StringBuilder sb = new StringBuilder(); + sb.append("User groups").append(name == null || name.isEmpty() ? "" : " matching \"" + name + "\"") + .append(" (").append(matches.size()).append(")\n"); + + int shown = Math.min(limit, matches.size()); + for (int i = 0; i < shown; i++) { + UserGroup g = matches.get(i); + sb.append("- id=").append(g.getId()) + .append(" | ").append(nz(g.getName())) + .append(" | ").append(safe(() -> nz(g.getDescription()))) + .append("\n"); + } + if (matches.size() > shown) { + sb.append("… showing ").append(shown).append(" of ").append(matches.size()) + .append("; increase limit (max ").append(McpRenderLimits.MAX_ROW_LIMIT).append(") or narrow filter\n"); + } + + return sb.toString(); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListUsersTool.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListUsersTool.java new file mode 100644 index 000000000..84c5e7d08 --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelyListUsersTool.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.tools.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gov.anl.aps.logr.portal.model.db.entities.UserInfo; +import gov.anl.aps.logr.rest.mcp.render.McpRenderLimits; +import gov.anl.aps.logr.rest.mcp.schema.JsonSchemaBuilder; +import gov.anl.aps.logr.rest.mcp.tools.AbstractMcpTool; +import gov.anl.aps.logr.rest.mcp.tools.McpArgumentException; +import gov.anl.aps.logr.rest.mcp.tools.McpToolContext; +import gov.anl.aps.logr.rest.mcp.tools.McpToolResult; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.nz; + +/** Lists users, optionally filtered by name substring: exact {@code findByUsername} first, then a substring scan. */ +public class BelyListUsersTool extends AbstractMcpTool { + + @Override + public String getName() { + return "bely_list_users"; + } + + @Override + public String getTitle() { + return "List BELY users"; + } + + @Override + public String getDescription() { + return "List users, optionally filtered by a username/first/last name substring."; + } + + @Override + public ObjectNode getInputSchema() { + return new JsonSchemaBuilder() + .stringProp("filter", "Case-insensitive substring to match against username, first name, or last name") + .integerProp("limit", "Maximum rows to return (default " + McpRenderLimits.DEFAULT_USER_LIMIT + + ", max " + McpRenderLimits.MAX_USER_LIMIT + ")") + .build(); + } + + @Override + public McpToolResult call(JsonNode args, McpToolContext ctx) throws McpArgumentException { + String filter = optString(args, "filter"); + int limit = optIntInRange(args, "limit", McpRenderLimits.DEFAULT_USER_LIMIT, 1, McpRenderLimits.MAX_USER_LIMIT); + + List matches; + if (filter == null || filter.isEmpty()) { + matches = ctx.getUserInfoFacade().findAll(); + } else { + UserInfo exact = ctx.getUserInfoFacade().findByUsername(filter); + if (exact != null) { + matches = Collections.singletonList(exact); + } else { + matches = substringFilter(ctx.getUserInfoFacade().findAll(), filter); + } + } + + return McpToolResult.text(render(filter, matches, limit)); + } + + private List substringFilter(List all, String filter) { + String needle = filter.toLowerCase(Locale.ROOT); + List result = new ArrayList<>(); + for (UserInfo u : all) { + if (contains(u.getUsername(), needle) || contains(u.getFirstName(), needle) || contains(u.getLastName(), needle)) { + result.add(u); + } + } + return result; + } + + private boolean contains(String value, String needle) { + return value != null && value.toLowerCase(Locale.ROOT).contains(needle); + } + + private String render(String filter, List matches, int limit) { + StringBuilder sb = new StringBuilder(); + sb.append("Users").append(filter == null || filter.isEmpty() ? "" : " matching \"" + filter + "\"") + .append(" (").append(matches.size()).append(")\n"); + + int shown = Math.min(limit, matches.size()); + for (int i = 0; i < shown; i++) { + UserInfo u = matches.get(i); + sb.append("- id=").append(u.getId()) + .append(" | ").append(nz(u.getUsername())) + .append(" | ").append(nz(u.getFirstName())).append(" ").append(nz(u.getLastName())) + .append("\n"); + } + if (matches.size() > shown) { + sb.append("… showing ").append(shown).append(" of ").append(matches.size()) + .append("; increase limit (max ").append(McpRenderLimits.MAX_USER_LIMIT).append(") or narrow filter\n"); + } + + return sb.toString(); + } +} diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelySearchTool.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelySearchTool.java new file mode 100644 index 000000000..0e12fdda7 --- /dev/null +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/mcp/tools/impl/BelySearchTool.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.tools.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gov.anl.aps.logr.common.mqtt.constants.CallSource; +import gov.anl.aps.logr.common.mqtt.model.entities.LogbookSearchOptions; +import gov.anl.aps.logr.portal.controllers.utilities.ItemDomainLogbookControllerUtility; +import gov.anl.aps.logr.portal.controllers.utilities.SearchControllerUtility; +import gov.anl.aps.logr.portal.model.db.entities.EntityType; +import gov.anl.aps.logr.portal.model.db.entities.ItemType; +import gov.anl.aps.logr.portal.model.db.entities.UserInfo; +import gov.anl.aps.logr.portal.utilities.SearchResult; +import gov.anl.aps.logr.rest.mcp.render.McpRenderLimits; +import gov.anl.aps.logr.rest.mcp.schema.JsonSchemaBuilder; +import gov.anl.aps.logr.rest.mcp.tools.AbstractMcpTool; +import gov.anl.aps.logr.rest.mcp.tools.McpArgumentException; +import gov.anl.aps.logr.rest.mcp.tools.McpToolContext; +import gov.anl.aps.logr.rest.mcp.tools.McpToolResult; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.collapseWhitespace; +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.isoDate; +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.nz; +import static gov.anl.aps.logr.rest.mcp.render.McpTextRenderer.truncate; + +/** Searches log documents and entries by text, reusing {@code SearchRoute}'s utility calls, tagged {@link CallSource#MCP}. */ +public class BelySearchTool extends AbstractMcpTool { + + @Override + public String getName() { + return "bely_search"; + } + + @Override + public String getTitle() { + return "Search BELY"; + } + + @Override + public String getDescription() { + return "Search log documents and log entries by text, optionally filtered by logbook type, " + + "system, user, and date ranges. Returns the top matches with enough detail to pick " + + "a document or entry to fetch in full with bely_get_log_document / bely_get_log_entry."; + } + + @Override + public ObjectNode getInputSchema() { + return new JsonSchemaBuilder() + .requiredStringProp("searchText", "Search text; supports ? (single char) and * (multiple chars) wildcards") + .booleanProp("caseInsensitive", "Use case-insensitive matching (default true)") + .integerArrayProp("logbookTypeIds", "Restrict to these logbook type ids (see bely_list_lookups kind=logbookTypes)") + .integerArrayProp("systemIds", "Restrict to these system ids (see bely_list_lookups kind=systems)") + .integerArrayProp("userIds", "Restrict to entries entered by these user ids") + .stringProp("startModifiedDate", "ISO 8601 or yyyy-MM-dd start of last-modified date range") + .stringProp("endModifiedDate", "ISO 8601 or yyyy-MM-dd end of last-modified date range") + .stringProp("startCreatedDate", "ISO 8601 or yyyy-MM-dd start of created date range") + .stringProp("endCreatedDate", "ISO 8601 or yyyy-MM-dd end of created date range") + .integerProp("limit", "Maximum rows per result group (default " + McpRenderLimits.DEFAULT_ROW_LIMIT + + ", max " + McpRenderLimits.MAX_ROW_LIMIT + ")") + .build(); + } + + @Override + public McpToolResult call(JsonNode args, McpToolContext ctx) throws McpArgumentException { + String searchText = reqString(args, "searchText"); + boolean caseInsensitive = optBoolean(args, "caseInsensitive", true); + int limit = optIntInRange(args, "limit", McpRenderLimits.DEFAULT_ROW_LIMIT, 1, McpRenderLimits.MAX_ROW_LIMIT); + + List entityTypeList; + List itemTypeList; + List userList; + Date startModifiedTime; + Date endModifiedTime; + Date startCreatedTime; + Date endCreatedTime; + try { + entityTypeList = resolveEntityTypes(ctx, optIntegerList(args, "logbookTypeIds")); + itemTypeList = resolveItemTypes(ctx, optIntegerList(args, "systemIds")); + userList = resolveUsers(ctx, optIntegerList(args, "userIds")); + startModifiedTime = optDate(args, "startModifiedDate"); + endModifiedTime = ItemDomainLogbookControllerUtility.adjustEndTimeForSearch(optDate(args, "endModifiedDate")); + startCreatedTime = optDate(args, "startCreatedDate"); + endCreatedTime = ItemDomainLogbookControllerUtility.adjustEndTimeForSearch(optDate(args, "endCreatedDate")); + } catch (McpArgumentException e) { + return McpToolResult.error(e.getMessage()); + } + + ItemDomainLogbookControllerUtility utility = new ItemDomainLogbookControllerUtility(); + Map searchArgs = utility.createAdvancedSearchMap( + entityTypeList, itemTypeList, userList, + startModifiedTime, endModifiedTime, + startCreatedTime, endCreatedTime); + + LinkedList documentResults = utility.performEntitySearch(searchText, searchArgs, caseInsensitive); + LinkedList logEntryResults = utility.searchLogEntries(searchText, caseInsensitive, searchArgs); + + LogbookSearchOptions searchOptions = new LogbookSearchOptions( + entityTypeList, itemTypeList, userList, + startModifiedTime, endModifiedTime, + startCreatedTime, endCreatedTime, caseInsensitive); + SearchControllerUtility.publishSearchMqttEvent(searchText, searchOptions, CallSource.MCP); + + return McpToolResult.text(render(searchText, documentResults, logEntryResults, limit)); + } + + private List resolveEntityTypes(McpToolContext ctx, List ids) throws McpArgumentException { + List resolved = new ArrayList<>(); + if (ids.isEmpty()) { + return resolved; + } + List available = ctx.getLogbookTypes(); + for (Integer id : ids) { + EntityType match = available.stream().filter(t -> t.getId().equals(id)).findFirst().orElse(null); + if (match == null) { + throw new McpArgumentException("Unknown logbookTypeId " + id + ". Valid ids: " + describeEntityTypes(available)); + } + resolved.add(match); + } + return resolved; + } + + private List resolveItemTypes(McpToolContext ctx, List ids) throws McpArgumentException { + List resolved = new ArrayList<>(); + if (ids.isEmpty()) { + return resolved; + } + List available = ctx.getLogbookSystems(); + for (Integer id : ids) { + ItemType match = available.stream().filter(t -> t.getId().equals(id)).findFirst().orElse(null); + if (match == null) { + throw new McpArgumentException("Unknown systemId " + id + ". Valid ids: " + describeItemTypes(available)); + } + resolved.add(match); + } + return resolved; + } + + private List resolveUsers(McpToolContext ctx, List ids) throws McpArgumentException { + List resolved = new ArrayList<>(); + for (Integer id : ids) { + UserInfo match = ctx.getUserInfoFacade().findById(id); + if (match == null) { + throw new McpArgumentException("Unknown userId " + id + ". Use bely_list_users to find valid ids."); + } + resolved.add(match); + } + return resolved; + } + + private String render(String searchText, List documentResults, List logEntryResults, int limit) { + StringBuilder sb = new StringBuilder(); + sb.append("Search results for \"").append(searchText).append("\"\n\n"); + + sb.append("Log documents (").append(documentResults.size()).append(" match") + .append(documentResults.size() == 1 ? "" : "es").append(")\n"); + int shown = Math.min(limit, documentResults.size()); + for (int i = 0; i < shown; i++) { + SearchResult r = documentResults.get(i); + sb.append("- logDocumentId=").append(r.getLogDocumentId()) + .append(" | ").append(nz(r.getObjectName())) + .append(" | ").append(nz(r.getLogbookType())) + .append(" / ").append(nz(r.getSystem())) + .append(" | modified ").append(isoDate(r.getLastModifiedOn())) + .append("\n"); + } + if (documentResults.size() > shown) { + sb.append("… showing ").append(shown).append(" of ").append(documentResults.size()) + .append(" document matches; increase limit (max ").append(McpRenderLimits.MAX_ROW_LIMIT) + .append(") or narrow filters to see more\n"); + } + + sb.append("\nLog entries (").append(logEntryResults.size()).append(" match") + .append(logEntryResults.size() == 1 ? "" : "es").append(")\n"); + shown = Math.min(limit, logEntryResults.size()); + for (int i = 0; i < shown; i++) { + SearchResult r = logEntryResults.get(i); + sb.append("- logDocumentId=").append(r.getLogDocumentId()) + .append(" logId=").append(r.getLogEntryId()) + .append(" | ").append(truncate(collapseWhitespace(r.getDisplay()), McpRenderLimits.SEARCH_SNIPPET_CHARS)) + .append(" | modified ").append(isoDate(r.getLastModifiedOn())) + .append("\n"); + } + if (logEntryResults.size() > shown) { + sb.append("… showing ").append(shown).append(" of ").append(logEntryResults.size()) + .append(" entry matches; call bely_get_log_entry with a specific logDocumentId/logId, ") + .append("narrow filters, or increase limit (max ").append(McpRenderLimits.MAX_ROW_LIMIT).append(")\n"); + } + + return sb.toString(); + } +} diff --git a/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/McpDiscoverShapeTest.java b/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/McpDiscoverShapeTest.java new file mode 100644 index 000000000..00e245306 --- /dev/null +++ b/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/McpDiscoverShapeTest.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** Regression guard for the {@code server/discover} shape bug: {@link McpRoute#discover} must match the spec's {@code DiscoverResult}. */ +public class McpDiscoverShapeTest { + + public static void main(String[] args) throws Exception { + int passed = 0; + int failed = 0; + for (java.lang.reflect.Method m : McpDiscoverShapeTest.class.getDeclaredMethods()) { + if (m.getName().startsWith("test") && m.getParameterCount() == 0) { + try { + m.setAccessible(true); + m.invoke(null); + System.out.println("PASS " + m.getName()); + passed++; + } catch (java.lang.reflect.InvocationTargetException e) { + System.out.println("FAIL " + m.getName() + ": " + e.getCause()); + failed++; + } + } + } + System.out.println(passed + " passed, " + failed + " failed"); + if (failed > 0) { + System.exit(1); + } + } + + static void check(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + static void testSupportedVersionsIsNonEmptyAndContainsProtocolVersion() { + ObjectNode result = new McpRoute().discover(); + JsonNode supported = result.get("supportedVersions"); + check(supported != null && supported.isArray() && supported.size() > 0, + "supportedVersions must be a non-empty array"); + boolean containsCurrent = false; + for (JsonNode v : supported) { + if (McpConstants.PROTOCOL_VERSION.equals(v.asText())) { + containsCurrent = true; + } + } + check(containsCurrent, "supportedVersions must contain " + McpConstants.PROTOCOL_VERSION); + } + + static void testDoesNotUseTheOldFieldName() { + ObjectNode result = new McpRoute().discover(); + check(!result.has("supportedProtocolVersions"), + "the old, spec-incorrect field name must not be present"); + } + + static void testResultTypeIsComplete() { + ObjectNode result = new McpRoute().discover(); + check("complete".equals(result.path("resultType").asText()), "resultType must be \"complete\""); + } + + static void testHasCapabilities() { + ObjectNode result = new McpRoute().discover(); + check(result.has("capabilities"), "discover() result must have capabilities"); + check(result.path("capabilities").has("tools"), "capabilities must advertise tools"); + } + + static void testMetaServerInfoNameIsPresent() { + ObjectNode result = new McpRoute().discover(); + JsonNode metaServerInfo = result.path("_meta").path(McpConstants.META_SERVER_INFO); + check(McpConstants.SERVER_NAME.equals(metaServerInfo.path("name").asText()), + "_meta[\"" + McpConstants.META_SERVER_INFO + "\"].name must be \"" + McpConstants.SERVER_NAME + "\""); + } +} diff --git a/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/McpInitializeLegacyTest.java b/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/McpInitializeLegacyTest.java new file mode 100644 index 000000000..8b5474047 --- /dev/null +++ b/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/McpInitializeLegacyTest.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import gov.anl.aps.logr.rest.mcp.protocol.JsonRpcRequest; + +/** Checks {@link McpRoute#initializeLegacy}, the sessionless legacy {@code initialize} handshake for pre-2026-07-28 clients. */ +public class McpInitializeLegacyTest { + + public static void main(String[] args) throws Exception { + int passed = 0; + int failed = 0; + for (java.lang.reflect.Method m : McpInitializeLegacyTest.class.getDeclaredMethods()) { + if (m.getName().startsWith("test") && m.getParameterCount() == 0) { + try { + m.setAccessible(true); + m.invoke(null); + System.out.println("PASS " + m.getName()); + passed++; + } catch (java.lang.reflect.InvocationTargetException e) { + System.out.println("FAIL " + m.getName() + ": " + e.getCause()); + failed++; + } + } + } + System.out.println(passed + " passed, " + failed + " failed"); + if (failed > 0) { + System.exit(1); + } + } + + static void check(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + private static JsonRpcRequest initializeRequest(String protocolVersion) throws Exception { + StringBuilder json = new StringBuilder(); + json.append("{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\",\"params\":{"); + if (protocolVersion != null) { + json.append("\"protocolVersion\":\"").append(protocolVersion).append("\","); + } + json.append("\"capabilities\":{},\"clientInfo\":{\"name\":\"x\",\"version\":\"1\"}}}"); + JsonNode root = McpConstants.MAPPER.readTree(json.toString()); + return JsonRpcRequest.parse(root); + } + + static void testSupportedLegacyVersionIsEchoedBack() throws Exception { + String requested = "2025-06-18"; + ObjectNode result = new McpRoute().initializeLegacy(initializeRequest(requested)); + check(requested.equals(result.path("protocolVersion").asText()), + "a supported legacy version must be echoed back unchanged"); + } + + static void testUnrecognizedVersionFallsBackToDefault() throws Exception { + ObjectNode result = new McpRoute().initializeLegacy(initializeRequest("1900-01-01")); + check(McpConstants.LEGACY_PROTOCOL_VERSION_DEFAULT.equals(result.path("protocolVersion").asText()), + "an unrecognized version must fall back to " + McpConstants.LEGACY_PROTOCOL_VERSION_DEFAULT); + } + + static void testMissingProtocolVersionFallsBackToDefault() throws Exception { + ObjectNode result = new McpRoute().initializeLegacy(initializeRequest(null)); + check(McpConstants.LEGACY_PROTOCOL_VERSION_DEFAULT.equals(result.path("protocolVersion").asText()), + "a missing protocolVersion must fall back to " + McpConstants.LEGACY_PROTOCOL_VERSION_DEFAULT); + } + + static void testResultShapeMatchesLegacyInitializeResult() throws Exception { + ObjectNode result = new McpRoute().initializeLegacy(initializeRequest(McpConstants.LEGACY_PROTOCOL_VERSION_DEFAULT)); + check(result.path("capabilities").has("tools"), "capabilities.tools must be present"); + check(McpConstants.SERVER_NAME.equals(result.path("serverInfo").path("name").asText()), + "serverInfo.name must be \"" + McpConstants.SERVER_NAME + "\""); + check(result.has("instructions"), "instructions must be present"); + check(!result.has("resultType"), "a legacy InitializeResult must not carry the modern-only \"resultType\" field"); + check(!result.has("_meta"), "a legacy InitializeResult must not carry modern-only \"_meta\""); + } +} diff --git a/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/McpRouteHeaderValidationTest.java b/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/McpRouteHeaderValidationTest.java new file mode 100644 index 000000000..9f795a7f9 --- /dev/null +++ b/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/McpRouteHeaderValidationTest.java @@ -0,0 +1,231 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import gov.anl.aps.logr.rest.mcp.protocol.JsonRpcRequest; +import gov.anl.aps.logr.rest.mcp.protocol.McpProtocolException; +import java.util.Base64; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.ws.rs.core.Cookie; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; + +/** Matrix test for {@link McpRoute#validateHeaders}, run outside a container via the {@link FakeHttpHeaders} stub. */ +public class McpRouteHeaderValidationTest { + + private static final String V = McpConstants.PROTOCOL_VERSION; + + public static void main(String[] args) throws Exception { + int passed = 0; + int failed = 0; + for (java.lang.reflect.Method m : McpRouteHeaderValidationTest.class.getDeclaredMethods()) { + if (m.getName().startsWith("test") && m.getParameterCount() == 0) { + try { + m.setAccessible(true); + m.invoke(null); + System.out.println("PASS " + m.getName()); + passed++; + } catch (java.lang.reflect.InvocationTargetException e) { + System.out.println("FAIL " + m.getName() + ": " + e.getCause()); + failed++; + } + } + } + System.out.println(passed + " passed, " + failed + " failed"); + if (failed > 0) { + System.exit(1); + } + } + + static void check(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + private static JsonRpcRequest request(String method, String metaVersion, String name) throws Exception { + StringBuilder json = new StringBuilder(); + json.append("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"").append(method).append("\",\"params\":{"); + if (metaVersion != null) { + json.append("\"_meta\":{\"").append(McpConstants.META_PROTOCOL_VERSION).append("\":\"").append(metaVersion).append("\"},"); + } + if (name != null) { + json.append("\"name\":\"").append(name).append("\","); + } + json.append("\"arguments\":{}}}"); + JsonNode root = McpConstants.MAPPER.readTree(json.toString()); + return JsonRpcRequest.parse(root); + } + + private static McpProtocolException expectThrow(JsonRpcRequest request, HttpHeaders headers) { + try { + new McpRoute().validateHeaders(request, headers); + } catch (McpProtocolException e) { + return e; + } + throw new AssertionError("expected validateHeaders to throw for " + request.getMethod()); + } + + static void testWellFormedModernRequestPasses() throws Exception { + JsonRpcRequest req = request("server/discover", V, null); + FakeHttpHeaders headers = new FakeHttpHeaders() + .with(McpConstants.HEADER_METHOD, "server/discover") + .with(McpConstants.HEADER_PROTOCOL_VERSION, V); + new McpRoute().validateHeaders(req, headers); // must not throw + } + + static void testMissingMcpMethodHeaderIsHeaderMismatch() throws Exception { + JsonRpcRequest req = request("tools/list", V, null); + FakeHttpHeaders headers = new FakeHttpHeaders().with(McpConstants.HEADER_PROTOCOL_VERSION, V); + McpProtocolException e = expectThrow(req, headers); + check(e.getJsonRpcCode() == McpConstants.ERR_HEADER_MISMATCH, "missing Mcp-Method must be -32020, got " + e.getJsonRpcCode()); + check(e.getHttpStatus() == 400, "must be HTTP 400"); + } + + static void testMcpMethodHeaderMismatchedAgainstBodyMethod() throws Exception { + JsonRpcRequest req = request("tools/call", V, "bely_search"); + FakeHttpHeaders headers = new FakeHttpHeaders() + .with(McpConstants.HEADER_METHOD, "tools/list") // wrong on purpose + .with(McpConstants.HEADER_PROTOCOL_VERSION, V) + .with(McpConstants.HEADER_NAME, "bely_search"); + McpProtocolException e = expectThrow(req, headers); + check(e.getJsonRpcCode() == McpConstants.ERR_HEADER_MISMATCH, "Mcp-Method mismatch must be -32020"); + } + + static void testMissingMetaProtocolVersionIsLegacyDiagnostic() throws Exception { + JsonRpcRequest req = request("server/discover", null, null); + FakeHttpHeaders headers = new FakeHttpHeaders().with(McpConstants.HEADER_METHOD, "server/discover"); + McpProtocolException e = expectThrow(req, headers); + check(e.getJsonRpcCode() == McpConstants.ERR_UNSUPPORTED_VERSION, "missing _meta protocolVersion must be -32022, got " + e.getJsonRpcCode()); + check(e.getMessage().contains(V), "the diagnostic must name the supported version so a legacy client can show it"); + } + + static void testProtocolVersionHeaderMissingIsHeaderMismatch() throws Exception { + JsonRpcRequest req = request("server/discover", V, null); + FakeHttpHeaders headers = new FakeHttpHeaders().with(McpConstants.HEADER_METHOD, "server/discover"); + McpProtocolException e = expectThrow(req, headers); + check(e.getJsonRpcCode() == McpConstants.ERR_HEADER_MISMATCH, "missing MCP-Protocol-Version header must be -32020"); + } + + static void testProtocolVersionHeaderDisagreesWithMeta() throws Exception { + JsonRpcRequest req = request("server/discover", V, null); + FakeHttpHeaders headers = new FakeHttpHeaders() + .with(McpConstants.HEADER_METHOD, "server/discover") + .with(McpConstants.HEADER_PROTOCOL_VERSION, "2025-06-18"); // disagrees with body's _meta + McpProtocolException e = expectThrow(req, headers); + check(e.getJsonRpcCode() == McpConstants.ERR_HEADER_MISMATCH, "header/body protocol version disagreement must be -32020, not -32022"); + } + + static void testUnsupportedButMirroredVersionIsUnsupportedVersion() throws Exception { + JsonRpcRequest req = request("server/discover", "1900-01-01", null); + FakeHttpHeaders headers = new FakeHttpHeaders() + .with(McpConstants.HEADER_METHOD, "server/discover") + .with(McpConstants.HEADER_PROTOCOL_VERSION, "1900-01-01"); // agrees with body, but nobody supports this + McpProtocolException e = expectThrow(req, headers); + check(e.getJsonRpcCode() == McpConstants.ERR_UNSUPPORTED_VERSION, "mirrored-but-unsupported version must be -32022"); + check(e.getData() != null && e.getData().get("requested").asText().equals("1900-01-01"), "data.requested must echo the bad version"); + check(e.getData().get("supported").isArray(), "data.supported must list what we do support"); + } + + static void testToolsCallWithMismatchedMcpNameHeader() throws Exception { + JsonRpcRequest req = request("tools/call", V, "bely_search"); + FakeHttpHeaders headers = new FakeHttpHeaders() + .with(McpConstants.HEADER_METHOD, "tools/call") + .with(McpConstants.HEADER_PROTOCOL_VERSION, V) + .with(McpConstants.HEADER_NAME, "bely_list_users"); // does not match params.name + McpProtocolException e = expectThrow(req, headers); + check(e.getJsonRpcCode() == McpConstants.ERR_HEADER_MISMATCH, "Mcp-Name mismatch must be -32020"); + } + + static void testToolsCallWithBase64WrappedMcpNameMatching() throws Exception { + JsonRpcRequest req = request("tools/call", V, "bely_search"); + String wrapped = "=?base64?" + Base64.getEncoder().encodeToString("bely_search".getBytes("UTF-8")) + "?="; + FakeHttpHeaders headers = new FakeHttpHeaders() + .with(McpConstants.HEADER_METHOD, "tools/call") + .with(McpConstants.HEADER_PROTOCOL_VERSION, V) + .with(McpConstants.HEADER_NAME, wrapped); + new McpRoute().validateHeaders(req, headers); // must not throw + } + + static void testNonToolsCallMethodIgnoresMcpNameHeader() throws Exception { + JsonRpcRequest req = request("tools/list", V, null); + FakeHttpHeaders headers = new FakeHttpHeaders() + .with(McpConstants.HEADER_METHOD, "tools/list") + .with(McpConstants.HEADER_PROTOCOL_VERSION, V); + new McpRoute().validateHeaders(req, headers); // must not throw: Mcp-Name is only checked for tools/call + } + + // Minimal HttpHeaders stub — validateHeaders only ever calls getHeaderString; every other method is unused. + private static class FakeHttpHeaders implements HttpHeaders { + + private final Map headers = new HashMap<>(); + + FakeHttpHeaders with(String name, String value) { + headers.put(name, value); + return this; + } + + @Override + public String getHeaderString(String name) { + return headers.get(name); + } + + @Override + public List getRequestHeader(String name) { + String value = headers.get(name); + return value == null ? Collections.emptyList() : Collections.singletonList(value); + } + + @Override + public MultivaluedMap getRequestHeaders() { + MultivaluedMap map = new MultivaluedHashMap<>(); + headers.forEach(map::add); + return map; + } + + @Override + public List getAcceptableMediaTypes() { + return Collections.emptyList(); + } + + @Override + public List getAcceptableLanguages() { + return Collections.emptyList(); + } + + @Override + public MediaType getMediaType() { + return null; + } + + @Override + public Locale getLanguage() { + return null; + } + + @Override + public Map getCookies() { + return Collections.emptyMap(); + } + + @Override + public Date getDate() { + return null; + } + + @Override + public int getLength() { + return -1; + } + } +} diff --git a/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/McpRouteSentinelTest.java b/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/McpRouteSentinelTest.java new file mode 100644 index 000000000..b12561ae3 --- /dev/null +++ b/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/McpRouteSentinelTest.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** Plain-Java checks for {@link McpRoute#decodeSentinel}, the {@code =?base64??=} unwrapper used for Mcp-Name. */ +public class McpRouteSentinelTest { + + public static void main(String[] args) throws Exception { + int passed = 0; + int failed = 0; + for (java.lang.reflect.Method m : McpRouteSentinelTest.class.getDeclaredMethods()) { + if (m.getName().startsWith("test") && m.getParameterCount() == 0) { + try { + m.setAccessible(true); + m.invoke(null); + System.out.println("PASS " + m.getName()); + passed++; + } catch (java.lang.reflect.InvocationTargetException e) { + System.out.println("FAIL " + m.getName() + ": " + e.getCause()); + failed++; + } + } + } + System.out.println(passed + " passed, " + failed + " failed"); + if (failed > 0) { + System.exit(1); + } + } + + static void check(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + static void testPlainValueWithoutSentinelIsUnchanged() { + check(McpRoute.decodeSentinel("bely_search").equals("bely_search"), "a plain tool name must pass through unchanged"); + } + + static void testNullIsNull() { + check(McpRoute.decodeSentinel(null) == null, "decodeSentinel(null) must return null, not throw"); + } + + static void testValidSentinelDecodesUtf8Payload() { + String encoded = Base64.getEncoder().encodeToString("bely_search".getBytes(StandardCharsets.UTF_8)); + String wrapped = "=?base64?" + encoded + "?="; + check(McpRoute.decodeSentinel(wrapped).equals("bely_search"), "a valid sentinel must decode to its original payload"); + } + + static void testValidSentinelDecodesNonAsciiPayload() { + String original = "bely_日本語"; + String encoded = Base64.getEncoder().encodeToString(original.getBytes(StandardCharsets.UTF_8)); + String wrapped = "=?base64?" + encoded + "?="; + check(McpRoute.decodeSentinel(wrapped).equals(original), "non-ASCII payloads are exactly what the sentinel exists for"); + } + + // Sentinel-shaped but not valid base64 (underscores) — must fall back to the literal value, not throw. + static void testSentinelShapedButInvalidBase64FallsBackToLiteral() { + String wrapped = "=?base64?bely_search?="; + check(McpRoute.decodeSentinel(wrapped).equals(wrapped), + "an invalid base64 payload must fall back to the original literal value, not throw"); + } + + static void testEmptyPayloadDecodesToEmptyString() { + check(McpRoute.decodeSentinel("=?base64??=").equals(""), "an empty base64 payload is validly empty, not a fallback case"); + } +} diff --git a/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/render/McpTextRendererTest.java b/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/render/McpTextRendererTest.java new file mode 100644 index 000000000..6f8c08ab7 --- /dev/null +++ b/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/render/McpTextRendererTest.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.render; + +import java.util.Date; + +/** Plain-Java checks for {@link McpTextRenderer}'s truncation boundaries; see {@code JsonSchemaBuilderTest} for why no JUnit. */ +public class McpTextRendererTest { + + public static void main(String[] args) throws Exception { + int passed = 0; + int failed = 0; + for (java.lang.reflect.Method m : McpTextRendererTest.class.getDeclaredMethods()) { + if (m.getName().startsWith("test") && m.getParameterCount() == 0) { + try { + m.setAccessible(true); + m.invoke(null); + System.out.println("PASS " + m.getName()); + passed++; + } catch (java.lang.reflect.InvocationTargetException e) { + System.out.println("FAIL " + m.getName() + ": " + e.getCause()); + failed++; + } + } + } + System.out.println(passed + " passed, " + failed + " failed"); + if (failed > 0) { + System.exit(1); + } + } + + static void check(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + static void testTruncateAtExactBoundaryIsUnchanged() { + String text = "0123456789"; + check(McpTextRenderer.truncate(text, 10).equals(text), "text.length() == maxChars must not be truncated"); + } + + static void testTruncateOneOverBoundaryIsCut() { + String text = "0123456789X"; + check(McpTextRenderer.truncate(text, 10).equals("0123456789"), "text.length() == maxChars + 1 must cut to exactly maxChars"); + } + + static void testTruncateNullIsEmpty() { + check(McpTextRenderer.truncate(null, 10).equals(""), "truncate(null, _) must be empty string, not null"); + } + + static void testTruncateWithMarkerUnderLimitIsUnchanged() { + String text = "short"; + check(McpTextRenderer.truncateWithMarker(text, 100, "call X").equals(text), "under the limit, no marker should be appended"); + } + + static void testTruncateWithMarkerAtExactBoundaryIsUnchanged() { + String text = "0123456789"; + check(McpTextRenderer.truncateWithMarker(text, 10, "call X").equals(text), "text.length() == maxChars must not get a marker"); + } + + static void testTruncateWithMarkerOverLimitNamesFollowUp() { + String text = "0123456789XYZ"; + String result = McpTextRenderer.truncateWithMarker(text, 10, "call bely_get_log_entry logId=5"); + check(result.startsWith("0123456789"), "truncated body must be preserved"); + check(result.contains("truncated 3 of 13 chars"), "marker must report exact truncated/total counts, got: " + result); + check(result.contains("call bely_get_log_entry logId=5"), "marker must name the exact follow-up call"); + } + + static void testCollapseWhitespaceTrimsAndCollapses() { + String text = " hello \n\n world\t! "; + check(McpTextRenderer.collapseWhitespace(text).equals("hello world !"), "must trim and collapse runs of whitespace to one space"); + } + + static void testCollapseWhitespaceNullIsEmpty() { + check(McpTextRenderer.collapseWhitespace(null).equals(""), "collapseWhitespace(null) must be empty string"); + } + + static void testIsoDateNullIsQuestionMark() { + check(McpTextRenderer.isoDate(null).equals("?"), "isoDate(null) must be \"?\", not throw or return null"); + } + + static void testIsoDateFormatsAsUtc() { + Date epoch = new Date(0L); + check(McpTextRenderer.isoDate(epoch).equals("1970-01-01T00:00:00Z"), "epoch must format as 1970-01-01T00:00:00Z"); + } + + static void testSafeReturnsValueOnSuccess() { + check(McpTextRenderer.safe(() -> "ok").equals("ok"), "safe() must pass through a successful supplier's value"); + } + + static void testSafeReturnsQuestionMarkOnNull() { + check(McpTextRenderer.safe(() -> null).equals("?"), "safe() must map a null supplier result to \"?\""); + } + + static void testSafeCatchesRuntimeException() { + String result = McpTextRenderer.safe(() -> { + throw new NullPointerException("simulated unfetchable lazy relation"); + }); + check(result.equals("?"), "safe() must catch RuntimeException from the supplier and degrade to \"?\""); + } + + static void testNz() { + check(McpTextRenderer.nz(null).equals(""), "nz(null) must be empty string"); + check(McpTextRenderer.nz("x").equals("x"), "nz(x) must pass through unchanged"); + } + + static void testCapResultUnderLimitIsUnchanged() { + String text = "short result"; + check(McpTextRenderer.capResult(text).equals(text), "under MAX_RESULT_CHARS, capResult must not alter the text"); + } + + static void testCapResultOverLimitTruncatesToExactCap() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < McpRenderLimits.MAX_RESULT_CHARS + 500; i++) { + sb.append('a'); + } + String result = McpTextRenderer.capResult(sb.toString()); + String[] parts = result.split("\n… \\[result truncated", 2); + check(parts[0].length() == McpRenderLimits.MAX_RESULT_CHARS, + "capResult must cut the body to exactly MAX_RESULT_CHARS, got " + parts[0].length()); + check(result.contains("truncated at " + McpRenderLimits.MAX_RESULT_CHARS), "cap marker must name the exact limit"); + } +} diff --git a/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/schema/JsonSchemaBuilderTest.java b/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/schema/JsonSchemaBuilderTest.java new file mode 100644 index 000000000..f323b4906 --- /dev/null +++ b/src/java/LogrPortal/test/gov/anl/aps/logr/rest/mcp/schema/JsonSchemaBuilderTest.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) UChicago Argonne, LLC. All rights reserved. + * See LICENSE file. + */ +package gov.anl.aps.logr.rest.mcp.schema; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** Plain-Java checks for {@link JsonSchemaBuilder} — no JUnit is wired into this build, so {@link #main} is the runner. */ +public class JsonSchemaBuilderTest { + + public static void main(String[] args) throws Exception { + int passed = 0; + int failed = 0; + for (java.lang.reflect.Method m : JsonSchemaBuilderTest.class.getDeclaredMethods()) { + if (m.getName().startsWith("test") && m.getParameterCount() == 0) { + try { + m.setAccessible(true); + m.invoke(null); + System.out.println("PASS " + m.getName()); + passed++; + } catch (java.lang.reflect.InvocationTargetException e) { + System.out.println("FAIL " + m.getName() + ": " + e.getCause()); + failed++; + } + } + } + System.out.println(passed + " passed, " + failed + " failed"); + if (failed > 0) { + System.exit(1); + } + } + + static void check(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } + + static void testRequiredStringPropAppearsInRequiredAndProperties() { + ObjectNode schema = new JsonSchemaBuilder() + .requiredStringProp("searchText", "text to search") + .build(); + check(schema.get("type").asText().equals("object"), "schema type must be object"); + check(schema.get("properties").get("searchText").get("type").asText().equals("string"), "searchText must be string"); + check(schema.get("properties").get("searchText").get("description").asText().equals("text to search"), "description must round-trip"); + check(schema.get("required").toString().contains("searchText"), "searchText must be in required"); + } + + static void testOptionalPropIsNotRequired() { + ObjectNode schema = new JsonSchemaBuilder() + .stringProp("filter", null) + .build(); + check(!schema.has("required"), "schema with no required props must omit \"required\" entirely"); + } + + static void testIntegerArrayPropShape() { + ObjectNode schema = new JsonSchemaBuilder() + .integerArrayProp("logbookTypeIds", "type ids") + .build(); + ObjectNode prop = (ObjectNode) schema.get("properties").get("logbookTypeIds"); + check(prop.get("type").asText().equals("array"), "must be array type"); + check(prop.get("items").get("type").asText().equals("integer"), "items must be integer type"); + } + + static void testEnumPropShape() { + ObjectNode schema = new JsonSchemaBuilder() + .enumProp("kind", "which kind", "logbookTypes", "systems", "templates") + .build(); + ObjectNode prop = (ObjectNode) schema.get("properties").get("kind"); + check(prop.get("enum").size() == 3, "enum must have 3 values"); + check(prop.get("enum").get(0).asText().equals("logbookTypes"), "first enum value must round-trip in order"); + check(!schema.has("required") || !schema.get("required").toString().contains("kind"), "plain enumProp must not be required"); + } + + static void testRequiredEnumPropAddsToRequired() { + ObjectNode schema = new JsonSchemaBuilder() + .requiredEnumProp("kind", "which kind", "a", "b") + .build(); + check(schema.get("required").toString().contains("kind"), "requiredEnumProp must add to required"); + } + + static void testMultiplePropsAccumulate() { + ObjectNode schema = new JsonSchemaBuilder() + .requiredIntegerProp("logDocumentId", null) + .requiredIntegerProp("logId", null) + .booleanProp("includeReplies", null) + .build(); + check(schema.get("properties").size() == 3, "all three properties must be present"); + check(schema.get("required").size() == 2, "only the two required props must be listed"); + } +}