Skip to content
This repository was archived by the owner on Jul 5, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,36 +237,37 @@ 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()
}
}

@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,
)
Expand All @@ -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")
Expand Down Expand Up @@ -916,6 +1006,61 @@ class CliIntegrationTest {
}
}

private fun runProcessUntilOutputContains(
command: List<String>,
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,
Expand Down
28 changes: 14 additions & 14 deletions cli/src/jvmMain/kotlin/Cli.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -116,7 +116,7 @@ private fun fetchUrl(url: String): String {
return response.body()
}

private fun fetchDocsApi(
internal fun fetchDocsApi(
endpoint: String,
queryParameters: Map<String, String> = emptyMap(),
): String = fetchUrl(docsApiUrl(endpoint, queryParameters))
Expand All @@ -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,
Expand Down Expand Up @@ -247,7 +247,7 @@ suspend fun main(args: Array<String>) {
Init(),
Add().subcommands(AddModule()),
Docs().subcommands(DocsList(), DocsSearch(), DocsGet()),
Install().subcommands(InstallMcp()))
Mcp().subcommands(McpInstall(), McpStart()))
.main(args)
}

Expand Down Expand Up @@ -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()

Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -1219,7 +1219,7 @@ fun cloneGradleProject(
)
}

private fun cloneGradleProjectAt(
internal fun cloneGradleProjectAt(
target: File,
packageName: String,
appName: String,
Expand Down Expand Up @@ -1707,7 +1707,7 @@ private fun stripPreviewSupport(content: String): String =
"\n",
)

private fun inferProjectConventions(projectRoot: File, targets: Set<String>): ProjectConventions {
internal fun inferProjectConventions(projectRoot: File, targets: Set<String>): ProjectConventions {
val normalizedTargets = normalizeTargets(targets)
val versionCatalog = File(projectRoot, "gradle/libs.versions.toml")
if (!versionCatalog.isFile) {
Expand Down Expand Up @@ -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<String>,
) {
Expand Down Expand Up @@ -2297,7 +2297,7 @@ private fun createLibraryModule(
return modulePath
}

private fun createModuleGroup(
internal fun createModuleGroup(
projectRoot: File,
appRootDir: File,
packageName: String,
Expand Down
Loading
Loading