From 37b2f4fe4273343c6b755c55ed7cdfff97c722b2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:09:45 +0300 Subject: [PATCH 01/10] Add MCP headless API for the JavaSE port Expose the running simulator / a Codename One desktop tool to an LLM agent over the Model Context Protocol. Built on the accessibility semantics tree: the same immutable tree that describes the screen to VoiceOver/TalkBack drives it for an agent, so any UI is drivable with zero code. Scope: the JavaSE port (simulator + tooling) only. Packaged desktop app / jar / cloud desktop builds are out of scope, so there is no launcher-arg plumbing and no cn1:mcp-setup goal. - Core engine com.codename1.mcp: MCP facade, MCPServer (JSON-RPC 2.0), MCPTransport + SocketTransport, McpUiTools built-in tools (ui_snapshot, ui_perform_action, ui_activate, ui_set_text, ui_find), screenshot resource, MCPClientRegistrar/MCPClientDescriptor for host detection/registration. Developer tools reuse com.codename1.ai.Tool. Enablement is API-invocation only. - ParparVM-safe core: no System.in/setOut/getenv or BufferedReader (missing on the iOS/Catalyst runtime). The stdio transport lives in the JavaSE port (MCPStdioTransport) behind a factory registered by JavaSEPort.init. - Simulator gains a Tools > Model Context Protocol menu (socket attach + detect hosts) in both window modes. - Unit + MCP-spec conformance tests (MCPServerTest), an MCP Inspector E2E harness (scripts/mcp*), and a developer guide chapter. Co-Authored-By: Claude Opus 4.8 (1M context) --- CodenameOne/src/com/codename1/mcp/MCP.java | 114 ++++++ .../codename1/mcp/MCPClientDescriptor.java | 82 ++++ .../com/codename1/mcp/MCPClientRegistrar.java | 345 +++++++++++++++++ .../src/com/codename1/mcp/MCPLineReader.java | 61 +++ .../src/com/codename1/mcp/MCPServer.java | 364 ++++++++++++++++++ .../src/com/codename1/mcp/MCPTransport.java | 46 +++ .../src/com/codename1/mcp/McpUiTools.java | 306 +++++++++++++++ .../com/codename1/mcp/SocketTransport.java | 134 +++++++ .../src/com/codename1/mcp/package-info.java | 53 +++ .../com/codename1/impl/javase/JavaSEPort.java | 62 +++ .../impl/javase/MCPStdioTransport.java | 113 ++++++ .../developer-guide/MCP-Headless-API.asciidoc | 64 +++ docs/developer-guide/developer-guide.asciidoc | 2 + docs/developer-guide/languagetool-accept.txt | 7 + .../java/com/codename1/mcp/MCPServerTest.java | 311 +++++++++++++++ scripts/mcp-inspector-e2e.sh | 54 +++ scripts/mcp/McpStdioDemo.java | 78 ++++ 17 files changed, 2196 insertions(+) create mode 100644 CodenameOne/src/com/codename1/mcp/MCP.java create mode 100644 CodenameOne/src/com/codename1/mcp/MCPClientDescriptor.java create mode 100644 CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java create mode 100644 CodenameOne/src/com/codename1/mcp/MCPLineReader.java create mode 100644 CodenameOne/src/com/codename1/mcp/MCPServer.java create mode 100644 CodenameOne/src/com/codename1/mcp/MCPTransport.java create mode 100644 CodenameOne/src/com/codename1/mcp/McpUiTools.java create mode 100644 CodenameOne/src/com/codename1/mcp/SocketTransport.java create mode 100644 CodenameOne/src/com/codename1/mcp/package-info.java create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioTransport.java create mode 100644 docs/developer-guide/MCP-Headless-API.asciidoc create mode 100644 maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerTest.java create mode 100755 scripts/mcp-inspector-e2e.sh create mode 100644 scripts/mcp/McpStdioDemo.java diff --git a/CodenameOne/src/com/codename1/mcp/MCP.java b/CodenameOne/src/com/codename1/mcp/MCP.java new file mode 100644 index 00000000000..552a095a054 --- /dev/null +++ b/CodenameOne/src/com/codename1/mcp/MCP.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.mcp; + +import com.codename1.ai.Tool; + +/// Public entry point for the Codename One MCP headless API. Invoking a starter here is +/// the enablement; there is no build hint or property to toggle. A single server +/// instance exists per process. +/// +/// This API is supported by the Codename One JavaSE port, which powers the simulator and +/// the desktop tooling. It is not part of a packaged application build. +/// +/// #### Typical usage +/// +/// ``` +/// // Let a running tool or the simulator accept an attaching agent on a local port: +/// MCP.startSocketServer(8642); +/// +/// // Or serve over stdio when a host launches the tool as a subprocess: +/// MCP.startStdioServer(); +/// +/// // Publish domain tools alongside the automatic UI driving tools: +/// MCP.addTool(myTool); +/// ``` +public class MCP { + private static MCPServer server; + private static StdioTransportFactory stdioTransportFactory; + + private MCP() { + } + + /// Supplies the platform stdio transport. The stdio transport lives outside the + /// portable core because it needs process standard input, which is not available on + /// every target (for example the ParparVM Java runtime). The JavaSE port registers + /// its implementation during initialization. + public interface StdioTransportFactory { + MCPTransport createStdioTransport(); + } + + /// Registers the platform stdio transport factory. Called by the JavaSE port. + public static void setStdioTransportFactory(StdioTransportFactory factory) { + stdioTransportFactory = factory; + } + + /// Whether an stdio transport is available on this platform. + public static boolean isStdioSupported() { + return stdioTransportFactory != null; + } + + /// Returns the shared server, creating it on first use. + public static synchronized MCPServer getServer() { + if (server == null) { + server = new MCPServer(); + } + return server; + } + + public static synchronized boolean isRunning() { + return server != null && server.isRunning(); + } + + /// Starts the stdio transport server (the standard MCP local transport). Requires a + /// platform stdio transport factory (registered by the JavaSE port). + public static synchronized MCPServer startStdioServer() { + if (stdioTransportFactory == null) { + throw new IllegalStateException( + "No stdio MCP transport is available on this platform. Use startSocketServer(int) " + + "for socket attach, or run on the JavaSE port."); + } + MCPServer s = getServer(); + s.start(stdioTransportFactory.createStdioTransport()); + return s; + } + + /// Starts a loopback socket server so an agent can attach to this running process. + public static synchronized MCPServer startSocketServer(int port) { + MCPServer s = getServer(); + s.start(new SocketTransport(port)); + return s; + } + + /// Stops the shared server if it is running. + public static synchronized void stop() { + if (server != null) { + server.stop(); + } + } + + /// Registers a developer defined tool with the shared server. + public static void addTool(Tool tool) { + getServer().addTool(tool); + } +} diff --git a/CodenameOne/src/com/codename1/mcp/MCPClientDescriptor.java b/CodenameOne/src/com/codename1/mcp/MCPClientDescriptor.java new file mode 100644 index 00000000000..a98fd6b811a --- /dev/null +++ b/CodenameOne/src/com/codename1/mcp/MCPClientDescriptor.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.mcp; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Describes the MCP server entry to register with a host: the stable server name it +/// appears under and the command line that launches the application in stdio MCP mode. +public final class MCPClientDescriptor { + private final String serverName; + private final String command; + private final List args; + private final Map env; + + public MCPClientDescriptor(String serverName, String command, List args) { + this(serverName, command, args, null); + } + + public MCPClientDescriptor(String serverName, String command, List args, + Map env) { + if (serverName == null || serverName.length() == 0) { + throw new IllegalArgumentException("serverName is required"); + } + if (command == null || command.length() == 0) { + throw new IllegalArgumentException("command is required"); + } + this.serverName = serverName; + this.command = command; + this.args = args == null ? new ArrayList() : new ArrayList(args); + this.env = env == null ? null : new LinkedHashMap(env); + } + + public String getServerName() { + return serverName; + } + + public String getCommand() { + return command; + } + + public List getArgs() { + return args; + } + + public Map getEnv() { + return env; + } + + /// Builds the JSON object stored under `mcpServers.` in a host config. + Map toServerEntry() { + Map entry = new LinkedHashMap(); + entry.put("command", command); + entry.put("args", new ArrayList(args)); + if (env != null && !env.isEmpty()) { + entry.put("env", new LinkedHashMap(env)); + } + return entry; + } +} diff --git a/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java b/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java new file mode 100644 index 00000000000..b099b5b74bc --- /dev/null +++ b/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java @@ -0,0 +1,345 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.mcp; + +import com.codename1.io.FileSystemStorage; +import com.codename1.io.JSONParser; +import com.codename1.io.Log; +import com.codename1.io.Util; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Detects installed MCP hosts and registers a Codename One application's stdio MCP +/// server with them, so an end user can point Claude Desktop, Claude Code and similar +/// tools at the application without editing config by hand. +/// +/// This is a plain reusable API. It is meant to be driven by Codename One tooling (the +/// certificate wizard, Game Builder, Settings, the simulator) and by applications +/// themselves, and it is exposed to the maven plugin as the `cn1:mcp-setup` goal. +/// +/// Registration is a desktop concern. File access goes through +/// {@link com.codename1.io.FileSystemStorage} so the class links on every target, but +/// {@link #isSupported()} is false where the platform provides no reachable home +/// directory (mobile), and each detected host reports whether its config is writable. +public final class MCPClientRegistrar { + private static final MCPClientRegistrar INSTANCE = new MCPClientRegistrar(); + + private final List knownClients = new ArrayList(); + + private MCPClientRegistrar() { + // Table driven registry: adding a JSON, mcpServers style host is a data change. + knownClients.add(new KnownClient("claude-desktop", "Claude Desktop", + "Library/Application Support/Claude/claude_desktop_config.json", + "Claude/claude_desktop_config.json", + ".config/Claude/claude_desktop_config.json", true)); + knownClients.add(new KnownClient("claude-code", "Claude Code", + ".claude.json", ".claude.json", ".claude.json", true)); + // Detect only for now: these hosts use non JSON or differently shaped configs + // (Codex config.toml, opencode opencode.json "mcp" block) that need dedicated + // writers. They are surfaced so the caller can guide the user manually. + knownClients.add(new KnownClient("codex", "Codex CLI", + ".codex/config.toml", ".codex/config.toml", ".codex/config.toml", false)); + knownClients.add(new KnownClient("opencode", "opencode", + ".config/opencode/opencode.json", + "opencode/opencode.json", + ".config/opencode/opencode.json", false)); + } + + public static MCPClientRegistrar getInstance() { + return INSTANCE; + } + + /// Returns true when this platform exposes a home directory the registrar can reach. + public boolean isSupported() { + return homePath() != null; + } + + /// Detects installed MCP hosts by looking for their config file or its parent + /// directory under the user home. + public List detectClients() { + List found = new ArrayList(); + String home = homePath(); + if (home == null) { + return found; + } + FileSystemStorage fs = FileSystemStorage.getInstance(); + for (int i = 0; i < knownClients.size(); i++) { + KnownClient known = knownClients.get(i); + String path = known.absolutePath(home); + if (path == null) { + continue; + } + boolean present = safeExists(fs, fsPath(path)) || safeExists(fs, fsPath(parentOf(path))); + if (present) { + found.add(new MCPClient(known.id, known.displayName, path, known.writable)); + } + } + return found; + } + + /// Registers the descriptor with every detected, writable host. Returns the list of + /// hosts that were updated. + public List register(MCPClientDescriptor descriptor) { + return register(descriptor, detectClients()); + } + + /// Registers the descriptor with the given hosts. Non writable hosts are skipped. + public List register(MCPClientDescriptor descriptor, List clients) { + List updated = new ArrayList(); + if (descriptor == null || clients == null) { + return updated; + } + for (int i = 0; i < clients.size(); i++) { + MCPClient client = clients.get(i); + if (!client.isWritable()) { + continue; + } + if (writeEntry(client, descriptor.getServerName(), descriptor.toServerEntry())) { + updated.add(client); + } + } + return updated; + } + + /// Removes the named server entry from every detected, writable host. Returns the + /// list of hosts that were updated. + public List unregister(String serverName) { + List updated = new ArrayList(); + if (serverName == null) { + return updated; + } + List clients = detectClients(); + for (int i = 0; i < clients.size(); i++) { + MCPClient client = clients.get(i); + if (client.isWritable() && writeEntry(client, serverName, null)) { + updated.add(client); + } + } + return updated; + } + + private boolean writeEntry(MCPClient client, String serverName, Map entry) { + try { + FileSystemStorage fs = FileSystemStorage.getInstance(); + String path = client.getConfigPath(); + String storagePath = fsPath(path); + Map root = readJson(fs, storagePath); + if (root == null) { + root = new LinkedHashMap(); + } + Object serversObj = root.get("mcpServers"); + Map servers; + if (serversObj instanceof Map) { + servers = asStringMap((Map) serversObj); + } else { + servers = new LinkedHashMap(); + } + if (entry == null) { + servers.remove(serverName); + } else { + servers.put(serverName, entry); + } + root.put("mcpServers", servers); + String parent = parentOf(path); + if (parent != null) { + try { + fs.mkdir(fsPath(parent)); + } catch (Throwable ignored) { + // parent likely already exists + } + } + OutputStream os = fs.openOutputStream(storagePath); + try { + os.write(JSONParser.toJson(root).getBytes("UTF-8")); + } finally { + os.close(); + } + return true; + } catch (Exception ex) { + Log.e(ex); + return false; + } + } + + private Map readJson(FileSystemStorage fs, String storagePath) { + try { + if (!fs.exists(storagePath)) { + return null; + } + String json = Util.readToString(fs.openInputStream(storagePath), "UTF-8"); + if (json == null || json.trim().length() == 0) { + return null; + } + return JSONParser.parseJSON(json); + } catch (Exception ex) { + Log.e(ex); + return null; + } + } + + @SuppressWarnings("unchecked") + private static Map asStringMap(Map raw) { + Map out = new LinkedHashMap(); + for (Object entryObj : raw.entrySet()) { + Map.Entry entry = (Map.Entry) entryObj; + out.put(String.valueOf(entry.getKey()), entry.getValue()); + } + return out; + } + + private static boolean safeExists(FileSystemStorage fs, String storagePath) { + try { + return storagePath != null && fs.exists(storagePath); + } catch (Throwable ex) { + return false; + } + } + + /// Converts an absolute OS path to the form {@link FileSystemStorage} expects. On a + /// unix style absolute path this yields a `file://` URI that round trips through the + /// JavaSE port; other paths are passed through for the exposed filesystem case. + private static String fsPath(String absolute) { + if (absolute == null) { + return null; + } + String forward = absolute.replace('\\', '/'); + if (forward.startsWith("/")) { + return "file://" + forward; + } + if (forward.length() > 1 && forward.charAt(1) == ':') { + // Windows drive path such as C:/Users/... + return "file:///" + forward; + } + return forward; + } + + private static String parentOf(String path) { + if (path == null) { + return null; + } + String forward = path.replace('\\', '/'); + int slash = forward.lastIndexOf('/'); + if (slash <= 0) { + return null; + } + return path.substring(0, slash); + } + + private static String homePath() { + try { + String home = System.getProperty("user.home"); + if (home != null && home.length() > 0) { + return home; + } + } catch (Throwable ignored) { + // property access unavailable on this platform + } + return null; + } + + private static String appDataPath() { + // Derived from the home directory rather than the APPDATA environment variable: + // System.getenv is not available on every Codename One target, and this class + // lives in the portable core, so it must link everywhere. + String home = homePath(); + return home == null ? null : home + "/AppData/Roaming"; + } + + private static boolean isWindows() { + String os = System.getProperty("os.name"); + return os != null && os.toLowerCase().indexOf("win") >= 0; + } + + private static boolean isMac() { + String os = System.getProperty("os.name"); + return os != null && os.toLowerCase().indexOf("mac") >= 0; + } + + /// A detected MCP host and where its config lives. + public static final class MCPClient { + private final String id; + private final String displayName; + private final String configPath; + private final boolean writable; + + MCPClient(String id, String displayName, String configPath, boolean writable) { + this.id = id; + this.displayName = displayName; + this.configPath = configPath; + this.writable = writable; + } + + public String getId() { + return id; + } + + public String getDisplayName() { + return displayName; + } + + public String getConfigPath() { + return configPath; + } + + /// True when the registrar can write this host's config automatically. False + /// for hosts whose config format is not yet supported, which the caller should + /// surface as a manual step. + public boolean isWritable() { + return writable; + } + } + + private static final class KnownClient { + private final String id; + private final String displayName; + private final String macRelative; + private final String winRelative; + private final String linuxRelative; + private final boolean writable; + + KnownClient(String id, String displayName, String macRelative, String winRelative, + String linuxRelative, boolean writable) { + this.id = id; + this.displayName = displayName; + this.macRelative = macRelative; + this.winRelative = winRelative; + this.linuxRelative = linuxRelative; + this.writable = writable; + } + + String absolutePath(String home) { + if (isWindows()) { + String base = appDataPath(); + return base == null ? null : base + "/" + winRelative; + } + if (isMac()) { + return home + "/" + macRelative; + } + return home + "/" + linuxRelative; + } + } +} diff --git a/CodenameOne/src/com/codename1/mcp/MCPLineReader.java b/CodenameOne/src/com/codename1/mcp/MCPLineReader.java new file mode 100644 index 00000000000..c8b97bcf236 --- /dev/null +++ b/CodenameOne/src/com/codename1/mcp/MCPLineReader.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.mcp; + +import java.io.IOException; +import java.io.Reader; + +/// Reads a single newline delimited message from a {@link Reader}. Used instead of +/// {@code java.io.BufferedReader}, which is not available on every Codename One target +/// (notably the ParparVM Java runtime), so that the portable transports link on all +/// platforms. Carriage returns are stripped so both `\n` and `\r\n` framing work. +final class MCPLineReader { + private MCPLineReader() { + } + + static String readLine(Reader reader) throws IOException { + StringBuilder sb = new StringBuilder(); + int read; + boolean any = false; + while ((read = reader.read()) != -1) { + any = true; + char c = (char) read; + if (c == '\n') { + return stripCr(sb); + } + sb.append(c); + } + if (!any) { + return null; + } + return stripCr(sb); + } + + private static String stripCr(StringBuilder sb) { + int length = sb.length(); + if (length > 0 && sb.charAt(length - 1) == '\r') { + sb.setLength(length - 1); + } + return sb.toString(); + } +} diff --git a/CodenameOne/src/com/codename1/mcp/MCPServer.java b/CodenameOne/src/com/codename1/mcp/MCPServer.java new file mode 100644 index 00000000000..bbcf4c18a7e --- /dev/null +++ b/CodenameOne/src/com/codename1/mcp/MCPServer.java @@ -0,0 +1,364 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.mcp; + +import com.codename1.ai.Tool; +import com.codename1.io.JSONParser; +import com.codename1.io.Log; +import com.codename1.util.Base64; +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// A Model Context Protocol server. Speaks JSON-RPC 2.0 over a pluggable +/// {@link MCPTransport}, dispatching the MCP methods `initialize`, `tools/list`, +/// `tools/call`, `resources/list`, `resources/read`, `ping` and the +/// `notifications/*` family. +/// +/// Tools are {@link com.codename1.ai.Tool} instances. The built in tools from +/// {@link McpUiTools} expose the accessibility semantics tree so any application is +/// drivable without code; applications add domain tools with {@link #addTool(Tool)}. +/// +/// The message dispatch is thread free and reentrant: {@link #handleMessage(String)} +/// takes one request line and returns one response line (or null for a notification), +/// which makes it directly unit testable. {@link #start(MCPTransport)} wraps that in a +/// reader thread over the transport. +public class MCPServer { + /// Standard JSON-RPC and MCP error codes. + public static final int PARSE_ERROR = -32700; + public static final int INVALID_REQUEST = -32600; + public static final int METHOD_NOT_FOUND = -32601; + public static final int INVALID_PARAMS = -32602; + public static final int INTERNAL_ERROR = -32603; + public static final int RESOURCE_NOT_FOUND = -32002; + + /// MCP protocol revision advertised when the client does not request one. + public static final String DEFAULT_PROTOCOL_VERSION = "2024-11-05"; + + private static final String SCREEN_RESOURCE_URI = "cn1://screen.png"; + + private final Map tools = new LinkedHashMap(); + private String serverName = "Codename One MCP"; + private String serverVersion = "1.0"; + private boolean screenshotEnabled = true; + private volatile boolean running; + private MCPTransport transport; + private Thread readerThread; + + public MCPServer() { + List builtIn = McpUiTools.builtInTools(); + for (int i = 0; i < builtIn.size(); i++) { + Tool t = builtIn.get(i); + tools.put(t.getName(), t); + } + } + + /// Registers a developer defined tool, replacing any existing tool with the same + /// name. This is how an application publishes domain specific data and actions. + public synchronized void addTool(Tool tool) { + if (tool != null) { + tools.put(tool.getName(), tool); + } + } + + /// Removes a previously registered tool by name. + public synchronized void removeTool(String name) { + tools.remove(name); + } + + /// Sets the server identity reported to the host during `initialize`. + public void setServerInfo(String name, String version) { + if (name != null) { + this.serverName = name; + } + if (version != null) { + this.serverVersion = version; + } + } + + /// Enables or disables the built in screenshot resource. Enabled by default. + public void setScreenshotEnabled(boolean screenshotEnabled) { + this.screenshotEnabled = screenshotEnabled; + } + + public boolean isRunning() { + return running; + } + + /// Starts serving over the given transport on a dedicated daemon reader thread. + public synchronized void start(MCPTransport transport) { + if (running) { + return; + } + this.transport = transport; + running = true; + readerThread = new Thread(new Runnable() { + @Override + public void run() { + runLoop(); + } + }, "cn1-mcp-server"); + readerThread.setDaemon(true); + readerThread.start(); + } + + /// Stops serving and closes the transport. + public synchronized void stop() { + running = false; + if (transport != null) { + transport.close(); + } + } + + private void runLoop() { + try { + transport.open(); + } catch (IOException ex) { + Log.e(ex); + running = false; + return; + } + while (running) { + String line; + try { + line = transport.readMessage(); + } catch (IOException ex) { + break; + } + if (line == null) { + break; + } + if (line.trim().length() == 0) { + continue; + } + String response = handleMessage(line); + if (response != null) { + try { + transport.writeMessage(response); + } catch (IOException ex) { + break; + } + } + } + running = false; + transport.close(); + } + + /// Handles one inbound JSON-RPC message and returns the response line, or null + /// when the message is a notification that warrants no reply. Never throws; every + /// failure is turned into a JSON-RPC error response. + public String handleMessage(String line) { + Map request; + try { + request = JSONParser.parseJSON(line); + } catch (Exception ex) { + return errorEnvelope(null, PARSE_ERROR, "Parse error"); + } + if (request == null) { + return errorEnvelope(null, PARSE_ERROR, "Parse error"); + } + boolean hasId = request.containsKey("id"); + Object id = request.get("id"); + try { + String method = JSONParser.getString(request, "method"); + Map params = JSONParser.asMap(request.get("params")); + if (method == null) { + return hasId ? errorEnvelope(id, INVALID_REQUEST, "Invalid Request") : null; + } + if (!hasId) { + handleNotification(method, params); + return null; + } + return dispatch(id, method, params); + } catch (Exception ex) { + return errorEnvelope(hasId ? id : null, INTERNAL_ERROR, + "Internal error: " + messageOf(ex)); + } + } + + private String dispatch(Object id, String method, Map params) throws Exception { + if ("initialize".equals(method)) { + return resultEnvelope(id, initializeResult(params)); + } + if ("ping".equals(method)) { + return resultEnvelope(id, new LinkedHashMap()); + } + if ("tools/list".equals(method)) { + return resultEnvelope(id, toolsList()); + } + if ("tools/call".equals(method)) { + return toolsCall(id, params); + } + if ("resources/list".equals(method)) { + return resultEnvelope(id, resourcesList()); + } + if ("resources/read".equals(method)) { + return resourcesRead(id, params); + } + return errorEnvelope(id, METHOD_NOT_FOUND, "Method not found: " + method); + } + + private void handleNotification(String method, Map params) { + // notifications/initialized and cancellation notices need no action here. + } + + private Map initializeResult(Map params) { + String protocol = params == null ? null : JSONParser.getString(params, "protocolVersion"); + if (protocol == null || protocol.length() == 0) { + protocol = DEFAULT_PROTOCOL_VERSION; + } + Map capabilities = new LinkedHashMap(); + capabilities.put("tools", new LinkedHashMap()); + capabilities.put("resources", new LinkedHashMap()); + Map serverInfo = new LinkedHashMap(); + serverInfo.put("name", serverName); + serverInfo.put("version", serverVersion); + Map result = new LinkedHashMap(); + result.put("protocolVersion", protocol); + result.put("capabilities", capabilities); + result.put("serverInfo", serverInfo); + return result; + } + + private synchronized Map toolsList() { + List list = new ArrayList(); + for (Tool tool : tools.values()) { + Map entry = new LinkedHashMap(); + entry.put("name", tool.getName()); + entry.put("description", tool.getDescription()); + entry.put("inputSchema", JSONParser.rawJson(tool.getParametersJsonSchema())); + list.add(entry); + } + Map result = new LinkedHashMap(); + result.put("tools", list); + return result; + } + + private String toolsCall(Object id, Map params) { + String name = params == null ? null : JSONParser.getString(params, "name"); + if (name == null || name.length() == 0) { + return errorEnvelope(id, INVALID_PARAMS, "Missing tool name"); + } + Tool tool; + synchronized (this) { + tool = tools.get(name); + } + if (tool == null) { + return errorEnvelope(id, METHOD_NOT_FOUND, "Unknown tool: " + name); + } + Object argObj = params.get("arguments"); + String argumentsJson = argObj == null ? "{}" : JSONParser.toJson(argObj); + String text; + boolean isError; + try { + text = tool.invoke(argumentsJson); + isError = false; + } catch (Exception ex) { + text = messageOf(ex); + isError = true; + } + if (text == null) { + text = ""; + } + Map content = new LinkedHashMap(); + content.put("type", "text"); + content.put("text", text); + List contentList = new ArrayList(); + contentList.add(content); + Map result = new LinkedHashMap(); + result.put("content", contentList); + result.put("isError", Boolean.valueOf(isError)); + return resultEnvelope(id, result); + } + + private Map resourcesList() { + List list = new ArrayList(); + if (screenshotEnabled) { + Map screen = new LinkedHashMap(); + screen.put("uri", SCREEN_RESOURCE_URI); + screen.put("name", "Current screen"); + screen.put("description", "A PNG screenshot of the current Codename One form."); + screen.put("mimeType", "image/png"); + list.add(screen); + } + Map result = new LinkedHashMap(); + result.put("resources", list); + return result; + } + + private String resourcesRead(Object id, Map params) { + String uri = params == null ? null : JSONParser.getString(params, "uri"); + if (uri == null || uri.length() == 0) { + return errorEnvelope(id, INVALID_PARAMS, "Missing resource uri"); + } + if (screenshotEnabled && SCREEN_RESOURCE_URI.equals(uri)) { + byte[] png = McpUiTools.screenshotPng(); + if (png == null) { + return errorEnvelope(id, INTERNAL_ERROR, "No screen available to capture"); + } + Map content = new LinkedHashMap(); + content.put("uri", uri); + content.put("mimeType", "image/png"); + content.put("blob", Base64.encodeNoNewline(png)); + List contents = new ArrayList(); + contents.add(content); + Map result = new LinkedHashMap(); + result.put("contents", contents); + return resultEnvelope(id, result); + } + return errorEnvelope(id, RESOURCE_NOT_FOUND, "Unknown resource: " + uri); + } + + private String resultEnvelope(Object id, Map result) { + return "{\"jsonrpc\":\"2.0\",\"id\":" + idJson(id) + + ",\"result\":" + JSONParser.toJson(result) + "}"; + } + + private String errorEnvelope(Object id, int code, String message) { + Map error = new LinkedHashMap(); + error.put("code", Integer.valueOf(code)); + error.put("message", message); + return "{\"jsonrpc\":\"2.0\",\"id\":" + idJson(id) + + ",\"error\":" + JSONParser.toJson(error) + "}"; + } + + /// Serializes the JSON-RPC id, preserving integer form. The lenient CN1 parser reads + /// `1` as a double, but the id must be echoed exactly so strict hosts can correlate + /// the response, so an integral double is emitted without a fractional part. + private static String idJson(Object id) { + if (id instanceof Double || id instanceof Float) { + double d = ((Number) id).doubleValue(); + if (!Double.isInfinite(d) && !Double.isNaN(d) && d == Math.floor(d)) { + return Long.toString((long) d); + } + } + return JSONParser.toJson(id); + } + + private static String messageOf(Throwable ex) { + String m = ex.getMessage(); + return m == null ? ex.getClass().getName() : m; + } +} diff --git a/CodenameOne/src/com/codename1/mcp/MCPTransport.java b/CodenameOne/src/com/codename1/mcp/MCPTransport.java new file mode 100644 index 00000000000..76ea61f3fc8 --- /dev/null +++ b/CodenameOne/src/com/codename1/mcp/MCPTransport.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.mcp; + +import java.io.IOException; + +/// A bidirectional line delimited transport for MCP JSON-RPC messages. +/// +/// The MCP stdio transport frames messages as newline delimited JSON, one complete +/// JSON-RPC object per line with no embedded newlines. Implementations therefore +/// exchange whole message strings and never deal with partial frames. +public interface MCPTransport { + /// Opens the transport, blocking until it is ready to exchange messages. For a + /// socket transport this waits for the first client connection. + void open() throws IOException; + + /// Reads the next complete JSON-RPC message. Returns null at end of stream. + String readMessage() throws IOException; + + /// Writes a complete JSON-RPC message and flushes it. The value must not contain + /// embedded newlines. + void writeMessage(String message) throws IOException; + + /// Closes the transport and releases any underlying resources. + void close(); +} diff --git a/CodenameOne/src/com/codename1/mcp/McpUiTools.java b/CodenameOne/src/com/codename1/mcp/McpUiTools.java new file mode 100644 index 00000000000..1d6d0e0c0d5 --- /dev/null +++ b/CodenameOne/src/com/codename1/mcp/McpUiTools.java @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.mcp; + +import com.codename1.ai.Tool; +import com.codename1.ai.ToolHandler; +import com.codename1.io.JSONParser; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.Graphics; +import com.codename1.ui.Image; +import com.codename1.ui.accessibility.AccessibilityAction; +import com.codename1.ui.accessibility.AccessibilityManager; +import com.codename1.ui.accessibility.AccessibilityNodeSnapshot; +import com.codename1.ui.accessibility.AccessibilityTreeSnapshot; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.util.ImageIO; +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Built in MCP tools that turn any Codename One application into an agent drivable +/// surface with no application code. They wrap the portable accessibility semantics +/// tree: {@link AccessibilityTreeSnapshot#toJson()} reads the current screen and +/// {@link AccessibilityAction} entries drive it. All tree access is marshalled onto +/// the Codename One EDT because the live component hierarchy must never be walked off +/// thread. +final class McpUiTools { + private McpUiTools() { + } + + static List builtInTools() { + List tools = new ArrayList(); + + tools.add(new Tool("ui_snapshot", + "Returns the accessibility semantics tree of the current screen as JSON: every " + + "visible node with its id, role, label, value, state and the ids of the " + + "actions that can be performed on it.", + "{\"type\":\"object\",\"properties\":{}}", + new ToolHandler() { + @Override + public String invoke(String argumentsJson) { + return snapshotJson(); + } + })); + + tools.add(new Tool("ui_perform_action", + "Performs an accessibility action on a node from ui_snapshot. actionId is one of the " + + "action ids listed on the node (for example activate, setText, increment, focus). " + + "Returns whether it succeeded and a fresh snapshot.", + "{\"type\":\"object\",\"properties\":{" + + "\"nodeId\":{\"type\":\"integer\",\"description\":\"id of the target node\"}," + + "\"actionId\":{\"type\":\"string\",\"description\":\"id of the action to perform\"}," + + "\"argument\":{\"type\":\"string\",\"description\":\"optional argument, e.g. text for setText\"}" + + "},\"required\":[\"nodeId\",\"actionId\"]}", + new ToolHandler() { + @Override + public String invoke(String argumentsJson) throws Exception { + Map args = parse(argumentsJson); + long nodeId = longArg(args, "nodeId", -1); + String actionId = JSONParser.getString(args, "actionId"); + Object argument = args == null ? null : args.get("argument"); + return JSONParser.toJson(performAction(nodeId, actionId, argument)); + } + })); + + tools.add(new Tool("ui_activate", + "Convenience wrapper that performs the activate action on a node (equivalent to a tap " + + "or click). Returns whether it succeeded and a fresh snapshot.", + "{\"type\":\"object\",\"properties\":{" + + "\"nodeId\":{\"type\":\"integer\",\"description\":\"id of the node to activate\"}" + + "},\"required\":[\"nodeId\"]}", + new ToolHandler() { + @Override + public String invoke(String argumentsJson) throws Exception { + Map args = parse(argumentsJson); + return JSONParser.toJson(performAction(longArg(args, "nodeId", -1), + AccessibilityAction.ACTIVATE, null)); + } + })); + + tools.add(new Tool("ui_set_text", + "Convenience wrapper that sets the text of an editable node (performs the setText " + + "action with the given text). Returns whether it succeeded and a fresh snapshot.", + "{\"type\":\"object\",\"properties\":{" + + "\"nodeId\":{\"type\":\"integer\",\"description\":\"id of the editable node\"}," + + "\"text\":{\"type\":\"string\",\"description\":\"the text to set\"}" + + "},\"required\":[\"nodeId\",\"text\"]}", + new ToolHandler() { + @Override + public String invoke(String argumentsJson) throws Exception { + Map args = parse(argumentsJson); + return JSONParser.toJson(performAction(longArg(args, "nodeId", -1), + AccessibilityAction.SET_TEXT, JSONParser.getString(args, "text"))); + } + })); + + tools.add(new Tool("ui_find", + "Finds nodes on the current screen by application identifier, by a case insensitive " + + "substring of their label, or by a screen coordinate. Returns a compact JSON array " + + "of matches with their id, role, label and available action ids.", + "{\"type\":\"object\",\"properties\":{" + + "\"identifier\":{\"type\":\"string\",\"description\":\"exact application identifier\"}," + + "\"label\":{\"type\":\"string\",\"description\":\"label substring to search for\"}," + + "\"x\":{\"type\":\"integer\",\"description\":\"screen x coordinate\"}," + + "\"y\":{\"type\":\"integer\",\"description\":\"screen y coordinate\"}" + + "}}", + new ToolHandler() { + @Override + public String invoke(String argumentsJson) throws Exception { + Map args = parse(argumentsJson); + return findNodes(JSONParser.getString(args, "identifier"), + JSONParser.getString(args, "label"), + (int) longArg(args, "x", Integer.MIN_VALUE), + (int) longArg(args, "y", Integer.MIN_VALUE)); + } + })); + + return tools; + } + + static String snapshotJson() { + final String[] holder = new String[1]; + runOnEdt(new Runnable() { + @Override + public void run() { + holder[0] = currentSnapshot().toJson(); + } + }); + return holder[0]; + } + + static Map performAction(final long nodeId, final String actionId, final Object argument) { + final Map out = new LinkedHashMap(); + runOnEdt(new Runnable() { + @Override + public void run() { + AccessibilityManager mgr = AccessibilityManager.getInstance(); + AccessibilityTreeSnapshot snap = currentSnapshot(); + AccessibilityNodeSnapshot node = snap.getNode(nodeId); + AccessibilityAction action = node == null ? null : node.getAction(actionId); + boolean ok = false; + if (node != null && action != null && action.isEnabled()) { + ok = action.perform(node.getComponent(), argument); + mgr.invalidate(node.getComponent(), AccessibilityManager.CHANGE_STATE + | AccessibilityManager.CHANGE_VALUE | AccessibilityManager.CHANGE_CONTENT + | AccessibilityManager.CHANGE_STRUCTURE); + } + out.put("success", Boolean.valueOf(ok)); + if (node == null) { + out.put("error", "no node with id " + nodeId); + } else if (action == null) { + out.put("error", "node " + nodeId + " has no action " + actionId); + } + out.put("snapshot", JSONParser.rawJson(currentSnapshot().toJson())); + } + }); + return out; + } + + static String findNodes(final String identifier, final String label, final int x, final int y) { + final List matches = new ArrayList(); + runOnEdt(new Runnable() { + @Override + public void run() { + AccessibilityTreeSnapshot snap = currentSnapshot(); + if (identifier != null && identifier.length() > 0) { + addNode(matches, snap.getNodeByIdentifier(identifier)); + } else if (x != Integer.MIN_VALUE && y != Integer.MIN_VALUE) { + addNode(matches, snap.getNodeAt(x, y)); + } else if (label != null && label.length() > 0) { + String needle = label.toLowerCase(); + for (AccessibilityNodeSnapshot node : snap.getNodes().values()) { + String candidate = node.getLabel(); + if (candidate != null && candidate.toLowerCase().indexOf(needle) >= 0) { + addNode(matches, node); + } + } + } + } + }); + return JSONParser.toJson(matches); + } + + /// Renders the current form to a PNG. Returns null when no form is showing or the + /// platform cannot encode PNG images. Runs on the EDT. + static byte[] screenshotPng() { + final byte[][] holder = new byte[1][]; + runOnEdt(new Runnable() { + @Override + public void run() { + holder[0] = renderCurrentFormPng(); + } + }); + return holder[0]; + } + + private static byte[] renderCurrentFormPng() { + Form form = Display.getInstance().getCurrent(); + ImageIO io = ImageIO.getImageIO(); + if (form == null || io == null) { + return null; + } + int w = form.getWidth(); + int h = form.getHeight(); + if (w <= 0 || h <= 0) { + return null; + } + Image image = Image.createImage(w, h, 0xffffffff); + Graphics g = image.getGraphics(); + form.paintComponent(g, true); + try { + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + io.save(image, bo, ImageIO.FORMAT_PNG, 1); + return bo.toByteArray(); + } catch (Exception ex) { + return null; + } + } + + private static void addNode(List matches, AccessibilityNodeSnapshot node) { + if (node == null) { + return; + } + Map out = new LinkedHashMap(); + out.put("id", Long.valueOf(node.getId())); + out.put("role", node.getRole().name()); + out.put("label", node.getLabel()); + out.put("value", node.getValue()); + out.put("identifier", node.getIdentifier()); + Rectangle b = node.getBounds(); + List bounds = new ArrayList(); + bounds.add(Integer.valueOf(b.getX())); + bounds.add(Integer.valueOf(b.getY())); + bounds.add(Integer.valueOf(b.getWidth())); + bounds.add(Integer.valueOf(b.getHeight())); + out.put("bounds", bounds); + List actions = new ArrayList(); + for (int i = 0; i < node.getActions().size(); i++) { + actions.add(node.getActions().get(i).getId()); + } + out.put("actions", actions); + matches.add(out); + } + + private static AccessibilityTreeSnapshot currentSnapshot() { + return AccessibilityManager.getInstance().getSnapshot(Display.getInstance().getCurrent()); + } + + private static void runOnEdt(Runnable r) { + Display display = Display.getInstance(); + if (display.isEdt()) { + r.run(); + } else { + display.callSeriallyAndWait(r); + } + } + + private static Map parse(String json) throws Exception { + if (json == null || json.length() == 0) { + return new LinkedHashMap(); + } + Map parsed = JSONParser.parseJSON(json); + return parsed == null ? new LinkedHashMap() : parsed; + } + + private static long longArg(Map args, String key, long defaultValue) { + if (args == null) { + return defaultValue; + } + Object v = args.get(key); + if (v instanceof Number) { + return ((Number) v).longValue(); + } + if (v instanceof String) { + try { + return Long.parseLong(((String) v).trim()); + } catch (NumberFormatException ex) { + return defaultValue; + } + } + return defaultValue; + } +} diff --git a/CodenameOne/src/com/codename1/mcp/SocketTransport.java b/CodenameOne/src/com/codename1/mcp/SocketTransport.java new file mode 100644 index 00000000000..5481117d58e --- /dev/null +++ b/CodenameOne/src/com/codename1/mcp/SocketTransport.java @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.mcp; + +import com.codename1.io.Socket; +import com.codename1.io.SocketConnection; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; + +/// Loopback socket MCP transport. Lets an already running, human visible application +/// accept an attaching MCP host on a local port, so an agent can drive a live session +/// the user is watching rather than launching a fresh headless subprocess. +/// +/// Only one socket transport is active at a time, which matches the single server per +/// process model of {@link MCP}. Built on the portable {@link com.codename1.io.Socket} +/// server API so it links on every target, while only functioning where +/// {@link com.codename1.io.Socket#isServerSocketSupported()} is true. +public class SocketTransport implements MCPTransport { + private static volatile SocketTransport active; + + private final int port; + private final Object connectionLock = new Object(); + private Socket.StopListening stopListening; + private InputStream in; + private OutputStream out; + private InputStreamReader reader; + private Writer writer; + + public SocketTransport(int port) { + this.port = port; + } + + public int getPort() { + return port; + } + + @Override + public void open() throws IOException { + if (!Socket.isServerSocketSupported()) { + throw new IOException("Server sockets are not supported on this platform"); + } + active = this; + stopListening = Socket.listen(port, Bridge.class); + synchronized (connectionLock) { + while (in == null) { + try { + connectionLock.wait(); + } catch (InterruptedException ex) { + throw new IOException("Interrupted while awaiting MCP client connection"); + } + } + } + reader = new InputStreamReader(in, "UTF-8"); + writer = new OutputStreamWriter(out, "UTF-8"); + } + + @Override + public String readMessage() throws IOException { + if (reader == null) { + return null; + } + return MCPLineReader.readLine(reader); + } + + @Override + public void writeMessage(String message) throws IOException { + writer.write(message); + writer.write('\n'); + writer.flush(); + } + + @Override + public void close() { + if (stopListening != null) { + try { + stopListening.stop(); + } catch (Throwable ignored) { + // best effort shutdown of the listening socket + } + stopListening = null; + } + active = null; + } + + void onConnection(InputStream is, OutputStream os) { + synchronized (connectionLock) { + this.in = is; + this.out = os; + connectionLock.notifyAll(); + } + } + + /// Delivered to {@link com.codename1.io.Socket#listen} which instantiates one per + /// accepted connection and reports the streams to the active transport. + public static final class Bridge extends SocketConnection { + @Override + public void connectionError(int errorCode, String message) { + // The listen loop reports failures here; the transport simply keeps waiting + // for a subsequent successful connection. + } + + @Override + public void connectionEstablished(InputStream is, OutputStream os) { + SocketTransport transport = active; + if (transport != null) { + transport.onConnection(is, os); + } + } + } +} diff --git a/CodenameOne/src/com/codename1/mcp/package-info.java b/CodenameOne/src/com/codename1/mcp/package-info.java new file mode 100644 index 00000000000..71090006e98 --- /dev/null +++ b/CodenameOne/src/com/codename1/mcp/package-info.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +/// Model Context Protocol (MCP) headless API for Codename One applications. +/// +/// This package lets any Codename One application expose itself to an LLM agent +/// (Claude Desktop/Code, Codex, opencode, and other MCP hosts) so the agent can +/// read the current screen, drive the user interface, and invoke domain specific +/// tools the application publishes. It is supported on desktop style targets that +/// own a standard input/output stream: the JavaSE port and ParparVM native desktop +/// builds. +/// +/// The server is intentionally thin. It reuses the portable accessibility semantics +/// tree from {@link com.codename1.ui.accessibility} for automatic user interface +/// driving and the {@link com.codename1.ai.Tool} contract for developer defined +/// tools, so no new tool abstraction is introduced. +/// +/// #### Getting started +/// +/// ``` +/// // From the application init/start, or via the desktop launcher --mcp-stdio flag: +/// MCP.startStdioServer(); +/// +/// // Publish an application specific tool: +/// MCP.addTool(new Tool("current_user", "Returns the signed in user", +/// "{\"type\":\"object\",\"properties\":{}}", +/// new ToolHandler() { +/// public String invoke(String argumentsJson) { +/// return "{\"name\":\"" + session.getUser().getName() + "\"}"; +/// } +/// })); +/// ``` +package com.codename1.mcp; diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 14155ef4ccf..bbf9b831a4b 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -5352,6 +5352,7 @@ public void actionPerformed(ActionEvent e) { }; buildHintEditor.addActionListener(l); toolsMenu.add(buildHintEditor); + addMcpMenu(toolsMenu); final JCheckBoxMenuItem useAppFrameMenu = new JCheckBoxMenuItem("Single Window Mode", useAppFrame); useAppFrameMenu.setToolTipText("Check this option to enable Single Window mode, in which the simulator, component inspector, network monitor and other tools are all included in a single, multi-panel window"); @@ -6727,6 +6728,7 @@ public void actionPerformed(ActionEvent e) { } toolsMenu.addSeparator(); toolsMenu.add(buildHintEditor); + addMcpMenu(toolsMenu); toolsMenu.add(autoLocalizationMenu); toolsMenu.add(clean); @@ -8676,10 +8678,70 @@ private static void installGeneratedSvgRegistry() { /** * @inheritDoc */ + // Model Context Protocol: expose the running simulator to an LLM agent, and detect + // installed MCP hosts. The socket server lets an agent attach to the live session and + // drive it through the accessibility semantics tree. + private void addMcpMenu(JMenu toolsMenu) { + JMenu mcpMenu = new JMenu("Model Context Protocol"); + registerMenuWithBlit(mcpMenu); + final java.util.prefs.Preferences mcpPref = + java.util.prefs.Preferences.userNodeForPackage(JavaSEPort.class); + final int mcpPort = mcpPref.getInt("cn1.mcp.port", 8765); + final JCheckBoxMenuItem mcpServerMenu = new JCheckBoxMenuItem("Expose App To Agents (MCP)", + com.codename1.mcp.MCP.isRunning()); + mcpServerMenu.setToolTipText("Starts a local MCP server on port " + mcpPort + + " so an LLM agent can read and drive this running app"); + mcpServerMenu.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + if (mcpServerMenu.isSelected()) { + com.codename1.mcp.MCP.startSocketServer(mcpPort); + JOptionPane.showMessageDialog(canvas, + "MCP server listening on 127.0.0.1:" + mcpPort + + ".\nPoint an MCP host at this port to drive the app.", + "MCP Server Started", JOptionPane.INFORMATION_MESSAGE); + } else { + com.codename1.mcp.MCP.stop(); + } + } + }); + mcpMenu.add(mcpServerMenu); + + JMenuItem mcpDetect = new JMenuItem("Detect MCP Clients..."); + mcpDetect.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + com.codename1.mcp.MCPClientRegistrar registrar = + com.codename1.mcp.MCPClientRegistrar.getInstance(); + java.util.List clients = + registrar.detectClients(); + StringBuilder sb = new StringBuilder(); + if (clients.isEmpty()) { + sb.append("No MCP hosts detected on this machine."); + } else { + sb.append("Detected MCP hosts:\n\n"); + for (int i = 0; i < clients.size(); i++) { + com.codename1.mcp.MCPClientRegistrar.MCPClient c = clients.get(i); + sb.append(c.getDisplayName()) + .append(c.isWritable() ? " (auto-configurable)" : " (manual config)") + .append("\n ").append(c.getConfigPath()).append("\n"); + } + } + JOptionPane.showMessageDialog(canvas, sb.toString(), "MCP Clients", + JOptionPane.INFORMATION_MESSAGE); + } + }); + mcpMenu.add(mcpDetect); + toolsMenu.add(mcpMenu); + } + public void init(Object m) { inInit = true; installGeneratedSvgRegistry(); + // Make the desktop stdio MCP transport available to com.codename1.mcp.MCP. + MCPStdioTransport.register(); + // Fire-and-forget probe so LlmClient.simulatorRedirect=auto // can detect a local Ollama install without blocking startup. probeOllamaAsync(); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioTransport.java b/Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioTransport.java new file mode 100644 index 00000000000..0b840106e71 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioTransport.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import com.codename1.mcp.MCP; +import com.codename1.mcp.MCPTransport; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintStream; +import java.io.Writer; + +/// The standard MCP stdio transport for the JavaSE desktop port: newline delimited +/// JSON-RPC over the process standard input and output. It lives in the JavaSE port +/// rather than the portable core because it depends on {@link System#in} and +/// {@link System#setOut(PrintStream)}, which are not available on every Codename One +/// target (notably the ParparVM Java runtime). +/// +/// While open, the process standard output is reserved exclusively for the protocol, so +/// {@link System#out} is redirected to {@link System#err} to keep application logging +/// from corrupting the JSON-RPC stream. The original stream is retained for writes. +public class MCPStdioTransport implements MCPTransport { + private final InputStream in; + private final PrintStream protocolOut; + private final boolean redirectStandardOut; + private BufferedReader reader; + private Writer writer; + private PrintStream previousOut; + + public MCPStdioTransport() { + this(System.in, System.out, true); + } + + public MCPStdioTransport(InputStream input, OutputStream output, boolean redirectStandardOut) { + this.in = input; + this.protocolOut = output instanceof PrintStream ? (PrintStream) output : new PrintStream(output); + this.redirectStandardOut = redirectStandardOut; + } + + /// Registers this transport as the platform stdio factory so + /// {@link MCP#startStdioServer()} works on the desktop. Safe to call more than once. + public static void register() { + MCP.setStdioTransportFactory(new MCP.StdioTransportFactory() { + @Override + public MCPTransport createStdioTransport() { + return new MCPStdioTransport(); + } + }); + } + + @Override + public void open() throws IOException { + reader = new BufferedReader(new InputStreamReader(in, "UTF-8")); + writer = new OutputStreamWriter(protocolOut, "UTF-8"); + if (redirectStandardOut) { + previousOut = System.out; + System.setOut(System.err); + } + } + + @Override + public String readMessage() throws IOException { + if (reader == null) { + return null; + } + return reader.readLine(); + } + + @Override + public void writeMessage(String message) throws IOException { + writer.write(message); + writer.write('\n'); + writer.flush(); + } + + @Override + public void close() { + if (redirectStandardOut && previousOut != null) { + System.setOut(previousOut); + previousOut = null; + } + try { + if (writer != null) { + writer.flush(); + } + } catch (IOException ignored) { + // best effort flush on shutdown + } + } +} diff --git a/docs/developer-guide/MCP-Headless-API.asciidoc b/docs/developer-guide/MCP-Headless-API.asciidoc new file mode 100644 index 00000000000..c76bce8835a --- /dev/null +++ b/docs/developer-guide/MCP-Headless-API.asciidoc @@ -0,0 +1,64 @@ += MCP Headless API + +The Model Context Protocol (MCP) headless API lets an application expose itself to a large language model agent. An agent such as Claude Desktop, Claude Code, Codex, or opencode can read the current screen, drive the user interface, and call tools the application publishes. + +The API reuses the accessibility semantics tree. The same immutable tree that describes the screen to VoiceOver and TalkBack also describes it to the agent, so any application is drivable without extra code. Application specific data and actions are exposed through the existing `com.codename1.ai.Tool` contract. + +The API is supported by the JavaSE port, which powers the simulator and the desktop tooling. It's not part of a packaged application build, and it's inert on mobile and web targets. + +== Starting the server + +Invoking the API is the switch. There is no build hint or property to set. The socket server lets an agent attach to a session a person is watching, which is the usual choice in the simulator: + +[source,java] +---- +MCP.startSocketServer(8765); +---- + +A tool that a host launches as a subprocess can serve over standard input and output instead: + +[source,java] +---- +MCP.startStdioServer(); +---- + +The stdio transport is the standard MCP local transport, exchanging newline delimited JSON-RPC messages. While it runs, application logging is redirected away from standard output so it can't corrupt the protocol stream. The stdio transport lives in the JavaSE port because it needs process standard input, which isn't available on every target. + +== Built-in user interface tools + +Every server registers a small set of tools that read and drive the screen through the accessibility tree. An agent calls `ui_snapshot` to get the semantics tree of the current form as JSON, including the identifier, role, label, value, and state of each node, and the identifiers of the actions each node supports. + +The agent drives the screen with `ui_perform_action`, which performs an action such as `activate` or `setText` on a node from the snapshot. The convenience tools `ui_activate` and `ui_set_text` wrap the common cases, and `ui_find` locates nodes by identifier, by label, or by screen coordinate. + +Actions run on the Codename One EDT, so an agent never touches the live component tree directly. Each action returns whether it succeeded together with a fresh snapshot. + +== Publishing application tools + +An application publishes its own data and actions as MCP tools. A `Tool` has a name, a description, a JSON schema for its parameters, and a handler: + +[source,java] +---- +MCP.addTool(new Tool( + "current_user", + "Returns the signed in user", + "{\"type\":\"object\",\"properties\":{}}", + new ToolHandler() { + public String invoke(String argumentsJson) { + return "{\"name\":\"" + session.getUser().getName() + "\"}"; + } + })); +---- + +The server merges these tools with the built-in tools when a host lists the available tools, and routes each call to the matching handler. The same `Tool` type is used by the Codename One AI client, so a tool defined once serves both a hosted agent and an in-application model. + +== Screenshots and the simulator + +The server exposes a screenshot of the current form as an MCP image resource, so a vision capable model can see the screen alongside the semantic tree. The resource is enabled by default and can be disabled per server. + +The JavaSE simulator exposes the socket server through a Tools menu, so an application can be driven from the running simulator during development. The same menu detects the MCP hosts installed on the machine. + +== Registering with installed hosts + +`MCPClientRegistrar` 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. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 8edd593c787..b21b9277658 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -59,6 +59,8 @@ include::The-Components-Of-Codename-One.asciidoc[] include::Accessibility-Semantics.asciidoc[] +include::MCP-Headless-API.asciidoc[] + include::Media-And-Audio.asciidoc[] include::Animations.asciidoc[] diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index 10c8fbc45c9..5056ab5eb72 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -610,3 +610,10 @@ screenY # AR / VR terms (Augmented-Reality.asciidoc, Virtual-Reality.asciidoc) raycasts? equirectangular + +# MCP headless API terms (MCP-Headless-API.asciidoc) +opencode +stdio +JSON-RPC +subprocess +Codex diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerTest.java new file mode 100644 index 00000000000..2b3dbcc53ef --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerTest.java @@ -0,0 +1,311 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.mcp; + +import com.codename1.ai.Tool; +import com.codename1.ai.ToolHandler; +import com.codename1.io.JSONParser; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.ui.Button; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/// Verifies MCP protocol conformance (JSON-RPC 2.0 framing, capabilities handshake, +/// error codes) and end to end user interface driving through the accessibility +/// semantics tree. Runs on the Codename One EDT via {@link FormTest}. +class MCPServerTest extends UITestBase { + + @FormTest + void initializeReportsProtocolCapabilitiesAndServerInfo() throws Exception { + MCPServer server = new MCPServer(); + Map response = parse(server.handleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + + "\"params\":{\"protocolVersion\":\"2024-11-05\"}}")); + + assertEquals("2.0", response.get("jsonrpc")); + assertEquals(1, asInt(response.get("id"))); + Map result = asMap(response.get("result")); + assertEquals("2024-11-05", result.get("protocolVersion")); + Map capabilities = asMap(result.get("capabilities")); + assertTrue(capabilities.containsKey("tools")); + assertTrue(capabilities.containsKey("resources")); + Map serverInfo = asMap(result.get("serverInfo")); + assertNotNull(serverInfo.get("name")); + assertNotNull(serverInfo.get("version")); + } + + @FormTest + void initializeDefaultsProtocolWhenClientOmitsIt() throws Exception { + MCPServer server = new MCPServer(); + Map result = asMap(parse(server.handleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")).get("result")); + assertEquals(MCPServer.DEFAULT_PROTOCOL_VERSION, result.get("protocolVersion")); + } + + @FormTest + void toolsListIncludesBuiltInAndDeveloperTools() throws Exception { + MCPServer server = new MCPServer(); + server.addTool(new Tool("current_user", "Returns the user", + "{\"type\":\"object\",\"properties\":{}}", echoHandler())); + + Map result = asMap(parse(server.handleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}")).get("result")); + List tools = asList(result.get("tools")); + assertTrue(toolNames(tools).contains("ui_snapshot")); + assertTrue(toolNames(tools).contains("ui_perform_action")); + assertTrue(toolNames(tools).contains("current_user")); + + Map uiSnapshot = toolByName(tools, "ui_snapshot"); + assertNotNull(uiSnapshot.get("description")); + assertNotNull(uiSnapshot.get("inputSchema")); + assertTrue(asMap(uiSnapshot.get("inputSchema")).containsKey("type")); + } + + @FormTest + void toolsCallDispatchesToDeveloperHandler() throws Exception { + MCPServer server = new MCPServer(); + server.addTool(new Tool("echo", "echoes arguments", + "{\"type\":\"object\",\"properties\":{}}", echoHandler())); + + Map result = asMap(parse(server.handleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"echo\",\"arguments\":{\"hello\":\"world\"}}}")).get("result")); + + assertFalse(asBool(result.get("isError"))); + List content = asList(result.get("content")); + Map first = asMap(content.get(0)); + assertEquals("text", first.get("type")); + assertTrue(((String) first.get("text")).indexOf("world") >= 0); + } + + @FormTest + void toolFailureIsReportedAsIsErrorResult() throws Exception { + MCPServer server = new MCPServer(); + server.addTool(new Tool("boom", "always fails", + "{\"type\":\"object\",\"properties\":{}}", new ToolHandler() { + @Override + public String invoke(String argumentsJson) throws Exception { + throw new IllegalStateException("kaboom"); + } + })); + + Map result = asMap(parse(server.handleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"boom\",\"arguments\":{}}}")).get("result")); + assertTrue(asBool(result.get("isError"))); + assertTrue(((String) asMap(asList(result.get("content")).get(0)).get("text")).indexOf("kaboom") >= 0); + } + + @FormTest + void unknownMethodReturnsMethodNotFound() throws Exception { + MCPServer server = new MCPServer(); + Map error = asMap(parse(server.handleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"does/notExist\"}")).get("error")); + assertEquals(MCPServer.METHOD_NOT_FOUND, asInt(error.get("code"))); + } + + @FormTest + void unknownToolReturnsMethodNotFound() throws Exception { + MCPServer server = new MCPServer(); + Map error = asMap(parse(server.handleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"nope\"}}")).get("error")); + assertEquals(MCPServer.METHOD_NOT_FOUND, asInt(error.get("code"))); + } + + @FormTest + void requestWithoutMethodReturnsInvalidRequest() throws Exception { + MCPServer server = new MCPServer(); + Map error = asMap(parse(server.handleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":7}")).get("error")); + assertEquals(MCPServer.INVALID_REQUEST, asInt(error.get("code"))); + } + + @FormTest + void notificationsProduceNoResponse() { + MCPServer server = new MCPServer(); + assertNull(server.handleMessage( + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}")); + } + + @FormTest + void uiSnapshotExposesTheLiveForm() throws Exception { + Form form = new Form("Login"); + Button save = new Button("Save"); + form.add(save); + form.add(new TextField("hello")); + form.show(); + + MCPServer server = new MCPServer(); + String snapshotJson = callTool(server, "ui_snapshot", "{}"); + assertTrue(snapshotJson.indexOf("\"BUTTON\"") >= 0); + assertTrue(snapshotJson.indexOf("Save") >= 0); + assertTrue(snapshotJson.indexOf("\"activate\"") >= 0); + } + + @FormTest + void uiSetTextDrivesAnEditableField() throws Exception { + Form form = new Form("Editor"); + TextField field = new TextField("old"); + form.add(field); + form.show(); + + MCPServer server = new MCPServer(); + long nodeId = firstNodeWithAction(callTool(server, "ui_snapshot", "{}"), "setText"); + assertTrue(nodeId >= 0, "expected an editable node exposing setText"); + + Map result = parse(callTool(server, "ui_set_text", + "{\"nodeId\":" + nodeId + ",\"text\":\"driven\"}")); + assertTrue(asBool(result.get("success"))); + assertEquals("driven", field.getText()); + } + + @FormTest + void uiActivateFiresTheComponentAction() throws Exception { + Form form = new Form("Actions"); + Button button = new Button("Go"); + final boolean[] fired = new boolean[1]; + button.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + fired[0] = true; + } + }); + form.add(button); + form.show(); + + MCPServer server = new MCPServer(); + long nodeId = firstNodeWithAction(callTool(server, "ui_snapshot", "{}"), "activate"); + assertTrue(nodeId >= 0, "expected a node exposing activate"); + Map result = parse(callTool(server, "ui_activate", + "{\"nodeId\":" + nodeId + "}")); + assertTrue(asBool(result.get("success"))); + assertTrue(fired[0], "activating the node should fire the button action"); + } + + @FormTest + void uiFindLocatesNodesByLabel() throws Exception { + Form form = new Form("Find"); + form.add(new Button("Unique Label")); + form.show(); + + MCPServer server = new MCPServer(); + String json = callTool(server, "ui_find", "{\"label\":\"unique\"}"); + // ui_find returns a JSON array; wrap it so the object-oriented parser can read it. + List matches = asList(parse("{\"m\":" + json + "}").get("m")); + assertNotNull(matches); + assertTrue(matches.size() >= 1); + assertEquals("Unique Label", asMap(matches.get(0)).get("label")); + } + + // ---- helpers ---------------------------------------------------------- + + private String callTool(MCPServer server, String name, String argumentsJson) throws Exception { + String line = "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"" + name + "\",\"arguments\":" + argumentsJson + "}}"; + Map result = asMap(parse(server.handleMessage(line)).get("result")); + assertFalse(asBool(result.get("isError")), "tool " + name + " reported an error"); + return (String) asMap(asList(result.get("content")).get(0)).get("text"); + } + + private long firstNodeWithAction(String snapshotJson, String actionId) throws Exception { + Map tree = parse(snapshotJson); + List nodes = asList(tree.get("nodes")); + for (int i = 0; i < nodes.size(); i++) { + Map node = asMap(nodes.get(i)); + List actions = asList(node.get("actions")); + if (actions != null) { + for (int a = 0; a < actions.size(); a++) { + if (actionId.equals(asMap(actions.get(a)).get("id"))) { + return asLong(node.get("id")); + } + } + } + } + return -1; + } + + private ToolHandler echoHandler() { + return new ToolHandler() { + @Override + public String invoke(String argumentsJson) { + return argumentsJson; + } + }; + } + + private java.util.List toolNames(List tools) { + java.util.List names = new java.util.ArrayList(); + for (int i = 0; i < tools.size(); i++) { + names.add((String) asMap(tools.get(i)).get("name")); + } + return names; + } + + private Map toolByName(List tools, String name) { + for (int i = 0; i < tools.size(); i++) { + Map tool = asMap(tools.get(i)); + if (name.equals(tool.get("name"))) { + return tool; + } + } + return null; + } + + private Map parse(String json) throws Exception { + return JSONParser.parseJSON(json); + } + + @SuppressWarnings("unchecked") + private Map asMap(Object o) { + return (Map) o; + } + + @SuppressWarnings("unchecked") + private List asList(Object o) { + return (List) o; + } + + private int asInt(Object o) { + return ((Number) o).intValue(); + } + + private long asLong(Object o) { + return ((Number) o).longValue(); + } + + /// The CN1 JSON parser returns booleans as strings by default, so accept either. + private boolean asBool(Object o) { + if (o instanceof Boolean) { + return ((Boolean) o).booleanValue(); + } + return "true".equals(String.valueOf(o)); + } +} diff --git a/scripts/mcp-inspector-e2e.sh b/scripts/mcp-inspector-e2e.sh new file mode 100755 index 00000000000..20ace9faf89 --- /dev/null +++ b/scripts/mcp-inspector-e2e.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# +# MCP spec conformance end-to-end check. +# +# Builds a minimal headless Codename One app (scripts/mcp/McpStdioDemo.java) that +# exposes itself over the MCP stdio transport, then drives it with the reference MCP +# Inspector CLI (npx @modelcontextprotocol/inspector). A green initialize handshake +# plus successful tools/list and tools/call is the conformance gate. +# +# Requirements: JDK 8 (JAVA_HOME), Maven, Node/npx with network access. +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT/maven" + +: "${JAVA_HOME:?Set JAVA_HOME to a JDK 8 install}" +JAVAC="$JAVA_HOME/bin/javac" +JAVA="$JAVA_HOME/bin/java" + +echo "==> Building core + javase and compiling core test classes" +mvn -q -o -pl core install -DskipTests +mvn -q -o -pl javase -Plocal-dev-javase install -DskipTests +mvn -q -o -pl core-unittests test-compile + +echo "==> Resolving classpath" +mvn -q -o -pl javase -Plocal-dev-javase dependency:build-classpath -Dmdep.outputFile=/tmp/mcp-e2e-cp.txt +# Freshly built classes come first so they shadow any older snapshot jar the +# dependency graph may resolve; the JavaSE port supplies MCPStdioTransport and the +# test-classes supply the headless TestCodenameOneImplementation. +CP="$REPO_ROOT/maven/core/target/classes:$REPO_ROOT/maven/javase/target/classes:$REPO_ROOT/maven/core-unittests/target/test-classes:$(cat /tmp/mcp-e2e-cp.txt)" + +OUT="$(mktemp -d)" +echo "==> Compiling demo server into $OUT" +"$JAVAC" -cp "$CP" -d "$OUT" "$REPO_ROOT/scripts/mcp/McpStdioDemo.java" + +cat > "$OUT/server.sh" < Inspector: tools/list" +npx -y @modelcontextprotocol/inspector --cli "$OUT/server.sh" --method tools/list + +echo "==> Inspector: tools/call ui_snapshot" +npx -y @modelcontextprotocol/inspector --cli "$OUT/server.sh" \ + --method tools/call --tool-name ui_snapshot + +echo "==> Inspector: tools/call ui_set_text (drives the TextField)" +npx -y @modelcontextprotocol/inspector --cli "$OUT/server.sh" \ + --method tools/call --tool-name ui_set_text --tool-arg nodeId=6 --tool-arg text=driven + +echo "==> MCP conformance E2E passed" diff --git a/scripts/mcp/McpStdioDemo.java b/scripts/mcp/McpStdioDemo.java new file mode 100644 index 00000000000..96ec41d603e --- /dev/null +++ b/scripts/mcp/McpStdioDemo.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ + +import com.codename1.impl.ImplementationFactory; +import com.codename1.io.Util; +import com.codename1.impl.javase.MCPStdioTransport; +import com.codename1.mcp.MCPServer; +import com.codename1.testing.SafeL10NManager; +import com.codename1.testing.TestCodenameOneImplementation; +import com.codename1.ui.Button; +import com.codename1.ui.Display; +import com.codename1.ui.Form; +import com.codename1.ui.TextField; +import java.io.PrintStream; + +/** + * A minimal headless Codename One application that exposes itself over the MCP stdio + * transport, used by scripts/mcp-inspector-e2e.sh to verify MCP spec conformance + * against the reference MCP Inspector client. It boots the lightweight test + * implementation (no Swing window) so it can run in headless CI. + */ +public class McpStdioDemo { + public static void main(String[] args) throws Exception { + // Reserve stdout for the protocol before anything else can print to it. + PrintStream realOut = System.out; + System.setOut(System.err); + + final TestCodenameOneImplementation impl = new TestCodenameOneImplementation(); + ImplementationFactory.setInstance(new ImplementationFactory() { + public Object createImplementation() { + return impl; + } + }); + impl.setLocalizationManager(new SafeL10NManager("en", "US")); + Display.init(null); + Util.setImplementation(impl); + + Display.getInstance().callSerially(new Runnable() { + public void run() { + Form f = new Form("Demo Login"); + f.add(new Button("Save")); + f.add(new TextField("hello")); + f.show(); + } + }); + Thread.sleep(600); + + MCPServer server = new MCPServer(); + server.setServerInfo("cn1-demo", "1.0"); + server.setScreenshotEnabled(false); // the test implementation cannot encode PNG + server.start(new MCPStdioTransport(System.in, realOut, false)); + + while (server.isRunning()) { + Thread.sleep(100); + } + System.exit(0); + } +} From 99fe370d05df18a32f946c30a2a34ad8430fc203 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:15:32 +0300 Subject: [PATCH 02/10] docs: move MCP guide snippets into docs/demos (guide-snippet gate) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../developerguide/snippets/McpSnippets.java | 57 +++++++++++++++++++ .../developer-guide/MCP-Headless-API.asciidoc | 20 +------ 2 files changed, 60 insertions(+), 17 deletions(-) create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java new file mode 100644 index 00000000000..55320470607 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codenameone.developerguide.snippets; + +import com.codename1.ai.Tool; +import com.codename1.ai.ToolHandler; +import com.codename1.mcp.MCP; + +/** Compiled source snippets for the MCP headless API guide chapter. */ +public class McpSnippets { + + public void startSocketServer() { + // tag::mcp-start-socket[] + MCP.startSocketServer(8765); + // end::mcp-start-socket[] + } + + public void startStdioServer() { + // tag::mcp-start-stdio[] + MCP.startStdioServer(); + // end::mcp-start-stdio[] + } + + public void publishTool(final String signedInUser) { + // tag::mcp-add-tool[] + MCP.addTool(new Tool( + "current_user", + "Returns the signed in user", + "{\"type\":\"object\",\"properties\":{}}", + new ToolHandler() { + public String invoke(String argumentsJson) { + return "{\"name\":\"" + signedInUser + "\"}"; + } + })); + // end::mcp-add-tool[] + } +} diff --git a/docs/developer-guide/MCP-Headless-API.asciidoc b/docs/developer-guide/MCP-Headless-API.asciidoc index c76bce8835a..a8fc80ad735 100644 --- a/docs/developer-guide/MCP-Headless-API.asciidoc +++ b/docs/developer-guide/MCP-Headless-API.asciidoc @@ -11,16 +11,12 @@ The API is supported by the JavaSE port, which powers the simulator and the desk Invoking the API is the switch. There is no build hint or property to set. The socket server lets an agent attach to a session a person is watching, which is the usual choice in the simulator: [source,java] ----- -MCP.startSocketServer(8765); ----- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java[tag=mcp-start-socket,indent=0] A tool that a host launches as a subprocess can serve over standard input and output instead: [source,java] ----- -MCP.startStdioServer(); ----- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java[tag=mcp-start-stdio,indent=0] The stdio transport is the standard MCP local transport, exchanging newline delimited JSON-RPC messages. While it runs, application logging is redirected away from standard output so it can't corrupt the protocol stream. The stdio transport lives in the JavaSE port because it needs process standard input, which isn't available on every target. @@ -37,17 +33,7 @@ Actions run on the Codename One EDT, so an agent never touches the live componen An application publishes its own data and actions as MCP tools. A `Tool` has a name, a description, a JSON schema for its parameters, and a handler: [source,java] ----- -MCP.addTool(new Tool( - "current_user", - "Returns the signed in user", - "{\"type\":\"object\",\"properties\":{}}", - new ToolHandler() { - public String invoke(String argumentsJson) { - return "{\"name\":\"" + session.getUser().getName() + "\"}"; - } - })); ----- +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java[tag=mcp-add-tool,indent=0] The server merges these tools with the built-in tools when a host lists the available tools, and routes each call to the matching handler. The same `Tool` type is used by the Codename One AI client, so a tool defined once serves both a hosted agent and an in-application model. From b04df0e31e98ab689cdf31bb628b94cd06828903 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:20:18 +0300 Subject: [PATCH 03/10] style: use foreach loops (PMD ForLoopCanBeForeach) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/com/codename1/mcp/MCPClientRegistrar.java | 9 +++------ CodenameOne/src/com/codename1/mcp/MCPServer.java | 3 +-- CodenameOne/src/com/codename1/mcp/McpUiTools.java | 4 ++-- .../JavaSE/src/com/codename1/impl/javase/JavaSEPort.java | 3 +-- .../codenameone/developerguide/snippets/McpSnippets.java | 1 + 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java b/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java index b099b5b74bc..3fc19641a37 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java +++ b/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java @@ -87,8 +87,7 @@ public List detectClients() { return found; } FileSystemStorage fs = FileSystemStorage.getInstance(); - for (int i = 0; i < knownClients.size(); i++) { - KnownClient known = knownClients.get(i); + for (KnownClient known : knownClients) { String path = known.absolutePath(home); if (path == null) { continue; @@ -113,8 +112,7 @@ public List register(MCPClientDescriptor descriptor, List if (descriptor == null || clients == null) { return updated; } - for (int i = 0; i < clients.size(); i++) { - MCPClient client = clients.get(i); + for (MCPClient client : clients) { if (!client.isWritable()) { continue; } @@ -133,8 +131,7 @@ public List unregister(String serverName) { return updated; } List clients = detectClients(); - for (int i = 0; i < clients.size(); i++) { - MCPClient client = clients.get(i); + for (MCPClient client : clients) { if (client.isWritable() && writeEntry(client, serverName, null)) { updated.add(client); } diff --git a/CodenameOne/src/com/codename1/mcp/MCPServer.java b/CodenameOne/src/com/codename1/mcp/MCPServer.java index bbcf4c18a7e..d3f5033c12e 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPServer.java +++ b/CodenameOne/src/com/codename1/mcp/MCPServer.java @@ -69,8 +69,7 @@ public class MCPServer { public MCPServer() { List builtIn = McpUiTools.builtInTools(); - for (int i = 0; i < builtIn.size(); i++) { - Tool t = builtIn.get(i); + for (Tool t : builtIn) { tools.put(t.getName(), t); } } diff --git a/CodenameOne/src/com/codename1/mcp/McpUiTools.java b/CodenameOne/src/com/codename1/mcp/McpUiTools.java index 1d6d0e0c0d5..ef01ea3e0ce 100644 --- a/CodenameOne/src/com/codename1/mcp/McpUiTools.java +++ b/CodenameOne/src/com/codename1/mcp/McpUiTools.java @@ -258,8 +258,8 @@ private static void addNode(List matches, AccessibilityNodeSnapshot node bounds.add(Integer.valueOf(b.getHeight())); out.put("bounds", bounds); List actions = new ArrayList(); - for (int i = 0; i < node.getActions().size(); i++) { - actions.add(node.getActions().get(i).getId()); + for (AccessibilityAction action : node.getActions()) { + actions.add(action.getId()); } out.put("actions", actions); matches.add(out); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index bbf9b831a4b..d0c0f0040fc 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -8720,8 +8720,7 @@ public void actionPerformed(ActionEvent e) { sb.append("No MCP hosts detected on this machine."); } else { sb.append("Detected MCP hosts:\n\n"); - for (int i = 0; i < clients.size(); i++) { - com.codename1.mcp.MCPClientRegistrar.MCPClient c = clients.get(i); + for (com.codename1.mcp.MCPClientRegistrar.MCPClient c : clients) { sb.append(c.getDisplayName()) .append(c.isWritable() ? " (auto-configurable)" : " (manual config)") .append("\n ").append(c.getConfigPath()).append("\n"); diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java index 55320470607..b3ee6ac02af 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java @@ -48,6 +48,7 @@ public void publishTool(final String signedInUser) { "Returns the signed in user", "{\"type\":\"object\",\"properties\":{}}", new ToolHandler() { + @Override public String invoke(String argumentsJson) { return "{\"name\":\"" + signedInUser + "\"}"; } From b86c775549564e413a86baf2eaa6f584b155532f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:28:45 +0300 Subject: [PATCH 04/10] fix: drop Thread.setDaemon (absent from CLDC Thread used by the ANT core build) Co-Authored-By: Claude Opus 4.8 (1M context) --- CodenameOne/src/com/codename1/mcp/MCPServer.java | 1 - 1 file changed, 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/mcp/MCPServer.java b/CodenameOne/src/com/codename1/mcp/MCPServer.java index d3f5033c12e..ce11488da6e 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPServer.java +++ b/CodenameOne/src/com/codename1/mcp/MCPServer.java @@ -119,7 +119,6 @@ public void run() { runLoop(); } }, "cn1-mcp-server"); - readerThread.setDaemon(true); readerThread.start(); } From 174afe1c20a8cdc02a1043fcbb92d6cee57ff3c8 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:44:52 +0300 Subject: [PATCH 05/10] fix: resolve SpotBugs findings (redundant null checks, broad catch, uninit field) Co-Authored-By: Claude Opus 4.8 (1M context) --- CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java | 6 +++--- CodenameOne/src/com/codename1/mcp/McpUiTools.java | 2 +- CodenameOne/src/com/codename1/mcp/SocketTransport.java | 3 +++ .../src/com/codename1/impl/javase/MCPStdioTransport.java | 3 +++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java b/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java index 3fc19641a37..691f51878f4 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java +++ b/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java @@ -176,7 +176,7 @@ private boolean writeEntry(MCPClient client, String serverName, Map readJson(FileSystemStorage fs, String storagePath) { return null; } String json = Util.readToString(fs.openInputStream(storagePath), "UTF-8"); - if (json == null || json.trim().length() == 0) { + if (json.trim().length() == 0) { return null; } return JSONParser.parseJSON(json); - } catch (Exception ex) { + } catch (Throwable ex) { Log.e(ex); return null; } diff --git a/CodenameOne/src/com/codename1/mcp/McpUiTools.java b/CodenameOne/src/com/codename1/mcp/McpUiTools.java index ef01ea3e0ce..49362c7ea88 100644 --- a/CodenameOne/src/com/codename1/mcp/McpUiTools.java +++ b/CodenameOne/src/com/codename1/mcp/McpUiTools.java @@ -81,7 +81,7 @@ public String invoke(String argumentsJson) throws Exception { Map args = parse(argumentsJson); long nodeId = longArg(args, "nodeId", -1); String actionId = JSONParser.getString(args, "actionId"); - Object argument = args == null ? null : args.get("argument"); + Object argument = args.get("argument"); return JSONParser.toJson(performAction(nodeId, actionId, argument)); } })); diff --git a/CodenameOne/src/com/codename1/mcp/SocketTransport.java b/CodenameOne/src/com/codename1/mcp/SocketTransport.java index 5481117d58e..8265c632cc2 100644 --- a/CodenameOne/src/com/codename1/mcp/SocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/SocketTransport.java @@ -88,6 +88,9 @@ public String readMessage() throws IOException { @Override public void writeMessage(String message) throws IOException { + if (writer == null) { + throw new IOException("Socket transport is not open"); + } writer.write(message); writer.write('\n'); writer.flush(); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioTransport.java b/Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioTransport.java index 0b840106e71..c233147fb0e 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioTransport.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioTransport.java @@ -91,6 +91,9 @@ public String readMessage() throws IOException { @Override public void writeMessage(String message) throws IOException { + if (writer == null) { + throw new IOException("Stdio transport is not open"); + } writer.write(message); writer.write('\n'); writer.flush(); From b1256687e6e36c64988e7f17b269ad20c71c1c9c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:05:50 +0300 Subject: [PATCH 06/10] style: resolve PMD violations (final class, no volatile, unused params/imports, preserve stack trace) Co-Authored-By: Claude Opus 4.8 (1M context) --- CodenameOne/src/com/codename1/mcp/MCP.java | 2 +- .../com/codename1/mcp/MCPClientRegistrar.java | 3 +-- .../src/com/codename1/mcp/MCPServer.java | 13 ++++-------- .../com/codename1/mcp/SocketTransport.java | 20 ++++++++++++++----- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/CodenameOne/src/com/codename1/mcp/MCP.java b/CodenameOne/src/com/codename1/mcp/MCP.java index 552a095a054..287e7924b24 100644 --- a/CodenameOne/src/com/codename1/mcp/MCP.java +++ b/CodenameOne/src/com/codename1/mcp/MCP.java @@ -43,7 +43,7 @@ /// // Publish domain tools alongside the automatic UI driving tools: /// MCP.addTool(myTool); /// ``` -public class MCP { +public final class MCP { private static MCPServer server; private static StdioTransportFactory stdioTransportFactory; diff --git a/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java b/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java index 691f51878f4..24f926973a0 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java +++ b/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java @@ -26,7 +26,6 @@ import com.codename1.io.JSONParser; import com.codename1.io.Log; import com.codename1.io.Util; -import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -39,7 +38,7 @@ /// /// This is a plain reusable API. It is meant to be driven by Codename One tooling (the /// certificate wizard, Game Builder, Settings, the simulator) and by applications -/// themselves, and it is exposed to the maven plugin as the `cn1:mcp-setup` goal. +/// themselves. /// /// Registration is a desktop concern. File access goes through /// {@link com.codename1.io.FileSystemStorage} so the class links on every target, but diff --git a/CodenameOne/src/com/codename1/mcp/MCPServer.java b/CodenameOne/src/com/codename1/mcp/MCPServer.java index ce11488da6e..01abc66728c 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPServer.java +++ b/CodenameOne/src/com/codename1/mcp/MCPServer.java @@ -63,9 +63,8 @@ public class MCPServer { private String serverName = "Codename One MCP"; private String serverVersion = "1.0"; private boolean screenshotEnabled = true; - private volatile boolean running; + private boolean running; private MCPTransport transport; - private Thread readerThread; public MCPServer() { List builtIn = McpUiTools.builtInTools(); @@ -106,14 +105,14 @@ public boolean isRunning() { return running; } - /// Starts serving over the given transport on a dedicated daemon reader thread. + /// Starts serving over the given transport on a dedicated reader thread. public synchronized void start(MCPTransport transport) { if (running) { return; } this.transport = transport; running = true; - readerThread = new Thread(new Runnable() { + Thread readerThread = new Thread(new Runnable() { @Override public void run() { runLoop(); @@ -186,7 +185,7 @@ public String handleMessage(String line) { return hasId ? errorEnvelope(id, INVALID_REQUEST, "Invalid Request") : null; } if (!hasId) { - handleNotification(method, params); + // A notification (no id) such as notifications/initialized needs no reply. return null; } return dispatch(id, method, params); @@ -218,10 +217,6 @@ private String dispatch(Object id, String method, Map params) th return errorEnvelope(id, METHOD_NOT_FOUND, "Method not found: " + method); } - private void handleNotification(String method, Map params) { - // notifications/initialized and cancellation notices need no action here. - } - private Map initializeResult(Map params) { String protocol = params == null ? null : JSONParser.getString(params, "protocolVersion"); if (protocol == null || protocol.length() == 0) { diff --git a/CodenameOne/src/com/codename1/mcp/SocketTransport.java b/CodenameOne/src/com/codename1/mcp/SocketTransport.java index 8265c632cc2..933c60883d2 100644 --- a/CodenameOne/src/com/codename1/mcp/SocketTransport.java +++ b/CodenameOne/src/com/codename1/mcp/SocketTransport.java @@ -40,7 +40,15 @@ /// server API so it links on every target, while only functioning where /// {@link com.codename1.io.Socket#isServerSocketSupported()} is true. public class SocketTransport implements MCPTransport { - private static volatile SocketTransport active; + private static SocketTransport active; + + private static synchronized void setActive(SocketTransport transport) { + active = transport; + } + + private static synchronized SocketTransport currentActive() { + return active; + } private final int port; private final Object connectionLock = new Object(); @@ -63,14 +71,16 @@ public void open() throws IOException { if (!Socket.isServerSocketSupported()) { throw new IOException("Server sockets are not supported on this platform"); } - active = this; + setActive(this); stopListening = Socket.listen(port, Bridge.class); synchronized (connectionLock) { while (in == null) { try { connectionLock.wait(); } catch (InterruptedException ex) { - throw new IOException("Interrupted while awaiting MCP client connection"); + IOException io = new IOException("Interrupted while awaiting MCP client connection"); + io.initCause(ex); + throw io; } } } @@ -106,7 +116,7 @@ public void close() { } stopListening = null; } - active = null; + setActive(null); } void onConnection(InputStream is, OutputStream os) { @@ -128,7 +138,7 @@ public void connectionError(int errorCode, String message) { @Override public void connectionEstablished(InputStream is, OutputStream os) { - SocketTransport transport = active; + SocketTransport transport = currentActive(); if (transport != null) { transport.onConnection(is, os); } From 1ddb3ae0576b985660ce646f241b9d9bcdd4378d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:27:40 +0300 Subject: [PATCH 07/10] Add the native MCP menu, install/uninstall and debug logging to CN1 tools Every desktop Codename One tool (the simulator, Codename One Settings, and any other CN1-app tool) now gets a native "MCP" pull-down menu, added by the JavaSE port with no per-tool code: - Expose This Tool To Agents - start/stop the loopback MCP server - Install in MCP Hosts - register the tool with detected hosts (Claude Desktop, Claude Code, ...) - Remove From MCP Hosts - unregister - Detect MCP Hosts - list installed hosts + config paths - Debug Logging - OFF / Errors / Summary / Full Install registers MCPStdioLauncher, a stdio<->socket bridge, so a stdio host such as Claude Desktop can drive the already-running, human-visible tool. Debug logging (MCPVerbosity OFF/ERRORS/SUMMARY/FULL) echoes the MCP conversation to the Codename One log so a user can watch and debug what an agent is doing. - Core: MCPVerbosity, MCPServer.setVerbosity + per-level logging, MCP facade. - JavaSE port: MCPDesktopMenu (the native menu), MCPStdioLauncher (the bridge), wired into buildNativeMenuBar (all desktop tools) and the simulator menu bar. - Developer guide: documents the menu, install/uninstall and debug logging. Co-Authored-By: Claude Opus 4.8 (1M context) --- CodenameOne/src/com/codename1/mcp/MCP.java | 10 + .../src/com/codename1/mcp/MCPServer.java | 49 +++++ .../src/com/codename1/mcp/MCPVerbosity.java | 44 ++++ .../com/codename1/impl/javase/JavaSEPort.java | 69 +----- .../codename1/impl/javase/MCPDesktopMenu.java | 206 ++++++++++++++++++ .../impl/javase/MCPStdioLauncher.java | 93 ++++++++ .../developerguide/snippets/McpSnippets.java | 8 + .../developer-guide/MCP-Headless-API.asciidoc | 27 ++- 8 files changed, 441 insertions(+), 65 deletions(-) create mode 100644 CodenameOne/src/com/codename1/mcp/MCPVerbosity.java create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/MCPDesktopMenu.java create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioLauncher.java diff --git a/CodenameOne/src/com/codename1/mcp/MCP.java b/CodenameOne/src/com/codename1/mcp/MCP.java index 287e7924b24..9807ae9f5f1 100644 --- a/CodenameOne/src/com/codename1/mcp/MCP.java +++ b/CodenameOne/src/com/codename1/mcp/MCP.java @@ -111,4 +111,14 @@ public static synchronized void stop() { public static void addTool(Tool tool) { getServer().addTool(tool); } + + /// Sets how much of the MCP conversation is echoed to the Codename One log so a + /// developer can watch and debug what an agent is doing. + public static void setVerbosity(MCPVerbosity verbosity) { + getServer().setVerbosity(verbosity); + } + + public static MCPVerbosity getVerbosity() { + return getServer().getVerbosity(); + } } diff --git a/CodenameOne/src/com/codename1/mcp/MCPServer.java b/CodenameOne/src/com/codename1/mcp/MCPServer.java index 01abc66728c..0b9de53a8a1 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPServer.java +++ b/CodenameOne/src/com/codename1/mcp/MCPServer.java @@ -65,6 +65,17 @@ public class MCPServer { private boolean screenshotEnabled = true; private boolean running; private MCPTransport transport; + private MCPVerbosity verbosity = MCPVerbosity.OFF; + + /// Sets how much of the MCP conversation is echoed to the Codename One log for + /// debugging. Defaults to {@link MCPVerbosity#OFF}. + public void setVerbosity(MCPVerbosity verbosity) { + this.verbosity = verbosity == null ? MCPVerbosity.OFF : verbosity; + } + + public MCPVerbosity getVerbosity() { + return verbosity; + } public MCPServer() { List builtIn = McpUiTools.builtInTools(); @@ -167,6 +178,12 @@ private void runLoop() { /// when the message is a notification that warrants no reply. Never throws; every /// failure is turned into a JSON-RPC error response. public String handleMessage(String line) { + String response = handleMessageInternal(line); + logConversation(line, response); + return response; + } + + private String handleMessageInternal(String line) { Map request; try { request = JSONParser.parseJSON(line); @@ -195,6 +212,38 @@ public String handleMessage(String line) { } } + /// Echoes the exchange to the log according to the configured verbosity. Never throws. + private void logConversation(String request, String response) { + if (verbosity == MCPVerbosity.OFF) { + return; + } + try { + boolean isError = response != null && (response.indexOf("\"error\"") >= 0 + || response.indexOf("\"isError\":true") >= 0); + if (verbosity == MCPVerbosity.ERRORS && !isError) { + return; + } + if (verbosity.includes(MCPVerbosity.FULL)) { + Log.p("MCP >> " + request); + if (response != null) { + Log.p("MCP << " + response); + } + return; + } + // SUMMARY / ERRORS: one concise line + Map req = JSONParser.parseJSON(request); + String method = req == null ? "?" : JSONParser.getString(req, "method"); + String detail = ""; + Map params = req == null ? null : JSONParser.asMap(req.get("params")); + if (params != null && "tools/call".equals(method)) { + detail = " " + JSONParser.getString(params, "name"); + } + Log.p("MCP " + method + detail + (isError ? " -> error" : " -> ok")); + } catch (Throwable ignored) { + // logging must never disrupt the protocol + } + } + private String dispatch(Object id, String method, Map params) throws Exception { if ("initialize".equals(method)) { return resultEnvelope(id, initializeResult(params)); diff --git a/CodenameOne/src/com/codename1/mcp/MCPVerbosity.java b/CodenameOne/src/com/codename1/mcp/MCPVerbosity.java new file mode 100644 index 00000000000..497b97133e1 --- /dev/null +++ b/CodenameOne/src/com/codename1/mcp/MCPVerbosity.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.mcp; + +/// How much of the MCP conversation the server echoes to the Codename One log, so a +/// developer can watch and debug what an agent is doing. +/// +/// The levels are ordered from quietest to loudest. Each includes everything the level +/// below it prints. +public enum MCPVerbosity { + /// No logging. + OFF, + /// Only failed calls (JSON-RPC errors and tools that report `isError`). + ERRORS, + /// One concise line per call: the method, the tool name, and whether it succeeded. + SUMMARY, + /// The full JSON-RPC request and response for every message. + FULL; + + /// Whether this level prints at least as much as the given level. + public boolean includes(MCPVerbosity level) { + return ordinal() >= level.ordinal(); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index d0c0f0040fc..20e90e12b60 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -1884,7 +1884,7 @@ public void setNativeCommands(Vector commands) { EventQueue.invokeLater(new Runnable() { @Override public void run() { - frame.setJMenuBar(buildNativeMenuBar(snapshot)); + frame.setJMenuBar(buildNativeMenuBar(snapshot, frame)); frame.revalidate(); } }); @@ -1894,11 +1894,8 @@ public void run() { /// menus by each command's desktop-menu placement hint (Command.getDesktopMenu()); commands /// with no hint fall under a default "Commands" menu. Each menu item dispatches back onto the /// Codename One EDT before invoking the command's action. - private JMenuBar buildNativeMenuBar(java.util.List commands) { + private JMenuBar buildNativeMenuBar(java.util.List commands, JFrame frame) { JMenuBar bar = new JMenuBar(); - if (commands.isEmpty()) { - return bar; - } // preserve first-seen order of the menu groups java.util.LinkedHashMap menus = new java.util.LinkedHashMap(); for (int i = 0; i < commands.size(); i++) { @@ -1931,6 +1928,9 @@ public void run() { }); menu.add(item); } + // Every desktop Codename One tool gets the native MCP menu so it can be exposed + // to and driven by an LLM agent. + bar.add(MCPDesktopMenu.build(frame != null ? frame.getTitle() : null, frame)); return bar; } @@ -5352,7 +5352,6 @@ public void actionPerformed(ActionEvent e) { }; buildHintEditor.addActionListener(l); toolsMenu.add(buildHintEditor); - addMcpMenu(toolsMenu); final JCheckBoxMenuItem useAppFrameMenu = new JCheckBoxMenuItem("Single Window Mode", useAppFrame); useAppFrameMenu.setToolTipText("Check this option to enable Single Window mode, in which the simulator, component inspector, network monitor and other tools are all included in a single, multi-panel window"); @@ -6728,7 +6727,6 @@ public void actionPerformed(ActionEvent e) { } toolsMenu.addSeparator(); toolsMenu.add(buildHintEditor); - addMcpMenu(toolsMenu); toolsMenu.add(autoLocalizationMenu); toolsMenu.add(clean); @@ -6741,6 +6739,7 @@ public void actionPerformed(ActionEvent e) { } bar.add(buildCarMenu()); bar.add(buildWidgetsMenu()); + bar.add(MCPDesktopMenu.build("Codename One Simulator", window)); bar.add(helpMenu); } @@ -8678,62 +8677,6 @@ private static void installGeneratedSvgRegistry() { /** * @inheritDoc */ - // Model Context Protocol: expose the running simulator to an LLM agent, and detect - // installed MCP hosts. The socket server lets an agent attach to the live session and - // drive it through the accessibility semantics tree. - private void addMcpMenu(JMenu toolsMenu) { - JMenu mcpMenu = new JMenu("Model Context Protocol"); - registerMenuWithBlit(mcpMenu); - final java.util.prefs.Preferences mcpPref = - java.util.prefs.Preferences.userNodeForPackage(JavaSEPort.class); - final int mcpPort = mcpPref.getInt("cn1.mcp.port", 8765); - final JCheckBoxMenuItem mcpServerMenu = new JCheckBoxMenuItem("Expose App To Agents (MCP)", - com.codename1.mcp.MCP.isRunning()); - mcpServerMenu.setToolTipText("Starts a local MCP server on port " + mcpPort - + " so an LLM agent can read and drive this running app"); - mcpServerMenu.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - if (mcpServerMenu.isSelected()) { - com.codename1.mcp.MCP.startSocketServer(mcpPort); - JOptionPane.showMessageDialog(canvas, - "MCP server listening on 127.0.0.1:" + mcpPort - + ".\nPoint an MCP host at this port to drive the app.", - "MCP Server Started", JOptionPane.INFORMATION_MESSAGE); - } else { - com.codename1.mcp.MCP.stop(); - } - } - }); - mcpMenu.add(mcpServerMenu); - - JMenuItem mcpDetect = new JMenuItem("Detect MCP Clients..."); - mcpDetect.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - com.codename1.mcp.MCPClientRegistrar registrar = - com.codename1.mcp.MCPClientRegistrar.getInstance(); - java.util.List clients = - registrar.detectClients(); - StringBuilder sb = new StringBuilder(); - if (clients.isEmpty()) { - sb.append("No MCP hosts detected on this machine."); - } else { - sb.append("Detected MCP hosts:\n\n"); - for (com.codename1.mcp.MCPClientRegistrar.MCPClient c : clients) { - sb.append(c.getDisplayName()) - .append(c.isWritable() ? " (auto-configurable)" : " (manual config)") - .append("\n ").append(c.getConfigPath()).append("\n"); - } - } - JOptionPane.showMessageDialog(canvas, sb.toString(), "MCP Clients", - JOptionPane.INFORMATION_MESSAGE); - } - }); - mcpMenu.add(mcpDetect); - toolsMenu.add(mcpMenu); - } - public void init(Object m) { inInit = true; installGeneratedSvgRegistry(); diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/MCPDesktopMenu.java b/Ports/JavaSE/src/com/codename1/impl/javase/MCPDesktopMenu.java new file mode 100644 index 00000000000..4da314f20c8 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/MCPDesktopMenu.java @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import com.codename1.mcp.MCP; +import com.codename1.mcp.MCPClientDescriptor; +import com.codename1.mcp.MCPClientRegistrar; +import com.codename1.mcp.MCPVerbosity; +import java.awt.Component; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import javax.swing.ButtonGroup; +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JRadioButtonMenuItem; + +/** + * The native "MCP" pull-down menu that the JavaSE port adds to every desktop Codename One + * tool (and the simulator). It lets a user expose the running tool to an LLM agent, install + * or remove the tool from the MCP hosts on the machine, and control how much of the MCP + * conversation is logged for debugging. + * + * 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. + */ +public final class MCPDesktopMenu { + private static final int DEFAULT_PORT = 8765; + + private MCPDesktopMenu() { + } + + /** + * Builds the "MCP" menu for the given tool name. Safe to call on the AWT thread. + * + * @param toolName human-readable tool name used as the server name in host configs + * @param anchor a component used to anchor dialogs (may be null) + */ + public static JMenu build(final String toolName, final Component anchor) { + JMenu menu = new JMenu("MCP"); + + final JCheckBoxMenuItem serve = new JCheckBoxMenuItem("Expose This Tool To Agents", + MCP.isRunning()); + serve.setToolTipText("Serve MCP on 127.0.0.1:" + DEFAULT_PORT + " so an agent can drive this tool"); + serve.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + if (serve.isSelected()) { + MCP.startSocketServer(DEFAULT_PORT); + } else { + MCP.stop(); + } + } + }); + menu.add(serve); + menu.addSeparator(); + + JMenuItem install = new JMenuItem("Install in MCP Hosts..."); + install.setToolTipText("Register this tool with Claude Desktop and other detected MCP hosts"); + install.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + if (!MCP.isRunning()) { + MCP.startSocketServer(DEFAULT_PORT); + serve.setSelected(true); + } + doInstall(toolName, anchor); + } + }); + menu.add(install); + + JMenuItem uninstall = new JMenuItem("Remove From MCP Hosts..."); + uninstall.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + doUninstall(toolName, anchor); + } + }); + menu.add(uninstall); + + JMenuItem detect = new JMenuItem("Detect MCP Hosts..."); + detect.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + doDetect(anchor); + } + }); + menu.add(detect); + menu.addSeparator(); + + JMenu verbose = new JMenu("Debug Logging"); + ButtonGroup group = new ButtonGroup(); + addVerbosity(verbose, group, "Off", MCPVerbosity.OFF); + addVerbosity(verbose, group, "Errors only", MCPVerbosity.ERRORS); + addVerbosity(verbose, group, "Summary (one line per call)", MCPVerbosity.SUMMARY); + addVerbosity(verbose, group, "Full (every request and response)", MCPVerbosity.FULL); + menu.add(verbose); + + return menu; + } + + private static void addVerbosity(JMenu menu, ButtonGroup group, String label, final MCPVerbosity level) { + JRadioButtonMenuItem item = new JRadioButtonMenuItem(label, MCP.getVerbosity() == level); + item.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + MCP.setVerbosity(level); + } + }); + group.add(item); + menu.add(item); + } + + private static MCPClientDescriptor bridgeDescriptor(String toolName) { + String javaBin = System.getProperty("java.home") + File.separator + "bin" + File.separator + "java"; + List args = new ArrayList(); + args.add("-cp"); + args.add(System.getProperty("java.class.path")); + args.add("com.codename1.impl.javase.MCPStdioLauncher"); + args.add("--attach"); + args.add(String.valueOf(DEFAULT_PORT)); + return new MCPClientDescriptor(serverName(toolName), javaBin, args); + } + + private static String serverName(String toolName) { + String base = toolName == null || toolName.length() == 0 ? "codename1-tool" : toolName; + return "cn1-" + base.toLowerCase().replace(' ', '-'); + } + + private static void doInstall(String toolName, Component anchor) { + try { + MCPClientRegistrar registrar = MCPClientRegistrar.getInstance(); + List updated = registrar.register(bridgeDescriptor(toolName)); + StringBuilder sb = new StringBuilder(); + if (updated.isEmpty()) { + sb.append("No auto-configurable MCP hosts were found.\n") + .append("Use 'Detect MCP Hosts' to see what is installed."); + } else { + sb.append("Installed '").append(serverName(toolName)).append("' in:\n\n"); + for (int i = 0; i < updated.size(); i++) { + sb.append(" ").append(updated.get(i).getDisplayName()).append('\n'); + } + sb.append("\nRestart the host and this tool will appear as an MCP server."); + } + 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); + } + } + + private static void doUninstall(String toolName, Component anchor) { + try { + List 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); + } catch (Throwable t) { + JOptionPane.showMessageDialog(anchor, "Remove failed: " + t.getMessage(), + "MCP Remove", JOptionPane.ERROR_MESSAGE); + } + } + + private static void doDetect(Component anchor) { + List clients = MCPClientRegistrar.getInstance().detectClients(); + StringBuilder sb = new StringBuilder(); + if (clients.isEmpty()) { + sb.append("No MCP hosts detected on this machine."); + } else { + sb.append("Detected MCP hosts:\n\n"); + for (int i = 0; i < clients.size(); i++) { + MCPClientRegistrar.MCPClient c = clients.get(i); + sb.append(c.getDisplayName()) + .append(c.isWritable() ? " (auto-configurable)" : " (manual config)") + .append("\n ").append(c.getConfigPath()).append("\n"); + } + } + JOptionPane.showMessageDialog(anchor, sb.toString(), "MCP Hosts", JOptionPane.INFORMATION_MESSAGE); + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioLauncher.java b/Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioLauncher.java new file mode 100644 index 00000000000..9839cf040be --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioLauncher.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.net.Socket; + +/** + * The command an MCP host (Claude Desktop, Codex, opencode, ...) launches after a tool's + * "Install" menu registers it. MCP hosts speak stdio; a running Codename One tool serves + * MCP over a loopback socket. This launcher bridges the two: it relays the host's stdio + * to the tool's socket, so the host drives the already-running, human-visible tool. + * + * Usage: {@code java -cp com.codename1.impl.javase.MCPStdioLauncher --attach } + */ +public final class MCPStdioLauncher { + private MCPStdioLauncher() { + } + + public static void main(String[] args) throws Exception { + // Reserve stdout for the protocol; everything else goes to stderr. + PrintStream hostOut = System.out; + System.setOut(System.err); + + int port = 8765; + for (int i = 0; i < args.length; i++) { + if ("--attach".equals(args[i]) && i + 1 < args.length) { + try { + port = Integer.parseInt(args[i + 1].trim()); + } catch (NumberFormatException ignored) { + // keep the default port + } + } + } + + Socket socket = new Socket("127.0.0.1", port); + final InputStream fromTool = socket.getInputStream(); + final OutputStream toTool = socket.getOutputStream(); + + // tool -> host + Thread pump = new Thread(new Runnable() { + @Override + public void run() { + relay(fromTool, hostOut); + } + }, "cn1-mcp-bridge-in"); + pump.setDaemon(true); + pump.start(); + + // host -> tool (on the main thread; ends when the host closes stdin) + relay(System.in, toTool); + try { + socket.close(); + } catch (Exception ignored) { + // best effort close + } + } + + private static void relay(InputStream in, OutputStream out) { + byte[] buffer = new byte[4096]; + try { + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + out.flush(); + } + } catch (Exception ignored) { + // the far side closed; the bridge is done + } + } +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java index b3ee6ac02af..f0249d997e8 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java @@ -25,10 +25,18 @@ import com.codename1.ai.Tool; import com.codename1.ai.ToolHandler; import com.codename1.mcp.MCP; +import com.codename1.mcp.MCPVerbosity; /** Compiled source snippets for the MCP headless API guide chapter. */ public class McpSnippets { + public void setVerbosity() { + // tag::mcp-verbosity[] + // Echo every request and response to the log while debugging an agent: + MCP.setVerbosity(MCPVerbosity.FULL); + // end::mcp-verbosity[] + } + public void startSocketServer() { // tag::mcp-start-socket[] MCP.startSocketServer(8765); diff --git a/docs/developer-guide/MCP-Headless-API.asciidoc b/docs/developer-guide/MCP-Headless-API.asciidoc index a8fc80ad735..07fc5bba9e2 100644 --- a/docs/developer-guide/MCP-Headless-API.asciidoc +++ b/docs/developer-guide/MCP-Headless-API.asciidoc @@ -6,6 +6,20 @@ The API reuses the accessibility semantics tree. The same immutable tree that de The API is supported by the JavaSE port, which powers the simulator and the desktop tooling. It's not part of a packaged application build, and it's inert on mobile and web targets. +== The MCP menu + +Every desktop Codename One tool, including the simulator and Codename One Settings, gets a native `MCP` pull-down menu. The menu is added by the JavaSE port, so a tool gets it without any code of its own. It's the main way a user turns a tool into something an agent can drive. + +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. +- *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. + +Install writes a command that launches `MCPStdioLauncher`, a small bridge that relays the host's standard input and output to the running tool's socket. Hosts speak stdio and the tool serves a socket, so the bridge lets a stdio host such as Claude Desktop drive the already-running, human-visible tool. + == Starting the server Invoking the API is the switch. There is no build hint or property to set. The socket server lets an agent attach to a session a person is watching, which is the usual choice in the simulator: @@ -41,10 +55,19 @@ The server merges these tools with the built-in tools when a host lists the avai The server exposes a screenshot of the current form as an MCP image resource, so a vision capable model can see the screen alongside the semantic tree. The resource is enabled by default and can be disabled per server. -The JavaSE simulator exposes the socket server through a Tools menu, so an application can be driven from the running simulator during development. The same menu detects the MCP hosts installed on the machine. +The JavaSE simulator drives the same MCP menu described above, so an application can be exposed and driven from the running simulator during development. + +== Debug logging + +Because an agent drives the tool on its own, it helps to watch what it does. The server echoes the MCP conversation to the Codename One log at a level chosen through the menu's *Debug Logging* item or in code: + +[source,java] +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/McpSnippets.java[tag=mcp-verbosity,indent=0] + +The levels of `MCPVerbosity` are, from quietest to loudest: `OFF`, `ERRORS` (only failed calls), `SUMMARY` (one line per call), and `FULL` (every request and response). Each level includes the one below it. == Registering with installed hosts -`MCPClientRegistrar` 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`. +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. From 09ec1453209c83385d4984460c0ff0c0376bcff1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:35:09 +0300 Subject: [PATCH 08/10] fix: declare hostOut final for the JavaSE port's source level (ANT build) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../JavaSE/src/com/codename1/impl/javase/MCPStdioLauncher.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioLauncher.java b/Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioLauncher.java index 9839cf040be..0c6da418eda 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioLauncher.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/MCPStdioLauncher.java @@ -41,7 +41,7 @@ private MCPStdioLauncher() { public static void main(String[] args) throws Exception { // Reserve stdout for the protocol; everything else goes to stderr. - PrintStream hostOut = System.out; + final PrintStream hostOut = System.out; System.setOut(System.err); int port = 8765; From 16e4d9617eed7c82740e5938a00422a64b43187c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:24:30 +0300 Subject: [PATCH 09/10] fix: preserve config types when editing MCP host files (Install/Uninstall) MCPClientRegistrar round-tripped the whole host config (e.g. Claude Desktop's claude_desktop_config.json) through the default JSONParser, which turns JSON booleans into "true"/"false" strings, integers into X.0 floats, and drops null values. That corrupted the user's other settings in the config, which could make the host reset its config and drop existing mcpServers entries. Read with a JSONParser instance configured for useBoolean + useLongs + includeNulls, and write with mapToJson (which preserves nulls) instead of toJson. Editing a host config now leaves every other setting byte-faithful. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/codename1/mcp/MCPClientRegistrar.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java b/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java index 24f926973a0..732f194d429 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java +++ b/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java @@ -26,6 +26,8 @@ import com.codename1.io.JSONParser; import com.codename1.io.Log; import com.codename1.io.Util; +import java.io.ByteArrayInputStream; +import java.io.InputStreamReader; import java.io.OutputStream; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -170,7 +172,10 @@ private boolean writeEntry(MCPClient client, String serverName, Map readJson(FileSystemStorage fs, String storagePath) { if (json.trim().length() == 0) { return null; } - return JSONParser.parseJSON(json); + // Parse faithfully: keep booleans as Boolean, integers as Long, and null + // values, so rewriting the config does not turn the user's other settings + // into strings or floats. The default static parser does not preserve these. + JSONParser parser = new JSONParser(); + parser.setUseBooleanInstance(true); + parser.setUseLongsInstance(true); + parser.setIncludeNullsInstance(true); + return parser.parseJSON(new InputStreamReader(new ByteArrayInputStream(json.getBytes("UTF-8")), "UTF-8")); } catch (Throwable ex) { Log.e(ex); return null; From a9e49eebd686cfa2e3cb2406631fd869daca315d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:54:57 +0300 Subject: [PATCH 10/10] fix: address PR review for MCP headless API (#5377) Resolves the six review findings: 1/4 Socket transport moved from core to the JavaSE port (MCPSocketTransport). It binds java.net.ServerSocket to the loopback address only (the portable com.codename1.io.Socket bound the wildcard address, exposing the control channel on every interface) and owns the listening and accepted sockets so stop() closes them and unblocks a pending accept()/read(). A fresh instance is created per run; the core SocketTransport and MCPLineReader are deleted. MCP exposes a SocketTransportFactory the port registers in init(). 2/5 MCPServer parses requests and re-serializes tool arguments through the new MCPJson helper, preserving booleans, integers (as longs) and nulls instead of turning them into strings/floats/dropped keys. initialize only echoes a protocolVersion the server actually implements. 3/6 MCPClientRegistrar refuses to overwrite a host config it could not read or that is not a complete JSON object (structural check, since CN1's parser returns a partial map rather than throwing), and writes through a temp file renamed into place so an interrupted write cannot truncate the config. Detection no longer treats the home directory as a present "parent" (a dotfile such as ~/.claude.json is only detected when the file itself exists), removing the false-positive Claude Code match. Adds tests for protocol negotiation, faithful argument types and the config completeness guard. PMD and SpotBugs clean; 17 mcp tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- CodenameOne/src/com/codename1/mcp/MCP.java | 27 ++- .../com/codename1/mcp/MCPClientRegistrar.java | 177 ++++++++++++++---- .../src/com/codename1/mcp/MCPJson.java | 60 ++++++ .../src/com/codename1/mcp/MCPLineReader.java | 61 ------ .../src/com/codename1/mcp/MCPServer.java | 32 +++- .../com/codename1/mcp/SocketTransport.java | 147 --------------- .../com/codename1/impl/javase/JavaSEPort.java | 4 +- .../impl/javase/MCPSocketTransport.java | 159 ++++++++++++++++ .../codename1/mcp/MCPClientRegistrarTest.java | 55 ++++++ .../java/com/codename1/mcp/MCPServerTest.java | 30 +++ 10 files changed, 500 insertions(+), 252 deletions(-) create mode 100644 CodenameOne/src/com/codename1/mcp/MCPJson.java delete mode 100644 CodenameOne/src/com/codename1/mcp/MCPLineReader.java delete mode 100644 CodenameOne/src/com/codename1/mcp/SocketTransport.java create mode 100644 Ports/JavaSE/src/com/codename1/impl/javase/MCPSocketTransport.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/mcp/MCPClientRegistrarTest.java diff --git a/CodenameOne/src/com/codename1/mcp/MCP.java b/CodenameOne/src/com/codename1/mcp/MCP.java index 9807ae9f5f1..be6800e08a8 100644 --- a/CodenameOne/src/com/codename1/mcp/MCP.java +++ b/CodenameOne/src/com/codename1/mcp/MCP.java @@ -46,6 +46,7 @@ public final class MCP { private static MCPServer server; private static StdioTransportFactory stdioTransportFactory; + private static SocketTransportFactory socketTransportFactory; private MCP() { } @@ -58,16 +59,35 @@ public interface StdioTransportFactory { MCPTransport createStdioTransport(); } + /// Supplies the platform loopback socket transport. The socket transport lives outside + /// the portable core because it binds a real server socket to the loopback interface, + /// which the portable {@link com.codename1.io.Socket} API does not allow (it binds the + /// wildcard address, exposing the channel on every network interface). The JavaSE port + /// registers its implementation during initialization. + public interface SocketTransportFactory { + MCPTransport createSocketTransport(int port); + } + /// Registers the platform stdio transport factory. Called by the JavaSE port. public static void setStdioTransportFactory(StdioTransportFactory factory) { stdioTransportFactory = factory; } + /// Registers the platform socket transport factory. Called by the JavaSE port. + public static void setSocketTransportFactory(SocketTransportFactory factory) { + socketTransportFactory = factory; + } + /// Whether an stdio transport is available on this platform. public static boolean isStdioSupported() { return stdioTransportFactory != null; } + /// Whether a loopback socket transport is available on this platform. + public static boolean isSocketSupported() { + return socketTransportFactory != null; + } + /// Returns the shared server, creating it on first use. public static synchronized MCPServer getServer() { if (server == null) { @@ -94,9 +114,14 @@ public static synchronized MCPServer startStdioServer() { } /// Starts a loopback socket server so an agent can attach to this running process. + /// Requires a platform socket transport factory (registered by the JavaSE port). public static synchronized MCPServer startSocketServer(int port) { + if (socketTransportFactory == null) { + throw new IllegalStateException( + "No socket MCP transport is available on this platform. Run on the JavaSE port."); + } MCPServer s = getServer(); - s.start(new SocketTransport(port)); + s.start(socketTransportFactory.createSocketTransport(port)); return s; } diff --git a/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java b/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java index 732f194d429..f4b28c39fa8 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java +++ b/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java @@ -23,11 +23,8 @@ package com.codename1.mcp; import com.codename1.io.FileSystemStorage; -import com.codename1.io.JSONParser; import com.codename1.io.Log; import com.codename1.io.Util; -import java.io.ByteArrayInputStream; -import java.io.InputStreamReader; import java.io.OutputStream; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -93,7 +90,18 @@ public List detectClients() { if (path == null) { continue; } - boolean present = safeExists(fs, fsPath(path)) || safeExists(fs, fsPath(parentOf(path))); + boolean present = safeExists(fs, fsPath(path)); + if (!present) { + // The config file itself is absent. A present parent directory can still + // signal the host is installed (e.g. .../Application Support/Claude/), but + // only when that parent is a dedicated subdirectory. A dotfile such as + // ~/.claude.json has the home directory as its parent, which exists on + // every desktop and would otherwise be a false positive. + String parent = parentOf(path); + if (parent != null && !samePath(parent, home)) { + present = safeExists(fs, fsPath(parent)); + } + } if (present) { found.add(new MCPClient(known.id, known.displayName, path, known.writable)); } @@ -145,8 +153,22 @@ private boolean writeEntry(MCPClient client, String serverName, Map root = readJson(fs, storagePath); - if (root == null) { + Map root; + if (safeExists(fs, storagePath)) { + root = readExistingConfig(fs, storagePath); + if (root == null) { + // The file is there but could not be read, or is not a complete JSON + // object (a truncated or corrupt config). Codename One's parser is + // lenient and would hand back a partial map, so rewriting would drop + // the user's other settings. Leave the file untouched instead. + Log.p("MCP: leaving " + path + " unchanged; it could not be safely parsed"); + return false; + } + } else { + if (entry == null) { + // Nothing to remove from a config that does not exist. + return false; + } root = new LinkedHashMap(); } Object serversObj = root.get("mcpServers"); @@ -162,6 +184,76 @@ private boolean writeEntry(MCPClient client, String serverName, Map readExistingConfig(FileSystemStorage fs, String storagePath) { + try { + String json = Util.readToString(fs.openInputStream(storagePath), "UTF-8"); + String trimmed = json.trim(); + if (trimmed.length() == 0) { + return new LinkedHashMap(); + } + if (!isCompleteJsonObject(trimmed)) { + return null; + } + // Parse faithfully so rewriting keeps booleans, integers and nulls intact. + return MCPJson.parse(json); + } catch (Throwable ex) { + Log.e(ex); + return null; + } + } + + /// Lightweight structural check that the text is a single, complete JSON object with + /// balanced braces, brackets and quotes. Codename One's JSON parser does not throw on + /// malformed input (it returns a partial map), so this guards against silently writing + /// over a truncated or corrupt config. + static boolean isCompleteJsonObject(String s) { + int n = s.length(); + if (n < 2 || s.charAt(0) != '{' || s.charAt(n - 1) != '}') { + return false; + } + int depth = 0; + boolean inString = false; + boolean escaped = false; + for (int i = 0; i < n; i++) { + char c = s.charAt(i); + if (inString) { + if (escaped) { + escaped = false; + } else if (c == '\\') { + escaped = true; + } else if (c == '"') { + inString = false; + } + continue; + } + if (c == '"') { + inString = true; + } else if (c == '{' || c == '[') { + depth++; + } else if (c == '}' || c == ']') { + depth--; + if (depth < 0) { + return false; + } + } + } + return depth == 0 && !inString; + } + + /// Writes the config through a temporary file that is renamed into place, so an + /// interrupted write can never truncate the user's existing config. + private boolean writeConfigAtomic(FileSystemStorage fs, String path, String storagePath, + Map root) { + try { String parent = parentOf(path); if (parent != null) { try { @@ -170,15 +262,29 @@ private boolean writeEntry(MCPClient client, String serverName, Map readJson(FileSystemStorage fs, String storagePath) { - try { - if (!fs.exists(storagePath)) { - return null; - } - String json = Util.readToString(fs.openInputStream(storagePath), "UTF-8"); - if (json.trim().length() == 0) { - return null; - } - // Parse faithfully: keep booleans as Boolean, integers as Long, and null - // values, so rewriting the config does not turn the user's other settings - // into strings or floats. The default static parser does not preserve these. - JSONParser parser = new JSONParser(); - parser.setUseBooleanInstance(true); - parser.setUseLongsInstance(true); - parser.setIncludeNullsInstance(true); - return parser.parseJSON(new InputStreamReader(new ByteArrayInputStream(json.getBytes("UTF-8")), "UTF-8")); - } catch (Throwable ex) { - Log.e(ex); - return null; - } - } - @SuppressWarnings("unchecked") private static Map asStringMap(Map raw) { Map out = new LinkedHashMap(); @@ -257,6 +340,32 @@ private static String parentOf(String path) { return path.substring(0, slash); } + private static String fileNameOf(String path) { + if (path == null) { + return null; + } + String forward = path.replace('\\', '/'); + int slash = forward.lastIndexOf('/'); + return slash < 0 ? path : path.substring(slash + 1); + } + + /// True when two paths refer to the same location, ignoring separator style and + /// trailing separators. + private static boolean samePath(String a, String b) { + if (a == null || b == null) { + return false; + } + return normalizePath(a).equals(normalizePath(b)); + } + + private static String normalizePath(String p) { + String forward = p.replace('\\', '/'); + while (forward.length() > 1 && forward.charAt(forward.length() - 1) == '/') { + forward = forward.substring(0, forward.length() - 1); + } + return forward; + } + private static String homePath() { try { String home = System.getProperty("user.home"); diff --git a/CodenameOne/src/com/codename1/mcp/MCPJson.java b/CodenameOne/src/com/codename1/mcp/MCPJson.java new file mode 100644 index 00000000000..421c60d5e91 --- /dev/null +++ b/CodenameOne/src/com/codename1/mcp/MCPJson.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.mcp; + +import com.codename1.io.JSONParser; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Map; + +/// JSON helpers that preserve the JSON-RPC type model. The default +/// {@link JSONParser} turns booleans into strings, integers into doubles, and drops +/// null values; that silently rewrites tool arguments and, when editing a host config, +/// the user's other settings. These helpers keep booleans, integers (as longs) and +/// nulls, and serialize them back faithfully. +final class MCPJson { + private MCPJson() { + } + + /// Parses a JSON object preserving booleans, longs and nulls. + static Map parse(String json) throws IOException { + JSONParser parser = new JSONParser(); + parser.setUseBooleanInstance(true); + parser.setUseLongsInstance(true); + parser.setIncludeNullsInstance(true); + return parser.parseJSON(new InputStreamReader( + new ByteArrayInputStream(json.getBytes("UTF-8")), "UTF-8")); + } + + /// Serializes a value back to JSON, keeping null-valued map entries. Maps go through + /// {@link JSONParser#mapToJson} (which preserves nulls); other values through + /// {@link JSONParser#toJson}. + @SuppressWarnings("unchecked") + static String toJson(Object value) { + if (value instanceof Map) { + return JSONParser.mapToJson((Map) value); + } + return JSONParser.toJson(value); + } +} diff --git a/CodenameOne/src/com/codename1/mcp/MCPLineReader.java b/CodenameOne/src/com/codename1/mcp/MCPLineReader.java deleted file mode 100644 index c8b97bcf236..00000000000 --- a/CodenameOne/src/com/codename1/mcp/MCPLineReader.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -package com.codename1.mcp; - -import java.io.IOException; -import java.io.Reader; - -/// Reads a single newline delimited message from a {@link Reader}. Used instead of -/// {@code java.io.BufferedReader}, which is not available on every Codename One target -/// (notably the ParparVM Java runtime), so that the portable transports link on all -/// platforms. Carriage returns are stripped so both `\n` and `\r\n` framing work. -final class MCPLineReader { - private MCPLineReader() { - } - - static String readLine(Reader reader) throws IOException { - StringBuilder sb = new StringBuilder(); - int read; - boolean any = false; - while ((read = reader.read()) != -1) { - any = true; - char c = (char) read; - if (c == '\n') { - return stripCr(sb); - } - sb.append(c); - } - if (!any) { - return null; - } - return stripCr(sb); - } - - private static String stripCr(StringBuilder sb) { - int length = sb.length(); - if (length > 0 && sb.charAt(length - 1) == '\r') { - sb.setLength(length - 1); - } - return sb.toString(); - } -} diff --git a/CodenameOne/src/com/codename1/mcp/MCPServer.java b/CodenameOne/src/com/codename1/mcp/MCPServer.java index 0b9de53a8a1..b7c61f98944 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPServer.java +++ b/CodenameOne/src/com/codename1/mcp/MCPServer.java @@ -54,9 +54,25 @@ public class MCPServer { public static final int INTERNAL_ERROR = -32603; public static final int RESOURCE_NOT_FOUND = -32002; - /// MCP protocol revision advertised when the client does not request one. + /// MCP protocol revision advertised when the client does not request one, or requests + /// one this server does not implement. public static final String DEFAULT_PROTOCOL_VERSION = "2024-11-05"; + /// The MCP protocol revisions this server actually implements. + private static final String[] SUPPORTED_PROTOCOL_VERSIONS = {"2024-11-05"}; + + private static boolean isSupportedProtocol(String version) { + if (version == null) { + return false; + } + for (String v : SUPPORTED_PROTOCOL_VERSIONS) { + if (v.equals(version)) { + return true; + } + } + return false; + } + private static final String SCREEN_RESOURCE_URI = "cn1://screen.png"; private final Map tools = new LinkedHashMap(); @@ -186,7 +202,7 @@ public String handleMessage(String line) { private String handleMessageInternal(String line) { Map request; try { - request = JSONParser.parseJSON(line); + request = MCPJson.parse(line); } catch (Exception ex) { return errorEnvelope(null, PARSE_ERROR, "Parse error"); } @@ -231,7 +247,7 @@ private void logConversation(String request, String response) { return; } // SUMMARY / ERRORS: one concise line - Map req = JSONParser.parseJSON(request); + Map req = MCPJson.parse(request); String method = req == null ? "?" : JSONParser.getString(req, "method"); String detail = ""; Map params = req == null ? null : JSONParser.asMap(req.get("params")); @@ -267,10 +283,10 @@ private String dispatch(Object id, String method, Map params) th } private Map initializeResult(Map params) { - String protocol = params == null ? null : JSONParser.getString(params, "protocolVersion"); - if (protocol == null || protocol.length() == 0) { - protocol = DEFAULT_PROTOCOL_VERSION; - } + // Only echo a protocol version we actually implement. MCP requires an unsupported + // request to be answered with a version the server supports, not the client's. + String requested = params == null ? null : JSONParser.getString(params, "protocolVersion"); + String protocol = isSupportedProtocol(requested) ? requested : DEFAULT_PROTOCOL_VERSION; Map capabilities = new LinkedHashMap(); capabilities.put("tools", new LinkedHashMap()); capabilities.put("resources", new LinkedHashMap()); @@ -311,7 +327,7 @@ private String toolsCall(Object id, Map params) { return errorEnvelope(id, METHOD_NOT_FOUND, "Unknown tool: " + name); } Object argObj = params.get("arguments"); - String argumentsJson = argObj == null ? "{}" : JSONParser.toJson(argObj); + String argumentsJson = argObj == null ? "{}" : MCPJson.toJson(argObj); String text; boolean isError; try { diff --git a/CodenameOne/src/com/codename1/mcp/SocketTransport.java b/CodenameOne/src/com/codename1/mcp/SocketTransport.java deleted file mode 100644 index 933c60883d2..00000000000 --- a/CodenameOne/src/com/codename1/mcp/SocketTransport.java +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Codename One designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Codename One through http://www.codenameone.com/ if you - * need additional information or have any questions. - */ -package com.codename1.mcp; - -import com.codename1.io.Socket; -import com.codename1.io.SocketConnection; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.Writer; - -/// Loopback socket MCP transport. Lets an already running, human visible application -/// accept an attaching MCP host on a local port, so an agent can drive a live session -/// the user is watching rather than launching a fresh headless subprocess. -/// -/// Only one socket transport is active at a time, which matches the single server per -/// process model of {@link MCP}. Built on the portable {@link com.codename1.io.Socket} -/// server API so it links on every target, while only functioning where -/// {@link com.codename1.io.Socket#isServerSocketSupported()} is true. -public class SocketTransport implements MCPTransport { - private static SocketTransport active; - - private static synchronized void setActive(SocketTransport transport) { - active = transport; - } - - private static synchronized SocketTransport currentActive() { - return active; - } - - private final int port; - private final Object connectionLock = new Object(); - private Socket.StopListening stopListening; - private InputStream in; - private OutputStream out; - private InputStreamReader reader; - private Writer writer; - - public SocketTransport(int port) { - this.port = port; - } - - public int getPort() { - return port; - } - - @Override - public void open() throws IOException { - if (!Socket.isServerSocketSupported()) { - throw new IOException("Server sockets are not supported on this platform"); - } - setActive(this); - stopListening = Socket.listen(port, Bridge.class); - synchronized (connectionLock) { - while (in == null) { - try { - connectionLock.wait(); - } catch (InterruptedException ex) { - IOException io = new IOException("Interrupted while awaiting MCP client connection"); - io.initCause(ex); - throw io; - } - } - } - reader = new InputStreamReader(in, "UTF-8"); - writer = new OutputStreamWriter(out, "UTF-8"); - } - - @Override - public String readMessage() throws IOException { - if (reader == null) { - return null; - } - return MCPLineReader.readLine(reader); - } - - @Override - public void writeMessage(String message) throws IOException { - if (writer == null) { - throw new IOException("Socket transport is not open"); - } - writer.write(message); - writer.write('\n'); - writer.flush(); - } - - @Override - public void close() { - if (stopListening != null) { - try { - stopListening.stop(); - } catch (Throwable ignored) { - // best effort shutdown of the listening socket - } - stopListening = null; - } - setActive(null); - } - - void onConnection(InputStream is, OutputStream os) { - synchronized (connectionLock) { - this.in = is; - this.out = os; - connectionLock.notifyAll(); - } - } - - /// Delivered to {@link com.codename1.io.Socket#listen} which instantiates one per - /// accepted connection and reports the streams to the active transport. - public static final class Bridge extends SocketConnection { - @Override - public void connectionError(int errorCode, String message) { - // The listen loop reports failures here; the transport simply keeps waiting - // for a subsequent successful connection. - } - - @Override - public void connectionEstablished(InputStream is, OutputStream os) { - SocketTransport transport = currentActive(); - if (transport != null) { - transport.onConnection(is, os); - } - } - } -} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 20e90e12b60..9a77893b38b 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -8681,8 +8681,10 @@ public void init(Object m) { inInit = true; installGeneratedSvgRegistry(); - // Make the desktop stdio MCP transport available to com.codename1.mcp.MCP. + // Make the desktop stdio and loopback socket MCP transports available to + // com.codename1.mcp.MCP. MCPStdioTransport.register(); + MCPSocketTransport.register(); // Fire-and-forget probe so LlmClient.simulatorRedirect=auto // can detect a local Ollama install without blocking startup. diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/MCPSocketTransport.java b/Ports/JavaSE/src/com/codename1/impl/javase/MCPSocketTransport.java new file mode 100644 index 00000000000..d41bf5e6ac3 --- /dev/null +++ b/Ports/JavaSE/src/com/codename1/impl/javase/MCPSocketTransport.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.javase; + +import com.codename1.mcp.MCP; +import com.codename1.mcp.MCPTransport; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; + +/// Loopback socket MCP transport for the JavaSE desktop port. Lets an already running, +/// human visible tool (the simulator, Settings, ...) accept an attaching MCP host on a +/// local port, so an agent can drive a live session the user is watching rather than +/// launching a fresh headless subprocess. +/// +/// It lives in the JavaSE port rather than the portable core so it can bind a real +/// {@link java.net.ServerSocket} to the loopback address only. A wildcard bind would +/// expose this control channel on every network interface; keeping it on the loopback +/// interface means an attaching agent must run on this machine. +/// +/// A fresh instance is created for every server run (there is no shared mutable state), +/// and it owns both the listening socket and the accepted client socket so {@link #close()} +/// can close them and unblock a pending {@code accept()} or {@code read()} promptly. +public final class MCPSocketTransport implements MCPTransport { + private final int port; + private final Object lock = new Object(); + private ServerSocket serverSocket; + private Socket clientSocket; + private boolean closed; + private BufferedReader reader; + private Writer writer; + + MCPSocketTransport(int port) { + this.port = port; + } + + public int getPort() { + return port; + } + + /// Registers this transport as the platform socket factory so + /// {@link MCP#startSocketServer(int)} works on the desktop. Safe to call repeatedly. + public static void register() { + MCP.setSocketTransportFactory(new MCP.SocketTransportFactory() { + @Override + public MCPTransport createSocketTransport(int port) { + return new MCPSocketTransport(port); + } + }); + } + + @Override + public void open() throws IOException { + // Bind to the loopback interface only so the MCP control channel is never exposed + // to the local network. Backlog of one: a single agent attaches at a time. + ServerSocket ss = new ServerSocket(port, 1, InetAddress.getLoopbackAddress()); + synchronized (lock) { + if (closed) { + try { + ss.close(); + } catch (IOException ignored) { + // best effort close of the socket we are abandoning + } + throw new IOException("Socket transport closed before it could listen"); + } + serverSocket = ss; + } + Socket socket; + try { + socket = ss.accept(); + } catch (IOException ex) { + if (isClosed()) { + throw new IOException("Socket transport closed before a client connected"); + } + throw ex; + } + synchronized (lock) { + clientSocket = socket; + } + reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8")); + writer = new OutputStreamWriter(socket.getOutputStream(), "UTF-8"); + } + + private boolean isClosed() { + synchronized (lock) { + return closed; + } + } + + @Override + public String readMessage() throws IOException { + if (reader == null) { + return null; + } + return reader.readLine(); + } + + @Override + public void writeMessage(String message) throws IOException { + if (writer == null) { + throw new IOException("Socket transport is not open"); + } + writer.write(message); + writer.write('\n'); + writer.flush(); + } + + @Override + public void close() { + Socket cs; + ServerSocket ss; + synchronized (lock) { + closed = true; + cs = clientSocket; + clientSocket = null; + ss = serverSocket; + serverSocket = null; + } + if (cs != null) { + try { + cs.close(); + } catch (IOException ignored) { + // best effort close of the accepted connection + } + } + if (ss != null) { + try { + ss.close(); + } catch (IOException ignored) { + // best effort close of the listening socket, unblocks a pending accept() + } + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPClientRegistrarTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPClientRegistrarTest.java new file mode 100644 index 00000000000..aa44cfe0ed2 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPClientRegistrarTest.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.mcp; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +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 { + + @Test + void acceptsWellFormedObjects() { + assertTrue(MCPClientRegistrar.isCompleteJsonObject("{}")); + assertTrue(MCPClientRegistrar.isCompleteJsonObject( + "{\"mcpServers\":{\"a\":{\"command\":\"x\",\"args\":[\"1\",\"2\"]}}}")); + // Braces and quotes inside strings must not confuse the scanner. + assertTrue(MCPClientRegistrar.isCompleteJsonObject("{\"a\":\"}{[]\\\"\"}")); + } + + @Test + void rejectsTruncatedOrMalformedObjects() { + assertFalse(MCPClientRegistrar.isCompleteJsonObject("")); + assertFalse(MCPClientRegistrar.isCompleteJsonObject("{")); + assertFalse(MCPClientRegistrar.isCompleteJsonObject("{\"a\":1")); + assertFalse(MCPClientRegistrar.isCompleteJsonObject("{\"a\":{\"b\":1}")); + assertFalse(MCPClientRegistrar.isCompleteJsonObject("{\"a\":\"unterminated}")); + assertFalse(MCPClientRegistrar.isCompleteJsonObject("[1,2,3]")); + assertFalse(MCPClientRegistrar.isCompleteJsonObject("null")); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerTest.java index 2b3dbcc53ef..7cc245b77f4 100644 --- a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPServerTest.java @@ -69,6 +69,36 @@ void initializeDefaultsProtocolWhenClientOmitsIt() throws Exception { assertEquals(MCPServer.DEFAULT_PROTOCOL_VERSION, result.get("protocolVersion")); } + @FormTest + void initializeRejectsUnsupportedProtocolVersionAndOffersOurOwn() throws Exception { + MCPServer server = new MCPServer(); + Map result = asMap(parse(server.handleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + + "\"params\":{\"protocolVersion\":\"2099-01-01\"}}")).get("result")); + // MCP requires an unsupported request to be answered with a version the server + // implements, not the version the client asked for. + assertEquals(MCPServer.DEFAULT_PROTOCOL_VERSION, result.get("protocolVersion")); + } + + @FormTest + void toolArgumentsPreserveJsonTypes() throws Exception { + MCPServer server = new MCPServer(); + server.addTool(new Tool("echo", "echoes arguments", + "{\"type\":\"object\",\"properties\":{}}", echoHandler())); + + Map result = asMap(parse(server.handleMessage( + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"echo\"," + + "\"arguments\":{\"flag\":true,\"count\":1,\"nothing\":null}}}")).get("result")); + String echoed = (String) asMap(asList(result.get("content")).get(0)).get("text"); + String compact = echoed.replaceAll("\\s+", ""); + // The developer tool must receive booleans, integers and nulls unchanged, not the + // string "true", the float 1.0, or a dropped null that the default parser produces. + assertTrue(compact.indexOf("\"flag\":true") >= 0, echoed); + assertTrue(compact.indexOf("\"count\":1") >= 0 && compact.indexOf("\"count\":1.0") < 0, echoed); + assertTrue(compact.indexOf("\"nothing\":null") >= 0, echoed); + } + @FormTest void toolsListIncludesBuiltInAndDeveloperTools() throws Exception { MCPServer server = new MCPServer();