From 62c0d4569d8076e6a15ff35969ad963574e051c3 Mon Sep 17 00:00:00 2001 From: Geert Bevin Date: Fri, 17 Jul 2026 17:28:29 -0400 Subject: [PATCH] Add experimental WIP MCP support that serves the build commands as tools --- src/main/java/rife/bld/BaseProject.java | 45 + src/main/java/rife/bld/BuildExecutor.java | 153 ++- src/main/java/rife/bld/help/McpHelp.java | 34 + .../rife/bld/operations/HelpOperation.java | 1 + .../java/rife/bld/operations/McpControl.java | 121 ++ .../rife/bld/operations/McpOperation.java | 1186 +++++++++++++++++ .../rife/bld/operations/McpToolRunner.java | 180 +++ src/main/java/rife/bld/wrapper/Wrapper.java | 6 + .../rife/bld/operations/TestMcpOperation.java | 996 ++++++++++++++ 9 files changed, 2681 insertions(+), 41 deletions(-) create mode 100644 src/main/java/rife/bld/help/McpHelp.java create mode 100644 src/main/java/rife/bld/operations/McpControl.java create mode 100644 src/main/java/rife/bld/operations/McpOperation.java create mode 100644 src/main/java/rife/bld/operations/McpToolRunner.java create mode 100644 src/test/java/rife/bld/operations/TestMcpOperation.java diff --git a/src/main/java/rife/bld/BaseProject.java b/src/main/java/rife/bld/BaseProject.java index 58b801b..19c4839 100644 --- a/src/main/java/rife/bld/BaseProject.java +++ b/src/main/java/rife/bld/BaseProject.java @@ -393,6 +393,7 @@ public class BaseProject extends BuildExecutor { private final CompileOperation compileOperation_ = new CompileOperation(); private final DependencyTreeOperation dependencyTreeOperation_ = new DependencyTreeOperation(); private final DownloadOperation downloadOperation_ = new DownloadOperation(); + private final McpOperation mcpOperation_ = new McpOperation(); private final PurgeOperation purgeOperation_ = new PurgeOperation(); private final PublishOperation publishOperation_ = new PublishOperation(); private final RunOperation runOperation_ = new RunOperation(); @@ -440,6 +441,16 @@ public class BaseProject extends BuildExecutor { return downloadOperation_; } + /** + * Retrieves the project's default MCP operation. + * + * @return the default MCP operation instance + * @since 2.4.0 + */ + public McpOperation mcpOperation() { + return mcpOperation_; + } + /** * Retrieves the project's default publish operation. * @@ -544,6 +555,18 @@ public class BaseProject extends BuildExecutor { downloadOperation().executeOnce(() -> downloadOperation().fromProject(this)); } + /** + * Standard build command, starts an MCP server that exposes the build + * commands as tools. + * + * @since 2.4.0 + */ + @BuildCommand(help = McpHelp.class) + public void mcp() + throws Exception { + mcpOperation().executeOnce(() -> mcpOperation().fromProject(this)); + } + /** * Standard build command, purges all unused artifacts from the project. * @@ -2054,6 +2077,22 @@ public class BaseProject extends BuildExecutor { purge(); } + /** + * Performs the automatic download and purge of the project + * dependencies when it's enabled through {@link #autoDownloadPurge()} + * and the project isn't offline. + *

+ * The dependencies are only refreshed when the dependency cache is + * stale, so that repeated invocations are cheap. + * + * @since 2.4.0 + */ + public void performAutoDownloadPurgeIfEnabled() { + if (!offline() && autoDownloadPurge()) { + performAutoDownloadPurge(); + } + } + private void performAutoDownloadPurge() { var resolution = new VersionResolution(properties()); var cache = new BldCache(libBldDirectory(), resolution); @@ -2077,6 +2116,12 @@ public class BaseProject extends BuildExecutor { var remainingArguments = new ArrayList<>(List.of(arguments)); var auto = remainingArguments.remove(AUTO_DOWNLOAD_PURGE_OPTION); + if (remainingArguments.contains(BuildExecutor.ARG_USE_STDERR)) { + // the build output is sent to standard error so that standard + // output stays free for the MCP protocol, done before the + // automatic download and purge produces any output + System.setOut(System.err); + } if (!offline() && (autoDownloadPurge() || auto)) { performAutoDownloadPurge(); diff --git a/src/main/java/rife/bld/BuildExecutor.java b/src/main/java/rife/bld/BuildExecutor.java index 95cf98e..1bc6c6a 100644 --- a/src/main/java/rife/bld/BuildExecutor.java +++ b/src/main/java/rife/bld/BuildExecutor.java @@ -32,6 +32,8 @@ public class BuildExecutor { public static final String LOCAL_PROPERTIES = "local.properties"; private static final String ARG_OFFLINE = "--offline"; + static final String ARG_USE_STDERR = "--use-stderr"; + private static final String ARG_HELP1 = "--help"; private static final String ARG_HELP2 = "-h"; private static final String ARG_HELP3 = "-?"; @@ -41,7 +43,7 @@ public class BuildExecutor { private static final String ARG_VERBOSE2 = "-v"; private final HierarchicalProperties properties_; - private List arguments_ = Collections.emptyList(); + private List arguments_ = new ArrayList<>(); private boolean offline_ = false; private boolean verbose_ = false; private Map buildCommands_ = null; @@ -138,6 +140,17 @@ public class BuildExecutor { return offline_; } + /** + * Changes whether the bld execution is intended to be offline. + * + * @param offline {@code true} if the execution is intended to be offline; + * or {@code false} otherwise + * @since 2.4.0 + */ + public void offline(boolean offline) { + offline_ = offline; + } + /** * Returns whether the bld execution should output detailed information * about the operations it performs. @@ -150,6 +163,42 @@ public class BuildExecutor { return verbose_; } + /** + * Changes whether the bld execution should output detailed information + * about the operations it performs. + * + * @param verbose {@code true} if the execution is verbose; + * or {@code false} otherwise + * @since 2.4.0 + */ + public void verbose(boolean verbose) { + verbose_ = verbose; + } + + /** + * Returns whether the bld execution prints out the stacktrace for + * exceptions. + * + * @return {@code true} if the stacktrace is printed; + * or {@code false} otherwise + * @since 2.4.0 + */ + public boolean showStacktrace() { + return showStacktrace; + } + + /** + * Changes whether the bld execution prints out the stacktrace for + * exceptions. + * + * @param showStacktrace {@code true} if the stacktrace is printed; + * or {@code false} otherwise + * @since 2.4.0 + */ + public void showStacktrace(boolean showStacktrace) { + this.showStacktrace = showStacktrace; + } + /** * Returns the properties uses for bld execution. * @@ -243,12 +292,19 @@ public class BuildExecutor { show_help |= arguments_.removeAll(List.of(ARG_HELP1, ARG_HELP2, ARG_HELP3)); showStacktrace = arguments_.removeAll(List.of(ARG_STACKTRACE1, ARG_STACKTRACE2)); verbose_ = arguments_.removeAll(List.of(ARG_VERBOSE1, ARG_VERBOSE2)); + if (arguments_.removeAll(List.of(ARG_USE_STDERR))) { + // the build output is sent to standard error so that standard + // output stays free for the MCP protocol, which the MCP server + // relies on to keep its protocol stream clean + System.setOut(System.err); + } if (show_help) { new HelpOperation(this, Collections.emptyList()).execute(); return exitStatus_; } - else if (arguments_.isEmpty()) { + + if (arguments_.isEmpty()) { showBldHelp(); return exitStatus_; } @@ -441,47 +497,14 @@ public class BuildExecutor { */ public boolean executeCommand(String command) throws Throwable { - var matched_command = command; - var definition = buildCommands().get(command); - - // try to find an alias - if (definition == null) { - var aliased_command = buildAliases().get(command); - if (aliased_command != null) { - matched_command = aliased_command; - definition = buildCommands().get(aliased_command); - } - } - - // try to find a match for the provided command amongst - // the ones that are known - if (definition == null) { - // try to find starting matching options - var matches = new ArrayList<>(buildCommands().keySet().stream() - .filter(c -> c.toLowerCase().startsWith(command.toLowerCase())) - .toList()); - - if (matches.isEmpty()) { - // try to find fuzzy matching options - var fuzzy_regexp = new StringBuilder("^.*"); - for (var ch : command.toCharArray()) { - fuzzy_regexp.append("\\Q"); - fuzzy_regexp.append(ch); - fuzzy_regexp.append("\\E.*"); - } - fuzzy_regexp.append('$'); - var fuzzy_pattern = Pattern.compile(fuzzy_regexp.toString()); - matches.addAll(buildCommands().keySet().stream() - .filter(c -> fuzzy_pattern.matcher(c.toLowerCase()).matches()) - .toList()); - } - - // only proceed if exactly one match was found - if (matches.size() == 1) { - matched_command = matches.get(0); + var matched_command = resolveCommand(command); + CommandDefinition definition = null; + if (matched_command != null) { + if (!matched_command.equals(command) && + !matched_command.equals(buildAliases().get(command))) { System.out.println("Executing matched command: " + matched_command); - definition = buildCommands().get(matched_command); } + definition = buildCommands().get(matched_command); } // execute the command if we found one @@ -507,6 +530,54 @@ public class BuildExecutor { return true; } + /** + * Resolves a command name to the name of the build command that would + * be executed for it, taking the command aliases, unique name prefixes + * and unique fuzzy matches into account. + * + * @param command the command name to resolve + * @return the name of the build command that would be executed; or + * {@code null} when the name couldn't be resolved + * @since 2.4.0 + */ + public String resolveCommand(String command) { + if (buildCommands().containsKey(command)) { + return command; + } + + // try to find an alias + var aliased_command = buildAliases().get(command); + if (aliased_command != null) { + return aliased_command; + } + + // try to find starting matching options + var matches = new ArrayList<>(buildCommands().keySet().stream() + .filter(c -> c.toLowerCase().startsWith(command.toLowerCase())) + .toList()); + + if (matches.isEmpty()) { + // try to find fuzzy matching options + var fuzzy_regexp = new StringBuilder("^.*"); + for (var ch : command.toCharArray()) { + fuzzy_regexp.append("\\Q"); + fuzzy_regexp.append(ch); + fuzzy_regexp.append("\\E.*"); + } + fuzzy_regexp.append('$'); + var fuzzy_pattern = Pattern.compile(fuzzy_regexp.toString()); + matches.addAll(buildCommands().keySet().stream() + .filter(c -> fuzzy_pattern.matcher(c.toLowerCase()).matches()) + .toList()); + } + + // only resolve if exactly one match was found + if (matches.size() == 1) { + return matches.get(0); + } + return null; + } + private void showBldHelp() { var help = new HelpOperation(this, arguments()); help.executePrintWelcome(); diff --git a/src/main/java/rife/bld/help/McpHelp.java b/src/main/java/rife/bld/help/McpHelp.java new file mode 100644 index 0000000..0011cef --- /dev/null +++ b/src/main/java/rife/bld/help/McpHelp.java @@ -0,0 +1,34 @@ +/* + * Copyright 2001-2026 Geert Bevin (gbevin[remove] at uwyn dot com) + * Licensed under the Apache License, Version 2.0 (the "License") + */ +package rife.bld.help; + +import rife.bld.CommandHelp; +import rife.tools.StringUtils; + +/** + * Provides help for the MCP command. + * + * @author Geert Bevin (gbevin[remove] at uwyn dot com) + * @since 2.4.0 + */ +public class McpHelp implements CommandHelp { + public String getSummary() { + return "Starts an MCP server that exposes the build commands"; + } + + public String getDescription(String topic) { + return StringUtils.replace(""" + Starts a Model Context Protocol (MCP) server that exposes the + build commands as tools, so that AI agents can drive the build. + This is an experimental feature and may still change. + + The server communicates over standard input and output with the + MCP stdio transport and runs until its input stream ends. Launch + bld with the --use-stderr option to keep standard output free + for the protocol while the build starts up. + + Usage : ${topic}""", "${topic}", topic); + } +} diff --git a/src/main/java/rife/bld/operations/HelpOperation.java b/src/main/java/rife/bld/operations/HelpOperation.java index f1de662..a510e00 100644 --- a/src/main/java/rife/bld/operations/HelpOperation.java +++ b/src/main/java/rife/bld/operations/HelpOperation.java @@ -190,6 +190,7 @@ public class HelpOperation { The following bld arguments are supported: --offline Works without Internet (only as first argument) + --use-stderr Sends the build output to standard error -?, -h, --help Shows the help -D= Sets a JVM system property -s, --stacktrace Prints out the stacktrace for exceptions diff --git a/src/main/java/rife/bld/operations/McpControl.java b/src/main/java/rife/bld/operations/McpControl.java new file mode 100644 index 0000000..254e19d --- /dev/null +++ b/src/main/java/rife/bld/operations/McpControl.java @@ -0,0 +1,121 @@ +/* + * Copyright 2001-2026 Geert Bevin (gbevin[remove] at uwyn dot com) + * Licensed under the Apache License, Version 2.0 (the "License") + */ +package rife.bld.operations; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * The details that a single MCP tool call hands to the build process it + * launches. + *

+ * The command, its arguments, the flags and the excluded commands are + * written to a control file instead of the command line, so they can never + * be mistaken for the command's own arguments. The values are escaped so + * that any character, including newlines, comes through intact. Only the + * MCP code uses this file, the build itself knows nothing about it. + * + * @author Geert Bevin (gbevin[remove] at uwyn dot com) + * @since 2.4.0 + */ +final class McpControl { + final String command; + final List arguments; + final boolean offline; + final boolean verbose; + final boolean stacktrace; + final Set exclusions; + + private McpControl(String command, List arguments, boolean offline, boolean verbose, boolean stacktrace, Set exclusions) { + this.command = command; + this.arguments = arguments; + this.offline = offline; + this.verbose = verbose; + this.stacktrace = stacktrace; + this.exclusions = exclusions; + } + + static void write(File file, String command, List arguments, + boolean offline, boolean verbose, boolean stacktrace, + Collection exclusions) + throws IOException { + var lines = new ArrayList(); + lines.add("command " + escape(command)); + for (var argument : arguments) { + lines.add("arg " + escape(argument)); + } + if (offline) { + lines.add("offline"); + } + if (verbose) { + lines.add("verbose"); + } + if (stacktrace) { + lines.add("stacktrace"); + } + for (var exclusion : exclusions) { + lines.add("exclude " + escape(exclusion)); + } + Files.write(file.toPath(), lines); + } + + static McpControl read(File file) + throws IOException { + String command = null; + var arguments = new ArrayList(); + var offline = false; + var verbose = false; + var stacktrace = false; + var exclusions = new LinkedHashSet(); + for (var line : Files.readAllLines(file.toPath())) { + if (line.equals("offline")) { + offline = true; + } else if (line.equals("verbose")) { + verbose = true; + } else if (line.equals("stacktrace")) { + stacktrace = true; + } else if (line.startsWith("command ")) { + command = unescape(line.substring("command ".length())); + } else if (line.startsWith("arg ")) { + arguments.add(unescape(line.substring("arg ".length()))); + } else if (line.startsWith("exclude ")) { + exclusions.add(unescape(line.substring("exclude ".length()))); + } + } + if (command == null) { + throw new IOException("the control file doesn't specify a command"); + } + return new McpControl(command, arguments, offline, verbose, stacktrace, exclusions); + } + + private static String escape(String value) { + return value.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r"); + } + + private static String unescape(String value) { + var result = new StringBuilder(); + for (var i = 0; i < value.length(); ++i) { + var c = value.charAt(i); + if (c == '\\' && i + 1 < value.length()) { + var next = value.charAt(++i); + switch (next) { + case 'n' -> result.append('\n'); + case 'r' -> result.append('\r'); + case '\\' -> result.append('\\'); + default -> result.append(next); + } + } else { + result.append(c); + } + } + return result.toString(); + } +} diff --git a/src/main/java/rife/bld/operations/McpOperation.java b/src/main/java/rife/bld/operations/McpOperation.java new file mode 100644 index 0000000..909ee06 --- /dev/null +++ b/src/main/java/rife/bld/operations/McpOperation.java @@ -0,0 +1,1186 @@ +/* + * Copyright 2001-2026 Geert Bevin (gbevin[remove] at uwyn dot com) + * Licensed under the Apache License, Version 2.0 (the "License") + */ +package rife.bld.operations; + +import rife.bld.BaseProject; +import rife.bld.BldVersion; +import rife.bld.BuildExecutor; +import rife.bld.wrapper.Wrapper; +import rife.json.Json; +import rife.json.JsonArray; +import rife.json.JsonObject; +import rife.json.JsonParseException; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileDescriptor; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.PrintStream; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * Serves the build commands of a project as tools over the Model Context + * Protocol (MCP), so that AI agents can drive the build. + *

+ * The server communicates over standard input and output with + * newline-delimited JSON-RPC messages, following the MCP stdio transport. + * Every build command is exposed as an MCP tool, invoking a tool executes + * the corresponding command as a separate build process and returns its + * console output, so that every call behaves exactly like a command line + * invocation and can never disturb the protocol streams. The project + * layout, the declared dependencies and the transitive dependency tree + * are exposed as MCP resources. The server runs until its input stream + * ends. + *

+ * MCP support is experimental and its behavior may still change. + * + * @author Geert Bevin (gbevin[remove] at uwyn dot com) + * @since 2.4.0 + */ +public class McpOperation extends AbstractOperation { + /** + * The most recent MCP protocol version that the server implements. + * + * @since 2.4.0 + */ + public static final String PROTOCOL_VERSION = "2025-06-18"; + + // only the batch-free revision is advertised, the 2024-11-05 and + // 2025-03-26 revisions mandate receiving JSON-RPC batches, which this + // server doesn't implement, batching was removed in 2025-06-18 + private static final List SUPPORTED_PROTOCOL_VERSIONS = List.of(PROTOCOL_VERSION); + + private static final int ERROR_PARSE = -32700; + private static final int ERROR_INVALID_REQUEST = -32600; + private static final int ERROR_METHOD_NOT_FOUND = -32601; + private static final int ERROR_INVALID_PARAMS = -32602; + private static final int ERROR_INTERNAL = -32603; + private static final int ERROR_RESOURCE_NOT_FOUND = -32002; + + /** + * The URI of the resource that describes the project identification and layout. + * + * @since 2.4.0 + */ + public static final String RESOURCE_PROJECT = "bld://project"; + + /** + * The URI of the resource that describes the declared dependencies and BOMs. + * + * @since 2.4.0 + */ + public static final String RESOURCE_DEPENDENCIES = "bld://dependencies"; + + /** + * The URI of the resource that describes the transitive dependency tree. + * + * @since 2.4.0 + */ + public static final String RESOURCE_DEPENDENCY_TREE = "bld://dependency-tree"; + + private BuildExecutor executor_; + private BaseProject project_ = null; + private String serverTitle_ = null; + private String instructions_ = null; + private boolean initialized_ = false; + private int toolCallTimeout_ = 0; + private int outputLimit_ = 1_000_000; + private final Set excludedCommands_ = new LinkedHashSet<>(List.of("mcp")); + private final Set confirmationCommands_ = new LinkedHashSet<>(List.of("publish")); + private final Set activeProcesses_ = ConcurrentHashMap.newKeySet(); + + private static final class ResourceNotFoundException extends RuntimeException { + ResourceNotFoundException(String uri) { + super("Resource not found: " + uri); + } + } + + // signals a client error in the request parameters, distinct from an + // internal server failure, so that the two are classified differently + private static final class InvalidParamsException extends RuntimeException { + InvalidParamsException(String message) { + super(message); + } + } + + /** + * Performs the MCP operation, serving requests from standard input + * until it ends. + *

+ * While the server runs, the console output of the executed build + * commands is captured into the tool results so that it never + * corrupts the protocol stream. + * + * @throws IOException when an error occurred while reading or writing + * @since 2.4.0 + */ + public void execute() + throws IOException { + var previous_output = System.out; + var reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); + // the protocol messages are written to the real standard output + // descriptor, even when System.out was redirected during startup + var writer = new PrintWriter(new FileOutputStream(FileDescriptor.out), false, StandardCharsets.UTF_8); + // a terminated server never leaves tool call processes behind + var cleanup = new Thread(() -> { + for (var process : activeProcesses_) { + destroyProcessTree(process); + } + }); + Runtime.getRuntime().addShutdownHook(cleanup); + // stray output outside of tool calls is thrown away so that + // standard output only carries the protocol messages + System.setOut(new PrintStream(OutputStream.nullOutputStream(), false, StandardCharsets.UTF_8)); + try { + executeServerLoop(reader, writer); + } finally { + for (var process : activeProcesses_) { + destroyProcessTree(process); + } + try { + Runtime.getRuntime().removeShutdownHook(cleanup); + } catch (IllegalStateException e) { + // the shutdown is already in progress + } + System.setOut(previous_output); + } + } + + /** + * Part of the {@link #execute} operation, reads newline-delimited + * JSON-RPC messages from the reader and writes the responses to the + * writer until the input ends. + * + * @param reader the reader to read the protocol messages from + * @param writer the writer to write the protocol responses to + * @throws IOException when an error occurred while reading or writing + * @since 2.4.0 + */ + protected void executeServerLoop(BufferedReader reader, PrintWriter writer) + throws IOException { + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) { + continue; + } + var response = processMessage(line); + if (response != null) { + writer.print(response); + writer.print('\n'); + writer.flush(); + } + } + } + + /** + * Part of the {@link #execute} operation, processes a single JSON-RPC + * message and returns the response. + * + * @param message the JSON-RPC message to process + * @return the JSON-RPC response; or {@code null} when the message is a + * notification that doesn't warrant one + * @since 2.4.0 + */ + protected String processMessage(String message) { + Object parsed; + try { + parsed = Json.parse(message); + } catch (JsonParseException e) { + return errorResponse(null, ERROR_PARSE, "Parse error"); + } + + // syntactically valid JSON that isn't a valid JSON-RPC request + // envelope is an invalid request, not a parse error + if (!(parsed instanceof JsonObject request)) { + return errorResponse(null, ERROR_INVALID_REQUEST, "Invalid request"); + } + var id = request.get("id"); + // a message that isn't a structurally valid request object is an + // invalid request, even without an id, its error uses a null id + // when the id is absent or itself invalid + if (!"2.0".equals(request.get("jsonrpc")) || !(request.get("method") instanceof String method)) { + return errorResponse(isValidId(id) ? id : null, ERROR_INVALID_REQUEST, "Invalid request"); + } + // a valid request object without an id is a notification, + // notifications are never answered, not even for parameter errors, + // and request methods must never execute as a notification + if (!request.containsKey("id")) { + return null; + } + // an id has to be a string or an integer, an explicitly null or + // fractional id is invalid + if (!isValidId(id)) { + return errorResponse(null, ERROR_INVALID_REQUEST, "Invalid request"); + } + // the params, when present, have to be a structured object, an + // explicit null is not the same as an omitted params + if (request.containsKey("params") && !(request.get("params") instanceof JsonObject)) { + return errorResponse(id, ERROR_INVALID_PARAMS, "Invalid params"); + } + var params = (JsonObject) request.get("params"); + + // besides pings, requests are only served after initialization + if (!initialized_ && !"initialize".equals(method) && !"ping".equals(method)) { + return errorResponse(id, ERROR_INVALID_REQUEST, "Server not initialized"); + } + + JsonObject result; + try { + result = switch (method) { + case "initialize" -> processInitialize(params); + case "ping" -> new JsonObject(); + case "tools/list" -> { + validateCursor(params); + yield processToolsList(); + } + case "tools/call" -> processToolsCall(params); + case "resources/list" -> { + validateCursor(params); + yield processResourcesList(); + } + case "resources/read" -> processResourcesRead(params); + default -> null; + }; + } catch (InvalidParamsException e) { + // a client error in the request parameters + return errorResponse(id, ERROR_INVALID_PARAMS, e.getMessage() == null ? "Invalid params" : e.getMessage()); + } catch (ResourceNotFoundException e) { + return errorResponse(id, ERROR_RESOURCE_NOT_FOUND, e.getMessage()); + } catch (RuntimeException e) { + // any other failure, including internal exceptions raised while + // handling a valid request, is a server error, the server + // survives it instead of terminating the process + return errorResponse(id, ERROR_INTERNAL, e.getMessage() == null ? "Internal error" : e.getMessage()); + } + + if ("initialize".equals(method) && result != null) { + initialized_ = true; + } + + if (result == null) { + return errorResponse(id, ERROR_METHOD_NOT_FOUND, "Method not found: " + method); + } + return new JsonObject() + .set("jsonrpc", "2.0") + .set("id", id) + .set("result", result) + .toString(); + } + + private static void validateCursor(JsonObject params) { + // MCP pagination cursors are strings, this server doesn't paginate + // but a malformed cursor is still an invalid parameter + if (params != null && params.containsKey("cursor") && !(params.get("cursor") instanceof String)) { + throw new InvalidParamsException("The cursor has to be a string"); + } + } + + private static boolean isValidId(Object id) { + if (id instanceof String) { + return true; + } + if (id instanceof Long) { + return true; + } + // JSON-RPC ids that are numbers have to be integers + if (id instanceof Double number) { + return number == Math.floor(number) && !number.isInfinite(); + } + return false; + } + + private static String errorResponse(Object id, int code, String message) { + return new JsonObject() + .set("jsonrpc", "2.0") + .set("id", id) + .object("error", e -> e + .set("code", code) + .set("message", message)) + .toString(); + } + + /** + * Part of the {@link #execute} operation, handles the MCP + * initialization handshake. + * + * @param params the parameters of the initialize request + * @return the initialization result + * @since 2.4.0 + */ + protected JsonObject processInitialize(JsonObject params) { + if (params == null || + !(params.get("protocolVersion") instanceof String requested_version) || + !(params.get("capabilities") instanceof JsonObject) || + !(params.get("clientInfo") instanceof JsonObject client_info) || + !(client_info.get("name") instanceof String) || + !(client_info.get("version") instanceof String)) { + throw new InvalidParamsException("Invalid initialize params"); + } + var version = SUPPORTED_PROTOCOL_VERSIONS.contains(requested_version) ? requested_version : PROTOCOL_VERSION; + return new JsonObject() + .set("protocolVersion", version) + .object("capabilities", c -> c + .object("tools", t -> {}) + .object("resources", r -> {})) + .object("serverInfo", s -> { + s.set("name", "bld"); + if (serverTitle_ != null) { + s.set("title", serverTitle_); + } + s.set("version", BldVersion.getVersion()); + }) + .set("instructions", instructions_ == null ? defaultInstructions() : instructions_); + } + + /** + * Part of the {@link #execute} operation, generates the default + * instructions that the server reports during initialization when + * none were provided. + * + * @return the default server instructions + * @since 2.4.0 + */ + protected String defaultInstructions() { + var target = serverTitle_ == null ? "a bld build" : "the bld project '" + serverTitle_ + "'"; + var instructions = new StringBuilder(); + instructions.append("This server exposes the build commands of ").append(target).append(" as tools. ") + .append("bld is a build tool for Java projects whose build logic is written in plain Java: ") + .append("the build is defined by a Java class in the src/bld/java directory, and every build ") + .append("command is a method of that class that executes immediately, without a daemon. ") + .append("Dependencies and build behavior are changed by editing the build class, not by ") + .append("editing XML or build scripts. ") + .append("Calling a tool executes the corresponding build command and returns its console output, ") + .append("optional command line arguments are passed through the 'arguments' array of the tool input."); + if (project_ != null) { + instructions.append(" The bld://project and bld://dependencies resources describe the project ") + .append("without executing anything, the bld://dependency-tree resource resolves the ") + .append("transitive dependency tree on demand."); + } + instructions.append(" Every tool call runs as a separate build process and picks up changes to ") + .append("the build sources, the tool listing and the project resources reflect the state ") + .append("at server start though: commands that were added to the build sources are callable ") + .append("by name even before they appear in the listing."); + if (!confirmationCommands_.isEmpty()) { + instructions.append(" Tools whose description requires explicit human confirmation must not ") + .append("be called without the human approving that specific call first."); + } + return instructions.toString(); + } + + /** + * Part of the {@link #execute} operation, lists the build commands + * as MCP tools. + * + * @return the tool listing result + * @since 2.4.0 + */ + protected JsonObject processToolsList() { + var tools = new JsonArray(); + for (var entry : executor_.buildCommands().entrySet()) { + if (excludedCommands_.contains(entry.getKey())) { + continue; + } + var help = entry.getValue().getHelp(); + var summary = help.getSummary(); + var details = help.getDescription(entry.getKey()); + var description = summary; + if (details != null && !details.isBlank()) { + description = summary == null || summary.isBlank() ? details : summary + "\n\n" + details; + } + var confirmation = confirmationCommands_.contains(entry.getKey()); + if (confirmation) { + description = description + "\n\nThis command has significant external effects, " + + "obtain explicit human confirmation before calling it."; + } + var tool_description = description; + tools.object(t -> { + t.set("name", entry.getKey()); + if (serverTitle_ != null) { + // the title distinguishes the same commands of multiple + // projects in human-facing tool listings + t.set("title", entry.getKey() + " (" + serverTitle_ + ")"); + } + t.set("description", tool_description) + .object("inputSchema", s -> s + .set("type", "object") + .object("properties", p -> p + .object("arguments", a -> a + .set("type", "array") + .object("items", i -> i.set("type", "string")) + .set("description", "The command line arguments to pass to the build command")))); + if (confirmation) { + t.object("annotations", a -> a + .set("destructiveHint", true) + .set("openWorldHint", true)); + } + }); + } + return new JsonObject().set("tools", tools); + } + + /** + * Part of the {@link #execute} operation, executes a build command + * for an MCP tool call. + * + * @param params the parameters of the tool call + * @return the tool call result with the captured console output + * @since 2.4.0 + */ + protected JsonObject processToolsCall(JsonObject params) { + if (params == null || !(params.get("name") instanceof String name)) { + throw new InvalidParamsException("Missing tool name"); + } + // the exclusions are checked against the command that the name + // would actually resolve to, so that aliases, prefixes and fuzzy + // matches can't reach excluded commands either + var resolved = executor_.resolveCommand(name); + if (excludedCommands_.contains(name) || + (resolved != null && excludedCommands_.contains(resolved))) { + throw new InvalidParamsException("Unknown tool: " + name); + } + + var arguments = new ArrayList(); + // the tool arguments, when present, have to be an object, an + // explicit null is not the same as an omitted arguments + if (params.containsKey("arguments") && !(params.get("arguments") instanceof JsonObject)) { + throw new InvalidParamsException("The tool arguments have to be an object"); + } + var tool_arguments = (JsonObject) params.get("arguments"); + if (tool_arguments != null && tool_arguments.containsKey("arguments")) { + // the inner arguments array has to be an array of strings + if (!(tool_arguments.get("arguments") instanceof JsonArray argument_list)) { + throw new InvalidParamsException("The arguments have to be an array"); + } + for (var argument : argument_list) { + if (!(argument instanceof String string)) { + throw new InvalidParamsException("The arguments have to be strings"); + } + arguments.add(string); + } + } + + // tools that aren't part of the startup listing are still executed, + // the build process is the authority on the commands that exist, + // commands that were added to the build sources are callable even + // before the listing refreshes + return executeToolCall(name, arguments); + } + + /** + * Part of the {@link #execute} operation, executes a single build + * command with the provided arguments as a separate build process, + * capturing its console output. + *

+ * Running every tool call as a separate process makes it behave + * exactly like a command line invocation: one-shot operations always + * execute, changes to the build classes are picked up, and the + * process can never disturb the protocol streams of the server. + * + * @param command the name of the build command to execute + * @param arguments the arguments to pass to the build command + * @return the tool call result with the captured console output + * @since 2.4.0 + */ + protected JsonObject executeToolCall(String command, List arguments) { + return executeToolCall(command, arguments, true); + } + + /** + * Part of the {@link #execute} operation, executes a single build + * command with the provided arguments as a separate build process, + * capturing its console output. + *

+ * The command name and the excluded commands are passed to the build + * through the control file, so that they can never get mixed up with + * the command's own arguments. The build reports an unknown or + * excluded command through a status file, so that it can never be + * confused with a regular exit status of a build command. + * + * @param command the name of the build command to execute + * @param arguments the arguments to pass to the build command + * @param enforceExclusions whether the excluded commands are refused, + * the internal resource generation doesn't + * enforce them + * @return the tool call result with the captured console output + * @since 2.4.0 + */ + protected JsonObject executeToolCall(String command, List arguments, boolean enforceExclusions) { + var output = new StringBuilder(); + var truncated = new boolean[]{false}; + // the descendants are collected while the process is alive, so that + // background children that were reparented before the process + // ended are still terminated + var descendants = ConcurrentHashMap.newKeySet(); + Process process; + File control_file = null; + File status_file = null; + try { + // the command, the flags and the excluded commands go through a + // control file that the build deletes as soon as it's read, so + // that they never get mixed up with the command's arguments and + // never leak into nested processes + // the temporary files are created with owner-only permissions, + // the control file can carry sensitive command arguments + control_file = Files.createTempFile("bld-mcp-control", ".txt").toFile(); + status_file = Files.createTempFile("bld-mcp-status", ".txt").toFile(); + // the command, its arguments, the flags and the exclusions all + // travel through the control file, nothing user-controlled is on + // the command line + McpControl.write(control_file, command, arguments, + executor_.offline(), executor_.verbose(), executor_.showStacktrace(), + enforceExclusions ? excludedCommands_ : List.of()); + var builder = new ProcessBuilder(toolCallCommand(command, arguments)); + if (project_ != null) { + builder.directory(project_.workDirectory()); + } + builder.redirectErrorStream(true); + builder.environment().put(McpToolRunner.ENV_CONTROL_FILE, control_file.getAbsolutePath()); + builder.environment().put(McpToolRunner.ENV_STATUS_FILE, status_file.getAbsolutePath()); + process = builder.start(); + } catch (IOException e) { + // failing to set up or start the tool call process is a server + // failure, not a failure of the tool itself, the temporary + // files are always cleaned up + deleteQuietly(control_file); + deleteQuietly(status_file); + throw new RuntimeException("Unable to start the tool call process", e); + } + + var final_status_file = status_file; + var final_control_file = control_file; + try { + return runToolCallProcess(command, process, descendants, output, truncated, final_status_file); + } finally { + deleteQuietly(final_control_file); + deleteQuietly(final_status_file); + } + } + + private static void deleteQuietly(File file) { + if (file != null) { + file.delete(); + } + } + + + private JsonObject runToolCallProcess(String command, Process process, Set descendants, + StringBuilder output, boolean[] truncated, File status_file) { + + var error = false; + var timed_out = false; + try { + // the process is tracked so that it's terminated when the + // server shuts down + activeProcesses_.add(process); + // the tool call never reads from the protocol stream + process.getOutputStream().close(); + + var alive = process; + var sampler = new Thread(() -> { + while (alive.isAlive()) { + alive.descendants().forEach(descendants::add); + try { + Thread.sleep(100); + } catch (InterruptedException e) { + return; + } + } + }); + sampler.setDaemon(true); + sampler.start(); + + // the output is collected on a separate thread so that the + // process can be timed out while it's still producing output, + // and it's capped so that it can never exhaust the memory of + // the server + var process_output = process.getInputStream(); + var reader = new Thread(() -> { + try (var stream = new InputStreamReader(process_output, StandardCharsets.UTF_8)) { + var buffer = new char[8192]; + int read; + while ((read = stream.read(buffer)) != -1) { + synchronized (output) { + var remaining = outputLimit_ - output.length(); + if (remaining > 0) { + output.append(buffer, 0, Math.min(read, remaining)); + } + if (read > remaining) { + truncated[0] = true; + } + } + } + } catch (IOException e) { + // the stream ended, the process is gone + } + }); + reader.setDaemon(true); + reader.start(); + + if (toolCallTimeout_ > 0) { + timed_out = !process.waitFor(toolCallTimeout_, TimeUnit.SECONDS); + if (timed_out) { + destroyProcessTree(process, descendants); + } + } + error = process.waitFor() != 0 || timed_out; + // a background child can keep the output pipe open, the join + // is bounded so that it can never block the server + reader.join(10_000); + + if (timed_out) { + synchronized (output) { + output.append("\nThe command timed out after ").append(toolCallTimeout_).append(" seconds and was terminated."); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + error = true; + synchronized (output) { + output.append(e).append('\n'); + } + } catch (IOException e) { + error = true; + synchronized (output) { + output.append(e.getMessage() != null ? e.getMessage() : e.getClass().getName()).append('\n'); + } + } finally { + // the process and the descendants it spawned are terminated + destroyProcessTree(process, descendants); + activeProcesses_.remove(process); + } + + // an unknown or excluded command comes back through the status file + // and is a protocol error, the build is the authority on the commands + // that exist and are available + String status; + try { + status = Files.readString(status_file.toPath()).trim(); + } catch (IOException e) { + status = ""; + } + if (!timed_out) { + // an unknown or excluded command is a client error, a runner + // setup failure is a server error, both are reported out of + // band so that they're never confused with a regular exit + // status of a build command + if (McpToolRunner.STATUS_UNKNOWN_COMMAND.equals(status) || McpToolRunner.STATUS_EXCLUDED_COMMAND.equals(status)) { + throw new InvalidParamsException("Unknown tool: " + command); + } + if (McpToolRunner.STATUS_RUNNER_ERROR.equals(status)) { + throw new RuntimeException("The tool call process couldn't be set up"); + } + } + + String output_text; + synchronized (output) { + if (truncated[0]) { + output.append("\nThe output was truncated after ").append(outputLimit_).append(" characters."); + } + output_text = output.toString(); + } + var error_result = error; + return new JsonObject() + .array("content", c -> c.object(o -> o + .set("type", "text") + .set("text", output_text))) + .set("isError", error_result); + } + + private static void destroyProcessTree(Process process) { + destroyProcessTree(process, Set.of()); + } + + private static void destroyProcessTree(Process process, Set descendants) { + // the descendants that were sampled while the process was alive + // are terminated too, the wrapper launches the actual build as a + // child and commands can spawn their own children + process.descendants().forEach(ProcessHandle::destroyForcibly); + descendants.forEach(ProcessHandle::destroyForcibly); + process.destroyForcibly(); + } + + /** + * Part of the {@link #execute} operation, composes the command line + * that executes a single tool call as a separate build process. + *

+ * The project's wrapper is launched directly through {@code java} + * when it's available, avoiding the platform specific wrapper + * scripts, so that changes to the build sources are compiled and + * picked up. Otherwise the build executor's class is launched with + * the class path of the server, it then has to provide a standard + * {@code main} method. The command name, the offline, verbose and + * stacktrace state, and the excluded commands are passed through the + * control file, only the command's own arguments are on the command + * line. + * + * @param command the name of the build command to execute + * @param arguments the arguments to pass to the build command + * @return the command line for the tool call process + * @since 2.4.0 + */ + protected List toolCallCommand(String command, List arguments) { + // the JVM properties of the original invocation carry over into + // the tool call processes, when the platform exposes the process + // arguments + var inherited_properties = new ArrayList(); + ProcessHandle.current().info().arguments().ifPresent(process_arguments -> { + for (var argument : process_arguments) { + if (argument.startsWith("-D")) { + inherited_properties.add(argument); + } + } + }); + // the output is forced to UTF-8 so that it's decoded reliably + // regardless of the platform default encoding, these come after the + // inherited properties so that they always win + var forced_properties = List.of( + "-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8"); + + var command_line = new ArrayList(); + command_line.add(new File(new File(System.getProperty("java.home"), "bin"), "java").getAbsolutePath()); + var wrapper_jar = wrapperJar(); + if (wrapper_jar != null) { + // the outer wrapper JVM is forced to UTF-8 too so that its + // diagnostics are decoded reliably + command_line.addAll(forced_properties); + command_line.add("-jar"); + command_line.add(wrapper_jar.getAbsolutePath()); + // the wrapper derives the project directory from the parent + // of the first argument, just like the wrapper scripts do + command_line.add(new File(project_.workDirectory(), "bld").getAbsolutePath()); + command_line.add(Wrapper.BUILD_ARGUMENT); + // the wrapper launches the MCP tool runner, which drives a + // single command of the build executor class + command_line.add(McpToolRunner.class.getName()); + if (executor_.offline()) { + // the wrapper recognizes the offline option right after the + // launched class, so that it doesn't check or download the + // distribution and extensions, the build takes its offline + // state from the control file + command_line.add("--offline"); + } + command_line.add(executor_.getClass().getName()); + } else { + // without a wrapper the build runs on the server's own class + // path, this is the fallback for setups that have no wrapper, + // the wrapper path is used for real projects + command_line.addAll(inherited_properties); + command_line.addAll(forced_properties); + command_line.add("-cp"); + // relative class path entries are resolved against the server's + // own working directory, the process may run in another one + var class_path = new StringBuilder(); + for (var entry : System.getProperty("java.class.path").split(File.pathSeparator)) { + if (!class_path.isEmpty()) { + class_path.append(File.pathSeparator); + } + class_path.append(new File(entry).getAbsolutePath()); + } + command_line.add(class_path.toString()); + // the MCP tool runner drives a single command of the build + // executor class, passed as its last argument + command_line.add(McpToolRunner.class.getName()); + command_line.add(executor_.getClass().getName()); + } + if (wrapper_jar != null) { + // the wrapper moves the JVM property arguments into the java + // command that launches the build, the forced encoding comes + // last so that it wins over an inherited file encoding + command_line.addAll(inherited_properties); + command_line.addAll(forced_properties); + } + // the command name, its arguments, the flags and the excluded + // commands all go through the control file, nothing that is + // user-controlled is on the command line, so that it can never be + // interpreted as a command, a global flag or a JVM property + return command_line; + } + + /** + * Part of the {@link #execute} operation, locates the wrapper jar + * of the project. + * + * @return the wrapper jar; or {@code null} when there is no project + * or the jar couldn't be found + * @since 2.4.0 + */ + protected File wrapperJar() { + if (project_ == null) { + return null; + } + var jar = new File(project_.libBldDirectory(), Wrapper.WRAPPER_PREFIX + ".jar"); + if (jar.isFile()) { + return jar; + } + return null; + } + + /** + * Part of the {@link #execute} operation, lists the project resources. + *

+ * Resources are only available when the operation was configured from + * a project. + * + * @return the resource listing result + * @since 2.4.0 + */ + protected JsonObject processResourcesList() { + var resources = new JsonArray(); + if (project_ != null) { + resources.object(r -> r + .set("uri", RESOURCE_PROJECT) + .set("name", "project") + .set("description", "The identification and directory layout of the project") + .set("mimeType", "application/json")); + resources.object(r -> r + .set("uri", RESOURCE_DEPENDENCIES) + .set("name", "dependencies") + .set("description", "The declared dependencies and BOMs of the project, per scope") + .set("mimeType", "application/json")); + resources.object(r -> r + .set("uri", RESOURCE_DEPENDENCY_TREE) + .set("name", "dependency-tree") + .set("description", "The transitive dependency tree of the project") + .set("mimeType", "text/plain")); + } + return new JsonObject().set("resources", resources); + } + + /** + * Part of the {@link #execute} operation, reads a project resource. + * + * @param params the parameters of the resource read request + * @return the resource contents result + * @since 2.4.0 + */ + protected JsonObject processResourcesRead(JsonObject params) { + if (params == null || !(params.get("uri") instanceof String uri)) { + throw new InvalidParamsException("Missing resource URI"); + } + if (project_ == null) { + throw new ResourceNotFoundException(uri); + } + + String mime_type; + String text; + switch (uri) { + case RESOURCE_PROJECT -> { + mime_type = "application/json"; + text = readProjectResource(); + } + case RESOURCE_DEPENDENCIES -> { + mime_type = "application/json"; + text = readDependenciesResource(); + } + case RESOURCE_DEPENDENCY_TREE -> { + mime_type = "text/plain"; + text = readDependencyTreeResource(); + } + default -> throw new ResourceNotFoundException(uri); + } + + var resource_text = text; + var resource_mime_type = mime_type; + return new JsonObject() + .array("contents", c -> c.object(o -> o + .set("uri", uri) + .set("mimeType", resource_mime_type) + .set("text", resource_text))); + } + + /** + * Part of the {@link #execute} operation, describes the project + * identification and directory layout as JSON. + * + * @return the JSON description of the project + * @since 2.4.0 + */ + protected String readProjectResource() { + return new JsonObject() + .set("name", project_.name()) + .set("version", project_.version() == null ? null : project_.version().toString()) + .set("package", project_.pkg()) + .set("mainClass", project_.mainClass()) + .set("javaRelease", project_.javaRelease()) + .object("directories", d -> d + .set("work", directoryPath(project_.workDirectory())) + .set("srcMainJava", directoryPath(project_.srcMainJavaDirectory())) + .set("srcTestJava", directoryPath(project_.srcTestJavaDirectory())) + .set("buildMain", directoryPath(project_.buildMainDirectory())) + .set("buildTest", directoryPath(project_.buildTestDirectory()))) + .toPrettyString(); + } + + private static String directoryPath(File directory) { + return directory == null ? null : directory.getAbsolutePath(); + } + + /** + * Part of the {@link #execute} operation, describes the declared + * dependencies and BOMs of the project as JSON, per scope. + * + * @return the JSON description of the dependencies + * @since 2.4.0 + */ + protected String readDependenciesResource() { + var json = new JsonObject(); + for (var entry : project_.dependencies().entrySet()) { + json.object(entry.getKey().toString(), s -> { + var boms = new JsonArray(); + for (var bom : entry.getValue().boms()) { + boms.append(bom.toString()); + } + if (!boms.isEmpty()) { + s.set("boms", boms); + } + var declared = new JsonArray(); + for (var dependency : entry.getValue()) { + declared.append(dependency.toString()); + } + s.set("dependencies", declared); + }); + } + return json.toPrettyString(); + } + + /** + * Part of the {@link #execute} operation, generates the transitive + * dependency tree of the project. + * + * @return the dependency tree description + * @since 2.4.0 + */ + protected String readDependencyTreeResource() { + // the resource generation runs the command internally and doesn't + // enforce the tool exclusions, so that excluding the tool doesn't + // break the still advertised resource + var result = executeToolCall("dependency-tree", List.of(), false); + var text = result.getArray("content").getObject(0).getString("text"); + if (result.getBoolean("isError")) { + throw new RuntimeException("Unable to generate the dependency tree:\n" + text); + } + return text; + } + + /** + * Configures the MCP operation from a project. + * + * @param project the project to configure the MCP operation from + * @return this operation instance + * @since 2.4.0 + */ + public McpOperation fromProject(BaseProject project) { + project_ = project; + var operation = executor(project); + if (project.name() != null) { + operation = operation.serverTitle(project.name()); + } + return operation; + } + + /** + * Provides the build executor whose commands are served as tools. + *

+ * A tool call runs a fresh build process that instantiates the + * executor's class through an accessible no-arg constructor and runs + * the single command through its public API, the class's {@code main} + * method is not invoked. The {@code --auto-download-purge} option that + * the MCP server itself was launched with isn't carried over to the + * tool call processes, an automatic download and purge only happens + * when it's configured on the project. + * + * @param executor the build executor to serve + * @return this operation instance + * @since 2.4.0 + */ + public McpOperation executor(BuildExecutor executor) { + executor_ = executor; + return this; + } + + /** + * Provides the title that the server reports during initialization. + * + * @param title the server title + * @return this operation instance + * @since 2.4.0 + */ + public McpOperation serverTitle(String title) { + serverTitle_ = title; + return this; + } + + /** + * Provides the instructions that the server reports during + * initialization, replacing the generated default. + * + * @param instructions the server instructions + * @return this operation instance + * @since 2.4.0 + */ + public McpOperation instructions(String instructions) { + instructions_ = instructions; + return this; + } + + /** + * Retrieves the instructions that the server reports during + * initialization. + * + * @return the server instructions; or {@code null} when the generated + * default is used + * @since 2.4.0 + */ + public String instructions() { + return instructions_; + } + + /** + * Excludes a build command from being served as a tool. + *

+ * The {@code mcp} command itself is always excluded. + * + * @param command the name of the build command to exclude + * @return this operation instance + * @since 2.4.0 + */ + public McpOperation excludeCommand(String command) { + excludedCommands_.add(command); + return this; + } + + /** + * Requires explicit human confirmation before a build command is + * called as a tool. + *

+ * The tool description instructs agents to obtain the confirmation + * and the tool annotations flag the command as destructive. The + * {@code publish} command requires confirmation by default. + *

+ * This is advisory metadata for agents and clients, the server itself + * doesn't block the call: MCP clients are responsible for enforcing + * their tool approval flows. Use {@link #excludeCommand} to make a + * command unavailable altogether. + * + * @param command the name of the build command that requires confirmation + * @return this operation instance + * @since 2.4.0 + */ + public McpOperation requireConfirmation(String command) { + confirmationCommands_.add(command); + return this; + } + + /** + * Provides the maximum number of seconds that a tool call process is + * allowed to run before it's terminated. + *

+ * By default no timeout applies. Commands that don't terminate on + * their own, like {@code run} for server applications, block the + * server until their process ends, provide a timeout or exclude such + * commands when that's a concern. + * + * @param seconds the tool call timeout in seconds, {@code 0} disables it + * @return this operation instance + * @since 2.4.0 + */ + public McpOperation toolCallTimeout(int seconds) { + toolCallTimeout_ = seconds; + return this; + } + + /** + * Retrieves the maximum number of seconds that a tool call process is + * allowed to run before it's terminated. + * + * @return the tool call timeout in seconds; or {@code 0} when no + * timeout applies + * @since 2.4.0 + */ + public int toolCallTimeout() { + return toolCallTimeout_; + } + + /** + * Provides the maximum number of characters of console output that a + * tool call collects, the remainder is truncated with a marker. + *

+ * The default limit is one million characters. + * + * @param characters the output limit in characters + * @return this operation instance + * @since 2.4.0 + */ + public McpOperation outputLimit(int characters) { + outputLimit_ = characters; + return this; + } + + /** + * Retrieves the maximum number of characters of console output that a + * tool call collects. + * + * @return the output limit in characters + * @since 2.4.0 + */ + public int outputLimit() { + return outputLimit_; + } + + /** + * Retrieves the names of the build commands that require explicit + * human confirmation before they're called as tools. + * + * @return the command names that require confirmation + * @since 2.4.0 + */ + public Set confirmationCommands() { + // the backing set isn't exposed, use requireConfirmation to add + return Set.copyOf(confirmationCommands_); + } + + /** + * Retrieves the build executor whose commands are served as tools. + * + * @return the build executor + * @since 2.4.0 + */ + public BuildExecutor executor() { + return executor_; + } + + /** + * Retrieves the title that the server reports during initialization. + * + * @return the server title; or {@code null} when none was provided + * @since 2.4.0 + */ + public String serverTitle() { + return serverTitle_; + } + + /** + * Retrieves the names of the build commands that are excluded from + * being served as tools. + * + * @return the excluded command names + * @since 2.4.0 + */ + public Set excludedCommands() { + // the mcp command is always excluded, the backing set isn't exposed + return Set.copyOf(excludedCommands_); + } +} diff --git a/src/main/java/rife/bld/operations/McpToolRunner.java b/src/main/java/rife/bld/operations/McpToolRunner.java new file mode 100644 index 0000000..ffc53ae --- /dev/null +++ b/src/main/java/rife/bld/operations/McpToolRunner.java @@ -0,0 +1,180 @@ +/* + * Copyright 2001-2026 Geert Bevin (gbevin[remove] at uwyn dot com) + * Licensed under the Apache License, Version 2.0 (the "License") + */ +package rife.bld.operations; + +import rife.bld.BaseProject; +import rife.bld.BuildExecutor; +import rife.bld.operations.exceptions.ExitStatusException; +import rife.tools.ExceptionUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * The entry point that runs exactly one build command for an MCP tool + * call, in a separate build process. + *

+ * It reads the command, its arguments, the flags and the excluded commands + * from the control file, creates the build executor, and runs the single + * command through its public API. All of this single command handling lives + * here in the MCP feature, the build itself stays unaware of it. The runner + * is launched with the build executor's class name as its last argument, + * any earlier arguments, like the wrapper's {@code --offline} option, are + * ignored. + * + * @author Geert Bevin (gbevin[remove] at uwyn dot com) + * @since 2.4.0 + */ +public final class McpToolRunner { + /** + * The environment variable with the path of the control file that + * requests the execution of exactly one command. + * @since 2.4.0 + */ + public static final String ENV_CONTROL_FILE = "BLD_MCP_CONTROL_FILE"; + + /** + * The environment variable with the path of the file that the runner + * writes its result to. + * @since 2.4.0 + */ + public static final String ENV_STATUS_FILE = "BLD_MCP_STATUS_FILE"; + + /** + * The outcome that is written when the command couldn't be resolved. + * @since 2.4.0 + */ + public static final String STATUS_UNKNOWN_COMMAND = "unknown-command"; + + /** + * The outcome that is written when the command is excluded. + * @since 2.4.0 + */ + public static final String STATUS_EXCLUDED_COMMAND = "excluded-command"; + + /** + * The outcome that is written when the runner itself couldn't be set + * up, for instance when the build executor class couldn't be + * instantiated, this is a server failure rather than a tool failure. + * @since 2.4.0 + */ + public static final String STATUS_RUNNER_ERROR = "runner-error"; + + private McpToolRunner() { + } + + /** + * Runs a single build command for an MCP tool call. + * + * @param arguments the build executor class name as the last argument + * @since 2.4.0 + */ + public static void main(String[] arguments) { + System.exit(run(arguments)); + } + + private static int run(String[] arguments) { + var control_path = System.getenv(ENV_CONTROL_FILE); + if (control_path == null) { + writeStatus(STATUS_RUNNER_ERROR); + System.err.println("ERROR: no MCP control file provided"); + return ExitStatusException.EXIT_FAILURE; + } + + var control_file = new File(control_path); + McpControl control; + try { + control = McpControl.read(control_file); + } catch (IOException e) { + writeStatus(STATUS_RUNNER_ERROR); + System.err.println("ERROR: the MCP control file couldn't be read"); + return ExitStatusException.EXIT_FAILURE; + } finally { + // the control file is consumed immediately, so that nested + // processes that inherit the environment don't re-enter the + // single command execution and don't overwrite its status + control_file.delete(); + } + + if (arguments.length == 0) { + writeStatus(STATUS_RUNNER_ERROR); + System.err.println("ERROR: no build executor class provided"); + return ExitStatusException.EXIT_FAILURE; + } + // the build executor class is instantiated directly through its + // no-arg constructor and driven through its public API, its main + // method is not invoked, so that a tool call runs exactly the + // requested command and nothing else + BuildExecutor executor; + try { + var executor_class = Class.forName(arguments[arguments.length - 1]); + executor = (BuildExecutor) executor_class.getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException | ClassCastException e) { + writeStatus(STATUS_RUNNER_ERROR); + System.err.println("ERROR: the build executor couldn't be instantiated: " + e); + return ExitStatusException.EXIT_FAILURE; + } + + executor.offline(control.offline); + executor.verbose(control.verbose); + executor.showStacktrace(control.stacktrace); + + var resolved = executor.resolveCommand(control.command); + if (resolved == null) { + // the result goes into the status file so that it can never be + // confused with a regular exit status of a build command + writeStatus(STATUS_UNKNOWN_COMMAND); + System.err.println("ERROR: Unknown command '" + control.command + "'"); + return ExitStatusException.EXIT_FAILURE; + } + // the exclusions are enforced here, in the freshly compiled build, + // so that aliases or commands added to the build sources can't + // reach an excluded command either + if (control.exclusions.contains(control.command) || control.exclusions.contains(resolved)) { + writeStatus(STATUS_EXCLUDED_COMMAND); + System.err.println("ERROR: Command '" + control.command + "' is not available"); + return ExitStatusException.EXIT_FAILURE; + } + + // an automatic dependency download and purge that the project is + // configured for runs before the command, exactly like it would on + // the command line + if (executor instanceof BaseProject project) { + project.performAutoDownloadPurgeIfEnabled(); + } + + // the command's arguments come from the control file, they are + // never interpreted as additional commands or as global flags + executor.arguments().clear(); + executor.arguments().addAll(control.arguments); + try { + executor.executeCommand(resolved); + } catch (Throwable e) { + executor.exitStatus(ExitStatusException.EXIT_FAILURE); + System.err.println(); + if (control.stacktrace) { + System.err.println(ExceptionUtils.getExceptionStackTrace(e)); + } else if (e.getMessage() != null) { + System.err.println(e.getMessage()); + } else { + System.err.println(e.getClass().getName()); + } + } + return executor.exitStatus(); + } + + private static void writeStatus(String status) { + var status_file = System.getenv(ENV_STATUS_FILE); + if (status_file != null) { + try { + Files.writeString(Path.of(status_file), status); + } catch (IOException e) { + // the caller falls back to the exit status + } + } + } +} diff --git a/src/main/java/rife/bld/wrapper/Wrapper.java b/src/main/java/rife/bld/wrapper/Wrapper.java index b561015..c528159 100644 --- a/src/main/java/rife/bld/wrapper/Wrapper.java +++ b/src/main/java/rife/bld/wrapper/Wrapper.java @@ -40,6 +40,7 @@ public class Wrapper { public static final String BUILD_ARGUMENT = "--build"; public static final String OFFLINE_ARGUMENT = "--offline"; + public static final String USE_STDERR_ARGUMENT = "--use-stderr"; public static final String WRAPPER_PREFIX = "bld-wrapper"; public static final String WRAPPER_PROPERTIES = WRAPPER_PREFIX + ".properties"; @@ -97,6 +98,11 @@ public class Wrapper { * @since 1.5 */ public static void main(String[] arguments) { + if (Arrays.asList(arguments).contains(USE_STDERR_ARGUMENT)) { + // the wrapper diagnostics are sent to standard error so that + // standard output stays free for the MCP protocol + System.setOut(System.err); + } System.exit(new Wrapper().installAndLaunch(new ArrayList<>(Arrays.asList(arguments)))); } diff --git a/src/test/java/rife/bld/operations/TestMcpOperation.java b/src/test/java/rife/bld/operations/TestMcpOperation.java new file mode 100644 index 0000000..7476062 --- /dev/null +++ b/src/test/java/rife/bld/operations/TestMcpOperation.java @@ -0,0 +1,996 @@ +/* + * Copyright 2001-2026 Geert Bevin (gbevin[remove] at uwyn dot com) + * Licensed under the Apache License, Version 2.0 (the "License") + */ +package rife.bld.operations; + +import org.junit.jupiter.api.Test; +import rife.bld.BuildCommand; +import rife.bld.BuildExecutor; +import rife.bld.Project; +import rife.bld.dependencies.Bom; +import rife.bld.dependencies.Dependency; +import rife.bld.dependencies.Scope; +import rife.bld.dependencies.VersionNumber; +import rife.json.Json; +import rife.json.JsonObject; +import rife.tools.FileUtils; + +import java.io.BufferedReader; +import java.io.File; +import java.io.OutputStream; +import java.io.PrintStream; +import java.io.PrintWriter; +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.file.Files; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class TestMcpOperation { + public static class McpBuild extends BuildExecutor { + @BuildCommand(summary = "Greets the caller", alias = "hi") + public void hello() { + System.out.println("hello " + String.join(" ", arguments())); + arguments().clear(); + } + + @BuildCommand(summary = "Always fails") + public void trouble() + throws Exception { + throw new Exception("boom"); + } + + @BuildCommand(value = "custom-greet", summary = "Greets with a custom name", description = "Prints a custom greeting to the console.") + public void customGreet() { + System.out.println("custom hello"); + } + + private final OnceOperation onceOperation_ = new OnceOperation(); + + @BuildCommand(summary = "Runs a one-shot operation") + public void once() + throws Exception { + onceOperation_.executeOnce(); + } + + @BuildCommand(summary = "Prints the invocation state") + public void state() { + System.out.println("offline=" + offline() + " verbose=" + verbose()); + } + + @BuildCommand(summary = "Sleeps for a minute") + public void sleepy() + throws Exception { + System.out.println("sleeping"); + Thread.sleep(60_000); + } + + @BuildCommand(summary = "Prints a lot of output") + public void spam() { + for (var i = 0; i < 100; ++i) { + System.out.println("x".repeat(100)); + } + } + + @BuildCommand(summary = "Spawns a sleeping child process") + public void nested() + throws Exception { + System.out.println("nested start"); + var child = new ProcessBuilder( + new File(new File(System.getProperty("java.home"), "bin"), "java").getAbsolutePath(), + "-cp", System.getProperty("java.class.path"), + SleepMain.class.getName()) + .inheritIO() + .start(); + child.waitFor(); + } + + public static void main(String[] args) { + new McpBuild().start(args); + } + } + + public static class SleepMain { + public static void main(String[] args) + throws Exception { + Thread.sleep(60_000); + } + } + + static class OnceOperation extends AbstractOperation { + public void execute() { + System.out.println("once executed"); + } + } + + // a build executor without a no-arg constructor can't be instantiated + // by the tool runner, which is a server error, not a tool failure + public static class NoDefaultConstructorBuild extends McpBuild { + public NoDefaultConstructorBuild(String ignored) { + } + } + + @Test + void testRunnerSetupFailureIsAServerError() { + var operation = new McpOperation() + .executor(new NoDefaultConstructorBuild("x")); + initialize(operation); + // the runner can't instantiate the class through a no-arg + // constructor, this is reported as an internal server error + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hello"}}"""); + assertEquals(-32603, response.getObject("error").getInt("code")); + } + + // commands inherited from parent classes are how bld extensions + // contribute commands, they're served like any other command + public static class ExtendedBuild extends McpBuild { + @BuildCommand(summary = "Provided by the extension") + public void extended() { + System.out.println("extended hello"); + } + + public static void main(String[] args) { + new ExtendedBuild().start(args); + } + } + + private McpBuild createBuild() { + return new McpBuild(); + } + + private void initialize(McpOperation operation) { + operation.processMessage(""" + {"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"""); + operation.processMessage(""" + {"jsonrpc":"2.0","method":"notifications/initialized"}"""); + } + + private McpOperation createOperation() { + var operation = new McpOperation() + .executor(createBuild()) + .serverTitle("mcp_test"); + initialize(operation); + return operation; + } + + private JsonObject process(McpOperation operation, String message) { + var response = operation.processMessage(message); + assertNotNull(response); + return Json.parseObject(response); + } + + @Test + void testInitialize() { + var operation = createOperation(); + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"""); + + assertEquals("2.0", response.getString("jsonrpc")); + assertEquals(1, response.getInt("id")); + var result = response.getObject("result"); + // a supported requested protocol version is echoed + assertEquals("2025-06-18", result.getString("protocolVersion")); + assertNotNull(result.getObject("capabilities").getObject("tools")); + assertEquals("bld", result.getObject("serverInfo").getString("name")); + assertEquals("mcp_test", result.getObject("serverInfo").getString("title")); + assertFalse(result.getObject("serverInfo").getString("version").isEmpty()); + assertTrue(result.getString("instructions").contains("the bld project 'mcp_test'")); + // the instructions explain the nature of bld builds + assertTrue(result.getString("instructions").contains("src/bld/java")); + assertTrue(result.getString("instructions").contains("editing the build class")); + + // an unsupported requested protocol version falls back to the + // most recent supported one + var fallback = process(operation, """ + {"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"1999-01-01","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"""); + assertEquals(McpOperation.PROTOCOL_VERSION, fallback.getObject("result").getString("protocolVersion")); + } + + @Test + void testInitializeParamsAreValidated() { + var operation = createOperation(); + + // the params are required + var missing_params = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"initialize"}"""); + assertEquals(-32602, missing_params.getObject("error").getInt("code")); + + // the protocol version has to be a string + var invalid_version = process(operation, """ + {"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":7,"capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"""); + assertEquals(-32602, invalid_version.getObject("error").getInt("code")); + + // the capabilities and client info are required objects + var missing_capabilities = process(operation, """ + {"jsonrpc":"2.0","id":3,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"test","version":"1.0"}}}"""); + assertEquals(-32602, missing_capabilities.getObject("error").getInt("code")); + var missing_client_info = process(operation, """ + {"jsonrpc":"2.0","id":4,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}"""); + assertEquals(-32602, missing_client_info.getObject("error").getInt("code")); + } + + @Test + void testStrictSchemaValidation() { + var operation = createOperation(); + + // fractional ids are rejected + var fractional = process(operation, """ + {"jsonrpc":"2.0","id":1.5,"method":"ping"}"""); + assertEquals(-32600, fractional.getObject("error").getInt("code")); + + // params, when present, have to be an object even for methods that + // ignore them + var array_params = process(operation, """ + {"jsonrpc":"2.0","id":2,"method":"ping","params":[]}"""); + assertEquals(-32602, array_params.getObject("error").getInt("code")); + + // an incomplete client info is rejected + var empty_client = process(operation, """ + {"jsonrpc":"2.0","id":3,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{}}}"""); + assertEquals(-32602, empty_client.getObject("error").getInt("code")); + + // a numeric tool name is rejected + var numeric_name = process(operation, """ + {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":5}}"""); + assertEquals(-32602, numeric_name.getObject("error").getInt("code")); + + // an explicit null params is not the same as an omitted params + var null_params = process(operation, """ + {"jsonrpc":"2.0","id":5,"method":"ping","params":null}"""); + assertEquals(-32602, null_params.getObject("error").getInt("code")); + + // a malformed envelope without an id gets an error with a null id, + // it isn't silently discarded as a notification + var empty_object = process(operation, "{}"); + assertEquals(-32600, empty_object.getObject("error").getInt("code")); + assertNull(empty_object.get("id")); + var wrong_jsonrpc = process(operation, """ + {"jsonrpc":"1.0","method":"ping"}"""); + assertEquals(-32600, wrong_jsonrpc.getObject("error").getInt("code")); + } + + @Test + void testRequestMethodsRequireAnId() { + var operation = createOperation(); + // an id-less request method is a malformed notification, it must + // not execute and gets no response + assertNull(operation.processMessage(""" + {"jsonrpc":"2.0","method":"tools/call","params":{"name":"hello"}}""")); + } + + @Test + void testNotificationsAreNeverAnswered() { + var operation = createOperation(); + // a notification with malformed params still gets no response, a + // notification is never answered + assertNull(operation.processMessage(""" + {"jsonrpc":"2.0","method":"ping","params":[]}""")); + // a notification with an unknown method gets no response either + assertNull(operation.processMessage(""" + {"jsonrpc":"2.0","method":"bogus"}""")); + } + + @Test + void testUnsupportedProtocolVersionsAreNotAdvertised() { + var operation = createOperation(); + // the 2025-03-26 revision, which mandates batch support, isn't + // offered, an unsupported request falls back to the newest + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"""); + assertEquals(McpOperation.PROTOCOL_VERSION, response.getObject("result").getString("protocolVersion")); + } + + @Test + void testInitializedNotificationIsSilent() { + var operation = createOperation(); + assertNull(operation.processMessage(""" + {"jsonrpc":"2.0","method":"notifications/initialized"}""")); + } + + @Test + void testPing() { + var operation = createOperation(); + var response = process(operation, """ + {"jsonrpc":"2.0","id":"ping-1","method":"ping"}"""); + assertEquals("ping-1", response.getString("id")); + assertTrue(response.getObject("result").isEmpty()); + } + + @Test + void testToolsList() { + var operation = createOperation(); + var response = process(operation, """ + {"jsonrpc":"2.0","id":3,"method":"tools/list"}"""); + + var tools = response.getObject("result").getArray("tools"); + JsonObject hello = null; + for (var element : tools) { + if ("hello".equals(((JsonObject) element).getString("name"))) { + hello = (JsonObject) element; + } + } + assertNotNull(hello); + // the title qualifies the command with the server title for + // human-facing tool listings + assertEquals("hello (mcp_test)", hello.getString("title")); + assertEquals("Greets the caller", hello.getString("description")); + assertEquals("object", hello.getObject("inputSchema").getString("type")); + assertEquals("array", hello.getObject("inputSchema").getObject("properties").getObject("arguments").getString("type")); + } + + @Test + void testInstructionsOverride() { + var operation = createOperation() + .instructions("Custom guidance"); + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}"""); + assertEquals("Custom guidance", response.getObject("result").getString("instructions")); + } + + @Test + void testCustomAndInheritedCommands() { + var operation = new McpOperation().executor(new ExtendedBuild()); + initialize(operation); + + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/list"}"""); + JsonObject custom = null; + var names = new StringBuilder(); + for (var element : response.getObject("result").getArray("tools")) { + var tool = (JsonObject) element; + names.append(tool.getString("name")).append(' '); + if ("custom-greet".equals(tool.getString("name"))) { + custom = tool; + } + } + // custom command names are used as the tool names, and inherited + // commands are listed alongside the class's own + assertTrue(names.toString().contains("extended")); + assertTrue(names.toString().contains("hello")); + assertNotNull(custom); + // the tool description combines the summary and the full help text + assertTrue(custom.getString("description").startsWith("Greets with a custom name")); + assertTrue(custom.getString("description").contains("Prints a custom greeting to the console.")); + + // both custom-named and inherited commands are callable + var custom_call = process(operation, """ + {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"custom-greet"}}"""); + assertEquals("custom hello", custom_call.getObject("result").getArray("content").getObject(0).getString("text").trim()); + var inherited_call = process(operation, """ + {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"extended"}}"""); + assertEquals("extended hello", inherited_call.getObject("result").getArray("content").getObject(0).getString("text").trim()); + } + + @Test + void testToolsListExcludesCommands() { + var operation = createOperation() + .excludeCommand("trouble"); + var response = process(operation, """ + {"jsonrpc":"2.0","id":4,"method":"tools/list"}"""); + + var names = new StringBuilder(); + for (var element : response.getObject("result").getArray("tools")) { + names.append(((JsonObject) element).getString("name")).append(' '); + } + assertTrue(names.toString().contains("hello")); + assertFalse(names.toString().contains("trouble")); + } + + @Test + void testToolsCall() { + var operation = createOperation(); + var response = process(operation, """ + {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"hello","arguments":{"arguments":["big","world"]}}}"""); + + var result = response.getObject("result"); + assertFalse(result.getBoolean("isError")); + var content = result.getArray("content").getObject(0); + assertEquals("text", content.getString("type")); + assertEquals("hello big world", content.getString("text").trim()); + } + + @Test + void testToolsCallFailure() { + var operation = createOperation(); + var response = process(operation, """ + {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"trouble"}}"""); + + var result = response.getObject("result"); + assertTrue(result.getBoolean("isError")); + assertTrue(result.getArray("content").getObject(0).getString("text").contains("boom")); + } + + @Test + void testToolsCallRepeatsOneShotOperations() { + var operation = createOperation(); + // each tool call behaves like a separate command line invocation, + // one-shot operations run every time + for (var id = 1; id <= 2; ++id) { + var response = process(operation, """ + {"jsonrpc":"2.0","id":%d,"method":"tools/call","params":{"name":"once"}}""".formatted(id)); + var result = response.getObject("result"); + assertFalse(result.getBoolean("isError")); + assertTrue(result.getArray("content").getObject(0).getString("text").contains("once executed")); + } + } + + @Test + void testInvocationStateCarriesOver() { + var build = createBuild(); + build.offline(true); + build.verbose(true); + var operation = new McpOperation().executor(build); + initialize(operation); + + // the offline and verbose state of the original invocation + // carries over into the tool call processes + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"state"}}"""); + assertEquals("offline=true verbose=true", + response.getObject("result").getArray("content").getObject(0).getString("text").trim()); + } + + @Test + void testInitializationGating() { + var operation = new McpOperation() + .executor(createBuild()) + .serverTitle("mcp_test"); + + // pings are served before initialization, other requests are not + var ping = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"ping"}"""); + assertNotNull(ping.getObject("result")); + var gated = process(operation, """ + {"jsonrpc":"2.0","id":2,"method":"tools/list"}"""); + assertEquals(-32600, gated.getObject("error").getInt("code")); + assertTrue(gated.getObject("error").getString("message").contains("not initialized")); + + // after initialization the same request is served + initialize(operation); + var listing = process(operation, """ + {"jsonrpc":"2.0","id":3,"method":"tools/list"}"""); + assertNotNull(listing.getObject("result").getArray("tools")); + } + + @Test + void testConfirmationCommands() { + var operation = createOperation() + .requireConfirmation("trouble"); + + // the publish command requires confirmation by default + assertTrue(operation.confirmationCommands().contains("publish")); + + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/list"}"""); + JsonObject trouble = null; + for (var element : response.getObject("result").getArray("tools")) { + if ("trouble".equals(((JsonObject) element).getString("name"))) { + trouble = (JsonObject) element; + } + } + assertNotNull(trouble); + // the description instructs agents to obtain confirmation and the + // annotations flag the command + assertTrue(trouble.getString("description").contains("explicit human confirmation")); + assertTrue(trouble.getObject("annotations").getBoolean("destructiveHint")); + assertTrue(trouble.getObject("annotations").getBoolean("openWorldHint")); + } + + @Test + void testNonStringArgumentsAreRejected() { + var operation = createOperation(); + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hello","arguments":{"arguments":[1,true]}}}"""); + assertEquals(-32602, response.getObject("error").getInt("code")); + } + + @Test + void testMalformedParamsDontTerminateTheServer() { + var operation = createOperation(); + // an array where an object is expected is an invalid parameter, + // not a server crash + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":[]}"""); + assertEquals(-32602, response.getObject("error").getInt("code")); + + // the server keeps serving afterwards + var ping = process(operation, """ + {"jsonrpc":"2.0","id":2,"method":"ping"}"""); + assertNotNull(ping.getObject("result")); + } + + @Test + void testToolsCallUnknownTool() { + var operation = createOperation(); + + // unknown tools are delegated to the build process, which is the + // authority on the commands that exist, an unknown command is + // reported as a protocol error rather than a tool failure + var response = process(operation, """ + {"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"nonexistent"}}"""); + assertEquals(-32602, response.getObject("error").getInt("code")); + + // excluded commands are rejected outright + operation.excludeCommand("hello"); + var excluded = process(operation, """ + {"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"hello"}}"""); + assertEquals(-32602, excluded.getObject("error").getInt("code")); + } + + @Test + void testArgumentsCannotChainCommands() { + var operation = createOperation() + .excludeCommand("state"); + // passing an excluded command as an argument to another command + // must not execute it, only the called command runs + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hello","arguments":{"arguments":["state"]}}}"""); + var text = response.getObject("result").getArray("content").getObject(0).getString("text"); + assertTrue(text.contains("hello state"), text); + // the chained command's output is absent, it never executed + assertFalse(text.contains("offline="), text); + } + + @Test + void testGlobalFlagArgumentsReachTheCommand() { + var operation = createOperation(); + // a leading --offline is the command's own argument, it must reach + // the command and not be consumed as global bld state + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hello","arguments":{"arguments":["--offline","kept"]}}}"""); + var text = response.getObject("result").getArray("content").getObject(0).getString("text"); + assertTrue(text.contains("hello --offline kept"), text); + } + + @Test + void testToolNameNewlineCannotInjectCommand() { + var operation = createOperation(); + // a tool name containing a newline that would inject a second + // command record must not execute a different command, the escaped + // value stays a single unknown command + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"missing\\ncommand hello"}}"""); + assertEquals(-32602, response.getObject("error").getInt("code")); + } + + @Test + void testPropertyLikeArgumentsReachTheCommand() { + var operation = createOperation(); + // a -D argument is the command's own argument, it must reach the + // command in both the direct and the wrapper mode, not be consumed + // as a JVM property + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hello","arguments":{"arguments":["-Dfoo=bar","kept"]}}}"""); + var text = response.getObject("result").getArray("content").getObject(0).getString("text"); + assertTrue(text.contains("hello -Dfoo=bar kept"), text); + } + + @Test + void testToolArgumentsMustBeStructured() { + var operation = createOperation(); + // an explicit null tool arguments is not the same as omitted + var null_args = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hello","arguments":null}}"""); + assertEquals(-32602, null_args.getObject("error").getInt("code")); + // the inner arguments array must be an array + var bad_list = process(operation, """ + {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"hello","arguments":{"arguments":"nope"}}}"""); + assertEquals(-32602, bad_list.getObject("error").getInt("code")); + // a numeric list cursor is rejected + var bad_cursor = process(operation, """ + {"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"cursor":5}}"""); + assertEquals(-32602, bad_cursor.getObject("error").getInt("code")); + } + + @Test + void testUnknownToolWithHelpArgumentStillFails() { + var operation = createOperation(); + // a --help argument to an unknown tool must not be interpreted as + // global help, the unknown tool is still a protocol error + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nonexistent","arguments":{"arguments":["--help"]}}}"""); + assertEquals(-32602, response.getObject("error").getInt("code")); + } + + @Test + void testChildProcessEnforcesExclusions() { + // the build process itself refuses an excluded command, this bypasses + // the server-side check and exercises the child enforcement, hi is + // an alias of the excluded hello and is refused there + var operation = createOperation() + .excludeCommand("hello"); + assertThrows(RuntimeException.class, + () -> operation.executeToolCall("hi", List.of(), true)); + } + + @Test + void testExclusionsCoverAliasesAndMatches() { + var operation = createOperation() + .excludeCommand("hello"); + + // aliases of excluded commands are rejected + var alias = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hi"}}"""); + assertEquals(-32602, alias.getObject("error").getInt("code")); + + // unique prefixes that resolve to excluded commands are rejected + var prefix = process(operation, """ + {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"hell"}}"""); + assertEquals(-32602, prefix.getObject("error").getInt("code")); + } + + @Test + void testToolCallTimeoutTerminatesDescendants() { + var operation = createOperation() + .toolCallTimeout(2); + var start = System.currentTimeMillis(); + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nested"}}"""); + var elapsed = System.currentTimeMillis() - start; + + var result = response.getObject("result"); + assertTrue(result.getBoolean("isError")); + assertTrue(result.getArray("content").getObject(0).getString("text").contains("timed out")); + // the wrapper-style child process is terminated too, the call + // doesn't wait for it to end on its own + assertTrue(elapsed < 20_000, String.valueOf(elapsed)); + } + + @Test + void testToolCallTimeout() { + var operation = createOperation() + .toolCallTimeout(1); + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"sleepy"}}"""); + var result = response.getObject("result"); + assertTrue(result.getBoolean("isError")); + assertTrue(result.getArray("content").getObject(0).getString("text").contains("timed out after 1 seconds")); + } + + @Test + void testOutputLimit() { + var operation = createOperation() + .outputLimit(150); + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"spam"}}"""); + var text = response.getObject("result").getArray("content").getObject(0).getString("text"); + assertTrue(text.contains("truncated after 150 characters"), text); + assertTrue(text.length() < 300, String.valueOf(text.length())); + } + + @Test + void testJvmPropertiesCarryOver() { + var operation = createOperation(); + var command_line = operation.toolCallCommand("hello", List.of()); + + // the JVM properties of the original invocation are part of the + // tool call command lines + ProcessHandle.current().info().arguments().ifPresent(process_arguments -> { + for (var argument : process_arguments) { + if (argument.startsWith("-D")) { + assertTrue(command_line.contains(argument), argument); + } + } + }); + // the encoding is forced to UTF-8 + assertTrue(command_line.contains("-Dfile.encoding=UTF-8"), command_line.toString()); + // the command name goes through the control file, not the command + // line, so that arguments can never be interpreted as commands + assertFalse(command_line.contains("hello"), command_line.toString()); + } + + @Test + void testUnknownMethod() { + var operation = createOperation(); + var response = process(operation, """ + {"jsonrpc":"2.0","id":8,"method":"bogus/method"}"""); + assertEquals(-32601, response.getObject("error").getInt("code")); + + // unknown notifications don't warrant a response + assertNull(operation.processMessage(""" + {"jsonrpc":"2.0","method":"bogus/notification"}""")); + } + + @Test + void testInvalidRequestEnvelopes() { + var operation = createOperation(); + + // syntactically valid JSON that isn't a request object + var array = process(operation, "[1,2]"); + assertEquals(-32600, array.getObject("error").getInt("code")); + assertNull(array.get("id")); + + // the jsonrpc version is required + var missing_version = process(operation, """ + {"id":1,"method":"ping"}"""); + assertEquals(-32600, missing_version.getObject("error").getInt("code")); + var wrong_version = process(operation, """ + {"jsonrpc":"1.0","id":1,"method":"ping"}"""); + assertEquals(-32600, wrong_version.getObject("error").getInt("code")); + assertEquals(1, wrong_version.getInt("id")); + + // the method has to be a string + var invalid_method = process(operation, """ + {"jsonrpc":"2.0","id":2,"method":5}"""); + assertEquals(-32600, invalid_method.getObject("error").getInt("code")); + + // the id has to be a string or a number + var invalid_id = process(operation, """ + {"jsonrpc":"2.0","id":{},"method":"ping"}"""); + assertEquals(-32600, invalid_id.getObject("error").getInt("code")); + assertNull(invalid_id.get("id")); + + // an explicitly null id is invalid, it's not a notification + var null_id = process(operation, """ + {"jsonrpc":"2.0","id":null,"method":"ping"}"""); + assertEquals(-32600, null_id.getObject("error").getInt("code")); + assertNull(null_id.get("id")); + } + + @Test + void testParseError() { + var operation = createOperation(); + var response = process(operation, "this is not json"); + assertEquals(-32700, response.getObject("error").getInt("code")); + assertNull(response.get("id")); + } + + public static class McpProject extends Project { + public McpProject() { + this(new File(System.getProperty("user.dir"))); + } + + McpProject(File tmp) { + workDirectory = tmp; + pkg = "test.pkg"; + name = "mcp_project"; + version = new VersionNumber(1, 0, 0); + repositories = List.of(); + } + + public static void main(String[] args) { + new McpProject().start(args); + } + } + + public static class FailingTreeProject extends McpProject { + public FailingTreeProject() { + } + + FailingTreeProject(File tmp) { + super(tmp); + } + + { + // a dependency without any repository to resolve it in + dependencies().scope(Scope.compile) + .include(new Dependency("com.example", "missing", new VersionNumber(1, 0, 0))); + } + + public static void main(String[] args) { + new FailingTreeProject().start(args); + } + } + + @Test + void testResourcesWithoutProject() { + var operation = createOperation(); + initialize(operation); + var listing = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"resources/list"}"""); + assertTrue(listing.getObject("result").getArray("resources").isEmpty()); + + var read = process(operation, """ + {"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"bld://project"}}"""); + assertEquals(-32002, read.getObject("error").getInt("code")); + } + + @Test + void testResources() + throws Exception { + var tmp = Files.createTempDirectory("mcpresources").toFile(); + try { + var project = new McpProject(tmp); + project.dependencies().scope(Scope.compile) + .include(new Bom("com.example", "bom1", new VersionNumber(1, 0, 0))) + .include(new Dependency("com.example", "a")); + project.dependencies().scope(Scope.test) + .include(new Dependency("com.example", "t", new VersionNumber(2, 0, 0))); + var operation = new McpOperation().fromProject(project); + initialize(operation); + + // the three project resources are listed + var listing = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"resources/list"}"""); + var uris = new StringBuilder(); + for (var element : listing.getObject("result").getArray("resources")) { + uris.append(((JsonObject) element).getString("uri")).append(' '); + } + assertTrue(uris.toString().contains("bld://project")); + assertTrue(uris.toString().contains("bld://dependencies")); + assertTrue(uris.toString().contains("bld://dependency-tree")); + + // the project resource describes the identification and layout + var read_project = process(operation, """ + {"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"bld://project"}}"""); + var project_content = read_project.getObject("result").getArray("contents").getObject(0); + assertEquals("application/json", project_content.getString("mimeType")); + var description = Json.parseObject(project_content.getString("text")); + assertEquals("mcp_project", description.getString("name")); + assertEquals("1.0.0", description.getString("version")); + assertEquals("test.pkg", description.getString("package")); + assertNotNull(description.getObject("directories").getString("srcMainJava")); + + // the dependencies resource describes the scopes + var read_dependencies = process(operation, """ + {"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"bld://dependencies"}}"""); + var dependencies = Json.parseObject(read_dependencies.getObject("result").getArray("contents").getObject(0).getString("text")); + assertEquals("com.example:bom1:1.0.0@bom", dependencies.getObject("compile").getArray("boms").getString(0)); + assertEquals("com.example:a", dependencies.getObject("compile").getArray("dependencies").getString(0)); + assertEquals("com.example:t:2.0.0", dependencies.getObject("test").getArray("dependencies").getString(0)); + + // unknown resources report the dedicated error + var unknown = process(operation, """ + {"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"bld://bogus"}}"""); + assertEquals(-32002, unknown.getObject("error").getInt("code")); + + // a missing URI is an invalid parameter + var missing = process(operation, """ + {"jsonrpc":"2.0","id":5,"method":"resources/read","params":{}}"""); + assertEquals(-32602, missing.getObject("error").getInt("code")); + } finally { + FileUtils.deleteDirectory(tmp); + } + } + + @Test + void testResourceDependencyTree() + throws Exception { + var tmp = Files.createTempDirectory("mcptree").toFile(); + try { + var operation = new McpOperation().fromProject(new McpProject(tmp)); + initialize(operation); + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"bld://dependency-tree"}}"""); + var content = response.getObject("result").getArray("contents").getObject(0); + assertEquals("text/plain", content.getString("mimeType")); + assertTrue(content.getString("text").contains("no dependencies"), content.getString("text")); + } finally { + FileUtils.deleteDirectory(tmp); + } + } + + @Test + void testExcludedToolDoesNotBreakResource() + throws Exception { + var tmp = Files.createTempDirectory("mcptreeexcl").toFile(); + try { + var operation = new McpOperation() + .fromProject(new McpProject(tmp)) + .excludeCommand("dependency-tree"); + initialize(operation); + + // excluding the dependency-tree tool doesn't break the still + // advertised dependency-tree resource, its generation doesn't + // enforce the tool exclusions + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"bld://dependency-tree"}}"""); + var content = response.getObject("result").getArray("contents").getObject(0); + assertTrue(content.getString("text").contains("no dependencies"), content.getString("text")); + } finally { + FileUtils.deleteDirectory(tmp); + } + } + + @Test + void testResourceDependencyTreeReportsFailures() + throws Exception { + var tmp = Files.createTempDirectory("mcptreefail").toFile(); + try { + var operation = new McpOperation().fromProject(new FailingTreeProject(tmp)); + initialize(operation); + var response = process(operation, """ + {"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"bld://dependency-tree"}}"""); + var error = response.getObject("error"); + assertEquals(-32603, error.getInt("code")); + assertTrue(error.getString("message").contains("dependency tree"), error.getString("message")); + } finally { + FileUtils.deleteDirectory(tmp); + } + } + + public static class E2eProject extends Project { + public E2eProject() { + this(new File(System.getProperty("user.dir"))); + } + + E2eProject(File work) { + workDirectory = work; + pkg = "test.pkg"; + name = "myapp"; + version = new VersionNumber(0, 0, 1); + } + + public static void main(String[] args) { + new E2eProject().start(args); + } + } + + @Test + void testMcpEndToEndWithRealProject() + throws Exception { + var tmp = Files.createTempDirectory("mcpe2e").toFile(); + try { + var create_operation = new CreateAppOperation() + .workDirectory(tmp) + .packageName("test.pkg") + .projectName("myapp") + .downloadDependencies(true); + create_operation.execute(); + var project_dir = new File(tmp, "myapp"); + // the generated wrapper would require a published bld + // distribution, remove it so that the tool calls launch the + // build class directly with the class path of this test + new File(project_dir, "lib/bld/bld-wrapper.jar").delete(); + var project = new E2eProject(project_dir); + + var operation = new McpOperation().fromProject(project); + var input = """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}} + {"jsonrpc":"2.0","method":"notifications/initialized"} + {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"compile"}} + {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"compile"}} + {"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"bld://project"}} + """; + var output = new StringWriter(); + operation.executeServerLoop(new BufferedReader(new StringReader(input)), new PrintWriter(output)); + + var responses = output.toString().trim().split("\n"); + assertEquals(4, responses.length); + + // both compile tool calls executed and reported their output, + // repeated calls behave like separate command line invocations + for (var i = 1; i <= 2; ++i) { + var compile = Json.parseObject(responses[i]); + var compile_result = compile.getObject("result"); + assertFalse(compile_result.getBoolean("isError")); + assertTrue(compile_result.getArray("content").getObject(0).getString("text").contains("Compilation finished successfully")); + } + + // the compiled classes are actually there + var listing = FileUtils.generateDirectoryListing(project.buildMainDirectory()); + assertTrue(listing.contains(".class"), listing); + + // the project resource reflects the created project + var read_project = Json.parseObject(responses[3]); + var description = Json.parseObject(read_project.getObject("result").getArray("contents").getObject(0).getString("text")); + assertEquals("myapp", description.getString("name")); + } finally { + FileUtils.deleteDirectory(tmp); + } + } + + @Test + void testServerLoop() + throws Exception { + var operation = createOperation(); + var input = """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}} + {"jsonrpc":"2.0","method":"notifications/initialized"} + + {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"hello","arguments":{"arguments":["loop"]}}} + """; + var output = new StringWriter(); + operation.executeServerLoop(new BufferedReader(new StringReader(input)), new PrintWriter(output)); + + var responses = output.toString().trim().split("\n"); + assertEquals(2, responses.length); + assertEquals(1, Json.parseObject(responses[0]).getInt("id")); + var call = Json.parseObject(responses[1]); + assertEquals(2, call.getInt("id")); + assertEquals("hello loop", call.getObject("result").getArray("content").getObject(0).getString("text").trim()); + } +}