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());
+ }
+}