From 5720e48568182b776065428b4ac895a7f617a95d Mon Sep 17 00:00:00 2001 From: Sam Edwards Date: Sun, 29 Mar 2026 09:30:37 -0400 Subject: [PATCH] Upstream 2026.03.28 --- .../AndroidDeviceCommandExecutor.android.kt | 8 +++ .../device/AndroidDeviceCommandExecutor.kt | 29 ++++++++ .../util/HostAndroidDeviceConnectUtils.kt | 24 ++++--- .../util/PrecompiledApkInstaller.kt | 72 +++++++++++++++++-- .../AndroidDeviceCommandExecutor.jvm.kt | 4 ++ trailblaze-desktop/build.gradle.kts | 13 ++-- .../devices/PlaywrightBrowserInstaller.kt | 17 ++++- .../trailblaze/mcp/TrailblazeMcpBridgeImpl.kt | 18 ++++- .../playwright/PlaywrightDriverManager.kt | 31 ++++++-- .../PlaywrightElectronBrowserManager.kt | 3 + .../block/trailblaze/logs/server/SslConfig.kt | 48 ++++++++----- .../logs/server/TrailblazeMcpServer.kt | 10 ++- .../trailblaze/mcp/utils/McpSchemaCompat.kt | 64 +++++++++++++++++ .../mcp/utils/TrailblazeToolToMcpBridge.kt | 15 +++- 14 files changed, 305 insertions(+), 51 deletions(-) create mode 100644 trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/McpSchemaCompat.kt diff --git a/trailblaze-common/src/androidMain/kotlin/xyz/block/trailblaze/device/AndroidDeviceCommandExecutor.android.kt b/trailblaze-common/src/androidMain/kotlin/xyz/block/trailblaze/device/AndroidDeviceCommandExecutor.android.kt index f1ec0743..8729d975 100644 --- a/trailblaze-common/src/androidMain/kotlin/xyz/block/trailblaze/device/AndroidDeviceCommandExecutor.android.kt +++ b/trailblaze-common/src/androidMain/kotlin/xyz/block/trailblaze/device/AndroidDeviceCommandExecutor.android.kt @@ -60,6 +60,13 @@ actual class AndroidDeviceCommandExecutor actual constructor( return AdbCommandUtil.isAppRunning(appId) } + actual fun grantAppOpsPermission(appId: String, permission: String) { + AdbCommandUtil.grantAppOpsPermission( + targetAppPackageName = appId, + permission = permission, + ) + } + actual fun writeFileToDownloads(fileName: String, content: ByteArray) { val context = InstrumentationRegistry.getInstrumentation().context FileReadWriteUtil.writeToDownloadsFile( @@ -188,4 +195,5 @@ actual class AndroidDeviceCommandExecutor actual constructor( tempFile.delete() } } + } diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/device/AndroidDeviceCommandExecutor.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/device/AndroidDeviceCommandExecutor.kt index 864e69d3..46145dca 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/device/AndroidDeviceCommandExecutor.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/device/AndroidDeviceCommandExecutor.kt @@ -6,6 +6,13 @@ import xyz.block.trailblaze.devices.TrailblazeDeviceId * Expected class for executing device commands on Android devices. * Implementations may use ADB (from host JVM) or direct Android APIs (from on-device Android). */ +/** + * AppOps operation name for the MANAGE_EXTERNAL_STORAGE permission (API 30+). + * Equivalent to [android.app.AppOpsManager.OPSTR_MANAGE_EXTERNAL_STORAGE], which is not + * available on JVM. + */ +const val APPOPS_MANAGE_EXTERNAL_STORAGE = "MANAGE_EXTERNAL_STORAGE" + expect class AndroidDeviceCommandExecutor( deviceId: TrailblazeDeviceId, ) { @@ -36,6 +43,28 @@ expect class AndroidDeviceCommandExecutor( */ fun isAppRunning(appId: String): Boolean + /** + * Grants an AppOps permission to the specified app via `appops set allow`. + * + * Requires ADB shell access or an instrumentation test context (which runs as the `shell` user + * via [UiDevice.executeShellCommand]). Does **not** require root. Will fail with a permission + * denial if called from a regular (non-privileged) app process. + * + * This only works for AppOps operations, **not** standard runtime permissions (which require + * `pm grant`). Common supported operations include: + * - `MANAGE_EXTERNAL_STORAGE` — broad file access (API 30+) + * - `REQUEST_INSTALL_PACKAGES` — install unknown apps + * - `SYSTEM_ALERT_WINDOW` — draw overlays + * - `WRITE_SETTINGS` — modify system settings + * - `ACCESS_NOTIFICATIONS` — read notifications + * - `PICTURE_IN_PICTURE` — picture-in-picture mode + * + * @param appId the application package name + * @param permission the AppOps operation name (e.g. [APPOPS_MANAGE_EXTERNAL_STORAGE]) + * @see AppOpsManager + */ + fun grantAppOpsPermission(appId: String, permission: String) + /** * Writes a file to the device's public Downloads directory. * On Android, this uses MediaStore/ContentResolver for Q+ compatibility. diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/util/HostAndroidDeviceConnectUtils.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/util/HostAndroidDeviceConnectUtils.kt index 2d5fbcea..4dcc1cfe 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/util/HostAndroidDeviceConnectUtils.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/util/HostAndroidDeviceConnectUtils.kt @@ -59,7 +59,6 @@ object HostAndroidDeviceConnectUtils { sendProgressMessage("Installing pre-compiled test APK...") val installSuccess = PrecompiledApkInstaller.extractAndInstallPrecompiledApk( - resourcePath = PrecompiledApkInstaller.PRECOMPILED_APK_RESOURCE_PATH, trailblazeDeviceId = trailblazeDeviceId, sendProgressMessage = sendProgressMessage, ) @@ -172,15 +171,24 @@ object HostAndroidDeviceConnectUtils { ) if (alreadyRunning) { - sendProgressMessage("On-device server already running — reusing existing connection.") - Console.log("On-device server already running for ${trailblazeOnDeviceInstrumentationTarget.testAppId}, skipping force-stop/reinstall.") - return DeviceConnectionStatus.WithTargetDevice.TrailblazeInstrumentationRunning( - trailblazeDeviceId = trailblazeDeviceId, - ) + // Even if running, verify the installed APK matches the bundled version. + // This handles brew upgrades and local source rebuilds transparently. + if (PrecompiledApkInstaller.isInstalledApkUpToDate(trailblazeDeviceId)) { + sendProgressMessage("On-device server already running — reusing existing connection.") + Console.log("On-device server already running for ${trailblazeOnDeviceInstrumentationTarget.testAppId}, skipping force-stop/reinstall.") + return DeviceConnectionStatus.WithTargetDevice.TrailblazeInstrumentationRunning( + trailblazeDeviceId = trailblazeDeviceId, + ) + } else { + sendProgressMessage("On-device APK is outdated — reinstalling...") + Console.log("APK SHA mismatch for ${trailblazeOnDeviceInstrumentationTarget.testAppId}, forcing reinstall.") + } } - // Server not running (or force restart requested) — clean slate setup - sendProgressMessage("On-device server not running — starting fresh...") + // Server not running, force restart requested, or APK outdated — clean slate setup + if (!alreadyRunning) { + sendProgressMessage("On-device server not running — starting fresh...") + } forceStopAllAndroidInstrumentationProcesses( trailblazeOnDeviceInstrumentationTargetTestApps = setOf(trailblazeOnDeviceInstrumentationTarget), deviceId = trailblazeDeviceId, diff --git a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/util/PrecompiledApkInstaller.kt b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/util/PrecompiledApkInstaller.kt index 9bd2817a..3172dd33 100644 --- a/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/util/PrecompiledApkInstaller.kt +++ b/trailblaze-common/src/jvmAndAndroid/kotlin/xyz/block/trailblaze/util/PrecompiledApkInstaller.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.withContext import xyz.block.trailblaze.devices.TrailblazeDeviceId import xyz.block.trailblaze.model.TrailblazeOnDeviceInstrumentationTarget import java.nio.file.Files +import java.security.MessageDigest object PrecompiledApkInstaller { @@ -14,18 +15,51 @@ object PrecompiledApkInstaller { */ const val PRECOMPILED_APK_RESOURCE_PATH = "/apks/trailblaze-ondevice-runner.apk" + /** Device-side path where the APK SHA marker is stored after installation. */ + private const val DEVICE_SHA_MARKER_PATH = "/data/local/tmp/trailblaze-runner-sha.txt" + + /** Cached SHA256 of the bundled APK resource, computed once per process. */ + private val bundledApkSha: String? by lazy { computeBundledApkSha() } + + /** + * Checks whether the on-device APK matches the bundled APK by comparing SHA256 hashes. + * Returns true if the installed version is up-to-date, false if a reinstall is needed. + */ + fun isInstalledApkUpToDate(trailblazeDeviceId: TrailblazeDeviceId): Boolean { + // If we can't compute the bundled SHA (e.g., resource missing in dev packaging), + // assume up-to-date to avoid tearing down a working on-device server. + val expectedSha = bundledApkSha ?: return true + return try { + val deviceSha = AndroidHostAdbUtils.execAdbShellCommand( + deviceId = trailblazeDeviceId, + args = listOf("cat", DEVICE_SHA_MARKER_PATH), + ).trim() + val match = deviceSha == expectedSha + if (!match) { + Console.log( + "APK version mismatch: device=$deviceSha, bundled=$expectedSha — will reinstall." + ) + } + match + } catch (e: Exception) { + Console.log("Could not read APK SHA marker from device: ${e.message}") + false + } + } + /** * Extracts a pre-compiled APK from resources and installs it on the device. * The APK is bundled during the desktop app build process, eliminating the need * for runtime Gradle compilation and significantly improving user experience. * - * @param resourcePath The path to the APK resource + * After successful installation a SHA256 marker is written to the device so that + * subsequent connections can skip reinstallation when the APK hasn't changed. + * * @param deviceId The device ID to install the APK on * @param sendProgressMessage Callback to send progress messages * @return true if installation was successful, false otherwise */ suspend fun extractAndInstallPrecompiledApk( - resourcePath: String, trailblazeDeviceId: TrailblazeDeviceId, sendProgressMessage: (String) -> Unit, ): Boolean { @@ -34,7 +68,7 @@ object PrecompiledApkInstaller { val tempApkFile = withContext(Dispatchers.IO) { // Get the APK from resources - val apkInputStream = PrecompiledApkInstaller::class.java.getResourceAsStream(resourcePath) + val apkInputStream = PrecompiledApkInstaller::class.java.getResourceAsStream(PRECOMPILED_APK_RESOURCE_PATH) ?: return@withContext null // Create a temporary file to store the APK @@ -50,7 +84,7 @@ object PrecompiledApkInstaller { tempFile } ?: return run { - sendProgressMessage("Error: Could not find APK at resource path: $resourcePath") + sendProgressMessage("Error: Could not find APK at resource path: $PRECOMPILED_APK_RESOURCE_PATH") false } @@ -64,6 +98,8 @@ object PrecompiledApkInstaller { if (installResult) { sendProgressMessage("Test APK installed successfully") + // Write the SHA marker so future connections can skip reinstallation + writeShaMarkerToDevice(trailblazeDeviceId) } else { sendProgressMessage("Failed to install test APK") } @@ -82,4 +118,32 @@ object PrecompiledApkInstaller { fun hasPrecompiledApk(target: TrailblazeOnDeviceInstrumentationTarget): Boolean { return PrecompiledApkInstaller::class.java.getResource(PRECOMPILED_APK_RESOURCE_PATH) != null } + + private fun computeBundledApkSha(): String? = try { + PrecompiledApkInstaller::class.java.getResourceAsStream(PRECOMPILED_APK_RESOURCE_PATH) + ?.use { input -> + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(8192) + var bytesRead: Int + while (input.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + digest.digest().joinToString("") { "%02x".format(it) } + } + } catch (e: Exception) { + Console.log("Failed to compute bundled APK SHA: ${e.message}") + null + } + + private fun writeShaMarkerToDevice(trailblazeDeviceId: TrailblazeDeviceId) { + val sha = bundledApkSha ?: return + try { + AndroidHostAdbUtils.execAdbShellCommand( + deviceId = trailblazeDeviceId, + args = listOf("sh", "-c", "echo '$sha' > $DEVICE_SHA_MARKER_PATH"), + ) + } catch (e: Exception) { + Console.log("Failed to write APK SHA marker to device: ${e.message}") + } + } } diff --git a/trailblaze-common/src/jvmMain/kotlin/xyz/block/trailblaze/device/AndroidDeviceCommandExecutor.jvm.kt b/trailblaze-common/src/jvmMain/kotlin/xyz/block/trailblaze/device/AndroidDeviceCommandExecutor.jvm.kt index 35adba79..9d48e064 100644 --- a/trailblaze-common/src/jvmMain/kotlin/xyz/block/trailblaze/device/AndroidDeviceCommandExecutor.jvm.kt +++ b/trailblaze-common/src/jvmMain/kotlin/xyz/block/trailblaze/device/AndroidDeviceCommandExecutor.jvm.kt @@ -56,6 +56,10 @@ actual class AndroidDeviceCommandExecutor actual constructor( ) } + actual fun grantAppOpsPermission(appId: String, permission: String) { + shellCommand("appops", "set", appId, permission, "allow") + } + /** * Writes a file to Downloads via the MediaStore content provider. * diff --git a/trailblaze-desktop/build.gradle.kts b/trailblaze-desktop/build.gradle.kts index 814ef172..59b152f7 100644 --- a/trailblaze-desktop/build.gradle.kts +++ b/trailblaze-desktop/build.gradle.kts @@ -155,11 +155,18 @@ val shrinkUberJar by tasks.registering(JavaExec::class) { outputs.file(outputJar) val javaHome = System.getProperty("java.home") + // Resolved in doFirst, reused in doLast so both operate on the same JAR. + var resolvedInputJar: File? = null + doFirst { outputJar.get().asFile.parentFile.mkdirs() + // Find the newest uber JAR (old builds may leave stale JARs in this directory). val jarsDir = layout.buildDirectory.dir("compose/jars").get().asFile - val actualJar = jarsDir.listFiles()?.firstOrNull { it.extension == "jar" } + val actualJar = jarsDir.listFiles() + ?.filter { it.extension == "jar" } + ?.maxByOrNull { it.lastModified() } ?: error("No uber JAR found in ${jarsDir.absolutePath}") + resolvedInputJar = actualJar val jmodsArgs = File("$javaHome/jmods").listFiles { f -> f.extension == "jmod" } ?.sorted() @@ -175,9 +182,7 @@ val shrinkUberJar by tasks.registering(JavaExec::class) { } doLast { - val jarsDir = layout.buildDirectory.dir("compose/jars").get().asFile - val originalJar = jarsDir.listFiles()?.firstOrNull { it.extension == "jar" } - ?: return@doLast + val originalJar = resolvedInputJar ?: return@doLast restoreArchiveEntries(originalJar, outputJar.get().asFile) } } diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/devices/PlaywrightBrowserInstaller.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/devices/PlaywrightBrowserInstaller.kt index 9d3cdccb..edee897f 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/host/devices/PlaywrightBrowserInstaller.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/host/devices/PlaywrightBrowserInstaller.kt @@ -207,13 +207,24 @@ class PlaywrightBrowserInstaller { return false } - return cachePath.listFiles()?.any { file -> + val dirs = cachePath.listFiles() ?: return false + // Check for INSTALLATION_COMPLETE marker — Playwright writes this after a successful download. + // Checking only directory names is insufficient (partial downloads leave empty directories). + val hasChromium = dirs.any { file -> file.isDirectory && - (file.name.startsWith("chromium-") || file.name.startsWith("chromium_headless_shell-")) - } ?: false + file.name.startsWith("chromium-") && + File(file, INSTALLATION_COMPLETE_MARKER).exists() + } + val hasHeadlessShell = dirs.any { file -> + file.isDirectory && + file.name.startsWith("chromium_headless_shell-") && + File(file, INSTALLATION_COMPLETE_MARKER).exists() + } + return hasChromium && hasHeadlessShell } companion object { private val PROGRESS_REGEX = Regex("""(\d{1,3})\s*%""") + private const val INSTALLATION_COMPLETE_MARKER = "INSTALLATION_COMPLETE" } } diff --git a/trailblaze-host/src/main/java/xyz/block/trailblaze/mcp/TrailblazeMcpBridgeImpl.kt b/trailblaze-host/src/main/java/xyz/block/trailblaze/mcp/TrailblazeMcpBridgeImpl.kt index cd42a589..227d1f51 100644 --- a/trailblaze-host/src/main/java/xyz/block/trailblaze/mcp/TrailblazeMcpBridgeImpl.kt +++ b/trailblaze-host/src/main/java/xyz/block/trailblaze/mcp/TrailblazeMcpBridgeImpl.kt @@ -901,8 +901,22 @@ class TrailblazeMcpBridgeImpl( } override suspend fun getAvailableDevices(): Set { - val availableDevices = trailblazeDeviceManager.loadDevicesSuspend(applyDriverFilter = true).toSet() - return availableDevices + // Load all devices (unfiltered) — the UI-level targetDeviceFilter may not work correctly + // in headless/MCP mode (e.g., testingEnvironment=null skips Playwright, but MCP always + // needs to offer web). Instead, deduplicate here using the configured driver type per platform. + val allDevices = trailblazeDeviceManager.loadDevicesSuspend(applyDriverFilter = false) + return allDevices + .groupBy { it.instanceId to it.platform } + .map { (_, variants) -> + val platform = variants.first().platform + val configuredType = getConfiguredDriverType(platform) + if (configuredType != null) { + variants.find { it.trailblazeDriverType == configuredType } ?: variants.first() + } else { + variants.first() + } + } + .toSet() } override suspend fun getInstalledAppIds(): Set { diff --git a/trailblaze-playwright/src/main/java/xyz/block/trailblaze/playwright/PlaywrightDriverManager.kt b/trailblaze-playwright/src/main/java/xyz/block/trailblaze/playwright/PlaywrightDriverManager.kt index 8554f350..4569c3a7 100644 --- a/trailblaze-playwright/src/main/java/xyz/block/trailblaze/playwright/PlaywrightDriverManager.kt +++ b/trailblaze-playwright/src/main/java/xyz/block/trailblaze/playwright/PlaywrightDriverManager.kt @@ -176,9 +176,12 @@ object PlaywrightDriverManager { * * Playwright separates the "driver" (Node.js runtime, ~7 MB) from the "browser" (Chromium * binary, ~150 MB). [ensureDriverAvailable] handles the driver; this method handles the - * browser. It checks for any `chromium-*` or `chromium_headless_shell-*` directory in the - * ms-playwright cache (or in [PLAYWRIGHT_BROWSERS_PATH] if set), and runs - * `playwright install chromium` if none is found. + * browser. It checks that both `chromium-*` and `chromium_headless_shell-*` directories exist + * in the ms-playwright cache (or in [PLAYWRIGHT_BROWSERS_PATH] if set) with a completed + * installation marker, and runs `playwright install chromium` if either is missing. + * + * Both variants are required because headless mode (the default) uses the headless shell, + * while headed mode uses the full Chromium browser. * * Must be called after [ensureDriverAvailable] so that `playwright.cli.dir` is already set. * Blocks the calling thread until the download completes (one-time, ~150 MB). @@ -201,6 +204,9 @@ object PlaywrightDriverManager { /** Prefix for the headless-shell Chromium directory in the Playwright cache. */ private const val CHROMIUM_HEADLESS_SHELL_DIR_PREFIX = "chromium_headless_shell-" + /** Marker file Playwright writes after a successful browser download. */ + private const val INSTALLATION_COMPLETE_MARKER = "INSTALLATION_COMPLETE" + /** * Returns the Playwright browser cache directory for the current platform. * @@ -228,10 +234,23 @@ object PlaywrightDriverManager { private fun isChromiumInstalled(): Boolean { val cacheDir = getPlaywrightBrowsersCacheDir() if (!cacheDir.exists() || !cacheDir.isDirectory) return false - return cacheDir.listFiles()?.any { file -> + // Playwright writes an INSTALLATION_COMPLETE marker file after downloading a browser. + // Checking only the directory name is insufficient — the directory can exist without + // the actual binary (e.g., partial download, interrupted install, or cleanup). + // We need *both* chromium and chromium_headless_shell because headless mode (the default) + // uses the headless shell variant, while headed mode needs the full browser. + val dirs = cacheDir.listFiles() ?: return false + val hasChromium = dirs.any { file -> file.isDirectory && - (file.name.startsWith(CHROMIUM_DIR_PREFIX) || file.name.startsWith(CHROMIUM_HEADLESS_SHELL_DIR_PREFIX)) - } ?: false + file.name.startsWith(CHROMIUM_DIR_PREFIX) && + File(file, INSTALLATION_COMPLETE_MARKER).exists() + } + val hasHeadlessShell = dirs.any { file -> + file.isDirectory && + file.name.startsWith(CHROMIUM_HEADLESS_SHELL_DIR_PREFIX) && + File(file, INSTALLATION_COMPLETE_MARKER).exists() + } + return hasChromium && hasHeadlessShell } private val progressRegex = Regex("""(\d{1,3})\s*%""") diff --git a/trailblaze-playwright/src/main/java/xyz/block/trailblaze/playwright/PlaywrightElectronBrowserManager.kt b/trailblaze-playwright/src/main/java/xyz/block/trailblaze/playwright/PlaywrightElectronBrowserManager.kt index 872814ff..4621b104 100644 --- a/trailblaze-playwright/src/main/java/xyz/block/trailblaze/playwright/PlaywrightElectronBrowserManager.kt +++ b/trailblaze-playwright/src/main/java/xyz/block/trailblaze/playwright/PlaywrightElectronBrowserManager.kt @@ -131,6 +131,9 @@ class PlaywrightElectronBrowserManager( init { try { + // Ensure the Playwright driver is available (downloads on first use if driver-bundle + // is not on the classpath, e.g., when running from the uber JAR). + PlaywrightDriverManager.ensureDriverAvailable() runBlocking(playwrightDispatcher) { playwright = Playwright.create() browser = playwright.chromium().connectOverCDP(cdpUrl) diff --git a/trailblaze-server/src/main/java/xyz/block/trailblaze/logs/server/SslConfig.kt b/trailblaze-server/src/main/java/xyz/block/trailblaze/logs/server/SslConfig.kt index ad26e5dd..76b2e659 100644 --- a/trailblaze-server/src/main/java/xyz/block/trailblaze/logs/server/SslConfig.kt +++ b/trailblaze-server/src/main/java/xyz/block/trailblaze/logs/server/SslConfig.kt @@ -9,28 +9,40 @@ import java.io.File object SslConfig { fun ApplicationEngine.Configuration.configureForSelfSignedSsl(requestedHttpPort: Int, requestedHttpsPort: Int) { - val keyStoreFile = File("build/keystore.jks") - val keyStore = buildKeyStore { - certificate("sampleAlias") { - password = "foobar" - domains = listOf("127.0.0.1", "0.0.0.0", "localhost") - } - } - keyStore.saveToFile(keyStoreFile, "123456") - + // Always configure the HTTP connector first so the server starts even if SSL setup fails. connector { host = "::" port = requestedHttpPort } - // We use SSL so Android devices don't need to allowlist the server as a trusted host. - sslConnector( - keyStore = keyStore, - keyAlias = "sampleAlias", - keyStorePassword = { "123456".toCharArray() }, - privateKeyPassword = { "foobar".toCharArray() }, - ) { - port = requestedHttpsPort - keyStorePath = keyStoreFile + + // Store the keystore under ~/.trailblaze/ so it works regardless of the working directory. + // Previously used a relative "build/keystore.jks" path which failed when the daemon was + // launched from a directory without a build/ folder (e.g., via the MCP stdio proxy). + val trailblazeDir = File(System.getProperty("user.home"), ".trailblaze") + trailblazeDir.mkdirs() + val keyStoreFile = File(trailblazeDir, "keystore.jks") + + try { + val keyStore = buildKeyStore { + certificate("sampleAlias") { + password = "foobar" + domains = listOf("127.0.0.1", "0.0.0.0", "localhost") + } + } + keyStore.saveToFile(keyStoreFile, "123456") + + // We use SSL so Android devices don't need to allowlist the server as a trusted host. + sslConnector( + keyStore = keyStore, + keyAlias = "sampleAlias", + keyStorePassword = { "123456".toCharArray() }, + privateKeyPassword = { "foobar".toCharArray() }, + ) { + port = requestedHttpsPort + keyStorePath = keyStoreFile + } + } catch (e: Exception) { + System.err.println("[SslConfig] HTTPS setup failed (HTTP still available): ${e.message}") } } } diff --git a/trailblaze-server/src/main/java/xyz/block/trailblaze/logs/server/TrailblazeMcpServer.kt b/trailblaze-server/src/main/java/xyz/block/trailblaze/logs/server/TrailblazeMcpServer.kt index 1f7d9922..713b1b84 100644 --- a/trailblaze-server/src/main/java/xyz/block/trailblaze/logs/server/TrailblazeMcpServer.kt +++ b/trailblaze-server/src/main/java/xyz/block/trailblaze/logs/server/TrailblazeMcpServer.kt @@ -89,6 +89,8 @@ import xyz.block.trailblaze.mcp.sampling.LocalLlmSamplingSource import xyz.block.trailblaze.mcp.toolsets.ToolSetCategory import xyz.block.trailblaze.mcp.toolsets.ToolSetCategoryMapping import xyz.block.trailblaze.mcp.utils.KoogToMcpExt.toMcpJsonSchemaObject +import xyz.block.trailblaze.mcp.utils.filterNonNullableRequired +import xyz.block.trailblaze.mcp.utils.simplifyNullableAnyOf import xyz.block.trailblaze.toolcalls.toKoogToolDescriptor import xyz.block.trailblaze.toolcalls.TrailblazeKoogTool.Companion.toTrailblazeToolDescriptor import xyz.block.trailblaze.mcp.utils.TrailblazeToolToMcpBridge @@ -357,14 +359,16 @@ class TrailblazeMcpServer( Console.log("[MCP] Registering ${newToolRegistry.tools.size} tools from Koog registry") newToolRegistry.tools.forEach { tool: Tool<*, *> -> try { - // Build properties JsonObject directly (following Koog pattern) + // Build properties JsonObject directly (following Koog pattern). + // Post-process each property to simplify nullable anyOf patterns for broad + // MCP client compatibility (see simplifyNullableAnyOf). val properties = buildJsonObject { (tool.descriptor.requiredParameters + tool.descriptor.optionalParameters).forEach { param -> - put(param.name, param.toMcpJsonSchemaObject()) + put(param.name, param.toMcpJsonSchemaObject().simplifyNullableAnyOf()) } } - val required = tool.descriptor.requiredParameters.map { it.name } + val required = tool.descriptor.requiredParameters.filterNonNullableRequired() // Always provide properties (even if empty) - Goose client expects properties to be present // Previously we used ToolSchema() for empty tools, but this omits the "properties" field diff --git a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/McpSchemaCompat.kt b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/McpSchemaCompat.kt new file mode 100644 index 00000000..a6eea433 --- /dev/null +++ b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/McpSchemaCompat.kt @@ -0,0 +1,64 @@ +package xyz.block.trailblaze.mcp.utils + +import ai.koog.agents.core.tools.ToolParameterDescriptor +import ai.koog.agents.core.tools.ToolParameterType +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Simplify nullable `anyOf` patterns in MCP tool JSON schemas for broad client compatibility. + * + * Koog represents nullable types as `anyOf: [{type: "null"}, {type: "string"}]`. This is valid + * JSON Schema but not supported by clients that use a restricted schema subset (e.g., OpenAI + * Codex uses OpenAI function calling which rejects `anyOf`). + * + * This function rewrites `anyOf: [{type: "null"}, {type: X, ...}]` → `{type: X, ...}`, + * stripping the null variant. The corresponding parameter should also be removed from the + * `required` list so that omitting the field is equivalent to passing null. + * + * True union types (multiple non-null variants) are left unchanged. + */ +fun JsonObject.simplifyNullableAnyOf(): JsonObject { + val anyOf = this["anyOf"] as? JsonArray ?: return this + + // Check if this is a nullable pattern: exactly one null type + one non-null type + val elements = anyOf.map { it.jsonObject } + val nullTypes = elements.filter { it["type"]?.jsonPrimitive?.content == "null" } + val nonNullTypes = elements.filter { it["type"]?.jsonPrimitive?.content != "null" } + + if (nullTypes.size != 1 || nonNullTypes.size != 1) { + // Not a simple nullable — leave as-is + return this + } + + // Merge: keep all fields from the original object (like "description") except "anyOf", + // and add all fields from the non-null type variant. + val nonNull = nonNullTypes.single() + val merged = buildMap { + this@simplifyNullableAnyOf.forEach { (key, value) -> + if (key != "anyOf") put(key, value) + } + nonNull.forEach { (key, value) -> + if (key != "description" || !containsKey("description")) { + put(key, value) + } + } + } + return JsonObject(merged) +} + +/** + * Filter required parameters to exclude nullable ones (AnyOf containing Null). + * + * Clients using restricted JSON Schema (e.g., OpenAI Codex) reject tools where required fields + * use `anyOf`. Since nullable parameters accept null, omitting them is semantically equivalent + * to passing null — so they can safely be treated as optional. + */ +fun List.filterNonNullableRequired(): List = + filter { param -> + val type = param.type + type !is ToolParameterType.AnyOf || + type.types.none { it.type is ToolParameterType.Null } + }.map { it.name } diff --git a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/TrailblazeToolToMcpBridge.kt b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/TrailblazeToolToMcpBridge.kt index 0e5b8208..ed464015 100644 --- a/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/TrailblazeToolToMcpBridge.kt +++ b/trailblaze-server/src/main/java/xyz/block/trailblaze/mcp/utils/TrailblazeToolToMcpBridge.kt @@ -18,6 +18,8 @@ import xyz.block.trailblaze.mcp.TrailblazeMcpBridge import xyz.block.trailblaze.mcp.TrailblazeMcpSessionContext import xyz.block.trailblaze.mcp.models.McpSessionId import xyz.block.trailblaze.mcp.utils.KoogToMcpExt.toMcpJsonSchemaObject +import xyz.block.trailblaze.mcp.utils.filterNonNullableRequired +import xyz.block.trailblaze.mcp.utils.simplifyNullableAnyOf import xyz.block.trailblaze.toolcalls.TrailblazeTool import xyz.block.trailblaze.toolcalls.TrailblazeToolSet import xyz.block.trailblaze.toolcalls.toKoogToolDescriptor @@ -108,14 +110,21 @@ class TrailblazeToolToMcpBridge( return@forEach } - // Build properties JsonObject for the tool parameters + // Build properties JsonObject for the tool parameters. + // Koog wraps nullable Kotlin types as anyOf: [{type:"null"}, {type:"string"}], which is + // valid JSON Schema but rejected by clients expecting a top-level "type" field. + // e.g. Codex: "Failed to convert MCP tool: Error("missing field 'type'")" + // See: https://github.com/openai/codex/issues/1973 + // https://github.com/JetBrains/koog/issues/642 val properties = buildJsonObject { (descriptor.requiredParameters + descriptor.optionalParameters).forEach { param -> - put(param.name, param.toMcpJsonSchemaObject()) + put(param.name, param.toMcpJsonSchemaObject().simplifyNullableAnyOf()) } } - val required = descriptor.requiredParameters.map { it.name } + // Once we strip null from the anyOf union above, nullable params must also be removed + // from required — otherwise clients reject requests that omit them. + val required = descriptor.requiredParameters.filterNonNullableRequired() Console.log("Registering MCP tool: ${descriptor.name}") Console.log(" Description: ${descriptor.description}")