sample: kotlinlang mcp server (#713)
add new sample: kotlinlang mcp server with 2 tools - search - get_page ## How Has This Been Tested? inspector/unit ## Breaking Changes none ## Checklist - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed
This commit is contained in:
parent
bf8dc6d6b1
commit
c946dfc3c2
21 changed files with 2149 additions and 0 deletions
|
|
@ -0,0 +1,28 @@
|
|||
package org.kotlinlang.mcp
|
||||
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.netty.*
|
||||
import io.ktor.server.plugins.cors.routing.*
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp
|
||||
import org.kotlinlang.mcp.config.toServerConfig
|
||||
|
||||
fun main(args: Array<String>) = EngineMain.main(args)
|
||||
|
||||
fun Application.module() {
|
||||
val config = environment.config.toServerConfig()
|
||||
val kotlinlangServer = KotlinlangServer(config)
|
||||
|
||||
monitor.subscribe(ApplicationStopped) {
|
||||
kotlinlangServer.close()
|
||||
}
|
||||
|
||||
install(CORS) {
|
||||
anyHost()
|
||||
allowHeader(HttpHeaders.ContentType)
|
||||
}
|
||||
|
||||
mcpStreamableHttp {
|
||||
kotlinlangServer.server
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
package org.kotlinlang.mcp
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.cio.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.Server
|
||||
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.*
|
||||
import kotlinx.serialization.json.*
|
||||
import org.kotlinlang.mcp.algolia.AlgoliaClient
|
||||
import org.kotlinlang.mcp.cache.TtlCache
|
||||
import org.kotlinlang.mcp.config.ServerConfig
|
||||
import org.kotlinlang.mcp.content.PageFetcher
|
||||
import org.kotlinlang.mcp.tools.GetKotlinlangPage
|
||||
import org.kotlinlang.mcp.tools.SearchKotlinlang
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.Closeable
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
internal class KotlinlangServer(config: ServerConfig) : Closeable {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(KotlinlangServer::class.java)
|
||||
|
||||
private val httpClient = HttpClient(CIO) {
|
||||
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
|
||||
install(HttpTimeout) { requestTimeoutMillis = 10_000 }
|
||||
expectSuccess = true
|
||||
}
|
||||
|
||||
private val searchCache = TtlCache<String, String>(10.minutes)
|
||||
private val pageCache = TtlCache<String, String>(1.hours)
|
||||
|
||||
private val algoliaClient = AlgoliaClient(config, httpClient)
|
||||
private val pageFetcher = PageFetcher(httpClient)
|
||||
private val searchTool = SearchKotlinlang(algoliaClient, searchCache)
|
||||
private val pageTool = GetKotlinlangPage(pageFetcher, pageCache)
|
||||
|
||||
val server: Server = Server(
|
||||
serverInfo = Implementation(name = "kotlinlang-mcp-server", version = "1.0.0"),
|
||||
options = ServerOptions(
|
||||
capabilities = ServerCapabilities(
|
||||
tools = ServerCapabilities.Tools(listChanged = false),
|
||||
),
|
||||
),
|
||||
) {
|
||||
addTool(
|
||||
name = "search_kotlinlang",
|
||||
description = "Search across Kotlin documentation on kotlinlang.org to find relevant pages, " +
|
||||
"code examples, API references, and guides. Use this tool when you need to answer questions " +
|
||||
"about Kotlin language features, standard library, coroutines, multiplatform, tooling, or any " +
|
||||
"other topic covered in the official Kotlin documentation. Returns up to 5 results with page " +
|
||||
"titles, paths, and text snippets. To get the full content of a specific page, use the " +
|
||||
"get_kotlinlang_page tool with the page path from the search results.",
|
||||
inputSchema = ToolSchema(
|
||||
properties = buildJsonObject {
|
||||
putJsonObject("query") {
|
||||
put("type", "string")
|
||||
put(
|
||||
"description",
|
||||
"A search query to find relevant Kotlin documentation pages " +
|
||||
"(e.g. 'coroutines', 'sealed classes', 'multiplatform setup')",
|
||||
)
|
||||
}
|
||||
},
|
||||
required = listOf("query"),
|
||||
),
|
||||
toolAnnotations = ToolAnnotations(readOnlyHint = true, openWorldHint = true),
|
||||
) { request ->
|
||||
val query = request.arguments?.get("query")?.jsonPrimitive?.content
|
||||
?: return@addTool CallToolResult(
|
||||
content = listOf(TextContent(text = "Missing required argument: query")),
|
||||
isError = true,
|
||||
)
|
||||
logger.info("search_kotlinlang query=\"{}\"", query)
|
||||
searchTool.handle(query)
|
||||
}
|
||||
|
||||
addTool(
|
||||
name = "get_kotlinlang_page",
|
||||
description = "Retrieve the full content of a specific Kotlin documentation page from " +
|
||||
"kotlinlang.org in md format. Use this tool when you already know the " +
|
||||
"page path (e.g., from search results) and need the complete content of that page rather " +
|
||||
"than just a snippet. If the page is not found, use search_kotlinlang to discover the " +
|
||||
"correct path.",
|
||||
inputSchema = ToolSchema(
|
||||
properties = buildJsonObject {
|
||||
putJsonObject("path") {
|
||||
put("type", "string")
|
||||
put(
|
||||
"description",
|
||||
"Page path relative to /docs/, without extension. Use the page paths " +
|
||||
"returned from search_kotlinlang results " +
|
||||
"(e.g. 'coroutines-overview', " +
|
||||
"'multiplatform/compose-multiplatform-and-jetpack-compose')",
|
||||
)
|
||||
}
|
||||
},
|
||||
required = listOf("path"),
|
||||
),
|
||||
toolAnnotations = ToolAnnotations(readOnlyHint = true, openWorldHint = true),
|
||||
) { request ->
|
||||
val path = request.arguments?.get("path")?.jsonPrimitive?.content
|
||||
?: return@addTool CallToolResult(
|
||||
content = listOf(TextContent(text = "Missing required argument: path")),
|
||||
isError = true,
|
||||
)
|
||||
logger.info("get_kotlinlang_page path=\"{}\"", path)
|
||||
pageTool.handle(path)
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package org.kotlinlang.mcp.algolia
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import org.kotlinlang.mcp.config.ServerConfig
|
||||
|
||||
internal class AlgoliaClient(
|
||||
private val config: ServerConfig,
|
||||
private val httpClient: HttpClient,
|
||||
) {
|
||||
suspend fun search(query: String): AlgoliaSearchResponse {
|
||||
val response = httpClient.post(buildSearchUrl()) {
|
||||
header("x-algolia-application-id", config.algoliaAppId)
|
||||
header("x-algolia-api-key", config.algoliaApiKey)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
AlgoliaSearchRequest(
|
||||
query = query,
|
||||
hitsPerPage = HITS_PER_PAGE,
|
||||
attributesToRetrieve = ATTRIBUTES_TO_RETRIEVE,
|
||||
attributesToSnippet = ATTRIBUTES_TO_SNIPPET,
|
||||
)
|
||||
)
|
||||
}
|
||||
return response.body()
|
||||
}
|
||||
|
||||
private fun buildSearchUrl(): String =
|
||||
"https://${config.algoliaAppId}-dsn.algolia.net/1/indexes/${config.algoliaIndexName}/query"
|
||||
|
||||
companion object {
|
||||
private const val HITS_PER_PAGE = 15
|
||||
private val ATTRIBUTES_TO_RETRIEVE = listOf("objectID", "mainTitle", "url", "headings")
|
||||
private val ATTRIBUTES_TO_SNIPPET = listOf("content:40")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package org.kotlinlang.mcp.algolia
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal data class AlgoliaSearchRequest(
|
||||
val query: String,
|
||||
val hitsPerPage: Int,
|
||||
val attributesToRetrieve: List<String>,
|
||||
val attributesToSnippet: List<String>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class AlgoliaSearchResponse(
|
||||
val hits: List<AlgoliaHit>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class AlgoliaHit(
|
||||
val objectID: String,
|
||||
val mainTitle: String? = null,
|
||||
val url: String,
|
||||
val headings: String? = null,
|
||||
@SerialName("_snippetResult")
|
||||
val snippetResult: SnippetResult? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class SnippetResult(
|
||||
val content: SnippetValue,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
internal data class SnippetValue(
|
||||
val value: String,
|
||||
val matchLevel: String,
|
||||
)
|
||||
71
samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/cache/TtlCache.kt
vendored
Normal file
71
samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/cache/TtlCache.kt
vendored
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package org.kotlinlang.mcp.cache
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.TimeMark
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
internal class TtlCache<K : Any, V : Any>(
|
||||
private val ttl: Duration,
|
||||
private val timeSource: TimeSource = TimeSource.Monotonic,
|
||||
) {
|
||||
private class Entry<V>(val value: V, val createdAt: TimeMark)
|
||||
|
||||
private val map = ConcurrentHashMap<K, Entry<V>>()
|
||||
private val inFlight = ConcurrentHashMap<K, CompletableDeferred<V>>()
|
||||
|
||||
fun get(key: K): V? {
|
||||
val entry = map[key] ?: return null
|
||||
if (entry.createdAt.elapsedNow() >= ttl) {
|
||||
map.remove(key, entry)
|
||||
return null
|
||||
}
|
||||
return entry.value
|
||||
}
|
||||
|
||||
fun put(key: K, value: V) {
|
||||
map[key] = Entry(value, timeSource.markNow())
|
||||
evictExpired()
|
||||
}
|
||||
|
||||
private fun evictExpired() {
|
||||
map.entries.removeIf { it.value.createdAt.elapsedNow() >= ttl }
|
||||
}
|
||||
|
||||
suspend fun getOrPut(key: K, loader: suspend () -> V): V {
|
||||
while (true) {
|
||||
get(key)?.let { return it }
|
||||
|
||||
val deferred = CompletableDeferred<V>()
|
||||
val existing = inFlight.putIfAbsent(key, deferred)
|
||||
if (existing != null) {
|
||||
try {
|
||||
return existing.await()
|
||||
} catch (_: CancellationException) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
// The loader's scope was cancelled, not ours — retry
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val value = loader()
|
||||
put(key, value)
|
||||
deferred.complete(value)
|
||||
return value
|
||||
} catch (e: CancellationException) {
|
||||
deferred.cancel()
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
deferred.completeExceptionally(e)
|
||||
throw e
|
||||
} finally {
|
||||
inFlight.remove(key, deferred)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package org.kotlinlang.mcp.config
|
||||
|
||||
import io.ktor.server.config.*
|
||||
|
||||
internal data class ServerConfig(
|
||||
val algoliaAppId: String,
|
||||
val algoliaApiKey: String,
|
||||
val algoliaIndexName: String,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"ServerConfig(algoliaAppId=$algoliaAppId, algoliaIndexName=$algoliaIndexName)"
|
||||
}
|
||||
|
||||
internal fun ApplicationConfig.toServerConfig(): ServerConfig = ServerConfig(
|
||||
algoliaAppId = property("algolia.appId").getString(),
|
||||
algoliaApiKey = property("algolia.apiKey").getString(),
|
||||
algoliaIndexName = property("algolia.indexName").getString(),
|
||||
)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package org.kotlinlang.mcp.content
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
|
||||
internal class PageFetcher(private val httpClient: HttpClient) {
|
||||
suspend fun fetch(url: String): String {
|
||||
return httpClient.get(url).bodyAsText()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package org.kotlinlang.mcp.content
|
||||
|
||||
private const val BASE_URL = "https://kotlinlang.org/docs"
|
||||
|
||||
internal fun normalizePath(path: String): String = path.trim().trim('/').removeSuffix(".html")
|
||||
|
||||
internal fun mapPathToUrl(path: String): String {
|
||||
val normalized = normalizePath(path)
|
||||
require(normalized.isNotBlank()) { "Path must not be empty" }
|
||||
require(".." !in normalized.split('/')) { "Path must not contain '..' segments" }
|
||||
|
||||
val lastSlash = normalized.lastIndexOf('/')
|
||||
return if (lastSlash == -1) {
|
||||
"$BASE_URL/_llms/$normalized.txt"
|
||||
} else {
|
||||
val dir = normalized.substring(0, lastSlash)
|
||||
val file = normalized.substring(lastSlash + 1)
|
||||
"$BASE_URL/$dir/_llms/$file.txt"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package org.kotlinlang.mcp.tools
|
||||
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.http.*
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import org.kotlinlang.mcp.cache.TtlCache
|
||||
import org.kotlinlang.mcp.content.PageFetcher
|
||||
import org.kotlinlang.mcp.content.mapPathToUrl
|
||||
import org.kotlinlang.mcp.content.normalizePath
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
internal class GetKotlinlangPage(
|
||||
private val pageFetcher: PageFetcher,
|
||||
private val cache: TtlCache<String, String>,
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(GetKotlinlangPage::class.java)
|
||||
suspend fun handle(path: String): CallToolResult {
|
||||
val normalizedPath = normalizePath(path)
|
||||
return try {
|
||||
val content = cache.getOrPut(normalizedPath) {
|
||||
val url = mapPathToUrl(normalizedPath)
|
||||
pageFetcher.fetch(url)
|
||||
}
|
||||
CallToolResult(content = listOf(TextContent(text = content)))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: IllegalArgumentException) {
|
||||
CallToolResult(
|
||||
content = listOf(TextContent(text = "Invalid path: ${e.message}")),
|
||||
isError = true,
|
||||
)
|
||||
} catch (e: ClientRequestException) {
|
||||
if (e.response.status != HttpStatusCode.NotFound) {
|
||||
logger.error("Failed to fetch page path=\"{}\"", path, e)
|
||||
}
|
||||
val message = if (e.response.status == HttpStatusCode.NotFound) {
|
||||
"Page not found: $path. Use search_kotlinlang to find the correct page path."
|
||||
} else {
|
||||
"Failed to fetch page: ${e.message}"
|
||||
}
|
||||
CallToolResult(
|
||||
content = listOf(TextContent(text = message)),
|
||||
isError = true,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to fetch page path=\"{}\"", path, e)
|
||||
CallToolResult(
|
||||
content = listOf(TextContent(text = "Failed to fetch page: ${e.message ?: "unknown error"}")),
|
||||
isError = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package org.kotlinlang.mcp.tools
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import org.kotlinlang.mcp.algolia.AlgoliaClient
|
||||
import org.kotlinlang.mcp.algolia.AlgoliaHit
|
||||
import org.kotlinlang.mcp.cache.TtlCache
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
internal class SearchKotlinlang(
|
||||
private val algoliaClient: AlgoliaClient,
|
||||
private val cache: TtlCache<String, String>,
|
||||
) {
|
||||
private val logger = LoggerFactory.getLogger(SearchKotlinlang::class.java)
|
||||
|
||||
companion object {
|
||||
private const val MAX_RESULTS = 5
|
||||
private val HTML_TAG_REGEX = Regex("<[^>]+>")
|
||||
}
|
||||
|
||||
suspend fun handle(query: String): CallToolResult {
|
||||
return try {
|
||||
val text = cache.getOrPut(query) {
|
||||
val response = algoliaClient.search(query)
|
||||
val filtered = response.hits
|
||||
.filter { it.url.startsWith("/docs/") }
|
||||
.take(MAX_RESULTS)
|
||||
if (filtered.isEmpty()) {
|
||||
"No results found for: $query"
|
||||
} else {
|
||||
formatResults(filtered)
|
||||
}
|
||||
}
|
||||
CallToolResult(content = listOf(TextContent(text = text)))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.error("Search failed for query=\"{}\"", query, e)
|
||||
CallToolResult(
|
||||
content = listOf(TextContent(text = "Search failed: ${e.message ?: "unknown error"}")),
|
||||
isError = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatResults(hits: List<AlgoliaHit>): String =
|
||||
hits.mapIndexed { index, hit ->
|
||||
val title = hit.mainTitle ?: "Untitled"
|
||||
val path = extractPath(hit.url)
|
||||
val snippetLine = hit.snippetResult?.content?.value
|
||||
?.let { "\n ${stripHtml(it)}" }
|
||||
?: ""
|
||||
"${index + 1}. $title [$path]$snippetLine"
|
||||
}.joinToString("\n\n")
|
||||
|
||||
private fun extractPath(url: String): String =
|
||||
url.removePrefix("/docs/").removeSuffix(".html")
|
||||
|
||||
private fun stripHtml(text: String): String =
|
||||
text.replace(HTML_TAG_REGEX, "")
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
ktor {
|
||||
deployment {
|
||||
port = 8080
|
||||
port = ${?SERVER_PORT}
|
||||
host = "0.0.0.0"
|
||||
host = ${?SERVER_HOST}
|
||||
}
|
||||
application {
|
||||
modules = [ org.kotlinlang.mcp.ApplicationKt.module ]
|
||||
}
|
||||
}
|
||||
|
||||
algolia {
|
||||
appId = ${ALGOLIA_APP_ID}
|
||||
apiKey = ${ALGOLIA_API_KEY}
|
||||
indexName = ${ALGOLIA_INDEX_NAME}
|
||||
}
|
||||
15
samples/kotlinlang-mcp-server/src/main/resources/logback.xml
Normal file
15
samples/kotlinlang-mcp-server/src/main/resources/logback.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<logger name="io.ktor" level="WARN" />
|
||||
<logger name="io.netty" level="WARN" />
|
||||
<logger name="io.modelcontextprotocol" level="WARN" />
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT" />
|
||||
</root>
|
||||
</configuration>
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package org.kotlinlang.mcp
|
||||
|
||||
import io.modelcontextprotocol.kotlin.sdk.ExperimentalMcpApi
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.Client
|
||||
import io.modelcontextprotocol.kotlin.sdk.client.ClientOptions
|
||||
import io.modelcontextprotocol.kotlin.sdk.testing.ChannelTransport
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.kotlinlang.mcp.config.ServerConfig
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@OptIn(ExperimentalMcpApi::class)
|
||||
class KotlinlangServerTest {
|
||||
|
||||
private val testConfig = ServerConfig(
|
||||
algoliaAppId = "test-app-id",
|
||||
algoliaApiKey = "test-api-key",
|
||||
algoliaIndexName = "test-index",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `server registers both tools with correct schema and annotations`() = runTest {
|
||||
val kotlinlangServer = KotlinlangServer(testConfig)
|
||||
kotlinlangServer.use {
|
||||
val (clientTransport, serverTransport) = ChannelTransport.createLinkedPair()
|
||||
|
||||
val client = Client(
|
||||
clientInfo = Implementation(name = "test-client", version = "1.0"),
|
||||
options = ClientOptions(),
|
||||
)
|
||||
|
||||
joinAll(
|
||||
launch { client.connect(clientTransport) },
|
||||
launch { kotlinlangServer.server.createSession(serverTransport) },
|
||||
)
|
||||
|
||||
val tools = client.listTools().tools
|
||||
|
||||
assertEquals(2, tools.size)
|
||||
|
||||
val searchTool = tools.find { it.name == "search_kotlinlang" }
|
||||
val searchProps = searchTool?.inputSchema?.properties
|
||||
assertTrue(searchProps?.containsKey("query") ?: false)
|
||||
assertEquals(listOf("query"), searchTool.inputSchema.required)
|
||||
assertEquals(true, searchTool.annotations?.readOnlyHint)
|
||||
assertEquals(true, searchTool.annotations?.openWorldHint)
|
||||
|
||||
val pageTool = tools.find { it.name == "get_kotlinlang_page" }
|
||||
val pageProps = pageTool?.inputSchema?.properties
|
||||
assertTrue(pageProps?.containsKey("path") ?: false)
|
||||
assertEquals(listOf("path"), pageTool.inputSchema.required)
|
||||
assertEquals(true, pageTool.annotations?.readOnlyHint)
|
||||
assertEquals(true, pageTool.annotations?.openWorldHint)
|
||||
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
package org.kotlinlang.mcp.algolia
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.mock.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.kotlinlang.mcp.config.ServerConfig
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AlgoliaClientTest {
|
||||
|
||||
private val testConfig = ServerConfig(
|
||||
algoliaAppId = "test-app-id",
|
||||
algoliaApiKey = "test-api-key",
|
||||
algoliaIndexName = "test-index",
|
||||
)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun createClient(mockEngine: MockEngine): AlgoliaClient {
|
||||
val httpClient = HttpClient(mockEngine) {
|
||||
install(ContentNegotiation) { json(json) }
|
||||
expectSuccess = true
|
||||
}
|
||||
return AlgoliaClient(testConfig, httpClient)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search sends correct URL and headers`() = runTest {
|
||||
val mockEngine = MockEngine { request ->
|
||||
assertEquals(
|
||||
"https://test-app-id-dsn.algolia.net/1/indexes/test-index/query",
|
||||
request.url.toString(),
|
||||
)
|
||||
assertEquals("test-app-id", request.headers["x-algolia-application-id"])
|
||||
assertEquals("test-api-key", request.headers["x-algolia-api-key"])
|
||||
assertEquals(HttpMethod.Post, request.method)
|
||||
|
||||
respond(
|
||||
content = """{"hits": []}""",
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/json"),
|
||||
)
|
||||
}
|
||||
|
||||
val client = createClient(mockEngine)
|
||||
client.search("coroutines")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search sends correct request body`() = runTest {
|
||||
val mockEngine = MockEngine { request ->
|
||||
val body = json.decodeFromString<AlgoliaSearchRequest>(request.body.toByteArray().decodeToString())
|
||||
assertEquals("coroutines", body.query)
|
||||
assertEquals(15, body.hitsPerPage)
|
||||
assertEquals(listOf("objectID", "mainTitle", "url", "headings"), body.attributesToRetrieve)
|
||||
assertEquals(listOf("content:40"), body.attributesToSnippet)
|
||||
|
||||
respond(
|
||||
content = """{"hits": []}""",
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/json"),
|
||||
)
|
||||
}
|
||||
|
||||
val client = createClient(mockEngine)
|
||||
client.search("coroutines")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search parses response with hits`() = runTest {
|
||||
val responseJson = """
|
||||
{
|
||||
"hits": [
|
||||
{
|
||||
"objectID": "abc123",
|
||||
"mainTitle": "Coroutines overview",
|
||||
"url": "/docs/coroutines-overview.html",
|
||||
"headings": "Introduction | First coroutine",
|
||||
"_snippetResult": {
|
||||
"content": {
|
||||
"value": "Kotlin provides coroutines support",
|
||||
"matchLevel": "full"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val mockEngine = MockEngine {
|
||||
respond(
|
||||
content = responseJson,
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/json"),
|
||||
)
|
||||
}
|
||||
|
||||
val client = createClient(mockEngine)
|
||||
val result = client.search("coroutines")
|
||||
|
||||
assertEquals(1, result.hits.size)
|
||||
val hit = result.hits[0]
|
||||
assertEquals("abc123", hit.objectID)
|
||||
assertEquals("Coroutines overview", hit.mainTitle)
|
||||
assertEquals("/docs/coroutines-overview.html", hit.url)
|
||||
assertEquals("Introduction | First coroutine", hit.headings)
|
||||
assertEquals("Kotlin provides coroutines support", hit.snippetResult?.content?.value)
|
||||
assertEquals("full", hit.snippetResult?.content?.matchLevel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search returns empty list when no hits`() = runTest {
|
||||
val mockEngine = MockEngine {
|
||||
respond(
|
||||
content = """{"hits": []}""",
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/json"),
|
||||
)
|
||||
}
|
||||
|
||||
val client = createClient(mockEngine)
|
||||
val result = client.search("nonexistent")
|
||||
|
||||
assertTrue(result.hits.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search throws on 4xx error`() = runTest {
|
||||
val mockEngine = MockEngine {
|
||||
respond(
|
||||
content = """{"message": "Invalid Application-ID or API key"}""",
|
||||
status = HttpStatusCode.Forbidden,
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/json"),
|
||||
)
|
||||
}
|
||||
|
||||
val client = createClient(mockEngine)
|
||||
assertFailsWith<ClientRequestException> {
|
||||
client.search("coroutines")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search throws on 5xx error`() = runTest {
|
||||
val mockEngine = MockEngine {
|
||||
respond(
|
||||
content = """{"message": "Internal server error"}""",
|
||||
status = HttpStatusCode.InternalServerError,
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/json"),
|
||||
)
|
||||
}
|
||||
|
||||
val client = createClient(mockEngine)
|
||||
assertFailsWith<ServerResponseException> {
|
||||
client.search("coroutines")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
package org.kotlinlang.mcp.algolia
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class AlgoliaModelsTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun `serializes search request to JSON`() {
|
||||
val request = AlgoliaSearchRequest(
|
||||
query = "coroutines",
|
||||
hitsPerPage = 15,
|
||||
attributesToRetrieve = listOf("mainTitle", "url", "headings"),
|
||||
attributesToSnippet = listOf("content:40"),
|
||||
)
|
||||
|
||||
val encoded = json.encodeToString(request)
|
||||
val decoded = json.decodeFromString<AlgoliaSearchRequest>(encoded)
|
||||
|
||||
assertEquals(request, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses full response with nested snippet`() {
|
||||
val responseJson = """
|
||||
{
|
||||
"hits": [
|
||||
{
|
||||
"objectID": "abc123",
|
||||
"mainTitle": "Coroutines overview",
|
||||
"url": "/docs/coroutines-overview.html",
|
||||
"headings": "Introduction | First coroutine",
|
||||
"_snippetResult": {
|
||||
"content": {
|
||||
"value": "Kotlin provides <em>coroutines</em> support",
|
||||
"matchLevel": "full"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<AlgoliaSearchResponse>(responseJson)
|
||||
|
||||
assertEquals(1, response.hits.size)
|
||||
val hit = response.hits[0]
|
||||
assertEquals("abc123", hit.objectID)
|
||||
assertEquals("Coroutines overview", hit.mainTitle)
|
||||
assertEquals("/docs/coroutines-overview.html", hit.url)
|
||||
assertEquals("Introduction | First coroutine", hit.headings)
|
||||
assertEquals("Kotlin provides <em>coroutines</em> support", hit.snippetResult?.content?.value)
|
||||
assertEquals("full", hit.snippetResult?.content?.matchLevel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses hit with missing nullable fields`() {
|
||||
val responseJson = """
|
||||
{
|
||||
"hits": [
|
||||
{
|
||||
"objectID": "def456",
|
||||
"url": "/docs/basic-syntax.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<AlgoliaSearchResponse>(responseJson)
|
||||
|
||||
val hit = response.hits[0]
|
||||
assertEquals("def456", hit.objectID)
|
||||
assertEquals("/docs/basic-syntax.html", hit.url)
|
||||
assertEquals(null, hit.mainTitle)
|
||||
assertEquals(null, hit.headings)
|
||||
assertEquals(null, hit.snippetResult)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses response ignoring unknown fields`() {
|
||||
val responseJson = """
|
||||
{
|
||||
"hits": [
|
||||
{
|
||||
"objectID": "ghi789",
|
||||
"url": "/docs/functions.html",
|
||||
"unknownField": 42,
|
||||
"_snippetResult": {
|
||||
"content": {
|
||||
"value": "snippet text",
|
||||
"matchLevel": "none",
|
||||
"fullyHighlighted": false
|
||||
},
|
||||
"anotherUnknown": "ignored"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nbHits": 100,
|
||||
"page": 0,
|
||||
"processingTimeMS": 5
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<AlgoliaSearchResponse>(responseJson)
|
||||
|
||||
assertEquals(1, response.hits.size)
|
||||
assertEquals("ghi789", response.hits[0].objectID)
|
||||
assertEquals("snippet text", response.hits[0].snippetResult?.content?.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses response with empty hits list`() {
|
||||
val responseJson = """
|
||||
{
|
||||
"hits": []
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val response = json.decodeFromString<AlgoliaSearchResponse>(responseJson)
|
||||
|
||||
assertEquals(emptyList(), response.hits)
|
||||
}
|
||||
}
|
||||
144
samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/cache/TtlCacheTest.kt
vendored
Normal file
144
samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/cache/TtlCacheTest.kt
vendored
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package org.kotlinlang.mcp.cache
|
||||
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.TestTimeSource
|
||||
|
||||
class TtlCacheTest {
|
||||
|
||||
@Test
|
||||
fun `get returns value that was put`() {
|
||||
val cache = TtlCache<String, String>(ttl = 10.minutes)
|
||||
cache.put("key", "value")
|
||||
assertEquals("value", cache.get("key"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get returns null for missing key`() {
|
||||
val cache = TtlCache<String, String>(ttl = 10.minutes)
|
||||
assertNull(cache.get("missing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get returns null after TTL expires`() {
|
||||
val timeSource = TestTimeSource()
|
||||
val cache = TtlCache<String, String>(ttl = 10.minutes, timeSource = timeSource)
|
||||
|
||||
cache.put("key", "value")
|
||||
assertEquals("value", cache.get("key"))
|
||||
|
||||
timeSource += 11.minutes
|
||||
assertNull(cache.get("key"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get returns value just before TTL expires`() {
|
||||
val timeSource = TestTimeSource()
|
||||
val cache = TtlCache<String, String>(ttl = 10.minutes, timeSource = timeSource)
|
||||
|
||||
cache.put("key", "value")
|
||||
timeSource += 9.minutes
|
||||
assertEquals("value", cache.get("key"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `put overwrites value and resets TTL`() {
|
||||
val timeSource = TestTimeSource()
|
||||
val cache = TtlCache<String, String>(ttl = 10.minutes, timeSource = timeSource)
|
||||
|
||||
cache.put("key", "old")
|
||||
timeSource += 8.minutes
|
||||
|
||||
cache.put("key", "new")
|
||||
timeSource += 8.minutes
|
||||
|
||||
assertEquals("new", cache.get("key"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getOrPut returns cached value without calling loader`() = runTest {
|
||||
val cache = TtlCache<String, String>(ttl = 10.minutes)
|
||||
cache.put("key", "cached")
|
||||
|
||||
var loaderCalled = false
|
||||
val result = cache.getOrPut("key") {
|
||||
loaderCalled = true
|
||||
"loaded"
|
||||
}
|
||||
|
||||
assertEquals("cached", result)
|
||||
assertEquals(false, loaderCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getOrPut calls loader on cache miss and caches result`() = runTest {
|
||||
val cache = TtlCache<String, String>(ttl = 10.minutes)
|
||||
|
||||
val result = cache.getOrPut("key") { "loaded" }
|
||||
|
||||
assertEquals("loaded", result)
|
||||
assertEquals("loaded", cache.get("key"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getOrPut calls loader again after TTL expires`() = runTest {
|
||||
val timeSource = TestTimeSource()
|
||||
val cache = TtlCache<String, String>(ttl = 10.minutes, timeSource = timeSource)
|
||||
|
||||
cache.getOrPut("key") { "first" }
|
||||
timeSource += 11.minutes
|
||||
|
||||
val result = cache.getOrPut("key") { "second" }
|
||||
assertEquals("second", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `put evicts expired entries`() {
|
||||
val timeSource = TestTimeSource()
|
||||
val cache = TtlCache<String, String>(ttl = 10.minutes, timeSource = timeSource)
|
||||
|
||||
cache.put("a", "1")
|
||||
cache.put("b", "2")
|
||||
timeSource += 11.minutes
|
||||
|
||||
// Both entries are expired but still in memory.
|
||||
// A new put triggers eviction of expired entries.
|
||||
cache.put("c", "3")
|
||||
|
||||
assertNull(cache.get("a"))
|
||||
assertNull(cache.get("b"))
|
||||
assertEquals("3", cache.get("c"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `put does not evict non-expired entries`() {
|
||||
val timeSource = TestTimeSource()
|
||||
val cache = TtlCache<String, String>(ttl = 10.minutes, timeSource = timeSource)
|
||||
|
||||
cache.put("a", "1")
|
||||
timeSource += 5.minutes
|
||||
cache.put("b", "2")
|
||||
|
||||
assertEquals("1", cache.get("a"))
|
||||
assertEquals("2", cache.get("b"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent put and get do not throw`() = runTest {
|
||||
val cache = TtlCache<Int, String>(ttl = 10.minutes)
|
||||
val jobs = (1..100).map { i ->
|
||||
launch {
|
||||
cache.put(i, "value-$i")
|
||||
cache.get(i)
|
||||
cache.put(i, "updated-$i")
|
||||
cache.get(i)
|
||||
}
|
||||
}
|
||||
jobs.joinAll()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package org.kotlinlang.mcp.config
|
||||
|
||||
import io.ktor.server.config.*
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class ServerConfigTest {
|
||||
|
||||
@Test
|
||||
fun `toServerConfig maps all Algolia properties`() {
|
||||
val config = MapApplicationConfig(
|
||||
"algolia.appId" to "test-app-id",
|
||||
"algolia.apiKey" to "test-api-key",
|
||||
"algolia.indexName" to "test-index",
|
||||
)
|
||||
|
||||
val serverConfig = config.toServerConfig()
|
||||
|
||||
assertEquals("test-app-id", serverConfig.algoliaAppId)
|
||||
assertEquals("test-api-key", serverConfig.algoliaApiKey)
|
||||
assertEquals("test-index", serverConfig.algoliaIndexName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toServerConfig throws when required property is missing`() {
|
||||
val config = MapApplicationConfig(
|
||||
"algolia.appId" to "test-app-id",
|
||||
)
|
||||
|
||||
assertFailsWith<ApplicationConfigurationException> {
|
||||
config.toServerConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package org.kotlinlang.mcp.content
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.mock.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class PageFetcherTest {
|
||||
|
||||
private fun createFetcher(mockEngine: MockEngine): PageFetcher {
|
||||
val httpClient = HttpClient(mockEngine) {
|
||||
expectSuccess = true
|
||||
}
|
||||
return PageFetcher(httpClient)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch sends GET request to provided URL`() = runTest {
|
||||
val mockEngine = MockEngine { request ->
|
||||
assertEquals("https://kotlinlang.org/docs/_llms/coroutines-overview.txt", request.url.toString())
|
||||
assertEquals(HttpMethod.Get, request.method)
|
||||
|
||||
respond(
|
||||
content = "# Coroutines overview",
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "text/plain"),
|
||||
)
|
||||
}
|
||||
|
||||
val fetcher = createFetcher(mockEngine)
|
||||
fetcher.fetch("https://kotlinlang.org/docs/_llms/coroutines-overview.txt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch returns response body as text`() = runTest {
|
||||
val pageContent = "# Coroutines\n\nKotlin provides coroutines support at the language level."
|
||||
|
||||
val mockEngine = MockEngine {
|
||||
respond(
|
||||
content = pageContent,
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "text/plain"),
|
||||
)
|
||||
}
|
||||
|
||||
val fetcher = createFetcher(mockEngine)
|
||||
val result = fetcher.fetch("https://kotlinlang.org/docs/_llms/coroutines-overview.txt")
|
||||
|
||||
assertEquals(pageContent, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch throws ClientRequestException on 404`() = runTest {
|
||||
val mockEngine = MockEngine {
|
||||
respond(
|
||||
content = "Not Found",
|
||||
status = HttpStatusCode.NotFound,
|
||||
headers = headersOf(HttpHeaders.ContentType, "text/plain"),
|
||||
)
|
||||
}
|
||||
|
||||
val fetcher = createFetcher(mockEngine)
|
||||
assertFailsWith<ClientRequestException> {
|
||||
fetcher.fetch("https://kotlinlang.org/docs/_llms/nonexistent.txt")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch throws ServerResponseException on 5xx`() = runTest {
|
||||
val mockEngine = MockEngine {
|
||||
respond(
|
||||
content = "Internal Server Error",
|
||||
status = HttpStatusCode.InternalServerError,
|
||||
headers = headersOf(HttpHeaders.ContentType, "text/plain"),
|
||||
)
|
||||
}
|
||||
|
||||
val fetcher = createFetcher(mockEngine)
|
||||
assertFailsWith<ServerResponseException> {
|
||||
fetcher.fetch("https://kotlinlang.org/docs/_llms/coroutines-overview.txt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package org.kotlinlang.mcp.content
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
class UrlMapperTest {
|
||||
|
||||
@Test
|
||||
fun `top-level path maps to _llms URL`() {
|
||||
assertEquals(
|
||||
"https://kotlinlang.org/docs/_llms/coroutines-overview.txt",
|
||||
mapPathToUrl("coroutines-overview"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nested path maps to _llms URL`() {
|
||||
assertEquals(
|
||||
"https://kotlinlang.org/docs/multiplatform/_llms/compose.txt",
|
||||
mapPathToUrl("multiplatform/compose"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deep nested path maps to _llms URL`() {
|
||||
assertEquals(
|
||||
"https://kotlinlang.org/docs/a/b/_llms/c.txt",
|
||||
mapPathToUrl("a/b/c"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trims leading and trailing slashes`() {
|
||||
assertEquals(
|
||||
"https://kotlinlang.org/docs/_llms/coroutines-overview.txt",
|
||||
mapPathToUrl("/coroutines-overview/"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strips html extension`() {
|
||||
assertEquals(
|
||||
"https://kotlinlang.org/docs/_llms/coroutines-overview.txt",
|
||||
mapPathToUrl("coroutines-overview.html"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalizes slashes and html extension together`() {
|
||||
assertEquals(
|
||||
"https://kotlinlang.org/docs/multiplatform/_llms/compose.txt",
|
||||
mapPathToUrl("/multiplatform/compose.html"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty string throws IllegalArgumentException`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
mapPathToUrl("")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only slashes throws IllegalArgumentException`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
mapPathToUrl("///")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank path throws IllegalArgumentException`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
mapPathToUrl(" ")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `path traversal throws IllegalArgumentException`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
mapPathToUrl("../../secret")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `path traversal in the middle throws IllegalArgumentException`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
mapPathToUrl("multiplatform/../secret")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
package org.kotlinlang.mcp.tools
|
||||
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.mock.*
|
||||
import io.ktor.http.*
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.kotlinlang.mcp.cache.TtlCache
|
||||
import org.kotlinlang.mcp.content.PageFetcher
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
class GetKotlinlangPageTest {
|
||||
|
||||
private fun createTool(mockEngine: MockEngine): GetKotlinlangPage {
|
||||
val httpClient = HttpClient(mockEngine) {
|
||||
expectSuccess = true
|
||||
}
|
||||
val pageFetcher = PageFetcher(httpClient)
|
||||
val cache = TtlCache<String, String>(ttl = 1.hours)
|
||||
return GetKotlinlangPage(pageFetcher, cache)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle returns page content for valid path`() = runTest {
|
||||
val pageContent = "# Coroutines\n\nKotlin provides coroutines support."
|
||||
val mockEngine = MockEngine { request ->
|
||||
assertEquals(
|
||||
"https://kotlinlang.org/docs/_llms/coroutines-overview.txt",
|
||||
request.url.toString(),
|
||||
)
|
||||
respond(
|
||||
content = pageContent,
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "text/plain"),
|
||||
)
|
||||
}
|
||||
val tool = createTool(mockEngine)
|
||||
|
||||
val result = tool.handle("coroutines-overview")
|
||||
val text = (result.content.first() as TextContent).text
|
||||
|
||||
assertNull(result.isError)
|
||||
assertEquals(pageContent, text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle returns cached content on second call`() = runTest {
|
||||
var callCount = 0
|
||||
val mockEngine = MockEngine {
|
||||
callCount++
|
||||
respond(
|
||||
content = "# Page content",
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "text/plain"),
|
||||
)
|
||||
}
|
||||
val tool = createTool(mockEngine)
|
||||
|
||||
val result1 = tool.handle("coroutines-overview")
|
||||
val result2 = tool.handle("coroutines-overview")
|
||||
|
||||
assertEquals(1, callCount)
|
||||
assertEquals(
|
||||
(result1.content.first() as TextContent).text,
|
||||
(result2.content.first() as TextContent).text,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle returns error with hint on 404`() = runTest {
|
||||
val mockEngine = MockEngine {
|
||||
respond(
|
||||
content = "Not Found",
|
||||
status = HttpStatusCode.NotFound,
|
||||
headers = headersOf(HttpHeaders.ContentType, "text/plain"),
|
||||
)
|
||||
}
|
||||
val tool = createTool(mockEngine)
|
||||
|
||||
val result = tool.handle("nonexistent-page")
|
||||
val text = (result.content.first() as TextContent).text
|
||||
|
||||
assertEquals(true, result.isError)
|
||||
assertTrue(text.contains("Page not found: nonexistent-page"))
|
||||
assertTrue(text.contains("search_kotlinlang"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle returns error on network failure`() = runTest {
|
||||
val mockEngine = MockEngine {
|
||||
respond(
|
||||
content = "Internal Server Error",
|
||||
status = HttpStatusCode.InternalServerError,
|
||||
headers = headersOf(HttpHeaders.ContentType, "text/plain"),
|
||||
)
|
||||
}
|
||||
val tool = createTool(mockEngine)
|
||||
|
||||
val result = tool.handle("coroutines-overview")
|
||||
|
||||
assertEquals(true, result.isError)
|
||||
val text = (result.content.first() as TextContent).text
|
||||
assertTrue(text.startsWith("Failed to fetch page:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle returns error on empty path`() = runTest {
|
||||
val mockEngine = MockEngine {
|
||||
respond(
|
||||
content = "should not reach",
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "text/plain"),
|
||||
)
|
||||
}
|
||||
val tool = createTool(mockEngine)
|
||||
|
||||
val result = tool.handle("")
|
||||
|
||||
assertEquals(true, result.isError)
|
||||
val text = (result.content.first() as TextContent).text
|
||||
assertTrue(text.contains("Invalid path:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle rethrows CancellationException`() = runTest {
|
||||
val mockEngine = MockEngine {
|
||||
throw kotlinx.coroutines.CancellationException("cancelled")
|
||||
}
|
||||
val tool = createTool(mockEngine)
|
||||
|
||||
kotlin.test.assertFailsWith<kotlinx.coroutines.CancellationException> {
|
||||
tool.handle("coroutines-overview")
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue