Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 199 additions & 52 deletions CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java

Large diffs are not rendered by default.

849 changes: 849 additions & 0 deletions CodenameOne/src/com/codename1/mcp/MCPToml.java

Large diffs are not rendered by default.

64 changes: 59 additions & 5 deletions Ports/JavaSE/src/com/codename1/impl/javase/MCPDesktopMenu.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
*
* The tools serve MCP over a loopback socket; {@link MCPStdioLauncher} bridges a host's
* stdio to that socket, so "Install" makes the running tool drivable from stdio hosts such
* as Claude Desktop, Codex and opencode.
* as Claude Desktop, Claude Code and Codex.
*/
public final class MCPDesktopMenu {
private static final int DEFAULT_PORT = 8765;
Expand Down Expand Up @@ -155,7 +155,9 @@ private static String serverName(String toolName) {
private static void doInstall(String toolName, Component anchor) {
try {
MCPClientRegistrar registrar = MCPClientRegistrar.getInstance();
List<MCPClientRegistrar.MCPClient> updated = registrar.register(bridgeDescriptor(toolName));
List<MCPClientRegistrar.MCPClient> detected = registrar.detectClients();
List<MCPClientRegistrar.MCPClient> updated =
registrar.register(bridgeDescriptor(toolName), detected);
StringBuilder sb = new StringBuilder();
if (updated.isEmpty()) {
sb.append("No auto-configurable MCP hosts were found.\n")
Expand All @@ -167,20 +169,72 @@ private static void doInstall(String toolName, Component anchor) {
}
sb.append("\nRestart the host and this tool will appear as an MCP server.");
}
appendSkipped(sb, detected, updated,
"Its configuration was left untouched; the log says why.");
JOptionPane.showMessageDialog(anchor, sb.toString(), "MCP Install", JOptionPane.INFORMATION_MESSAGE);
} catch (Throwable t) {
JOptionPane.showMessageDialog(anchor, "Install failed: " + t.getMessage(),
"MCP Install", JOptionPane.ERROR_MESSAGE);
}
}

/// Names the hosts that could be written to and were not, so a refusal to rewrite a
/// configuration the registrar could not safely edit reaches the user instead of only
/// the log. Hosts that need manual configuration are listed by "Detect MCP Hosts" and
/// are not failures, so they are left out.
private static void appendSkipped(StringBuilder sb, List<MCPClientRegistrar.MCPClient> detected,
List<MCPClientRegistrar.MCPClient> updated, String why) {
StringBuilder skipped = new StringBuilder();
for (int i = 0; i < detected.size(); i++) {
MCPClientRegistrar.MCPClient client = detected.get(i);
if (!client.isWritable() || containsId(updated, client.getId())) {
continue;
}
skipped.append(" ").append(client.getDisplayName())
.append(" (").append(client.getConfigPath()).append(")\n");
}
if (skipped.length() == 0) {
return;
}
if (sb.length() > 0) {
sb.append("\n\n");
}
sb.append("Not updated:\n\n").append(skipped).append('\n').append(why);
}

private static boolean containsId(List<MCPClientRegistrar.MCPClient> clients, String id) {
for (int i = 0; i < clients.size(); i++) {
if (clients.get(i).getId().equals(id)) {
return true;
}
}
return false;
}

private static void doUninstall(String toolName, Component anchor) {
try {
List<MCPClientRegistrar.MCPClient> updated =
MCPClientRegistrar.getInstance().unregister(serverName(toolName));
String msg = updated.isEmpty() ? "No matching MCP host entries were found."
: "Removed '" + serverName(toolName) + "' from " + updated.size() + " host(s).";
JOptionPane.showMessageDialog(anchor, msg, "MCP Remove", JOptionPane.INFORMATION_MESSAGE);
StringBuilder sb = new StringBuilder();
if (updated.isEmpty()) {
// Deliberately not "no matching entries were found": an empty result also
// covers a host whose config the registrar refused to rewrite, and the two
// are indistinguishable from here. Nor does this list the hosts that were
// not updated, the way Install does - on removal that set is every host
// the tool was never registered with, so naming them would report the
// normal case as a failure. The log says which host was refused and why.
sb.append("Nothing was removed.\n")
.append("Either '").append(serverName(toolName))
.append("' was not registered with any host, or a host's ")
.append("configuration could not be edited - see the log.");
} else {
sb.append("Removed '").append(serverName(toolName)).append("' from:\n\n");
for (int i = 0; i < updated.size(); i++) {
sb.append(" ").append(updated.get(i).getDisplayName()).append('\n');
}
sb.append("\nRestart the host for the change to take effect.");
}
JOptionPane.showMessageDialog(anchor, sb.toString(), "MCP Remove", JOptionPane.INFORMATION_MESSAGE);
} catch (Throwable t) {
JOptionPane.showMessageDialog(anchor, "Remove failed: " + t.getMessage(),
"MCP Remove", JOptionPane.ERROR_MESSAGE);
Expand Down
14 changes: 12 additions & 2 deletions docs/developer-guide/MCP-Headless-API.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Every desktop Codename One tool, including the simulator and Codename One Settin
The menu has these items:

- *Expose This Tool To Agents* starts and stops the loopback MCP server for the running tool.
- *Install in MCP Hosts* registers this tool with the MCP hosts detected on the machine, such as Claude Desktop, Claude Code, Codex, and opencode. After a host restarts, the tool appears as an MCP server the agent can use.
- *Install in MCP Hosts* registers this tool with the MCP hosts detected on the machine: Claude Desktop, Claude Code, and Codex. After a host restarts, the tool appears as an MCP server the agent can use. A detected host whose configuration format isn't supported yet is reported rather than written to.
- *Remove From MCP Hosts* removes that registration.
- *Detect MCP Hosts* lists the hosts found on the machine and where their configuration lives.
- *Debug Logging* controls how much of the MCP conversation is echoed to the log.
Expand Down Expand Up @@ -95,4 +95,14 @@ The levels of `MCPVerbosity` are, from quietest to loudest: `OFF`, `ERRORS` (onl

The Install and Remove items in the MCP menu call `MCPClientRegistrar`, which detects the MCP hosts installed on the machine and writes a server entry into each host configuration, so an end user doesn't edit configuration by hand. Detection and registration are available to any Codename One tool, and they run inside the runtime because they use the portable `FileSystemStorage`.

A caller describes the server with an `MCPClientDescriptor` (a name and the command that launches it) and registers it with the detected hosts. Hosts whose configuration format isn't yet supported are reported so the user can add the entry manually.
A caller describes the server with an `MCPClientDescriptor` (a name and the command that launches it) and registers it with the detected hosts. Hosts whose configuration format isn't yet supported are reported so the user can add the entry manually. `opencode` is the one that's left: it nests its servers in an `mcp` block whose entries have a shape of their own.

Claude Desktop and Claude Code keep their servers in a JSON `mcpServers` object. Codex keeps its own in `~/.codex/config.toml`, as one `[mcp_servers.<name>]` table per server, and that file is shared by the ChatGPT desktop app, the Codex CLI, and the Codex IDE extension, so registering once reaches all three.

The default path is the one that's used. If you've moved your Codex configuration with `CODEX_HOME`, add the entry there by hand: the registrar runs inside the Codename One runtime, which has no `System.getenv`, so it can't follow the variable.

The TOML file is edited as text rather than parsed and written back. Only the lines belonging to the one server are replaced, so every other server, setting, comment, and choice of formatting in the file survives the edit exactly as the user left it, and the file keeps its own line endings. Registering again replaces the entry instead of adding a second one, and removing takes the server's `env` sub-table with it.

A configuration the editor can't make sense of is left untouched, and the reason is logged. That covers a file that isn't valid TOML, and the shapes it won't rewrite: the server declared through a dotted key or an inline table, `mcp_servers` declared as an array of tables, or the same server declared twice. Losing a Codex configuration is a worse outcome than not registering.

The host reads its configuration at startup, so a host that's already running needs a restart before the new server appears.
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,28 @@
*/
package com.codename1.mcp;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

/// Guards the completeness check that stops the registrar from overwriting a truncated or
/// corrupt host config. Codename One's JSON parser is lenient and returns a partial map
/// for malformed input rather than throwing, so this structural check is what protects the
/// user's other MCP servers.
class MCPClientRegistrarTest {
private final String originalOsName = System.getProperty("os.name");

@AfterEach
void restoreOsName() {
if (originalOsName != null) {
System.setProperty("os.name", originalOsName);
}
}


@Test
void acceptsWellFormedObjects() {
Expand All @@ -52,4 +64,41 @@ void rejectsTruncatedOrMalformedObjects() {
assertFalse(MCPClientRegistrar.isCompleteJsonObject("[1,2,3]"));
assertFalse(MCPClientRegistrar.isCompleteJsonObject("null"));
}

@Test
void windowsPathsFollowEachHostsOwnConvention() {
// A wrong Windows base is silent: the config is simply never found, so the host is
// never detected and nothing is ever written. Codex and Claude Code keep dotfile
// configs under the user profile; only Claude Desktop, an installed application
// with a per user directory of its own, lives under %APPDATA%.
System.setProperty("os.name", "Windows 11");
MCPClientRegistrar registrar = MCPClientRegistrar.getInstance();
assertEquals("C:/Users/dev/.codex/config.toml",
registrar.configPathFor("codex", "C:/Users/dev"));
assertEquals("C:/Users/dev/.claude.json",
registrar.configPathFor("claude-code", "C:/Users/dev"));
assertEquals("C:/Users/dev/AppData/Roaming/Claude/claude_desktop_config.json",
registrar.configPathFor("claude-desktop", "C:/Users/dev"));
}

@Test
void otherPlatformsResolveAgainstTheHome() {
MCPClientRegistrar registrar = MCPClientRegistrar.getInstance();
System.setProperty("os.name", "Mac OS X");
assertEquals("/Users/dev/.codex/config.toml",
registrar.configPathFor("codex", "/Users/dev"));
assertEquals("/Users/dev/Library/Application Support/Claude/claude_desktop_config.json",
registrar.configPathFor("claude-desktop", "/Users/dev"));

System.setProperty("os.name", "Linux");
assertEquals("/home/dev/.codex/config.toml",
registrar.configPathFor("codex", "/home/dev"));
assertEquals("/home/dev/.config/Claude/claude_desktop_config.json",
registrar.configPathFor("claude-desktop", "/home/dev"));
}

@Test
void anUnknownHostHasNoPath() {
assertNull(MCPClientRegistrar.getInstance().configPathFor("nope", "/home/dev"));
}
}
Loading
Loading