From fcb348615e220a24c02d32b3373f0cffb0e33a82 Mon Sep 17 00:00:00 2001 From: Geert Bevin Date: Fri, 17 Jul 2026 22:43:51 -0400 Subject: [PATCH] Add the mcp install argument that registers the server with MCP clients --- src/main/java/rife/bld/help/McpHelp.java | 15 +- .../rife/bld/operations/McpOperation.java | 131 +++++++++++++++++ .../rife/bld/operations/TestMcpOperation.java | 133 ++++++++++++++++++ 3 files changed, 278 insertions(+), 1 deletion(-) diff --git a/src/main/java/rife/bld/help/McpHelp.java b/src/main/java/rife/bld/help/McpHelp.java index 0011cef..0da5a7a 100644 --- a/src/main/java/rife/bld/help/McpHelp.java +++ b/src/main/java/rife/bld/help/McpHelp.java @@ -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); } } diff --git a/src/main/java/rife/bld/operations/McpOperation.java b/src/main/java/rife/bld/operations/McpOperation.java index 6f24db6..6c37548 100644 --- a/src/main/java/rife/bld/operations/McpOperation.java +++ b/src/main/java/rife/bld/operations/McpOperation.java @@ -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 { private static final List 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 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 { */ 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 { 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. + *

+ * 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 { 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; } diff --git a/src/test/java/rife/bld/operations/TestMcpOperation.java b/src/test/java/rife/bld/operations/TestMcpOperation.java index eacfeb3..6be1385 100644 --- a/src/test/java/rife/bld/operations/TestMcpOperation.java +++ b/src/test/java/rife/bld/operations/TestMcpOperation.java @@ -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")));