diff --git a/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java b/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java index f4b28c39fa8..c05c7d2d949 100644 --- a/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java +++ b/CodenameOne/src/com/codename1/mcp/MCPClientRegistrar.java @@ -25,6 +25,7 @@ import com.codename1.io.FileSystemStorage; 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; @@ -32,8 +33,13 @@ 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. +/// server with them, so an end user can point Claude Desktop, Claude Code, Codex and +/// similar tools at the application without editing config by hand. +/// +/// Two config shapes are written: the JSON `mcpServers` object the Claude hosts use, and +/// the `[mcp_servers.]` tables Codex keeps in `~/.codex/config.toml` (see +/// [MCPToml]). Either way the user's other servers and settings are preserved, and a +/// config that cannot be edited safely is reported and left alone. /// /// 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 @@ -46,25 +52,60 @@ public final class MCPClientRegistrar { private static final MCPClientRegistrar INSTANCE = new MCPClientRegistrar(); + /// A host whose config this class cannot write. The caller surfaces it as a manual step. + private static final int FORMAT_MANUAL = 0; + /// A JSON config with an `mcpServers` object at the root. + private static final int FORMAT_JSON = 1; + /// A TOML config with one `[mcp_servers.]` table per server. + private static final int FORMAT_TOML = 2; + + /// The Windows path is relative to the HOME directory, as a dotfile config is. + private static final boolean WIN_UNDER_HOME = false; + /// The Windows path is relative to %APPDATA%, as an installed application's own + /// per user directory is. + private static final boolean WIN_UNDER_APP_DATA = true; + private final List knownClients = new ArrayList(); private MCPClientRegistrar() { - // Table driven registry: adding a JSON, mcpServers style host is a data change. + // Table driven registry: adding a host whose config is one of the shapes below is + // a data change. + // The Windows column is relative to %APPDATA% for a host that keeps a per user + // application directory there, and to the HOME directory for one whose config is a + // dotfile. Getting that wrong is silent: the host is simply never detected, and the + // registrar has nothing to write to. Claude Desktop is an Electron app and really + // does live under %APPDATA%\Claude; the dotfile hosts do not. 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)); + "Claude/claude_desktop_config.json", WIN_UNDER_APP_DATA, + ".config/Claude/claude_desktop_config.json", FORMAT_JSON)); 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)); + ".claude.json", ".claude.json", WIN_UNDER_HOME, ".claude.json", FORMAT_JSON)); + // Codex keeps its servers as [mcp_servers.] tables in a TOML file that the + // ChatGPT desktop app, the Codex CLI and the Codex IDE extension all share, so one + // writer serves all three. CODEX_HOME names that directory and defaults to + // ~/.codex on every platform - which on Windows is %USERPROFILE%, not %APPDATA%. + // + // A machine that SETS CODEX_HOME is not followed, and cannot be from here: the + // Codename One runtime has no System.getenv. It is absent from both bootclasspaths + // core is compiled against - ../cn1-binaries/CLDC11.jar and the java-runtime jar + // the bytecode compliance check uses - so the call does not compile, whatever a + // desktop JVM would do at runtime. (Check that with javac, not javap: javap + // resolves java.lang.System from the JDK even when a jar is on the classpath, and + // reports a getenv that core cannot call.) Following it would mean a desktop port, + // which does have an environment, handing it to this class through new API. Until + // then such a machine either fails to detect Codex at all, or - if a stale + // ~/.codex survives the move - has that stale copy updated instead. + knownClients.add(new KnownClient("codex", "Codex", + ".codex/config.toml", ".codex/config.toml", WIN_UNDER_HOME, + ".codex/config.toml", FORMAT_TOML)); + // Detect only for now: opencode nests its servers in an "mcp" block whose entries + // have a different shape, so it needs a writer of its own. It is surfaced so the + // caller can guide the user manually. knownClients.add(new KnownClient("opencode", "opencode", ".config/opencode/opencode.json", - "opencode/opencode.json", - ".config/opencode/opencode.json", false)); + "opencode/opencode.json", WIN_UNDER_APP_DATA, + ".config/opencode/opencode.json", FORMAT_MANUAL)); } public static MCPClientRegistrar getInstance() { @@ -103,12 +144,26 @@ public List detectClients() { } } if (present) { - found.add(new MCPClient(known.id, known.displayName, path, known.writable)); + found.add(new MCPClient(known.id, known.displayName, path, known.format)); } } return found; } + /// Where the named host's config would live under the given home on THIS platform, or + /// null when the id is unknown. Package private: it exists so a test can pin each + /// host's per platform path convention without a filesystem, which is the only way a + /// wrong Windows base shows up - the failure is otherwise silent, the host simply + /// never being detected. + String configPathFor(String id, String home) { + for (KnownClient known : knownClients) { + if (known.id.equals(id)) { + return known.absolutePath(home); + } + } + return null; + } + /// Registers the descriptor with every detected, writable host. Returns the list of /// hosts that were updated. public List register(MCPClientDescriptor descriptor) { @@ -125,7 +180,7 @@ public List register(MCPClientDescriptor descriptor, List if (!client.isWritable()) { continue; } - if (writeEntry(client, descriptor.getServerName(), descriptor.toServerEntry())) { + if (writeEntry(client, descriptor.getServerName(), descriptor)) { updated.add(client); } } @@ -148,7 +203,16 @@ public List unregister(String serverName) { return updated; } - private boolean writeEntry(MCPClient client, String serverName, Map entry) { + /// Writes or removes one server entry in a host config, in whichever format that host + /// uses. A null descriptor removes the entry. + private boolean writeEntry(MCPClient client, String serverName, MCPClientDescriptor descriptor) { + if (client.format == FORMAT_TOML) { + return writeTomlEntry(client, serverName, descriptor); + } + return writeJsonEntry(client, serverName, descriptor); + } + + private boolean writeJsonEntry(MCPClient client, String serverName, MCPClientDescriptor descriptor) { try { FileSystemStorage fs = FileSystemStorage.getInstance(); String path = client.getConfigPath(); @@ -165,7 +229,7 @@ private boolean writeEntry(MCPClient client, String serverName, Map(); } - if (entry == null) { + if (descriptor == null) { servers.remove(serverName); } else { - servers.put(serverName, entry); + servers.put(serverName, descriptor.toServerEntry()); } root.put("mcpServers", servers); - return writeConfigAtomic(fs, path, storagePath, root); + // mapToJson preserves booleans, integers and null values, so the user's other + // settings survive the round trip; toJson would drop null-valued entries. + return writeConfig(fs, path, storagePath, MCPJson.toJson(root)); + } catch (Throwable ex) { + Log.e(ex); + return false; + } + } + + /// Edits a Codex style TOML config. The document is rewritten as text with only the + /// one `[mcp_servers.]` table replaced, so the user's other servers, settings, + /// comments and formatting are left exactly as they were. A document the editor + /// cannot make sense of is reported and left untouched. + private boolean writeTomlEntry(MCPClient client, String serverName, MCPClientDescriptor descriptor) { + try { + FileSystemStorage fs = FileSystemStorage.getInstance(); + String path = client.getConfigPath(); + String storagePath = fsPath(path); + String existing; + if (safeExists(fs, storagePath)) { + existing = readExistingText(fs, storagePath); + if (existing == null) { + Log.p("MCP: leaving " + path + " unchanged; it could not be read"); + return false; + } + } else { + if (descriptor == null) { + // Nothing to remove from a config that does not exist. + return false; + } + existing = ""; + } + MCPToml.Result result = MCPToml.applyServerEntry(existing, serverName, descriptor); + if (!result.isApplied()) { + Log.p("MCP: leaving " + path + " unchanged; " + result.getProblem()); + return false; + } + if (result.getText().equals(existing)) { + // Nothing to write either way, but the two cases mean opposite things. + // Registering: the entry is already exactly what would be written, so the + // host IS registered and reporting a failure would list it as "not + // updated" with nothing in the log to explain why. Removing: there was no + // entry to take out, so nothing was updated. + return descriptor != null; + } + return writeConfig(fs, path, storagePath, result.getText()); } catch (Throwable ex) { Log.e(ex); return false; @@ -211,6 +320,17 @@ private Map readExistingConfig(FileSystemStorage fs, String stor } } + /// Reads a host config as text, returning null when it cannot be read so the caller + /// refuses to overwrite it. + private String readExistingText(FileSystemStorage fs, String storagePath) { + try { + return Util.readToString(fs.openInputStream(storagePath), "UTF-8"); + } 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 @@ -249,10 +369,23 @@ static boolean isCompleteJsonObject(String s) { 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) { + /// Writes the config, staging the complete new content in a sibling file first so an + /// interrupted write never leaves the user with half a config and no copy of the rest. + /// + /// An EXISTING config is then written through rather than replaced. Deleting it and + /// renaming the staged file over it would turn a symlinked config - a common dotfiles + /// arrangement - into a regular file, and would reset the file's mode to whatever the + /// process umask says. These files carry other tools' API keys in their `env` blocks, + /// so widening a hand-set 600 to 644 is not cosmetic. {@link FileSystemStorage} cannot + /// read a link or copy a mode, so keeping the original file is the only portable way + /// to keep either. + /// + /// Writing through is not atomic - nothing available here is, since rename cannot + /// overwrite on every platform and delete-then-rename has a window of its own where + /// the config is missing entirely. The staged file is the recovery path: if the write + /// through fails, it still holds the complete content that should have landed. + private boolean writeConfig(FileSystemStorage fs, String path, String storagePath, + String content) { try { String parent = parentOf(path); if (parent != null) { @@ -265,26 +398,20 @@ private boolean writeConfigAtomic(FileSystemStorage fs, String path, String stor String fileName = fileNameOf(path); String tmpName = fileName + ".cn1mcp-tmp"; String tmpPath = parent == null ? fsPath(tmpName) : fsPath(parent + "/" + tmpName); - // mapToJson preserves booleans, integers and null values, so the user's other - // settings survive the round trip; toJson would drop null-valued entries. - byte[] data = MCPJson.toJson(root).getBytes("UTF-8"); - OutputStream os = fs.openOutputStream(tmpPath); - try { - os.write(data); - } finally { - os.close(); - } - // rename() takes a bare name and moves within the same directory. renameTo - // cannot overwrite an existing target on every platform, so remove it first; - // the temporary file is already fully written, so there is no truncation risk. + byte[] data = content.getBytes("UTF-8"); + writeAll(fs, tmpPath, data); if (safeExists(fs, storagePath)) { + writeAll(fs, storagePath, data); try { - fs.delete(storagePath); + fs.delete(tmpPath); } catch (Throwable ignored) { - // fall through and let rename report the real failure + // the config is already correct; a leftover staging file is harmless } + } else { + // Nothing to preserve: rename() takes a bare name and moves the staged + // file into place within the same directory. + fs.rename(tmpPath, fileName); } - fs.rename(tmpPath, fileName); return true; } catch (Throwable ex) { Log.e(ex); @@ -292,6 +419,16 @@ private boolean writeConfigAtomic(FileSystemStorage fs, String path, String stor } } + private static void writeAll(FileSystemStorage fs, String storagePath, byte[] data) + throws IOException { + OutputStream os = fs.openOutputStream(storagePath); + try { + os.write(data); + } finally { + os.close(); + } + } + @SuppressWarnings("unchecked") private static Map asStringMap(Map raw) { Map out = new LinkedHashMap(); @@ -378,11 +515,14 @@ private static String homePath() { 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(); + /// %APPDATA% for the given home, derived from the home directory rather than read from + /// 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. + /// + /// Takes the home as an argument rather than reading it back, so a path is a pure + /// function of the home it is resolved against and cannot half-follow one home and + /// half-follow another. + private static String appDataPath(String home) { return home == null ? null : home + "/AppData/Roaming"; } @@ -401,13 +541,15 @@ public static final class MCPClient { private final String id; private final String displayName; private final String configPath; - private final boolean writable; + /// One of the FORMAT_ constants. Kept package private: which writer a host needs + /// is the registrar's business, and callers only ever ask whether it is writable. + final int format; - MCPClient(String id, String displayName, String configPath, boolean writable) { + MCPClient(String id, String displayName, String configPath, int format) { this.id = id; this.displayName = displayName; this.configPath = configPath; - this.writable = writable; + this.format = format; } public String getId() { @@ -426,7 +568,7 @@ public String getConfigPath() { /// for hosts whose config format is not yet supported, which the caller should /// surface as a manual step. public boolean isWritable() { - return writable; + return format != FORMAT_MANUAL; } } @@ -435,22 +577,27 @@ private static final class KnownClient { private final String displayName; private final String macRelative; private final String winRelative; + private final boolean winUnderAppData; private final String linuxRelative; - private final boolean writable; + private final int format; KnownClient(String id, String displayName, String macRelative, String winRelative, - String linuxRelative, boolean writable) { + boolean winUnderAppData, String linuxRelative, int format) { this.id = id; this.displayName = displayName; this.macRelative = macRelative; this.winRelative = winRelative; + this.winUnderAppData = winUnderAppData; this.linuxRelative = linuxRelative; - this.writable = writable; + this.format = format; } String absolutePath(String home) { if (isWindows()) { - String base = appDataPath(); + if (!winUnderAppData) { + return home + "/" + winRelative; + } + String base = appDataPath(home); return base == null ? null : base + "/" + winRelative; } if (isMac()) { diff --git a/CodenameOne/src/com/codename1/mcp/MCPToml.java b/CodenameOne/src/com/codename1/mcp/MCPToml.java new file mode 100644 index 00000000000..f6401ca6f47 --- /dev/null +++ b/CodenameOne/src/com/codename1/mcp/MCPToml.java @@ -0,0 +1,849 @@ +/* + * 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.List; +import java.util.Map; + +/// Edits the `mcp_servers` tables of a Codex style `config.toml` in place, so a +/// Codename One tool can register and unregister itself without a TOML library and +/// without disturbing the rest of the user's configuration. +/// +/// This is deliberately a STRUCTURAL editor rather than a parser: it walks the document +/// far enough to know where every table and every key assignment begins and ends, and +/// then rewrites only the byte range that belongs to one server. Everything else - +/// comments, key order, formatting, values whose types it never inspects - survives +/// untouched, which a parse-and-reserialize round trip could not promise. +/// +/// The other half of that promise is refusing. A document the walk cannot make sense of, +/// or one that defines this server in a shape the editor does not rewrite (a dotted key, +/// an array of tables, or twice), is reported as a problem and left exactly as it was. +/// Losing the user's Codex configuration is a far worse outcome than not registering. +final class MCPToml { + /// The Codex table that holds one sub table per MCP server. + static final String TABLE_NAME = "mcp_servers"; + + private static final int KIND_HEADER = 0; + private static final int KIND_ARRAY_HEADER = 1; + private static final int KIND_ASSIGNMENT = 2; + + private MCPToml() { + } + + /// The outcome of an edit: either the new document text, or the reason the document + /// was left alone. + static final class Result { + private final String text; + private final String problem; + + private Result(String text, String problem) { + this.text = text; + this.problem = problem; + } + + static Result applied(String text) { + return new Result(text, null); + } + + static Result refused(String problem) { + return new Result(null, problem); + } + + /// True when [#getText()] holds the document to write. + boolean isApplied() { + return problem == null; + } + + /// The new document text, or null when the edit was refused. + String getText() { + return text; + } + + /// Why the document was left untouched, or null when the edit was applied. + String getProblem() { + return problem; + } + } + + /// Adds, replaces or removes the `[mcp_servers.]` table. + /// + /// #### Parameters + /// + /// - `toml`: the current document, null or empty for a config that does not exist yet + /// - `serverName`: the server to write or remove + /// - `descriptor`: the entry to write, or null to remove the entry + static Result applyServerEntry(String toml, String serverName, MCPClientDescriptor descriptor) { + if (serverName == null || serverName.length() == 0) { + return Result.refused("no server name was given"); + } + String text = toml == null ? "" : toml; + // A byte order mark is legal at the head of a UTF-8 config and is not part of the + // TOML grammar, so it is set aside for the walk and put back afterwards. + String bom = ""; + if (text.length() > 0 && text.charAt(0) == '\uFEFF') { + bom = text.substring(0, 1); + text = text.substring(1); + } + Walker walker = new Walker(text); + List items = walker.walk(); + if (items == null) { + return Result.refused("it is not valid TOML: " + walker.getError()); + } + int exactHeaders = 0; + for (Item item : items) { + if (item.kind == KIND_ASSIGNMENT) { + if (item.table.isEmpty() && item.fullKey.size() == 1 + && TABLE_NAME.equals(item.fullKey.get(0))) { + // The root table assigns mcp_servers ITSELF a value. TOML then forbids + // a later [mcp_servers.x] header, so appending one would produce a file + // Codex cannot read. + // + // Only the whole-table assignment is fatal. A root dotted key that + // reaches THROUGH the table, `mcp_servers.docs.command = "d"`, leaves + // mcp_servers defined by dotted keys, and TOML explicitly allows a + // [table] header to add a sub-table to one of those - so another + // server declared that way must not block this one. + return Result.refused("'" + TABLE_NAME + "' is declared as a value, which " + + "this editor does not rewrite"); + } + // An assignment INSIDE the server's own table is replaced wholesale with + // the rest of it. One that reaches into it from anywhere else declares the + // server in a shape this editor does not rewrite. + if (startsWithServer(item.fullKey, serverName) + && !startsWithServer(item.table, serverName)) { + return Result.refused("the server is declared with a dotted key or an inline " + + "table, which this editor does not rewrite"); + } + } else if (item.kind == KIND_ARRAY_HEADER + && isMcpServersRelated(item.fullKey, serverName)) { + return Result.refused("'" + TABLE_NAME + "' is declared as an array of tables"); + } else if (item.kind == KIND_HEADER && isServer(item.fullKey, serverName)) { + exactHeaders++; + } + } + if (exactHeaders > 1) { + // Two [mcp_servers.] headers is not valid TOML in the first place, and + // guessing which one Codex would honour is not this editor's job. + return Result.refused("it declares the server more than once"); + } + // The runs of lines the server owns: each of its tables, and everything up to the + // next table that is not also the server's. A config written out of order can hold + // more than one such run. + List regions = new ArrayList(); + int i = 0; + while (i < items.size()) { + Item item = items.get(i); + i++; + if (item.kind != KIND_HEADER || !startsWithServer(item.fullKey, serverName)) { + continue; + } + int runEnd = lastLineOfRun(items, i - 1, serverName); + regions.add(new int[] {item.lineStart, runEnd}); + while (i < items.size() && items.get(i).lineStart < runEnd) { + i++; + } + } + String newline = detectNewline(text); + String block = descriptor == null ? "" : renderBlock(serverName, descriptor, newline); + String updated; + if (regions.isEmpty()) { + if (descriptor == null) { + // Nothing to remove. Reporting success with an unchanged document lets the + // caller skip the write entirely. + updated = text; + } else { + updated = append(text, block, newline); + } + } else { + // Copied front to back rather than spliced in place: StringBuilder.replace is + // a JDK method the Codename One runtime does not have (see vm/JavaAPI and + // Ports/CLDC11), so core cannot call it however well it compiles on a desktop. + StringBuilder sb = new StringBuilder(); + int cursor = 0; + for (int r = 0; r < regions.size(); r++) { + int[] region = regions.get(r); + int start = region[0]; + int end = region[1]; + // Only the first run keeps the entry; a later run is a stray table the + // config should never have had two of. + boolean removing = descriptor == null || r > 0; + if (removing) { + // Removing has to undo what appending did, or a config that is + // registered and unregistered repeatedly grows a blank line each time. + // The separator goes with the entry: the one after it when there is + // one, otherwise the one before it. + int afterBlanks = skipBlankLines(text, end); + if (afterBlanks > end) { + end = afterBlanks; + } else { + start = backOverBlankLines(text, start); + } + } + if (start > cursor) { + // Two runs separated by blank lines only: backOverBlankLines can reach + // behind the previous run's end, and that text is already consumed. + sb.append(text.substring(cursor, start)); + } + if (!removing) { + sb.append(block); + } + if (end > cursor) { + cursor = end; + } + } + sb.append(text.substring(cursor)); + updated = sb.toString(); + } + return Result.applied(bom + updated); + } + + /// The end of the byte range that belongs to the server's tables starting at `found`: + /// its own lines plus the lines of every sub table such as `.env` that follows it + /// directly. Blank lines and comments around it belong to the neighbours and are left + /// where they are. + private static int lastLineOfRun(List items, int found, String serverName) { + int end = items.get(found).end; + for (int i = found + 1; i < items.size(); i++) { + Item item = items.get(i); + if ((item.kind == KIND_HEADER || item.kind == KIND_ARRAY_HEADER) + && !startsWithServer(item.fullKey, serverName)) { + break; + } + end = item.end; + } + return end; + } + + private static int skipBlankLines(String text, int from) { + int end = from; + while (end < text.length()) { + int lineEnd = endOfLine(text, end); + if (!isBlank(text.substring(end, lineEnd))) { + break; + } + end = lineEnd; + } + return end; + } + + private static int backOverBlankLines(String text, int from) { + int start = from; + while (start > 0) { + int previousStart = startOfLine(text, start - 1); + if (!isBlank(text.substring(previousStart, start))) { + break; + } + start = previousStart; + } + return start; + } + + private static int startOfLine(String text, int from) { + int start = from; + while (start > 0 && text.charAt(start - 1) != '\n') { + start--; + } + return start; + } + + private static int endOfLine(String text, int from) { + int i = from; + while (i < text.length() && text.charAt(i) != '\n') { + i++; + } + return i < text.length() ? i + 1 : i; + } + + private static boolean isBlank(String line) { + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (c != ' ' && c != '\t' && c != '\r' && c != '\n') { + return false; + } + } + return true; + } + + private static String append(String text, String block, String newline) { + StringBuilder sb = new StringBuilder(text); + if (sb.length() > 0) { + if (sb.charAt(sb.length() - 1) != '\n') { + sb.append(newline); + } + // One blank line between the previous table and ours, unless the document + // already ends with one. + if (!endsWithBlankLine(sb)) { + sb.append(newline); + } + } + sb.append(block); + return sb.toString(); + } + + private static boolean endsWithBlankLine(StringBuilder sb) { + int i = sb.length() - 1; + if (i < 0 || sb.charAt(i) != '\n') { + return false; + } + i--; + if (i >= 0 && sb.charAt(i) == '\r') { + i--; + } + return i < 0 || sb.charAt(i) == '\n'; + } + + /// Keeps the document's own line ending, so editing a config written on Windows does + /// not leave one table in LF among CRLF neighbours. + private static String detectNewline(String text) { + int nl = text.indexOf('\n'); + if (nl > 0 && text.charAt(nl - 1) == '\r') { + return "\r\n"; + } + return "\n"; + } + + private static String renderBlock(String serverName, MCPClientDescriptor descriptor, String nl) { + String table = TABLE_NAME + "." + renderKey(serverName); + StringBuilder sb = new StringBuilder(); + sb.append('[').append(table).append(']').append(nl); + sb.append("command = ").append(renderString(descriptor.getCommand())).append(nl); + sb.append("args = ["); + List args = descriptor.getArgs(); + if (args != null) { + for (int i = 0; i < args.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(renderString(args.get(i))); + } + } + sb.append(']').append(nl); + Map env = descriptor.getEnv(); + if (env != null && !env.isEmpty()) { + sb.append(nl).append('[').append(table).append(".env]").append(nl); + for (Map.Entry entry : env.entrySet()) { + sb.append(renderKey(entry.getKey())).append(" = ") + .append(renderString(entry.getValue())).append(nl); + } + } + return sb.toString(); + } + + /// A bare key where TOML allows one, a quoted key otherwise. A server name carrying a + /// dot is the case that matters: written bare it would silently become two nested + /// tables rather than one server. + static String renderKey(String key) { + if (key == null || key.length() == 0) { + return "\"\""; + } + for (int i = 0; i < key.length(); i++) { + if (!isBareKeyChar(key.charAt(i))) { + return renderString(key); + } + } + return key; + } + + /// A TOML basic string. Non ASCII characters are emitted as themselves because the + /// file is written as UTF-8; control characters have no literal form and are escaped. + static String renderString(String value) { + String s = value == null ? "" : value; + StringBuilder sb = new StringBuilder(); + sb.append('"'); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"') { + sb.append("\\\""); + } else if (c == '\\') { + sb.append("\\\\"); + } else if (c == '\b') { + sb.append("\\b"); + } else if (c == '\t') { + sb.append("\\t"); + } else if (c == '\n') { + sb.append("\\n"); + } else if (c == '\f') { + sb.append("\\f"); + } else if (c == '\r') { + sb.append("\\r"); + } else if (c < 0x20 || c == 0x7f) { + sb.append("\\u").append(hex4(c)); + } else { + sb.append(c); + } + } + sb.append('"'); + return sb.toString(); + } + + private static String hex4(int c) { + String digits = "0123456789ABCDEF"; + StringBuilder sb = new StringBuilder(); + sb.append(digits.charAt((c >> 12) & 0xf)); + sb.append(digits.charAt((c >> 8) & 0xf)); + sb.append(digits.charAt((c >> 4) & 0xf)); + sb.append(digits.charAt(c & 0xf)); + return sb.toString(); + } + + private static boolean isBareKeyChar(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') + || c == '_' || c == '-'; + } + + private static boolean isServer(List key, String serverName) { + return key.size() == 2 && TABLE_NAME.equals(key.get(0)) && serverName.equals(key.get(1)); + } + + private static boolean startsWithServer(List key, String serverName) { + return key.size() >= 2 && TABLE_NAME.equals(key.get(0)) && serverName.equals(key.get(1)); + } + + /// True for a key that would make `mcp_servers` itself, or this one server, an array + /// of tables. An unrelated server declared that way is left alone: it does not change + /// where ours goes. + private static boolean isMcpServersRelated(List key, String serverName) { + if (key.isEmpty() || !TABLE_NAME.equals(key.get(0))) { + return false; + } + return key.size() == 1 || serverName.equals(key.get(1)); + } + + /// One table header or one key assignment, with the line range it occupies. + private static final class Item { + private int kind; + /// The key as resolved against the enclosing table. + private List fullKey; + /// The table this item sits in, empty for the root table. + private List table; + /// Offset of the first character of the item's first line. + private int lineStart; + /// Offset just past the newline that ends the item's last line. + private int end; + } + + /// Walks the document into a list of items. It reads the grammar's SHAPE - keys, + /// strings, arrays, inline tables, comments - and deliberately not its values, which + /// is all that is needed to find and replace one table, and is what lets everything + /// it does not understand pass through unchanged. + private static final class Walker { + private final String s; + private int i; + private String error; + + Walker(String s) { + this.s = s; + } + + String getError() { + return error; + } + + /// Returns the items, or null when the document is not valid TOML. + List walk() { + List items = new ArrayList(); + List currentTable = new ArrayList(); + while (true) { + skipIgnorable(); + if (i >= s.length()) { + return items; + } + Item item = new Item(); + item.lineStart = lineStartAt(i); + item.table = currentTable; + if (s.charAt(i) == '[') { + boolean arrayHeader = i + 1 < s.length() && s.charAt(i + 1) == '['; + i += arrayHeader ? 2 : 1; + List key = readDottedKey(arrayHeader ? "]]" : "]"); + if (key == null || !finishLine()) { + return null; + } + item.kind = arrayHeader ? KIND_ARRAY_HEADER : KIND_HEADER; + item.fullKey = key; + currentTable = key; + } else { + List key = readDottedKey("="); + if (key == null || !skipValue() || !finishLine()) { + return null; + } + item.kind = KIND_ASSIGNMENT; + List full = new ArrayList(currentTable); + full.addAll(key); + item.fullKey = full; + } + item.end = i; + items.add(item); + } + } + + private int lineStartAt(int pos) { + int start = pos; + while (start > 0 && s.charAt(start - 1) != '\n') { + start--; + } + return start; + } + + private void skipIgnorable() { + while (i < s.length()) { + char c = s.charAt(i); + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { + i++; + } else if (c == '#') { + skipToEndOfLine(); + } else { + return; + } + } + } + + private void skipToEndOfLine() { + while (i < s.length() && s.charAt(i) != '\n') { + i++; + } + } + + private void skipSpaces() { + while (i < s.length() && (s.charAt(i) == ' ' || s.charAt(i) == '\t')) { + i++; + } + } + + /// Reads a dotted key up to and including `terminator`, which is `=` for an + /// assignment and `]` or `]]` for a table header. Each part is unquoted, so the + /// parts compare equal however the user chose to write them. + private List readDottedKey(String terminator) { + List parts = new ArrayList(); + boolean more = true; + while (more) { + skipSpaces(); + if (i >= s.length()) { + error = "a key runs off the end of the file"; + return null; + } + char c = s.charAt(i); + String part = c == '"' || c == '\'' ? readQuotedKey(c) : readBareKey(); + if (part == null) { + return null; + } + parts.add(part); + skipSpaces(); + if (i >= s.length()) { + error = "a key runs off the end of the file"; + return null; + } + more = s.charAt(i) == '.'; + if (more) { + i++; + } + } + char c = s.charAt(i); + if ("=".equals(terminator)) { + if (c == '=') { + i++; + return parts; + } + } else if (c == ']') { + if (terminator.length() == 1) { + i++; + return parts; + } + if (i + 1 < s.length() && s.charAt(i + 1) == ']') { + i += 2; + return parts; + } + error = "an array of tables header is not closed"; + return null; + } + error = "unexpected '" + c + "' in a key"; + return null; + } + + private String readBareKey() { + int start = i; + while (i < s.length() && isBareKeyChar(s.charAt(i))) { + i++; + } + if (i == start) { + error = i < s.length() ? "unexpected '" + s.charAt(i) + "' where a key was expected" + : "a key is missing at the end of the file"; + return null; + } + return s.substring(start, i); + } + + private String readQuotedKey(char quote) { + int start = i; + if (!skipString()) { + return null; + } + String raw = s.substring(start + 1, i - 1); + if (quote == '\'') { + // A literal key has no escapes at all. + return raw; + } + return unescape(raw); + } + + /// Resolves the escapes of a basic string. Only the value of a KEY is ever + /// unescaped, because only keys are compared; every other string is skipped. + private String unescape(String raw) { + StringBuilder sb = new StringBuilder(); + int p = 0; + while (p < raw.length()) { + char c = raw.charAt(p); + if (c != '\\') { + sb.append(c); + p++; + continue; + } + p++; + if (p >= raw.length()) { + error = "a key ends with an incomplete escape"; + return null; + } + char e = raw.charAt(p); + p++; + if (e == 'b') { + sb.append('\b'); + } else if (e == 't') { + sb.append('\t'); + } else if (e == 'n') { + sb.append('\n'); + } else if (e == 'f') { + sb.append('\f'); + } else if (e == 'r') { + sb.append('\r'); + } else if (e == '"' || e == '\\') { + sb.append(e); + } else if (e == 'u' || e == 'U') { + int digits = e == 'u' ? 4 : 8; + if (p + digits > raw.length()) { + error = "a key has a truncated unicode escape"; + return null; + } + int value = 0; + for (int k = 0; k < digits; k++) { + int d = hexValue(raw.charAt(p + k)); + if (d < 0) { + error = "a key has a malformed unicode escape"; + return null; + } + value = (value << 4) + d; + } + p += digits; + appendCodePoint(sb, value); + } else { + error = "a key has an unknown escape"; + return null; + } + } + return sb.toString(); + } + + private void appendCodePoint(StringBuilder sb, int value) { + if (value > 0xffff) { + int v = value - 0x10000; + sb.append((char) (0xd800 + (v >> 10))); + sb.append((char) (0xdc00 + (v & 0x3ff))); + } else { + sb.append((char) value); + } + } + + private int hexValue(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return -1; + } + + /// Skips a value of any type without interpreting it. + private boolean skipValue() { + skipSpaces(); + if (i >= s.length()) { + error = "a value is missing at the end of the file"; + return false; + } + char c = s.charAt(i); + if (c == '"' || c == '\'') { + return skipString(); + } + if (c == '[' || c == '{') { + return skipContainer(); + } + if (c == '\n' || c == '\r') { + error = "a value is missing"; + return false; + } + // A number, boolean or date: it runs to the end of the line or to a comment. + while (i < s.length() && s.charAt(i) != '\n' && s.charAt(i) != '#') { + i++; + } + return true; + } + + /// Skips a basic or literal string, single line or multi line, leaving `i` just + /// past the closing quote. + private boolean skipString() { + char quote = s.charAt(i); + boolean basic = quote == '"'; + if (i + 2 < s.length() && s.charAt(i + 1) == quote && s.charAt(i + 2) == quote) { + i += 3; + boolean closed = false; + while (!closed && i < s.length()) { + char c = s.charAt(i); + if (basic && c == '\\') { + i += 2; + continue; + } + if (c != quote) { + i++; + continue; + } + int run = quoteRun(quote); + if (run < 3) { + // One or two quotes are ordinary content inside a multi line string. + i += run; + } else { + // The delimiter is the LAST three of the run: TOML lets the value + // itself end in one or two quotes, so a string opened with three + // quotes can close on four or five. A longer run is not legal, so + // consume three and let the leftovers be reported rather than + // swallowed. + i += run <= 5 ? run : 3; + closed = true; + } + } + if (!closed) { + error = "a multi line string is not closed"; + } + return closed; + } + i++; + while (i < s.length()) { + char c = s.charAt(i); + if (c == '\n') { + break; + } + if (basic && c == '\\') { + i += 2; + continue; + } + if (c == quote) { + i++; + return true; + } + i++; + } + error = "a string is not closed"; + return false; + } + + /// The number of consecutive `quote` characters starting at the cursor. + private int quoteRun(char quote) { + int run = 0; + while (i + run < s.length() && s.charAt(i + run) == quote) { + run++; + } + return run; + } + + /// Skips an array or an inline table, including nested ones and any strings or + /// comments inside them, so a `[` at the head of a line inside an array is never + /// mistaken for a table header. + private boolean skipContainer() { + // A stack of the closers still owed, not a depth count: counting alone accepts + // `[}` as balanced, and this walk is what decides whether the document is + // trustworthy enough to edit at all. + StringBuilder expected = new StringBuilder(); + while (i < s.length()) { + char c = s.charAt(i); + if (c == '"' || c == '\'') { + if (!skipString()) { + return false; + } + continue; + } + if (c == '#') { + skipToEndOfLine(); + continue; + } + if (c == '[' || c == '{') { + expected.append(c == '[' ? ']' : '}'); + i++; + continue; + } + if (c == ']' || c == '}') { + int last = expected.length() - 1; + if (last < 0 || expected.charAt(last) != c) { + error = "an array or inline table closes with the wrong delimiter"; + return false; + } + expected.deleteCharAt(last); + i++; + if (expected.length() == 0) { + return true; + } + continue; + } + i++; + } + error = "an array or inline table is not closed"; + return false; + } + + /// Consumes the rest of the line, which may hold only spaces and a comment, and + /// the newline that ends it. + private boolean finishLine() { + skipSpaces(); + if (i < s.length() && s.charAt(i) == '#') { + skipToEndOfLine(); + } + if (i >= s.length()) { + return true; + } + char c = s.charAt(i); + if (c == '\r') { + i++; + if (i < s.length() && s.charAt(i) == '\n') { + i++; + } + return true; + } + if (c == '\n') { + i++; + return true; + } + error = "unexpected '" + c + "' at the end of a line"; + return false; + } + } +} diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/MCPDesktopMenu.java b/Ports/JavaSE/src/com/codename1/impl/javase/MCPDesktopMenu.java index 4da314f20c8..e06dbcd29a9 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/MCPDesktopMenu.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/MCPDesktopMenu.java @@ -47,7 +47,7 @@ * * The tools serve MCP over a loopback socket; {@link MCPStdioLauncher} bridges a host's * stdio to that socket, so "Install" makes the running tool drivable from stdio hosts such - * as Claude Desktop, Codex and opencode. + * as Claude Desktop, Claude Code and Codex. */ public final class MCPDesktopMenu { private static final int DEFAULT_PORT = 8765; @@ -155,7 +155,9 @@ private static String serverName(String toolName) { private static void doInstall(String toolName, Component anchor) { try { MCPClientRegistrar registrar = MCPClientRegistrar.getInstance(); - List updated = registrar.register(bridgeDescriptor(toolName)); + List detected = registrar.detectClients(); + List updated = + registrar.register(bridgeDescriptor(toolName), detected); StringBuilder sb = new StringBuilder(); if (updated.isEmpty()) { sb.append("No auto-configurable MCP hosts were found.\n") @@ -167,6 +169,8 @@ private static void doInstall(String toolName, Component anchor) { } sb.append("\nRestart the host and this tool will appear as an MCP server."); } + appendSkipped(sb, detected, updated, + "Its configuration was left untouched; the log says why."); JOptionPane.showMessageDialog(anchor, sb.toString(), "MCP Install", JOptionPane.INFORMATION_MESSAGE); } catch (Throwable t) { JOptionPane.showMessageDialog(anchor, "Install failed: " + t.getMessage(), @@ -174,13 +178,63 @@ private static void doInstall(String toolName, Component anchor) { } } + /// Names the hosts that could be written to and were not, so a refusal to rewrite a + /// configuration the registrar could not safely edit reaches the user instead of only + /// the log. Hosts that need manual configuration are listed by "Detect MCP Hosts" and + /// are not failures, so they are left out. + private static void appendSkipped(StringBuilder sb, List detected, + List updated, String why) { + StringBuilder skipped = new StringBuilder(); + for (int i = 0; i < detected.size(); i++) { + MCPClientRegistrar.MCPClient client = detected.get(i); + if (!client.isWritable() || containsId(updated, client.getId())) { + continue; + } + skipped.append(" ").append(client.getDisplayName()) + .append(" (").append(client.getConfigPath()).append(")\n"); + } + if (skipped.length() == 0) { + return; + } + if (sb.length() > 0) { + sb.append("\n\n"); + } + sb.append("Not updated:\n\n").append(skipped).append('\n').append(why); + } + + private static boolean containsId(List clients, String id) { + for (int i = 0; i < clients.size(); i++) { + if (clients.get(i).getId().equals(id)) { + return true; + } + } + return false; + } + private static void doUninstall(String toolName, Component anchor) { try { List updated = MCPClientRegistrar.getInstance().unregister(serverName(toolName)); - String msg = updated.isEmpty() ? "No matching MCP host entries were found." - : "Removed '" + serverName(toolName) + "' from " + updated.size() + " host(s)."; - JOptionPane.showMessageDialog(anchor, msg, "MCP Remove", JOptionPane.INFORMATION_MESSAGE); + StringBuilder sb = new StringBuilder(); + if (updated.isEmpty()) { + // Deliberately not "no matching entries were found": an empty result also + // covers a host whose config the registrar refused to rewrite, and the two + // are indistinguishable from here. Nor does this list the hosts that were + // not updated, the way Install does - on removal that set is every host + // the tool was never registered with, so naming them would report the + // normal case as a failure. The log says which host was refused and why. + sb.append("Nothing was removed.\n") + .append("Either '").append(serverName(toolName)) + .append("' was not registered with any host, or a host's ") + .append("configuration could not be edited - see the log."); + } else { + sb.append("Removed '").append(serverName(toolName)).append("' from:\n\n"); + for (int i = 0; i < updated.size(); i++) { + sb.append(" ").append(updated.get(i).getDisplayName()).append('\n'); + } + sb.append("\nRestart the host for the change to take effect."); + } + JOptionPane.showMessageDialog(anchor, sb.toString(), "MCP Remove", JOptionPane.INFORMATION_MESSAGE); } catch (Throwable t) { JOptionPane.showMessageDialog(anchor, "Remove failed: " + t.getMessage(), "MCP Remove", JOptionPane.ERROR_MESSAGE); diff --git a/docs/developer-guide/MCP-Headless-API.asciidoc b/docs/developer-guide/MCP-Headless-API.asciidoc index 5877340c591..451253fed04 100644 --- a/docs/developer-guide/MCP-Headless-API.asciidoc +++ b/docs/developer-guide/MCP-Headless-API.asciidoc @@ -13,7 +13,7 @@ Every desktop Codename One tool, including the simulator and Codename One Settin The menu has these items: - *Expose This Tool To Agents* starts and stops the loopback MCP server for the running tool. -- *Install in MCP Hosts* registers this tool with the MCP hosts detected on the machine, such as Claude Desktop, Claude Code, Codex, and opencode. After a host restarts, the tool appears as an MCP server the agent can use. +- *Install in MCP Hosts* registers this tool with the MCP hosts detected on the machine: Claude Desktop, Claude Code, and Codex. After a host restarts, the tool appears as an MCP server the agent can use. A detected host whose configuration format isn't supported yet is reported rather than written to. - *Remove From MCP Hosts* removes that registration. - *Detect MCP Hosts* lists the hosts found on the machine and where their configuration lives. - *Debug Logging* controls how much of the MCP conversation is echoed to the log. @@ -95,4 +95,14 @@ The levels of `MCPVerbosity` are, from quietest to loudest: `OFF`, `ERRORS` (onl The Install and Remove items in the MCP menu call `MCPClientRegistrar`, which detects the MCP hosts installed on the machine and writes a server entry into each host configuration, so an end user doesn't edit configuration by hand. Detection and registration are available to any Codename One tool, and they run inside the runtime because they use the portable `FileSystemStorage`. -A caller describes the server with an `MCPClientDescriptor` (a name and the command that launches it) and registers it with the detected hosts. Hosts whose configuration format isn't yet supported are reported so the user can add the entry manually. +A caller describes the server with an `MCPClientDescriptor` (a name and the command that launches it) and registers it with the detected hosts. Hosts whose configuration format isn't yet supported are reported so the user can add the entry manually. `opencode` is the one that's left: it nests its servers in an `mcp` block whose entries have a shape of their own. + +Claude Desktop and Claude Code keep their servers in a JSON `mcpServers` object. Codex keeps its own in `~/.codex/config.toml`, as one `[mcp_servers.]` table per server, and that file is shared by the ChatGPT desktop app, the Codex CLI, and the Codex IDE extension, so registering once reaches all three. + +The default path is the one that's used. If you've moved your Codex configuration with `CODEX_HOME`, add the entry there by hand: the registrar runs inside the Codename One runtime, which has no `System.getenv`, so it can't follow the variable. + +The TOML file is edited as text rather than parsed and written back. Only the lines belonging to the one server are replaced, so every other server, setting, comment, and choice of formatting in the file survives the edit exactly as the user left it, and the file keeps its own line endings. Registering again replaces the entry instead of adding a second one, and removing takes the server's `env` sub-table with it. + +A configuration the editor can't make sense of is left untouched, and the reason is logged. That covers a file that isn't valid TOML, and the shapes it won't rewrite: the server declared through a dotted key or an inline table, `mcp_servers` declared as an array of tables, or the same server declared twice. Losing a Codex configuration is a worse outcome than not registering. + +The host reads its configuration at startup, so a host that's already running needs a restart before the new server appears. 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 index aa44cfe0ed2..bdb556f38de 100644 --- a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPClientRegistrarTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPClientRegistrarTest.java @@ -22,9 +22,12 @@ */ package com.codename1.mcp; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /// Guards the completeness check that stops the registrar from overwriting a truncated or @@ -32,6 +35,15 @@ /// for malformed input rather than throwing, so this structural check is what protects the /// user's other MCP servers. class MCPClientRegistrarTest { + private final String originalOsName = System.getProperty("os.name"); + + @AfterEach + void restoreOsName() { + if (originalOsName != null) { + System.setProperty("os.name", originalOsName); + } + } + @Test void acceptsWellFormedObjects() { @@ -52,4 +64,41 @@ void rejectsTruncatedOrMalformedObjects() { assertFalse(MCPClientRegistrar.isCompleteJsonObject("[1,2,3]")); assertFalse(MCPClientRegistrar.isCompleteJsonObject("null")); } + + @Test + void windowsPathsFollowEachHostsOwnConvention() { + // A wrong Windows base is silent: the config is simply never found, so the host is + // never detected and nothing is ever written. Codex and Claude Code keep dotfile + // configs under the user profile; only Claude Desktop, an installed application + // with a per user directory of its own, lives under %APPDATA%. + System.setProperty("os.name", "Windows 11"); + MCPClientRegistrar registrar = MCPClientRegistrar.getInstance(); + assertEquals("C:/Users/dev/.codex/config.toml", + registrar.configPathFor("codex", "C:/Users/dev")); + assertEquals("C:/Users/dev/.claude.json", + registrar.configPathFor("claude-code", "C:/Users/dev")); + assertEquals("C:/Users/dev/AppData/Roaming/Claude/claude_desktop_config.json", + registrar.configPathFor("claude-desktop", "C:/Users/dev")); + } + + @Test + void otherPlatformsResolveAgainstTheHome() { + MCPClientRegistrar registrar = MCPClientRegistrar.getInstance(); + System.setProperty("os.name", "Mac OS X"); + assertEquals("/Users/dev/.codex/config.toml", + registrar.configPathFor("codex", "/Users/dev")); + assertEquals("/Users/dev/Library/Application Support/Claude/claude_desktop_config.json", + registrar.configPathFor("claude-desktop", "/Users/dev")); + + System.setProperty("os.name", "Linux"); + assertEquals("/home/dev/.codex/config.toml", + registrar.configPathFor("codex", "/home/dev")); + assertEquals("/home/dev/.config/Claude/claude_desktop_config.json", + registrar.configPathFor("claude-desktop", "/home/dev")); + } + + @Test + void anUnknownHostHasNoPath() { + assertNull(MCPClientRegistrar.getInstance().configPathFor("nope", "/home/dev")); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/mcp/MCPTomlTest.java b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPTomlTest.java new file mode 100644 index 00000000000..629043fddc1 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/mcp/MCPTomlTest.java @@ -0,0 +1,292 @@ +/* + * 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 java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Guards the Codex `config.toml` writer. The file being edited is the user's own Codex +/// configuration, so the two properties that matter are that everything the entry does not +/// own survives the edit unchanged, and that a document the editor cannot make sense of is +/// refused rather than rewritten. +class MCPTomlTest { + private static final String SERVER = "cn1-my-app"; + + /// The byte order mark a UTF-8 config is allowed to start with. + private static final char BOM = '\uFEFF'; + + private static final String EXISTING = + "# Codex configuration\n" + + "model = \"gpt-5\"\n" + + "approval_policy = \"on-request\"\n" + + "\n" + + "[mcp_servers.docs]\n" + + "command = \"docs-server\"\n" + + "args = [\"--stdio\"]\n" + + "\n" + + "[tui]\n" + + "theme = \"dark\"\n"; + + private static MCPClientDescriptor descriptor() { + List args = new ArrayList(); + args.add("-cp"); + args.add("/Users/me/My Project/target/classes"); + args.add("com.codename1.impl.javase.MCPStdioLauncher"); + args.add("--attach"); + args.add("8765"); + return new MCPClientDescriptor(SERVER, "/usr/bin/java", args); + } + + private static MCPClientDescriptor descriptorWithEnv() { + Map env = new LinkedHashMap(); + env.put("CN1_HOME", "/opt/cn1"); + return new MCPClientDescriptor(SERVER, "java", new ArrayList(), env); + } + + private static String apply(String toml, MCPClientDescriptor descriptor) { + MCPToml.Result result = MCPToml.applyServerEntry(toml, SERVER, descriptor); + assertTrue(result.isApplied(), "expected the edit to be applied: " + result.getProblem()); + assertNotNull(result.getText()); + return result.getText(); + } + + private static String refuse(String toml, MCPClientDescriptor descriptor) { + MCPToml.Result result = MCPToml.applyServerEntry(toml, SERVER, descriptor); + assertFalse(result.isApplied(), "expected the edit to be refused"); + assertNull(result.getText()); + assertNotNull(result.getProblem()); + return result.getProblem(); + } + + @Test + void writesTheCodexTableIntoAnEmptyConfig() { + assertEquals("[mcp_servers.cn1-my-app]\n" + + "command = \"/usr/bin/java\"\n" + + "args = [\"-cp\", \"/Users/me/My Project/target/classes\", " + + "\"com.codename1.impl.javase.MCPStdioLauncher\", \"--attach\", \"8765\"]\n", + apply("", descriptor())); + } + + @Test + void appendsWithoutTouchingExistingSettings() { + String updated = apply(EXISTING, descriptor()); + assertTrue(updated.startsWith(EXISTING), + "the user's settings must survive the edit unchanged:\n" + updated); + assertTrue(updated.indexOf("[mcp_servers.cn1-my-app]") > 0); + // The other server is left exactly as it was. + assertTrue(updated.indexOf("[mcp_servers.docs]\ncommand = \"docs-server\"") > 0); + } + + @Test + void replacesTheEntryRatherThanDuplicatingIt() { + String once = apply(EXISTING, descriptor()); + String twice = apply(once, descriptor()); + assertEquals(once, twice); + assertEquals(once.indexOf("[mcp_servers.cn1-my-app]"), + once.lastIndexOf("[mcp_servers.cn1-my-app]")); + } + + @Test + void replacingAnEntryInTheMiddleKeepsWhatFollows() { + String doc = "[mcp_servers.cn1-my-app]\n" + + "command = \"stale\"\n" + + "args = [\"gone\"]\n" + + "\n" + + "[tui]\n" + + "theme = \"dark\"\n"; + String updated = apply(doc, descriptor()); + assertTrue(updated.endsWith("\n[tui]\ntheme = \"dark\"\n"), updated); + assertEquals(-1, updated.indexOf("stale")); + assertEquals(-1, updated.indexOf("gone")); + } + + @Test + void environmentGoesIntoItsOwnSubTable() { + assertEquals("[mcp_servers.cn1-my-app]\n" + + "command = \"java\"\n" + + "args = []\n" + + "\n" + + "[mcp_servers.cn1-my-app.env]\n" + + "CN1_HOME = \"/opt/cn1\"\n", apply("", descriptorWithEnv())); + } + + @Test + void removingTakesTheSubTablesAndLeavesTheRestAsItWas() { + String withEntry = apply(EXISTING, descriptorWithEnv()); + assertTrue(withEntry.indexOf("[mcp_servers.cn1-my-app.env]") > 0); + assertEquals(EXISTING, apply(withEntry, null)); + } + + @Test + void removingAnEntryThatIsNotThereChangesNothing() { + assertEquals(EXISTING, apply(EXISTING, null)); + assertEquals("", apply("", null)); + } + + @Test + void consolidatesTablesTheUserWroteOutOfOrder() { + String doc = "[mcp_servers.cn1-my-app]\n" + + "command = \"stale\"\n" + + "\n" + + "[other]\n" + + "z = 1\n" + + "\n" + + "[mcp_servers.cn1-my-app.env]\n" + + "STALE = \"1\"\n"; + String updated = apply(doc, descriptor()); + assertEquals(-1, updated.indexOf("STALE")); + assertEquals(-1, updated.indexOf("stale")); + assertTrue(updated.indexOf("[other]\nz = 1") > 0); + assertEquals(updated.indexOf("[mcp_servers.cn1-my-app]"), + updated.lastIndexOf("[mcp_servers.cn1-my-app]")); + } + + @Test + void quotesAServerNameThatIsNotABareKey() { + // Written bare, a dot would make this two nested tables instead of one server. + MCPClientDescriptor descriptor = + new MCPClientDescriptor("cn1-my.app 2", "java", new ArrayList()); + MCPToml.Result result = MCPToml.applyServerEntry("", "cn1-my.app 2", descriptor); + assertTrue(result.isApplied()); + assertTrue(result.getText().startsWith("[mcp_servers.\"cn1-my.app 2\"]\n"), + result.getText()); + } + + @Test + void escapesQuotesBackslashesAndControlCharacters() { + assertEquals("\"a\\\"b\\\\c\\td\\ne\"", MCPToml.renderString("a\"b\\c\td\ne")); + assertEquals("\"\\u0000\\u001F\"", + MCPToml.renderString(new String(new char[] {0, 0x1f}))); + // A bare key needs no quoting; anything else does. + assertEquals("cn1-my-app", MCPToml.renderKey("cn1-my-app")); + assertEquals("\"has space\"", MCPToml.renderKey("has space")); + assertEquals("\"has.dot\"", MCPToml.renderKey("has.dot")); + assertEquals("\"\"", MCPToml.renderKey("")); + } + + @Test + void keepsTheDocumentsOwnLineEnding() { + String updated = apply("model = \"gpt-5\"\r\n", descriptor()); + assertTrue(updated.indexOf("[mcp_servers.cn1-my-app]\r\n") > 0, updated); + assertEquals(-1, updated.replace("\r\n", "").indexOf('\n')); + } + + @Test + void keepsAByteOrderMark() { + String updated = apply(BOM + "model = \"gpt-5\"\n", descriptor()); + assertEquals(BOM, updated.charAt(0)); + assertEquals(0, updated.lastIndexOf(BOM)); + } + + @Test + void aTableHeaderInsideAMultiLineStringIsNotATable() { + // The scan has to read the shape of the document rather than search its text, or + // this banner would look like a table of the server's own. + String doc = "banner = \"\"\"\n[mcp_servers.cn1-my-app]\ncommand = \"evil\"\n\"\"\"\n"; + String updated = apply(doc, descriptor()); + assertTrue(updated.startsWith(doc), updated); + assertTrue(updated.indexOf("command = \"evil\"") > 0); + } + + @Test + void aBracketInsideAnArrayIsNotATableHeader() { + String doc = "matrix = [\n[1, 2],\n[3, 4],\n]\ninline = { a = \"]\", b = 1 } # note\n"; + assertTrue(apply(doc, descriptor()).startsWith(doc)); + } + + @Test + void aMultiLineStringMayEndInExtraQuotes() { + // TOML lets the VALUE end in one or two quotes, so the closing run is four or five + // characters and only the last three are the delimiter. Reading the first three as + // the terminator left a stray quote behind and refused a perfectly valid config. + String doc = "a = \"\"\"ends in one quote\"\"\"\"\n" + + "b = \"\"\"ends in two quotes\"\"\"\"\"\n" + + "c = '''literal ends in one quote''''\n" + + "d = \"\"\"two \"\" inside\"\"\"\n"; + String updated = apply(doc, descriptor()); + assertTrue(updated.startsWith(doc), updated); + assertTrue(updated.indexOf("[mcp_servers.cn1-my-app]") > 0); + } + + @Test + void refusesAConfigThatIsNotValidToml() { + assertTrue(refuse("[mcp_servers.docs]\ncommand = \"unterminated\n", descriptor()) + .indexOf("not valid TOML") >= 0); + assertTrue(refuse("[unclosed\n", descriptor()).indexOf("not valid TOML") >= 0); + // A container has to close with the delimiter it opened with. Counting depth + // alone accepted this, and the file was then edited despite the promise not to. + assertTrue(refuse("value = [}\n", descriptor()).indexOf("wrong delimiter") >= 0); + assertTrue(refuse("value = { a = 1 ]\n", descriptor()).indexOf("wrong delimiter") >= 0); + assertTrue(refuse("value = [[1, 2}]\n", descriptor()).indexOf("wrong delimiter") >= 0); + } + + @Test + void refusesShapesItWouldHaveToGuessAt() { + // Assigning mcp_servers itself is fatal: TOML forbids a [mcp_servers.x] header + // after it, so appending one would produce a file Codex cannot read. + assertTrue(refuse("mcp_servers = { docs = { command = \"d\" } }\n", descriptor()) + .indexOf("declared as a value") >= 0); + // A root dotted key that names THIS server would be a second declaration of it. + assertTrue(refuse("mcp_servers.cn1-my-app.command = \"d\"\n", descriptor()) + .indexOf("dotted key") >= 0); + // The server itself declared in a shape the editor does not rewrite. + assertTrue(refuse("[mcp_servers]\n\"cn1-my-app\" = { command = \"x\" }\n", descriptor()) + .indexOf("inline table") >= 0); + assertTrue(refuse("[[mcp_servers.cn1-my-app]]\ncommand = \"x\"\n", descriptor()) + .indexOf("array of tables") >= 0); + assertTrue(refuse("[mcp_servers.cn1-my-app]\na = 1\n[mcp_servers.cn1-my-app]\nb = 2\n", + descriptor()).indexOf("more than once") >= 0); + } + + @Test + void anotherServersRootDottedKeyDoesNotBlockThisOne() { + // `mcp_servers.docs.command = "d"` leaves mcp_servers defined by dotted keys, and + // TOML explicitly allows a [table] header to add a sub-table to one of those. This + // used to refuse every registration, and every removal, because of a neighbour. + String doc = "mcp_servers.docs.command = \"d\"\n"; + String updated = apply(doc, descriptor()); + assertTrue(updated.startsWith(doc), updated); + assertTrue(updated.indexOf("[mcp_servers.cn1-my-app]") > 0); + // ...and the entry can be taken out again. + assertEquals(doc, apply(updated, null)); + } + + @Test + void leavesAnotherServersUnusualShapeAlone() { + // Only the entry being written has to be in a shape the editor understands. + String doc = "[mcp_servers]\ndocs = { command = \"d\" }\n"; + String updated = apply(doc, descriptor()); + assertTrue(updated.startsWith(doc), updated); + assertTrue(updated.indexOf("[mcp_servers.cn1-my-app]") > 0); + } +} diff --git a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java index f569fb14c91..a71a133545c 100644 --- a/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java +++ b/scripts/initializr/common/src/main/java/com/codename1/initializr/model/GeneratorModel.java @@ -109,6 +109,8 @@ public class GeneratorModel { + " deliberate subset, see `.agent-skills/codename-one/references/css.md`).\n" + "- Run the simulator with `mvn -pl common cn1:run`.\n" + "- Run tests with `mvn -pl common cn1:test` (on Linux CI use `xvfb-run -a`).\n" + + "- You can drive the RUNNING simulator yourself over MCP (read the screen, type,\n" + + " tap) - see `.agent-skills/codename-one/references/mcp-agent-control.md`.\n" + "- Native cloud builds use `mvn -pl package -Dcodename1.platform=... -Dcodename1.buildTarget=...`.\n" + "\n" + "When in doubt, open `.agent-skills/codename-one/SKILL.md` and follow the\n" diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index e3d88dbd20b..815af7dca98 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -43,6 +43,7 @@ This skill teaches you how to write code for a Codename One (CN1) cross-platform - `references/3d-graphics.md` — Portable GPU 3D (`com.codename1.gpu`): the `RenderView` + `Renderer` loop, declarative `Material` / `VertexFormat` (engine-generated shaders — no GLSL), `Primitives`, `GltfLoader` for glTF models, `Camera` / `Light` / `Matrix4`, and platform backends. Read this for product viewers, 3D scenes, or custom GPU rendering. - `references/snapshot-builds.md` — Edge case: compiling against a Codename One SNAPSHOT from git. - `references/debugging.md` — `jdb`-attach workflow for an agent: start the simulator paused, set breakpoints, dump locals, drive the session non-interactively from a script. +- `references/mcp-agent-control.md` — Driving the **running** simulator yourself over MCP: turn the server on from the simulator's `MCP` menu, register it with Claude Desktop / Claude Code / Codex in one click, then read the screen with `ui_snapshot`, find a field with `ui_find`, type with `ui_set_text` and tap with `ui_activate`. Read this when you need to know whether a flow *behaves* correctly, not just how it looks. - `tools/` — runnable Java 17 single-file utilities. `tools/IsApiSupported.java` answers "is this `java.*` class in the CN1 subset?"; `tools/IsCssValid.java` answers "does this `theme.css` compile?"; `tools/CompareToMockup.java` scores a rendered screenshot against a designer mockup (similarity %, with region masking); `tools/DesignImport.java` turns a Figma/Sketch/Adobe XD design — or an HTML/React design's `tokens.css`/`styles.css` (Claude-generated mockups) — into starter CN1 CSS + tokens + a layout map. **`tools/DumpForm.java`** boots the app in **desktop mode** and dumps a model of the current screen, which **`tools/DescribeForm.java`** (vision-free outline), **`tools/AlignmentCheck.java`** (designer alignment guides) and **`tools/GuiLint.java`** (nested scroll, opaque text/containers, image borders) analyse. **`tools/UpdateSkills.java`** self-updates this whole skill from GitHub. Run with `java tools/.java `. When the user's task hits any one of those topics, **read the matching reference before generating code**. Do not paste large snippets without checking. @@ -317,6 +318,7 @@ If you cannot run the simulator (e.g. headless environment), **say so explicitly | "Store an LLM API key" / non-prompting SecureStorage | `references/ai-and-speech.md` | | "Build against a Codename One SNAPSHOT from git" | `references/snapshot-builds.md` | | "Debug a faulty screen — attach `jdb` to the simulator" | `references/debugging.md` | +| "Try the flow" / "fill in this form and press submit" / "drive the running app" / MCP | `references/mcp-agent-control.md` | | Quick yes/no check: "is this `java.*` class supported", "does my `theme.css` compile" | `tools/` directory — `java tools/IsApiSupported.java ` / `java tools/IsCssValid.java ` | | "Score this screen against a mockup" / "Import a Figma/Sketch/XD design" | `tools/` directory — `java tools/CompareToMockup.java ` / `java tools/DesignImport.java ` (see `references/mockup-comparison.md`) | | "Describe this screen" / "are these elements aligned" / "lint this UI for bugs" | `tools/` — `java -cp tools/DumpForm.java ` then `tools/DescribeForm.java` / `tools/AlignmentCheck.java` / `tools/GuiLint.java` on the model (see `references/mockup-comparison.md`) | diff --git a/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md b/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md new file mode 100644 index 00000000000..4ddef4b5582 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/skill/references/mcp-agent-control.md @@ -0,0 +1,71 @@ +# Driving the Running App — MCP + +You can attach to the **running simulator** and drive the app yourself: read the screen, find a field by its label, type into it, tap a button, and call tools the app publishes. Codename One serves this over the [Model Context Protocol](https://modelcontextprotocol.io/), built on the same accessibility semantics tree that describes the screen to VoiceOver and TalkBack — so every screen is drivable with no extra code in the app. + +This is the loop to reach for when a screen "looks right" in a screenshot but you need to know whether it *behaves* right: fill the form, press submit, read what the next screen says. It complements the other two loops in this skill — `tools/DumpForm.java` (a static model of one screen, no interaction) and `references/debugging.md` (`jdb`, for stepping through code). + +## Turn it on + +Two halves, both one-time: + +1. **Serve.** Run the simulator (`mvn -pl common cn1:run`), then in its menu bar choose **MCP -> Expose This Tool To Agents**. The simulator starts an MCP server on `127.0.0.1:8765`. +2. **Register.** In the same menu choose **MCP -> Install in MCP Hosts...**. This writes a server entry into the configuration of every MCP host it finds on the machine: + + | Host | Configuration it writes | + | --- | --- | + | Claude Desktop | `claude_desktop_config.json` (`mcpServers`) | + | Claude Code | `~/.claude.json` (`mcpServers`) | + | Codex — the ChatGPT desktop app, the Codex CLI, and the Codex IDE extension all share one file | `~/.codex/config.toml` (`[mcp_servers.]`) | + + Your other servers and settings are left exactly as they were. **Restart the host afterwards**: every one of them reads its configuration at startup, so the new server does not appear until it does. `MCP -> Detect MCP Hosts...` lists what was found and where each configuration lives, and `MCP -> Remove From MCP Hosts...` takes the entry out again. + +The entry launches a small bridge that relays the host's stdin/stdout to the running simulator's socket. That means **the simulator has to be running and serving** when you call a tool — you are driving the live window a human can watch, not a second headless copy. + +If you are running the app yourself rather than through the menu, the same switch is one line of code: + +```java +// In MyAppName.start(), for development builds only. +MCP.startSocketServer(8765); +``` + +## What you can call + +Every server publishes these, with no work from the app: + +| Tool | What it does | +| --- | --- | +| `ui_snapshot` | The whole screen as JSON: every node with its `id`, `role`, `label`, `value`, `state`, and the ids of the actions it supports. Start here. | +| `ui_find` | Nodes by application identifier, by a case-insensitive substring of their label, or by screen coordinate. Cheaper than a full snapshot when you know what you want. | +| `ui_activate` | Tap or click a node (`nodeId` from a snapshot or a find). | +| `ui_set_text` | Type into an editable node (`nodeId`, `text`). | +| `ui_perform_action` | Any other action a node advertises: `longPress`, `increment`, `decrement`, `focus`, `expand`, `scrollForward`, and so on. | + +Every action runs on the Codename One EDT and returns whether it succeeded **plus a fresh snapshot**, so one call tells you what the screen became. The server also exposes a screenshot of the current form as an MCP image resource, which is worth reading when the semantic tree looks right and you suspect a layout or styling problem instead. + +A typical loop: `ui_snapshot` to see the screen -> `ui_find` the field by label -> `ui_set_text` -> `ui_activate` the submit button -> read the returned snapshot to confirm what happened. + +## Publishing the app's own tools + +Anything the agent should be able to ask the app directly — not by driving its UI — is a `com.codename1.ai.Tool`, the same type the Codename One AI client uses, so a tool defined once serves both: + +```java +MCP.addTool(new Tool( + "current_user", + "Returns the signed in user", + "{\"type\":\"object\",\"properties\":{}}", + new ToolHandler() { + @Override + public String invoke(String argumentsJson) { + return "{\"name\":\"" + currentUser + "\"}"; + } + })); +``` + +The server merges these with the built-in UI tools when a host lists what is available. + +## Rules to respect + +- **Development builds only.** The socket server refuses to bind on a release build and throws `IllegalStateException` instead: the loopback interface is shared by everything on a device, so any other installed app could otherwise drive yours. `MCP.setAllowOnReleaseBuilds(true)` lifts that, and is for a controlled fleet (a kiosk, a test lab, a managed deployment) — not for an app that ships to users. +- **Do not leave a starter in shipping code** on the assumption the gate will catch it. The JavaSE port cannot tell a packaged desktop app from the simulator, so it reports a development build in every case and the gate does not protect a desktop release. +- **Watch what you are doing.** `MCP -> Debug Logging` echoes the conversation to the Codename One log; `SUMMARY` gives one line per call, `FULL` gives every request and response. +- **Say when you could not check.** If the simulator is not running, or the environment is headless, say so instead of reporting that a flow works. diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java index 4aff01c6b57..c7fc516ecdf 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelIntegrationBuildTest.java @@ -1,3 +1,25 @@ +/* + * 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.initializr.model; import com.codename1.io.Util; @@ -32,22 +54,33 @@ public boolean runTest() throws Exception { Path java8Or11 = findJava8Or11Home(); Path java17 = findJavaHomeForMajor(17); + Path buildClient = findBuildClientJar(); + if (buildClient == null) { + // Every goal in the generated project's compile runs out of this jar, so + // without it there is nothing to test. Skip rather than fail: it is installed + // by setup-workspace.sh, not by this repository's build. + System.out.println("[WARN] Skipping integration build checks. No " + + ".codenameone/CodeNameOneBuildClient.jar found under the user home."); + return true; + } + if (java8Or11 == null) { System.out.println("[WARN] Skipping Java 8/11 integration build check. No JDK 8 or 11 found."); } else { - buildGeneratedProject(ProjectOptions.JavaVersion.JAVA_8, java8Or11, "java8-or-11"); + buildGeneratedProject(ProjectOptions.JavaVersion.JAVA_8, java8Or11, buildClient, "java8-or-11"); } if (java17 == null) { System.out.println("[WARN] Skipping Java 17 integration build check. No JDK 17 found."); } else { - buildGeneratedProject(ProjectOptions.JavaVersion.JAVA_17, java17, "java17"); + buildGeneratedProject(ProjectOptions.JavaVersion.JAVA_17, java17, buildClient, "java17"); } return true; } - private void buildGeneratedProject(ProjectOptions.JavaVersion version, Path javaHome, String suffix) throws Exception { + private void buildGeneratedProject(ProjectOptions.JavaVersion version, Path javaHome, + Path buildClient, String suffix) throws Exception { String appName = "Integration" + suffix.replace("-", "") + "App"; String packageName = "com.acme.initializr." + suffix.replace("-", ""); @@ -63,11 +96,11 @@ private void buildGeneratedProject(ProjectOptions.JavaVersion version, Path java byte[] zip = createProjectZip(options, appName, packageName); Path projectDir = Files.createTempDirectory("initializr-integration-" + suffix + "-"); Path homeDir = Files.createTempDirectory("initializr-home-" + suffix + "-"); - ensureCodenameOneHome(homeDir); + ensureCodenameOneHome(homeDir, buildClient); unzipProject(zip, projectDir); int exitCode = runMavenCompile(projectDir, homeDir, javaHome); - assertTrue(exitCode == 0, "Generated project should compile with selected JDK. Version=" + version.label + " | exitCode=" + exitCode); + assertTrue(exitCode == 0, "Generated project should build with selected JDK. Version=" + version.label + " | exitCode=" + exitCode); // Localization bundles were requested -- they must end up baked into theme.res so // that Resources.getGlobalResources().getL10N("messages", lang) resolves at runtime. @@ -79,7 +112,7 @@ private void buildGeneratedProject(ProjectOptions.JavaVersion version, Path java private void assertLocalizationBakedIntoThemeRes(Path projectDir, ProjectOptions.JavaVersion version) throws Exception { Path themeRes = projectDir.resolve("common/target/classes/theme.res"); assertTrue(Files.isRegularFile(themeRes), - "theme.res should exist after compile. Version=" + version.label + " | path=" + themeRes); + "theme.res should exist after the build. Version=" + version.label + " | path=" + themeRes); Resources res; try (FileInputStream in = new FileInputStream(themeRes.toFile())) { @@ -115,7 +148,11 @@ private int runMavenCompile(Path projectDir, Path homeDir, Path javaHome) throws "-DskipTests=true", "-Dcodename1.platform=javase", "-Duser.home=" + homeDir.toString(), - "compile" + // process-classes, not compile: the generated pom binds the cn1 css goal + // (theme.css -> theme.res, localization bundles and all) to that phase, so + // stopping at compile leaves nothing for assertLocalizationBakedIntoThemeRes + // to read. + "process-classes" ); pb.directory(projectDir.toFile()); pb.redirectErrorStream(true); @@ -148,11 +185,27 @@ private int runMavenCompile(Path projectDir, Path homeDir, Path javaHome) throws return exit; } - private void ensureCodenameOneHome(Path homeDir) throws IOException { + /// Populates the throwaway home the generated build runs against. Both jars belong in + /// `.codenameone/`: that is where the generated pom's systemPath points and where + /// `generate-gui-sources` looks. The build client has to be the real one - the mojo + /// loads `com.codename1.build.client.GenerateGuiSources` out of it, so an empty + /// placeholder fails the build before it compiles a line. + private void ensureCodenameOneHome(Path homeDir, Path buildClient) throws IOException { Path cn1Dir = homeDir.resolve(".codenameone"); Files.createDirectories(cn1Dir); Files.write(cn1Dir.resolve("guibuilder.jar"), new byte[0]); - Files.write(homeDir.resolve("CodeNameOneBuildClient.jar"), new byte[0]); + Files.copy(buildClient, cn1Dir.resolve("CodeNameOneBuildClient.jar")); + } + + /// The build client installed on this machine, or null when there is none. The test + /// overrides `user.home` for the child build only, so the real home still holds it. + private Path findBuildClientJar() { + String home = System.getProperty("user.home"); + if (home == null || home.length() == 0) { + return null; + } + Path jar = Paths.get(home, ".codenameone", "CodeNameOneBuildClient.jar"); + return Files.isRegularFile(jar) ? jar : null; } private void unzipProject(byte[] zipData, Path destination) throws IOException { diff --git a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java index df22dea602f..c5cf223a582 100644 --- a/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java +++ b/scripts/initializr/common/src/test/java/com/codename1/initializr/model/GeneratorModelMatrixTest.java @@ -169,6 +169,7 @@ private void validateClaudeSkillBundled() throws Exception { ".agent-skills/codename-one/references/cn1libs.md", ".agent-skills/codename-one/references/snapshot-builds.md", ".agent-skills/codename-one/references/debugging.md", + ".agent-skills/codename-one/references/mcp-agent-control.md", ".agent-skills/codename-one/references/ai-and-speech.md", ".agent-skills/codename-one/tools/README.md", ".agent-skills/codename-one/tools/IsApiSupported.java", @@ -195,6 +196,10 @@ private void validateClaudeSkillBundled() throws Exception { String agentsMd = getText(entries, "AGENTS.md"); assertContains(agentsMd, ".agent-skills/codename-one/SKILL.md", "AGENTS.md should point agents at the canonical skill location"); + // An agent that never learns the running app is drivable will only ever look at + // screenshots, so the pointer to the MCP loop belongs in the root file too. + assertContains(agentsMd, "references/mcp-agent-control.md", + "AGENTS.md should point agents at the MCP control loop"); String claudeStub = getText(entries, ".claude/skills/codename-one/SKILL.md"); assertContains(claudeStub, "name: codename-one", "Claude stub must keep the skill frontmatter"); diff --git a/scripts/initializr/javase/src/main/java/com/codename1/initializr/WebsiteThemeNativeImpl.java b/scripts/initializr/javase/src/main/java/com/codename1/initializr/WebsiteThemeNativeImpl.java index 42a5166eabf..e036255bd7e 100644 --- a/scripts/initializr/javase/src/main/java/com/codename1/initializr/WebsiteThemeNativeImpl.java +++ b/scripts/initializr/javase/src/main/java/com/codename1/initializr/WebsiteThemeNativeImpl.java @@ -1,3 +1,25 @@ +/* + * 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.initializr; public class WebsiteThemeNativeImpl implements com.codename1.initializr.WebsiteThemeNative{ @@ -10,6 +32,16 @@ public boolean isSupported() { } public void notifyUiReady() {} + /** + * The browser download bridge has no simulator equivalent: the generated zip is + * written to the app home directory instead. isSupported() is false here, so the + * generator never calls this - it exists to satisfy the interface, and answers + * false so a caller that skips that check does not believe a download happened. + */ + public boolean downloadProject(String fileName, String dataUrl) { + return false; + } + public int chatLauncherClearance() { return 0; }