Add experimental WIP MCP support that serves the build commands as tools

This commit is contained in:
Geert Bevin 2026-07-17 17:28:29 -04:00
parent ec185788a1
commit 62c0d4569d
9 changed files with 2681 additions and 41 deletions

View file

@ -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.
* <p>
* 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();

View file

@ -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<String> arguments_ = Collections.emptyList();
private List<String> arguments_ = new ArrayList<>();
private boolean offline_ = false;
private boolean verbose_ = false;
private Map<String, CommandDefinition> 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();

View file

@ -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);
}
}

View file

@ -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<name>=<value> Sets a JVM system property
-s, --stacktrace Prints out the stacktrace for exceptions

View file

@ -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.
* <p>
* 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<String> arguments;
final boolean offline;
final boolean verbose;
final boolean stacktrace;
final Set<String> exclusions;
private McpControl(String command, List<String> arguments, boolean offline, boolean verbose, boolean stacktrace, Set<String> 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<String> arguments,
boolean offline, boolean verbose, boolean stacktrace,
Collection<String> exclusions)
throws IOException {
var lines = new ArrayList<String>();
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<String>();
var offline = false;
var verbose = false;
var stacktrace = false;
var exclusions = new LinkedHashSet<String>();
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();
}
}

File diff suppressed because it is too large Load diff

View file

@ -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.
* <p>
* 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
}
}
}
}

View file

@ -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))));
}

View file

@ -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<OnceOperation> {
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());
}
}