mirror of
https://github.com/rife2/bld
synced 2026-07-25 08:57:48 +02:00
Add the mcp install argument that registers the server with MCP clients
This commit is contained in:
parent
124c7695e9
commit
fcb348615e
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
|
|
|
|||
Loading…
Reference in a new issue