mirror of
https://github.com/rife2/bld
synced 2026-07-29 02:37:58 +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 CompileOperation compileOperation_ = new CompileOperation();
|
||||||
private final DependencyTreeOperation dependencyTreeOperation_ = new DependencyTreeOperation();
|
private final DependencyTreeOperation dependencyTreeOperation_ = new DependencyTreeOperation();
|
||||||
private final DownloadOperation downloadOperation_ = new DownloadOperation();
|
private final DownloadOperation downloadOperation_ = new DownloadOperation();
|
||||||
|
private final McpOperation mcpOperation_ = new McpOperation();
|
||||||
private final PurgeOperation purgeOperation_ = new PurgeOperation();
|
private final PurgeOperation purgeOperation_ = new PurgeOperation();
|
||||||
private final PublishOperation publishOperation_ = new PublishOperation();
|
private final PublishOperation publishOperation_ = new PublishOperation();
|
||||||
private final RunOperation runOperation_ = new RunOperation();
|
private final RunOperation runOperation_ = new RunOperation();
|
||||||
|
|
@ -440,6 +441,16 @@ public class BaseProject extends BuildExecutor {
|
||||||
return downloadOperation_;
|
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.
|
* Retrieves the project's default publish operation.
|
||||||
*
|
*
|
||||||
|
|
@ -544,6 +555,18 @@ public class BaseProject extends BuildExecutor {
|
||||||
downloadOperation().executeOnce(() -> downloadOperation().fromProject(this));
|
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.
|
* Standard build command, purges all unused artifacts from the project.
|
||||||
*
|
*
|
||||||
|
|
@ -2054,6 +2077,22 @@ public class BaseProject extends BuildExecutor {
|
||||||
purge();
|
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() {
|
private void performAutoDownloadPurge() {
|
||||||
var resolution = new VersionResolution(properties());
|
var resolution = new VersionResolution(properties());
|
||||||
var cache = new BldCache(libBldDirectory(), resolution);
|
var cache = new BldCache(libBldDirectory(), resolution);
|
||||||
|
|
@ -2077,6 +2116,12 @@ public class BaseProject extends BuildExecutor {
|
||||||
var remainingArguments = new ArrayList<>(List.of(arguments));
|
var remainingArguments = new ArrayList<>(List.of(arguments));
|
||||||
var auto = remainingArguments.remove(AUTO_DOWNLOAD_PURGE_OPTION);
|
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() &&
|
if (!offline() &&
|
||||||
(autoDownloadPurge() || auto)) {
|
(autoDownloadPurge() || auto)) {
|
||||||
performAutoDownloadPurge();
|
performAutoDownloadPurge();
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ public class BuildExecutor {
|
||||||
public static final String LOCAL_PROPERTIES = "local.properties";
|
public static final String LOCAL_PROPERTIES = "local.properties";
|
||||||
|
|
||||||
private static final String ARG_OFFLINE = "--offline";
|
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_HELP1 = "--help";
|
||||||
private static final String ARG_HELP2 = "-h";
|
private static final String ARG_HELP2 = "-h";
|
||||||
private static final String ARG_HELP3 = "-?";
|
private static final String ARG_HELP3 = "-?";
|
||||||
|
|
@ -41,7 +43,7 @@ public class BuildExecutor {
|
||||||
private static final String ARG_VERBOSE2 = "-v";
|
private static final String ARG_VERBOSE2 = "-v";
|
||||||
|
|
||||||
private final HierarchicalProperties properties_;
|
private final HierarchicalProperties properties_;
|
||||||
private List<String> arguments_ = Collections.emptyList();
|
private List<String> arguments_ = new ArrayList<>();
|
||||||
private boolean offline_ = false;
|
private boolean offline_ = false;
|
||||||
private boolean verbose_ = false;
|
private boolean verbose_ = false;
|
||||||
private Map<String, CommandDefinition> buildCommands_ = null;
|
private Map<String, CommandDefinition> buildCommands_ = null;
|
||||||
|
|
@ -138,6 +140,17 @@ public class BuildExecutor {
|
||||||
return offline_;
|
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
|
* Returns whether the bld execution should output detailed information
|
||||||
* about the operations it performs.
|
* about the operations it performs.
|
||||||
|
|
@ -150,6 +163,42 @@ public class BuildExecutor {
|
||||||
return verbose_;
|
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.
|
* 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));
|
show_help |= arguments_.removeAll(List.of(ARG_HELP1, ARG_HELP2, ARG_HELP3));
|
||||||
showStacktrace = arguments_.removeAll(List.of(ARG_STACKTRACE1, ARG_STACKTRACE2));
|
showStacktrace = arguments_.removeAll(List.of(ARG_STACKTRACE1, ARG_STACKTRACE2));
|
||||||
verbose_ = arguments_.removeAll(List.of(ARG_VERBOSE1, ARG_VERBOSE2));
|
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) {
|
if (show_help) {
|
||||||
new HelpOperation(this, Collections.emptyList()).execute();
|
new HelpOperation(this, Collections.emptyList()).execute();
|
||||||
return exitStatus_;
|
return exitStatus_;
|
||||||
}
|
}
|
||||||
else if (arguments_.isEmpty()) {
|
|
||||||
|
if (arguments_.isEmpty()) {
|
||||||
showBldHelp();
|
showBldHelp();
|
||||||
return exitStatus_;
|
return exitStatus_;
|
||||||
}
|
}
|
||||||
|
|
@ -441,47 +497,14 @@ public class BuildExecutor {
|
||||||
*/
|
*/
|
||||||
public boolean executeCommand(String command)
|
public boolean executeCommand(String command)
|
||||||
throws Throwable {
|
throws Throwable {
|
||||||
var matched_command = command;
|
var matched_command = resolveCommand(command);
|
||||||
var definition = buildCommands().get(command);
|
CommandDefinition definition = null;
|
||||||
|
if (matched_command != null) {
|
||||||
// try to find an alias
|
if (!matched_command.equals(command) &&
|
||||||
if (definition == null) {
|
!matched_command.equals(buildAliases().get(command))) {
|
||||||
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);
|
|
||||||
System.out.println("Executing matched command: " + matched_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
|
// execute the command if we found one
|
||||||
|
|
@ -507,6 +530,54 @@ public class BuildExecutor {
|
||||||
return true;
|
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() {
|
private void showBldHelp() {
|
||||||
var help = new HelpOperation(this, arguments());
|
var help = new HelpOperation(this, arguments());
|
||||||
help.executePrintWelcome();
|
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:
|
The following bld arguments are supported:
|
||||||
|
|
||||||
--offline Works without Internet (only as first argument)
|
--offline Works without Internet (only as first argument)
|
||||||
|
--use-stderr Sends the build output to standard error
|
||||||
-?, -h, --help Shows the help
|
-?, -h, --help Shows the help
|
||||||
-D<name>=<value> Sets a JVM system property
|
-D<name>=<value> Sets a JVM system property
|
||||||
-s, --stacktrace Prints out the stacktrace for exceptions
|
-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 BUILD_ARGUMENT = "--build";
|
||||||
public static final String OFFLINE_ARGUMENT = "--offline";
|
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_PREFIX = "bld-wrapper";
|
||||||
public static final String WRAPPER_PROPERTIES = WRAPPER_PREFIX + ".properties";
|
public static final String WRAPPER_PROPERTIES = WRAPPER_PREFIX + ".properties";
|
||||||
|
|
@ -97,6 +98,11 @@ public class Wrapper {
|
||||||
* @since 1.5
|
* @since 1.5
|
||||||
*/
|
*/
|
||||||
public static void main(String[] arguments) {
|
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))));
|
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