mirror of
https://github.com/rife2/bld
synced 2026-07-25 00:47:47 +02:00
Add experimental WIP MCP support that serves the build commands as tools
This commit is contained in:
parent
ec185788a1
commit
62c0d4569d
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
34
src/main/java/rife/bld/help/McpHelp.java
Normal file
34
src/main/java/rife/bld/help/McpHelp.java
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
121
src/main/java/rife/bld/operations/McpControl.java
Normal file
121
src/main/java/rife/bld/operations/McpControl.java
Normal 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();
|
||||
}
|
||||
}
|
||||
1186
src/main/java/rife/bld/operations/McpOperation.java
Normal file
1186
src/main/java/rife/bld/operations/McpOperation.java
Normal file
File diff suppressed because it is too large
Load diff
180
src/main/java/rife/bld/operations/McpToolRunner.java
Normal file
180
src/main/java/rife/bld/operations/McpToolRunner.java
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))));
|
||||
}
|
||||
|
||||
|
|
|
|||
996
src/test/java/rife/bld/operations/TestMcpOperation.java
Normal file
996
src/test/java/rife/bld/operations/TestMcpOperation.java
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue