Skip to content
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
1 change: 1 addition & 0 deletions cli/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ sourceSets.main {
dependencies {
implementation(projects.config)
implementation(projects.library.server)
implementation(projects.library.mcp)
implementation(projects.ai.discover)
implementation(projects.library.core)
implementation(projects.library.sqlite)
Expand Down
113 changes: 113 additions & 0 deletions cli/src/main/kotlin/com/linroid/ketch/cli/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import com.linroid.ketch.config.generateConfig
import com.linroid.ketch.core.Ketch
import com.linroid.ketch.engine.KtorHttpEngine
import com.linroid.ketch.ftp.FtpDownloadSource
import com.linroid.ketch.mcp.KetchMcpServer
import com.linroid.ketch.server.KetchServer
import com.linroid.ketch.sqlite.DriverFactory
import com.linroid.ketch.sqlite.SqliteTaskStore
Expand Down Expand Up @@ -56,6 +57,10 @@ fun main(args: Array<String>) {
runAiDiscover(remaining.drop(1))
return
}
"mcp" -> {
runMcp(remaining.drop(1))
return
}
}

var url: String? = null
Expand Down Expand Up @@ -577,9 +582,112 @@ private fun runAiDiscover(args: List<String>) {
}
}

private fun runMcp(args: List<String>) {
var configPath: String? = null
var cliDownloadDir: String? = null

var i = 0
while (i < args.size) {
when (args[i]) {
"--help", "-h" -> {
printMcpUsage()
return
}
"--config" -> {
if (i + 1 >= args.size) {
println("Error: --config requires a value")
println()
printMcpUsage()
return
}
configPath = args[++i]
}
"--dir" -> {
if (i + 1 >= args.size) {
println("Error: --dir requires a value")
println()
printMcpUsage()
return
}
cliDownloadDir = args[++i]
}
}
i++
}

val fileConfig = if (configPath != null) {
FileConfigStore(configPath).load()
} else {
val defaultPath = defaultConfigPath()
if (File(defaultPath).exists()) {
FileConfigStore(defaultPath).load()
} else {
KetchConfig()
}
}

val defaultDownloadDir = System.getProperty("user.home") +
File.separator + "Downloads"
val downloadConfig = fileConfig.download.copy(
defaultDirectory = cliDownloadDir
?: fileConfig.download.defaultDirectory
?: defaultDownloadDir,
)

File(downloadConfig.defaultDirectory!!).mkdirs()

val dbPath = defaultDbPath()
val driver = DriverFactory(dbPath).createDriver()
val taskStore = SqliteTaskStore(driver)

val ketch = Ketch(
httpEngine = KtorHttpEngine(),
taskStore = taskStore,
config = downloadConfig,
logger = Logger.console(ketchLogLevel),
additionalSources = listOf(FtpDownloadSource()),
)

Runtime.getRuntime().addShutdownHook(Thread {
ketch.close()
})

val mcpServer = KetchMcpServer(ketch)

runBlocking {
ketch.start()
mcpServer.startStdio()
}
}

private fun printMcpUsage() {
println("Usage: ketch mcp [options]")
println()
println("Start Ketch as an MCP (Model Context Protocol) server")
println("using stdio transport. AI agents like Claude Desktop")
println("can manage downloads through MCP tools.")
println()
println("Options:")
println(" --config <path> Path to TOML config file")
println(" --dir <path> Download directory")
println(" (default: ~/Downloads)")
println(" --help, -h Show this help message")
println()
println("MCP client configuration (e.g. claude_desktop_config.json):")
println(" {")
println(" \"mcpServers\": {")
println(" \"ketch\": {")
println(" \"command\": \"ketch\",")
println(" \"args\": [\"mcp\"]")
println(" }")
println(" }")
println(" }")
}

private fun printUsage() {
println("Usage: ketch [options] <url> [destination]")
println(" ketch server [options]")
println(" ketch mcp [options]")
println(" ketch ai-discover <query> [options]")
println()
println("Global Options:")
Expand All @@ -600,6 +708,11 @@ private fun printUsage() {
println(" Run `ketch server --help`")
println(" for server options")
println()
println("MCP Server:")
println(" mcp [options] Start MCP server (stdio)")
println(" Run `ketch mcp --help`")
println(" for MCP options")
println()
println("AI Discovery:")
println(" ai-discover <query> Discover downloadable resources")
println(" --sites <domains> Comma-separated domain allowlist")
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ okio-nodefilesystem = { module = "com.squareup.okio:okio-nodefilesystem", versio
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
koog-agents = { module = "ai.koog:koog-agents", version.ref = "koog" }
koog-mcp-server = { module = "ai.koog:agents-mcp-server", version.ref = "koog" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
Expand Down
15 changes: 15 additions & 0 deletions library/mcp/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
plugins {
alias(libs.plugins.kotlinJvm)
alias(libs.plugins.kotlinx.serialization)
}

dependencies {
api(projects.library.api)

implementation(libs.koog.mcp.server)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)

testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.test)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.linroid.ketch.mcp

import ai.koog.agents.core.tools.ToolRegistry
import ai.koog.agents.core.tools.reflect.tools
import ai.koog.agents.mcp.server.startSseMcpServer
import ai.koog.agents.mcp.server.startStdioMcpServer
import com.linroid.ketch.api.KetchApi
import io.ktor.server.engine.ApplicationEngineFactory
import kotlinx.coroutines.Job

/**
* Exposes a [KetchApi] instance as an MCP (Model Context Protocol)
* server, allowing AI agents to manage downloads via MCP tools.
*
* Supports two transport modes:
* - **stdio** — for CLI/editor integration (Claude Desktop, VS Code, etc.)
* - **SSE** — for remote HTTP access
*
* Usage:
* ```kotlin
* val ketch = Ketch(httpEngine = KtorHttpEngine())
* val mcp = KetchMcpServer(ketch)
* mcp.startStdio() // suspends until closed
* ```
*/
class KetchMcpServer(
private val ketch: KetchApi,
) {
private val toolRegistry = ToolRegistry {
tools(KetchToolSet(ketch))
}

/**
* Starts the MCP server using stdio transport.
* Reads JSON-RPC messages from stdin and writes responses to stdout.
* This is the standard transport for MCP clients like Claude Desktop.
*
* This function suspends until the server is closed.
*/
suspend fun startStdio() {
val server = startStdioMcpServer(toolRegistry)
val done = Job()
server.onClose { done.complete() }
done.join()
}

/**
* Starts the MCP server using SSE (Server-Sent Events) transport
* over HTTP.
*
* @param factory the Ktor server engine factory (e.g., `CIO`)
* @param port the port to listen on
* @param host the host to bind to
*/
suspend fun startSse(
factory: ApplicationEngineFactory<*, *>,
port: Int = 3001,
host: String = "localhost",
) {
val server = startSseMcpServer(factory, port, host, toolRegistry)
val done = Job()
server.onClose { done.complete() }
done.join()
}
}
Loading
Loading