Add the mcp install argument that registers the server with MCP clients

This commit is contained in:
Geert Bevin 2026-07-17 22:43:51 -04:00
parent 124c7695e9
commit fcb348615e
3 changed files with 278 additions and 1 deletions

View file

@ -29,6 +29,19 @@ public class McpHelp implements CommandHelp {
bld with the --use-stderr option to keep standard output free
for the protocol while the build starts up.
Usage : ${topic}""", "${topic}", topic);
The install argument doesn't start the server, it registers the
project with an MCP client by writing the standard configuration
file inside the project directory:
install writes .mcp.json (Claude Code and compatible)
install cursor writes .cursor/mcp.json
install vscode writes .vscode/mcp.json
install --print prints the configuration instead of writing it
Existing configuration files are merged with, other servers are
preserved. The registered command launches the wrapper directly
through java, so that the same file works on every platform.
Usage : ${topic} [install [claude | cursor | vscode] [--print]]""", "${topic}", topic);
}
}

View file

@ -7,6 +7,7 @@ package rife.bld.operations;
import rife.bld.BaseProject;
import rife.bld.BldVersion;
import rife.bld.BuildExecutor;
import rife.bld.operations.exceptions.OperationOptionException;
import rife.bld.wrapper.Wrapper;
import rife.json.Json;
import rife.json.JsonArray;
@ -96,10 +97,25 @@ public class McpOperation extends AbstractOperation<McpOperation> {
private static final List<String> LOG_LEVELS = List.of(
"debug", "info", "notice", "warning", "error", "critical", "alert", "emergency");
/**
* The command argument that registers the MCP server with a client
* instead of starting it.
* @since 2.4.0
*/
public static final String ARGUMENT_INSTALL = "install";
private static final String ARGUMENT_INSTALL_PRINT = "--print";
private static final String INSTALL_TARGET_CLAUDE = "claude";
private static final String INSTALL_TARGET_CURSOR = "cursor";
private static final String INSTALL_TARGET_VSCODE = "vscode";
private static final List<String> INSTALL_TARGETS = List.of(INSTALL_TARGET_CLAUDE, INSTALL_TARGET_CURSOR, INSTALL_TARGET_VSCODE);
private BuildExecutor executor_;
private BaseProject project_ = null;
private String serverTitle_ = null;
private String instructions_ = null;
private String installTarget_ = null;
private boolean installPrint_ = false;
private boolean initialized_ = false;
private int toolCallTimeout_ = 0;
private int outputLimit_ = 1_000_000;
@ -139,6 +155,13 @@ public class McpOperation extends AbstractOperation<McpOperation> {
*/
public void execute()
throws IOException {
// the install argument registers the server with an MCP client
// instead of starting it
if (installTarget_ != null) {
executeInstall();
return;
}
var previous_output = System.out;
var reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
// the protocol messages are written to the real standard output
@ -240,6 +263,95 @@ public class McpOperation extends AbstractOperation<McpOperation> {
writeProtocolMessage(writer, notification);
}
/**
* Part of the {@link #execute} operation, registers the MCP server
* with an MCP client by writing the standard configuration file
* inside the project directory.
* <p>
* The registered command launches the wrapper jar directly through
* {@code java}, the wrapper scripts are platform specific while the
* same committed configuration file is shared by every platform.
* An existing configuration file is merged with: other servers are
* preserved and repeated installs update the same entry. A file that
* can't be parsed is never modified.
*
* @throws IOException when the configuration couldn't be read or written
* @since 2.4.0
*/
protected void executeInstall()
throws IOException {
if (project_ == null) {
throw new OperationOptionException("ERROR: The MCP install requires a project.");
}
var name = project_.name() == null ? "bld" : project_.name();
var wrapper_jar = "lib/bld/" + Wrapper.WRAPPER_PREFIX + ".jar";
var entry = new JsonObject();
if (INSTALL_TARGET_VSCODE.equals(installTarget_)) {
entry.set("type", "stdio");
}
// the entry replicates the wrapper script invocation: the wrapper
// jar is launched in build mode with the project's build class,
// the leading script path lets the wrapper derive the project
// directory, the client launches the server with the project
// directory as the working directory so the relative paths resolve
entry.set("command", "java")
.array("args", a -> a
.append("-jar")
.append(wrapper_jar)
.append("./bld")
.append(Wrapper.BUILD_ARGUMENT)
.append(project_.getClass().getName())
.append(Wrapper.USE_STDERR_ARGUMENT)
.append("mcp"));
var root_key = INSTALL_TARGET_VSCODE.equals(installTarget_) ? "servers" : "mcpServers";
var relative_path = switch (installTarget_) {
case INSTALL_TARGET_CURSOR -> ".cursor/mcp.json";
case INSTALL_TARGET_VSCODE -> ".vscode/mcp.json";
default -> ".mcp.json";
};
if (installPrint_) {
System.out.println(new JsonObject().object(root_key, r -> r.set(name, entry)).toPrettyString());
return;
}
var file = new File(project_.workDirectory(), relative_path);
JsonObject config;
if (file.exists()) {
try {
config = Json.parseObject(Files.readString(file.toPath()));
} catch (JsonParseException e) {
throw new OperationOptionException("ERROR: Unable to parse the existing '" + relative_path + "', not modifying it.");
}
} else {
config = new JsonObject();
}
var existing_root = config.get(root_key);
JsonObject servers;
if (existing_root == null) {
servers = new JsonObject();
config.set(root_key, servers);
} else if (existing_root instanceof JsonObject existing_servers) {
servers = existing_servers;
} else {
throw new OperationOptionException("ERROR: '" + root_key + "' in '" + relative_path + "' isn't an object, not modifying it.");
}
servers.set(name, entry);
file.getParentFile().mkdirs();
Files.writeString(file.toPath(), config.toPrettyString() + "\n");
if (!silent()) {
System.out.println("The MCP server '" + name + "' was registered in '" + relative_path + "'.");
System.out.println("The registered command launches the wrapper through 'java', which has to be on the PATH of the MCP client.");
if (!new File(project_.workDirectory(), wrapper_jar).isFile()) {
System.out.println("Warning: '" + wrapper_jar + "' wasn't found, the registered command expects the project wrapper.");
}
}
}
/**
* Part of the {@link #execute} operation, processes a single JSON-RPC
* message and returns the response.
@ -1167,6 +1279,25 @@ public class McpOperation extends AbstractOperation<McpOperation> {
if (project.name() != null) {
operation = operation.serverTitle(project.name());
}
// the install argument and its options are consumed from the
// command arguments
var arguments = project.arguments();
if (!arguments.isEmpty() && arguments.get(0).equals(ARGUMENT_INSTALL)) {
arguments.remove(0);
installTarget_ = INSTALL_TARGET_CLAUDE;
if (!arguments.isEmpty() && !arguments.get(0).startsWith("-")) {
var target = arguments.remove(0);
if (!INSTALL_TARGETS.contains(target)) {
throw new OperationOptionException("ERROR: Unknown MCP install target '" + target + "', " +
"expecting " + String.join(", ", INSTALL_TARGETS) + ".");
}
installTarget_ = target;
}
if (!arguments.isEmpty() && arguments.get(0).equals(ARGUMENT_INSTALL_PRINT)) {
arguments.remove(0);
installPrint_ = true;
}
}
return operation;
}

View file

@ -12,6 +12,7 @@ import rife.bld.dependencies.Bom;
import rife.bld.dependencies.Dependency;
import rife.bld.dependencies.Scope;
import rife.bld.dependencies.VersionNumber;
import rife.bld.operations.exceptions.OperationOptionException;
import rife.json.Json;
import rife.json.JsonObject;
import rife.tools.FileUtils;
@ -926,6 +927,138 @@ public class TestMcpOperation {
}
}
@Test
void testMcpInstall()
throws Exception {
var tmp = Files.createTempDirectory("mcpinstall").toFile();
try {
var project = new McpProject(tmp);
project.arguments().add("install");
var operation = new McpOperation().fromProject(project);
// the install arguments are consumed and can't chain commands
assertTrue(project.arguments().isEmpty());
operation.silent(true).execute();
var config = Json.parseObject(Files.readString(new File(tmp, ".mcp.json").toPath()));
var server = config.getObject("mcpServers").getObject("mcp_project");
// the wrapper is launched directly through java, so that the
// same committed file works on every platform
assertEquals("java", server.getString("command"));
var args = server.getArray("args");
assertEquals("-jar", args.getString(0));
assertEquals("lib/bld/bld-wrapper.jar", args.getString(1));
assertEquals("./bld", args.getString(2));
// the wrapper is launched in build mode with the project's
// build class, mirroring the wrapper script invocation
assertEquals("--build", args.getString(3));
assertEquals(McpProject.class.getName(), args.getString(4));
assertEquals("--use-stderr", args.getString(5));
assertEquals("mcp", args.getString(6));
} finally {
FileUtils.deleteDirectory(tmp);
}
}
@Test
void testMcpInstallMergesAndUpdates()
throws Exception {
var tmp = Files.createTempDirectory("mcpinstallmerge").toFile();
try {
Files.writeString(new File(tmp, ".mcp.json").toPath(), """
{"mcpServers":{"other":{"command":"other-cmd"}},"custom":true}""");
for (var round = 1; round <= 2; ++round) {
var project = new McpProject(tmp);
project.arguments().add("install");
new McpOperation().fromProject(project).silent(true).execute();
}
var config = Json.parseObject(Files.readString(new File(tmp, ".mcp.json").toPath()));
// other servers and unrelated settings are preserved, repeated
// installs update the same single entry
assertEquals("other-cmd", config.getObject("mcpServers").getObject("other").getString("command"));
assertTrue(config.getBoolean("custom"));
assertEquals(2, config.getObject("mcpServers").size());
assertEquals("java", config.getObject("mcpServers").getObject("mcp_project").getString("command"));
} finally {
FileUtils.deleteDirectory(tmp);
}
}
@Test
void testMcpInstallVscode()
throws Exception {
var tmp = Files.createTempDirectory("mcpinstallvscode").toFile();
try {
var project = new McpProject(tmp);
project.arguments().addAll(List.of("install", "vscode"));
new McpOperation().fromProject(project).silent(true).execute();
var config = Json.parseObject(Files.readString(new File(tmp, ".vscode/mcp.json").toPath()));
// the VS Code format uses the servers root and a stdio type
var server = config.getObject("servers").getObject("mcp_project");
assertEquals("stdio", server.getString("type"));
assertEquals("java", server.getString("command"));
} finally {
FileUtils.deleteDirectory(tmp);
}
}
@Test
void testMcpInstallUnknownTarget()
throws Exception {
var tmp = Files.createTempDirectory("mcpinstallbogus").toFile();
try {
var project = new McpProject(tmp);
project.arguments().addAll(List.of("install", "bogus"));
assertThrows(OperationOptionException.class, () -> new McpOperation().fromProject(project));
} finally {
FileUtils.deleteDirectory(tmp);
}
}
@Test
void testMcpInstallRefusesCorruptConfig()
throws Exception {
var tmp = Files.createTempDirectory("mcpinstallcorrupt").toFile();
try {
var config_file = new File(tmp, ".mcp.json");
Files.writeString(config_file.toPath(), "this is not json");
var project = new McpProject(tmp);
project.arguments().add("install");
var operation = new McpOperation().fromProject(project).silent(true);
// a file that can't be parsed is reported and never modified
assertThrows(OperationOptionException.class, operation::execute);
assertEquals("this is not json", Files.readString(config_file.toPath()));
} finally {
FileUtils.deleteDirectory(tmp);
}
}
@Test
void testMcpInstallPrint()
throws Exception {
var tmp = Files.createTempDirectory("mcpinstallprint").toFile();
var previous_out = System.out;
try {
var project = new McpProject(tmp);
project.arguments().addAll(List.of("install", "--print"));
var captured = new java.io.ByteArrayOutputStream();
System.setOut(new PrintStream(captured, true));
new McpOperation().fromProject(project).execute();
System.setOut(previous_out);
// the configuration is printed instead of written
var printed = Json.parseObject(captured.toString());
assertEquals("java", printed.getObject("mcpServers").getObject("mcp_project").getString("command"));
assertFalse(new File(tmp, ".mcp.json").exists());
} finally {
System.setOut(previous_out);
FileUtils.deleteDirectory(tmp);
}
}
public static class E2eProject extends Project {
public E2eProject() {
this(new File(System.getProperty("user.dir")));