diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index ea077ec..7db3954 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -82,6 +82,8 @@ dependencies { implementation(libs.debugln) implementation(libs.clikt) implementation(libs.kotlinx.serialization.json) + implementation(libs.mcp.kotlin.server) + runtimeOnly(libs.slf4j.nop) testImplementation(libs.assertk) testImplementation(kotlin("test")) diff --git a/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt b/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt index 9eac655..601638f 100644 --- a/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt +++ b/cli/src/integrationTest/kotlin/com/composables/cli/CliIntegrationTest.kt @@ -237,21 +237,22 @@ class CliIntegrationTest { } @Test - fun `cli install help lists the mcp subcommand`() { - val rootDir = createTempRoot("composables-cli-install-help") + fun `cli mcp help lists mcp subcommands`() { + val rootDir = createTempRoot("composables-cli-mcp-help") try { val launcher = installedLauncher() val result = runProcess( - command = listOf(launcher.absolutePath, "install", "--help"), + command = listOf(launcher.absolutePath, "mcp", "--help"), workingDir = rootDir, timeoutSeconds = 60, ) assertThat(result.finished).isTrue() assertThat(result.exitCode).isEqualTo(0) - assertThat(result.output).contains("mcp") + assertThat(result.output).contains("install") + assertThat(result.output).contains("start") assertThat(result.output).doesNotContain("--client") } finally { rootDir.deleteRecursively() @@ -259,14 +260,14 @@ class CliIntegrationTest { } @Test - fun `cli install mcp help exposes client selection`() { - val rootDir = createTempRoot("composables-cli-install-mcp") + fun `cli mcp install help exposes client selection`() { + val rootDir = createTempRoot("composables-cli-mcp-install") try { val launcher = installedLauncher() val result = runProcess( - command = listOf(launcher.absolutePath, "install", "mcp", "--help"), + command = listOf(launcher.absolutePath, "mcp", "install", "--help"), workingDir = rootDir, timeoutSeconds = 60, ) @@ -280,6 +281,95 @@ class CliIntegrationTest { } } + @Test + fun `cli mcp start exposes tools and creates project over stdio`() { + val rootDir = createTempRoot("composables-cli-mcp-server") + try { + val launcher = installedLauncher() + val projectDir = File(rootDir, "mcp-app") + val stdin = + mcpFrame( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}""") + + mcpFrame("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") + + mcpFrame("""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""") + + mcpFrame( + """{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"composables_create_project","arguments":{"directory":"mcp-app","packageName":"com.example.mcpapp","appName":"MCP App","targets":"jvm"}}}""") + + val result = + runProcessUntilOutputContains( + command = listOf(launcher.absolutePath, "mcp", "start"), + workingDir = rootDir, + stdin = stdin, + expectedOutput = "Created Compose Multiplatform project", + timeoutSeconds = 60, + ) + + assertThat(result.finished).isTrue() + assertThat(result.output).contains("composables_docs_search") + assertThat(result.output).contains("composables_create_project") + assertThat(result.output).contains("Created Compose Multiplatform project") + assertThat(File(projectDir, "settings.gradle.kts").exists()).isTrue() + assertThat( + File(projectDir, "shared/src/commonMain/kotlin/com/example/mcpapp/App.kt").exists()) + .isTrue() + } finally { + rootDir.deleteRecursively() + } + } + + @Test + fun `cli mcp start adds module over stdio`() { + val rootDir = createTempRoot("composables-cli-mcp-add-module") + try { + val launcher = installedLauncher() + val projectDir = File(rootDir, "base-app") + val createResult = + runProcess( + command = + listOf( + launcher.absolutePath, + "init", + projectDir.absolutePath, + "--package", + "com.example.baseapp", + "--app-name", + "Base App", + "--targets", + "jvm", + ), + workingDir = rootDir, + timeoutSeconds = 60, + ) + assertThat(createResult.finished).isTrue() + assertThat(createResult.exitCode).isEqualTo(0) + + val stdin = + mcpFrame( + """{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}""") + + mcpFrame("""{"jsonrpc":"2.0","method":"notifications/initialized"}""") + + mcpFrame( + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"composables_add_module","arguments":{"projectRoot":"base-app","path":"features/chat","packageName":"com.example.chat","appName":"Chat","targets":"jvm"}}}""") + + val result = + runProcessUntilOutputContains( + command = listOf(launcher.absolutePath, "mcp", "start"), + workingDir = rootDir, + stdin = stdin, + expectedOutput = "Added Compose app module group", + timeoutSeconds = 60, + ) + + assertThat(result.finished).isTrue() + assertThat(result.output).contains("Added Compose app module group") + assertThat(File(projectDir, "features/chat/shared").isDirectory).isTrue() + assertThat(File(projectDir, "features/chat/desktopApp").isDirectory).isTrue() + assertThat(File(projectDir, "settings.gradle.kts").readText()) + .contains("""include(":features:chat:shared")""") + } finally { + rootDir.deleteRecursively() + } + } + @Test fun `cli init requires overwrite for non-empty directories`() { val rootDir = createTempRoot("composables-cli-init-existing") @@ -916,6 +1006,61 @@ class CliIntegrationTest { } } + private fun runProcessUntilOutputContains( + command: List, + workingDir: File, + stdin: String, + expectedOutput: String, + timeoutSeconds: Long, + ): ProcessResult { + val process = + ProcessBuilder(platformCommand(command)) + .directory(workingDir) + .redirectErrorStream(true) + .start() + + val output = StringBuilder() + val readerThread = + Thread { + process.inputStream.bufferedReader().useLines { lines -> + lines.forEach { line -> output.appendLine(line) } + } + } + .apply { start() } + + process.outputStream.bufferedWriter().use { writer -> + writer.write(stdin) + writer.flush() + } + + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds) + var matched = false + while (System.nanoTime() < deadline) { + if (output.contains(expectedOutput)) { + matched = true + break + } + if (!process.isAlive) { + break + } + Thread.sleep(25) + } + + if (process.isAlive) { + process.destroyForcibly() + process.waitFor(5, TimeUnit.SECONDS) + } + readerThread.join() + + return ProcessResult( + finished = matched, + exitCode = if (!process.isAlive) process.exitValue() else -1, + output = output.toString(), + ) + } + + private fun mcpFrame(json: String): String = "$json\n" + private data class ProcessResult( val finished: Boolean, val exitCode: Int, diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index 6217793..363ecd3 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -92,7 +92,7 @@ internal fun mcpUrl(): String = buildString { append("/mcp") } -private fun fetchUrl(url: String): String { +internal fun fetchUrl(url: String): String { val request = HttpRequest.newBuilder() .uri(URI.create(url)) @@ -116,7 +116,7 @@ private fun fetchUrl(url: String): String { return response.body() } -private fun fetchDocsApi( +internal fun fetchDocsApi( endpoint: String, queryParameters: Map = emptyMap(), ): String = fetchUrl(docsApiUrl(endpoint, queryParameters)) @@ -129,7 +129,7 @@ private data class ProjectInstruction( val command: String? = null, ) -private data class ProjectConventions( +internal data class ProjectConventions( val kotlinMultiplatformPlugin: String, val composePlugin: String, val composeCompilerPlugin: String, @@ -247,7 +247,7 @@ suspend fun main(args: Array) { Init(), Add().subcommands(AddModule()), Docs().subcommands(DocsList(), DocsSearch(), DocsGet()), - Install().subcommands(InstallMcp())) + Mcp().subcommands(McpInstall(), McpStart())) .main(args) } @@ -427,10 +427,10 @@ class DocsGet : CliktCommand("get") { } } -class Install : CliktCommand("install") { +class Mcp : CliktCommand("mcp") { override fun help(context: Context): String = """ - Installs Composables integrations for supported clients. + Works with the Composables MCP server. """ .trimIndent() @@ -441,7 +441,7 @@ class Install : CliktCommand("install") { } } -class InstallMcp : CliktCommand("mcp") { +class McpInstall : CliktCommand("install") { override fun help(context: Context): String = """ Installs Composables MCP for supported clients. @@ -823,14 +823,14 @@ internal fun validateInitTargetDirectory(target: File, overwrite: Boolean) { } } -private fun validateExistingGradleProject(projectRoot: File) { +internal fun validateExistingGradleProject(projectRoot: File) { if (!File(projectRoot, "settings.gradle.kts").exists()) { throw UsageError( "This command must be run from the root of an existing Gradle project with a settings.gradle.kts file.") } } -private fun validateModuleTargetDirectory( +internal fun validateModuleTargetDirectory( projectRoot: File, target: File, overwrite: Boolean, @@ -892,7 +892,7 @@ private fun readNewModuleDirectory(projectRoot: File): File { } } -private fun toRelativeModulePath(projectRoot: File, target: File): String = +internal fun toRelativeModulePath(projectRoot: File, target: File): String = projectRoot.toPath().normalize().relativize(target.toPath().normalize()).joinToString("/") { it.toString() } @@ -1219,7 +1219,7 @@ fun cloneGradleProject( ) } -private fun cloneGradleProjectAt( +internal fun cloneGradleProjectAt( target: File, packageName: String, appName: String, @@ -1707,7 +1707,7 @@ private fun stripPreviewSupport(content: String): String = "\n", ) -private fun inferProjectConventions(projectRoot: File, targets: Set): ProjectConventions { +internal fun inferProjectConventions(projectRoot: File, targets: Set): ProjectConventions { val normalizedTargets = normalizeTargets(targets) val versionCatalog = File(projectRoot, "gradle/libs.versions.toml") if (!versionCatalog.isFile) { @@ -2151,7 +2151,7 @@ private fun updateSection(content: String, sectionName: String, newEntries: List return lines.joinToString("\n") } -private fun addModuleToSettings( +internal fun addModuleToSettings( projectRoot: File, modulePaths: List, ) { @@ -2297,7 +2297,7 @@ private fun createLibraryModule( return modulePath } -private fun createModuleGroup( +internal fun createModuleGroup( projectRoot: File, appRootDir: File, packageName: String, diff --git a/cli/src/jvmMain/kotlin/McpServer.kt b/cli/src/jvmMain/kotlin/McpServer.kt new file mode 100644 index 0000000..4a1b30b --- /dev/null +++ b/cli/src/jvmMain/kotlin/McpServer.kt @@ -0,0 +1,340 @@ +package com.composables.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.UsageError +import io.ktor.utils.io.streams.asInput +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations +import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import java.io.File +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking +import kotlinx.io.asSink +import kotlinx.io.buffered +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +class McpStart : CliktCommand("start") { + override fun help(context: Context): String = + """ + Starts the Composables MCP server over stdio. + """ + .trimIndent() + + override fun run() { + runMcpServer() + } +} + +internal fun runMcpServer() { + System.setProperty("kotlin-logging.logStartupMessage", "false") + + val server = + Server( + Implementation( + name = "composables", + version = BuildConfig.Version, + ), + ServerOptions( + capabilities = + ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = false)), + ), + ) + + server.registerComposablesTools() + + val transport = + StdioServerTransport( + input = System.`in`.asInput(), + output = System.out.asSink().buffered(), + ) + + runBlocking { + val session = server.createSession(transport) + val done = Job() + session.onClose { done.complete() } + done.join() + } +} + +private fun Server.registerComposablesTools() { + addTool( + name = "composables_docs_list", + description = "List all Composables UI documentation pages.", + inputSchema = ToolSchema(), + toolAnnotations = ToolAnnotations(readOnlyHint = true, openWorldHint = true), + ) { + callTool { fetchDocsApi("list") } + } + + addTool( + name = "composables_docs_search", + description = "Search Composables UI documentation pages.", + inputSchema = + ToolSchema( + properties = + buildJsonObject { + putJsonObject("query") { + put("type", "string") + put("description", "Search query, for example 'dropdown menu'.") + } + }, + required = listOf("query"), + ), + toolAnnotations = ToolAnnotations(readOnlyHint = true, openWorldHint = true), + ) { request -> + callTool { + val query = request.arguments.requiredString("query") + fetchDocsApi("search", mapOf("q" to query)) + } + } + + addTool( + name = "composables_docs_get", + description = "Get one Composables UI documentation page as Markdown by slug.", + inputSchema = + ToolSchema( + properties = + buildJsonObject { + putJsonObject("slug") { + put("type", "string") + put("description", "Documentation page slug.") + } + }, + required = listOf("slug"), + ), + toolAnnotations = ToolAnnotations(readOnlyHint = true, openWorldHint = true), + ) { request -> + callTool { + val slug = request.arguments.requiredString("slug") + fetchUrl(docsMarkdownUrl(slug)) + } + } + + addTool( + name = "composables_create_project", + description = + "Create a new Compose Multiplatform project using the bundled Composables template.", + inputSchema = + ToolSchema( + properties = + buildJsonObject { + putJsonObject("directory") { + put("type", "string") + put("description", "Directory path to create the new project in.") + } + putJsonObject("packageName") { + put("type", "string") + put("description", "Java/Kotlin package name, for example 'com.example.app'.") + } + putJsonObject("appName") { + put("type", "string") + put("description", "Display name for the app.") + } + putJsonObject("targets") { + put("type", "string") + put("description", "Comma-separated targets: android,jvm,ios,wasm.") + } + putJsonObject("workingDirectory") { + put("type", "string") + put( + "description", + "Base directory for resolving a relative project directory.") + } + putJsonObject("moduleName") { + put("type", "string") + put("description", "Shared module name. Defaults to 'shared'.") + } + putJsonObject("overwrite") { + put("type", "boolean") + put("description", "Overwrite an existing target directory.") + } + }, + required = listOf("directory", "packageName", "appName", "targets"), + ), + toolAnnotations = ToolAnnotations(readOnlyHint = false, destructiveHint = true), + ) { request -> + callTool { + val arguments = request.arguments + val packageName = arguments.requiredString("packageName") + val appName = arguments.requiredString("appName") + val targets = parseTargets(arguments.requiredString("targets")) + val workingDirectory = + arguments.optionalString("workingDirectory") ?: System.getProperty("user.dir") + val target = + resolveTargetDirectory( + workingDir = workingDirectory, projectPath = arguments.requiredString("directory")) + val moduleName = arguments.optionalString("moduleName") ?: SHARED_MODULE + val overwrite = arguments.optionalBoolean("overwrite") ?: false + + requireValidPackageName(packageName) + requireValidAppName(appName) + if (!isValidModuleName(moduleName)) { + throw UsageError( + "Invalid module name. Must contain at least one letter or digit and only letters, digits, hyphens, or underscores.") + } + validateInitTargetDirectory(target, overwrite) + if (!target.exists() && !target.mkdirs()) { + throw UsageError("Failed to create directory at ${target.absolutePath}") + } + + cloneGradleProjectAt( + target = target, + packageName = packageName, + appName = appName, + targets = targets, + moduleName = moduleName, + ) + + """ + Created Compose Multiplatform project at ${target.absolutePath} + App name: $appName + Package: $packageName + Shared module: $moduleName + Targets: ${targets.joinToString(", ")} + """ + .trimIndent() + } + } + + addTool( + name = "composables_add_module", + description = "Add a new Compose app module group to an existing Gradle project.", + inputSchema = + ToolSchema( + properties = + buildJsonObject { + putJsonObject("projectRoot") { + put("type", "string") + put("description", "Root directory of an existing Gradle project.") + } + putJsonObject("path") { + put("type", "string") + put("description", "Module group path to create inside the project.") + } + putJsonObject("packageName") { + put("type", "string") + put( + "description", + "Java/Kotlin package name, for example 'com.example.feature'.") + } + putJsonObject("appName") { + put("type", "string") + put("description", "Display name for the generated app module group.") + } + putJsonObject("targets") { + put("type", "string") + put("description", "Comma-separated targets: android,jvm,ios,wasm.") + } + putJsonObject("overwrite") { + put("type", "boolean") + put("description", "Overwrite an existing target directory.") + } + }, + required = listOf("path", "packageName", "appName", "targets"), + ), + toolAnnotations = ToolAnnotations(readOnlyHint = false, destructiveHint = true), + ) { request -> + callTool { + val arguments = request.arguments + val projectRoot = + File(arguments.optionalString("projectRoot") ?: System.getProperty("user.dir")) + .absoluteFile + val packageName = arguments.requiredString("packageName") + val appName = arguments.requiredString("appName") + val targets = parseTargets(arguments.requiredString("targets")) + val target = + resolveTargetDirectory( + workingDir = projectRoot.absolutePath, projectPath = arguments.requiredString("path")) + val overwrite = arguments.optionalBoolean("overwrite") ?: false + + validateExistingGradleProject(projectRoot) + requireValidPackageName(packageName) + requireValidAppName(appName) + validateModuleTargetDirectory(projectRoot, target, overwrite) + + val modulePath = toRelativeModulePath(projectRoot, target) + val conventions = inferProjectConventions(projectRoot, targets) + val includedModules = + createModuleGroup( + projectRoot = projectRoot, + appRootDir = target, + packageName = packageName, + appName = appName, + targets = targets, + conventions = conventions, + ) + addModuleToSettings(projectRoot, includedModules) + + """ + Added Compose app module group at ${target.absolutePath} + App name: $appName + Package: $packageName + Module: $modulePath + Targets: ${targets.joinToString(", ")} + Included Gradle modules: ${includedModules.joinToString(", ")} + """ + .trimIndent() + } + } +} + +private fun callTool(block: () -> String): CallToolResult = + try { + CallToolResult(content = listOf(TextContent(block()))) + } catch (error: UsageError) { + CallToolResult( + content = listOf(TextContent(error.message ?: "Invalid request.")), isError = true) + } catch (error: IllegalArgumentException) { + CallToolResult( + content = listOf(TextContent(error.message ?: "Invalid request.")), isError = true) + } catch (error: Exception) { + CallToolResult(content = listOf(TextContent(error.message ?: "Tool failed.")), isError = true) + } + +private fun JsonObject?.requiredString(name: String): String { + val value = optionalString(name) + if (value.isNullOrBlank()) { + throw UsageError("The '$name' parameter is required.") + } + return value +} + +private fun JsonObject?.optionalString(name: String): String? = + this?.get(name)?.stringValueOrNull()?.trim()?.takeIf { it.isNotEmpty() } + +private fun JsonObject?.optionalBoolean(name: String): Boolean? = + this?.get(name)?.let { element -> + (element as? JsonPrimitive)?.booleanOrNull + ?: throw UsageError("The '$name' parameter must be a boolean.") + } + +private fun JsonElement.stringValueOrNull(): String? = + (this as? JsonPrimitive)?.jsonPrimitive?.contentOrNull + +private fun requireValidPackageName(packageName: String) { + if (!isValidPackageName(packageName)) { + throw UsageError( + "Invalid package name. Must be a valid Java package name (e.g., com.example.app)") + } +} + +private fun requireValidAppName(appName: String) { + if (!isValidAppName(appName)) { + throw UsageError("Invalid app name. Must contain at least one letter or digit") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6faa190..60d4d77 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,12 +7,16 @@ buildconfig = "6.0.6" debugln = "1.0.3" clikt = "5.0.3" serializationJson = "1.9.0" +mcpKotlin = "0.14.0" +slf4j = "2.0.17" assertk = "0.28.1" [libraries] debugln = { group = "com.alexstyl", name = "debugln", version.ref = "debugln" } clikt = { group = "com.github.ajalt.clikt", name = "clikt", version.ref = "clikt" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" } +mcp-kotlin-server = { group = "io.modelcontextprotocol", name = "kotlin-sdk-server", version.ref = "mcpKotlin" } +slf4j-nop = { group = "org.slf4j", name = "slf4j-nop", version.ref = "slf4j" } assertk = { group = "com.willowtreeapps.assertk", name = "assertk", version.ref = "assertk" } [plugins]