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:
Pavel Gorgulov 2026-04-27 15:07:03 +02:00 • committed by GitHub
commit c946dfc3c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 2149 additions and 0 deletions

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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")
}
}

View file

@ -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,
)

View 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)
}
}
}
}

View file

@ -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(),
)

View file

@ -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()
}
}

View file

@ -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"
}
}

View file

@ -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,
)
}
}
}

View file

@ -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, "")
}

View file

@ -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}
}

View 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>

View file

@ -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()
}
}
}

View file

@ -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")
}
}
}

View file

@ -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)
}
}

View 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()
}
}

View file

@ -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()
}
}
}

View file

@ -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")
}
}
}

View file

@ -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")
}
}
}

View file

@ -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