diff --git a/samples/README.md b/samples/README.md index 97893782d..f831a74b1 100644 --- a/samples/README.md +++ b/samples/README.md @@ -9,6 +9,7 @@ For background on the protocol itself, see the [MCP documentation](https://model | Sample | Type | Transport | MCP Features | |--------------------------------------------------------|-------------------|-----------------|------------------------------------| | [simple-streamable-server](./simple-streamable-server) | Server | Streamable HTTP | Tools, Resources, Prompts, Logging | +| [kotlinlang-mcp-server](./kotlinlang-mcp-server) | Server | Streamable HTTP | Tools | | [kotlin-mcp-server](./kotlin-mcp-server) | Server | STDIO, SSE | Tools, Resources, Prompts | | [weather-stdio-server](./weather-stdio-server) | Server | STDIO | Tools | | [kotlin-mcp-client](./kotlin-mcp-client) | Client | STDIO | Tool discovery & invocation | @@ -30,6 +31,13 @@ A minimal Streamable HTTP server with optional Bearer token authentication. Demo (`greet`, `multi-greet`), a prompt template, a resource, and server-to-client logging notifications. [Read more →](./simple-streamable-server) +### Kotlinlang MCP Server + +A Streamable HTTP server that exposes the official Kotlin documentation (kotlinlang.org) to LLM +clients — full-text search via Algolia and page retrieval in markdown. Demonstrates wrapping a real +external API in an MCP server with in-memory caching. +[Read more →](./kotlinlang-mcp-server) + ### Kotlin MCP Server A multi-transport server supporting STDIO, SSE (plain), and SSE (Ktor plugin). Useful for exploring diff --git a/samples/kotlinlang-mcp-server/Dockerfile b/samples/kotlinlang-mcp-server/Dockerfile new file mode 100644 index 000000000..ada7e567f --- /dev/null +++ b/samples/kotlinlang-mcp-server/Dockerfile @@ -0,0 +1,14 @@ +FROM gradle:jdk21-alpine AS build +WORKDIR /project +COPY gradle/ gradle/ +COPY gradlew settings.gradle.kts build.gradle.kts ./ +COPY src/ src/ +RUN ./gradlew shadowJar --no-daemon + +FROM eclipse-temurin:21-jre-alpine +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +WORKDIR /app +COPY --from=build /project/build/libs/*-all.jar app.jar +USER appuser +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/samples/kotlinlang-mcp-server/README.md b/samples/kotlinlang-mcp-server/README.md new file mode 100644 index 000000000..df0dd3e94 --- /dev/null +++ b/samples/kotlinlang-mcp-server/README.md @@ -0,0 +1,105 @@ +# Kotlinlang MCP Server + +A Streamable HTTP MCP server that exposes the official +[Kotlin documentation](https://kotlinlang.org/docs/) to LLM clients — full-text search via +[Algolia](https://www.algolia.com/) plus page retrieval in markdown through kotlinlang.org's +`_llms` endpoints. + +## Overview + +This sample demonstrates an MCP server that wraps a real external documentation source. It uses the +recommended Streamable HTTP transport, supports multiple concurrent client sessions, and caches +responses in memory to reduce upstream load. The server is intentionally read-only — it exposes two +tools, no prompts, no resources. + +## Prerequisites + +- JDK 21+ +- Algolia credentials for `kotlinlang.org` (exported as environment variables, see + [Configuration](#configuration)) + +## Build & Run + +```shell +export ALGOLIA_APP_ID=... +export ALGOLIA_API_KEY=... +export ALGOLIA_INDEX_NAME=... + +./gradlew run +``` + +The server starts on `http://localhost:8080/mcp` by default. + +Connect with the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector): + +```shell +npx @modelcontextprotocol/inspector +``` + +In the Inspector UI, select **Streamable HTTP** transport and enter `http://localhost:8080/mcp`. + +## MCP Capabilities + +### Tools + +| Name | Description | +|-----------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| `search_kotlinlang` | Full-text search across Kotlin documentation. Returns up to 5 results filtered to `/docs/` pages. Responses cached for 10 minutes. | +| `get_kotlinlang_page` | Fetches a documentation page as markdown via kotlinlang.org's `_llms` endpoint. Accepts a path relative to `/docs/`. Cached for 1 hour. | + +Example paths accepted by `get_kotlinlang_page`: `coroutines-overview`, +`multiplatform/compose-multiplatform-and-jetpack-compose`. Leading/trailing slashes and an optional +`.html` suffix are normalized before resolution. + +## Configuration + +| Variable | Required | Default | Description | +|----------------------|----------|-----------|--------------------------| +| `ALGOLIA_APP_ID` | yes | — | Algolia application ID | +| `ALGOLIA_API_KEY` | yes | — | Algolia search API key | +| `ALGOLIA_INDEX_NAME` | yes | — | Algolia index name | +| `SERVER_PORT` | no | `8080` | HTTP server port | +| `SERVER_HOST` | no | `0.0.0.0` | HTTP server bind address | + +The server does not provide fallback values for `ALGOLIA_*`. Startup fails if any of those +variables are missing. + +## Docker + +Multi-stage build with Alpine-based images. Runs as a non-root user. + +```shell +docker build -t kotlinlang-mcp-server . + +docker run \ + -p 8080:8080 \ + -e ALGOLIA_APP_ID=... \ + -e ALGOLIA_API_KEY=... \ + -e ALGOLIA_INDEX_NAME=... \ + kotlinlang-mcp-server +``` + +## Connecting an MCP client + +Any MCP client that supports Streamable HTTP transport can connect: + +```json +{ + "mcpServers": { + "kotlinlang": { + "url": "http://localhost:8080/mcp" + } + } +} +``` + +## Limitations + +This sample is intended **for demonstration only**. Before production use, consider: + +- **In-memory cache only** — cached data is lost on server restart. +- **No authentication or authorization** — run behind a trusted proxy or restrict network access. +- **No rate limiting** on outgoing requests to Algolia and kotlinlang.org. +- **Permissive CORS** (`anyHost()`) — restrict to specific origins for any non-local deployment. +- **Streamable HTTP only** — no STDIO transport for local-only usage. +- **Tools only** — no MCP resources or prompts are exposed. diff --git a/samples/kotlinlang-mcp-server/build.gradle.kts b/samples/kotlinlang-mcp-server/build.gradle.kts new file mode 100644 index 000000000..f46f006ba --- /dev/null +++ b/samples/kotlinlang-mcp-server/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.shadow) + application +} + +group = "org.example" +version = "0.1.0" + +application { + mainClass.set("org.kotlinlang.mcp.ApplicationKt") +} + +dependencies { + implementation(platform(libs.ktor.bom)) + implementation(libs.mcp.kotlin.server) + implementation(libs.ktor.server.netty) + implementation(libs.ktor.server.sse) + implementation(libs.ktor.server.content.negotiation) + implementation(libs.ktor.server.cors) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.logback.classic) + + testImplementation(kotlin("test")) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.ktor.client.mock) + testImplementation(libs.mcp.kotlin.client) + testImplementation(libs.mcp.kotlin.testing) +} + +tasks.test { + useJUnitPlatform() +} + +kotlin { + jvmToolchain(21) +} diff --git a/samples/kotlinlang-mcp-server/gradle.properties b/samples/kotlinlang-mcp-server/gradle.properties new file mode 100644 index 000000000..24a59763f --- /dev/null +++ b/samples/kotlinlang-mcp-server/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true + diff --git a/samples/kotlinlang-mcp-server/gradle/libs.versions.toml b/samples/kotlinlang-mcp-server/gradle/libs.versions.toml new file mode 100644 index 000000000..20dff78b5 --- /dev/null +++ b/samples/kotlinlang-mcp-server/gradle/libs.versions.toml @@ -0,0 +1,31 @@ +[versions] +kotlin = "2.3.20" +ktor = "3.3.3" +mcp-kotlin = "0.11.1" +serialization = "1.10.0" +coroutines = "1.10.2" +logback = "1.5.32" +shadow = "9.4.1" + +[libraries] +ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } +ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty" } +ktor-server-sse = { group = "io.ktor", name = "ktor-server-sse" } +ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation" } +ktor-server-cors = { group = "io.ktor", name = "ktor-server-cors" } +ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio" } +ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation" } +ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json" } +mcp-kotlin-server = { group = "io.modelcontextprotocol", name = "kotlin-sdk-server", version.ref = "mcp-kotlin" } +mcp-kotlin-client = { group = "io.modelcontextprotocol", name = "kotlin-sdk-client", version.ref = "mcp-kotlin" } +mcp-kotlin-testing = { group = "io.modelcontextprotocol", name = "kotlin-sdk-testing", version.ref = "mcp-kotlin" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } diff --git a/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.jar b/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..d997cfc60 Binary files /dev/null and b/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.properties b/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..c61a118f7 --- /dev/null +++ b/samples/kotlinlang-mcp-server/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/samples/kotlinlang-mcp-server/gradlew b/samples/kotlinlang-mcp-server/gradlew new file mode 100755 index 000000000..739907dfd --- /dev/null +++ b/samples/kotlinlang-mcp-server/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/samples/kotlinlang-mcp-server/gradlew.bat b/samples/kotlinlang-mcp-server/gradlew.bat new file mode 100644 index 000000000..c4bdd3ab8 --- /dev/null +++ b/samples/kotlinlang-mcp-server/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/samples/kotlinlang-mcp-server/settings.gradle.kts b/samples/kotlinlang-mcp-server/settings.gradle.kts new file mode 100644 index 000000000..bf867e05a --- /dev/null +++ b/samples/kotlinlang-mcp-server/settings.gradle.kts @@ -0,0 +1,25 @@ +rootProject.name = "kotlinlang-mcp-server" + +plugins { + // Apply the foojay-resolver plugin to allow automatic download of JDKs + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositories { + mavenLocal() + mavenCentral() + } + + versionCatalogs { + create("libs") { + val mcpKotlinVersion = providers.gradleProperty( + "mcp.kotlin.overrideVersion", + ).orNull + if (mcpKotlinVersion != null) { + logger.lifecycle("Using the override version $mcpKotlinVersion of MCP Kotlin SDK") + version("mcp-kotlin", mcpKotlinVersion) + } + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/Application.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/Application.kt new file mode 100644 index 000000000..e50b1ad43 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/Application.kt @@ -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) = 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 + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/KotlinlangServer.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/KotlinlangServer.kt new file mode 100644 index 000000000..081434295 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/KotlinlangServer.kt @@ -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(10.minutes) + private val pageCache = TtlCache(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() + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/algolia/AlgoliaClient.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/algolia/AlgoliaClient.kt new file mode 100644 index 000000000..0d0db42f6 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/algolia/AlgoliaClient.kt @@ -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") + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/algolia/AlgoliaModels.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/algolia/AlgoliaModels.kt new file mode 100644 index 000000000..88c465d75 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/algolia/AlgoliaModels.kt @@ -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, + val attributesToSnippet: List, +) + +@Serializable +internal data class AlgoliaSearchResponse( + val hits: List, +) + +@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, +) diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/cache/TtlCache.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/cache/TtlCache.kt new file mode 100644 index 000000000..7ba8de12e --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/cache/TtlCache.kt @@ -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( + private val ttl: Duration, + private val timeSource: TimeSource = TimeSource.Monotonic, +) { + private class Entry(val value: V, val createdAt: TimeMark) + + private val map = ConcurrentHashMap>() + private val inFlight = ConcurrentHashMap>() + + 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() + 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) + } + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/config/ServerConfig.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/config/ServerConfig.kt new file mode 100644 index 000000000..0c61e13b7 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/config/ServerConfig.kt @@ -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(), +) diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/content/PageFetcher.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/content/PageFetcher.kt new file mode 100644 index 000000000..a8495b266 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/content/PageFetcher.kt @@ -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() + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/content/UrlMapper.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/content/UrlMapper.kt new file mode 100644 index 000000000..ab0a8ff9a --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/content/UrlMapper.kt @@ -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" + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/tools/GetKotlinlangPage.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/tools/GetKotlinlangPage.kt new file mode 100644 index 000000000..d4a5097ac --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/tools/GetKotlinlangPage.kt @@ -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, +) { + 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, + ) + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/tools/SearchKotlinlang.kt b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/tools/SearchKotlinlang.kt new file mode 100644 index 000000000..886f3f76f --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/kotlin/org/kotlinlang/mcp/tools/SearchKotlinlang.kt @@ -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, +) { + 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): 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, "") +} diff --git a/samples/kotlinlang-mcp-server/src/main/resources/application.conf b/samples/kotlinlang-mcp-server/src/main/resources/application.conf new file mode 100644 index 000000000..4908da580 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/resources/application.conf @@ -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} +} diff --git a/samples/kotlinlang-mcp-server/src/main/resources/logback.xml b/samples/kotlinlang-mcp-server/src/main/resources/logback.xml new file mode 100644 index 000000000..0ffe8a34b --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/main/resources/logback.xml @@ -0,0 +1,15 @@ + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/KotlinlangServerTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/KotlinlangServerTest.kt new file mode 100644 index 000000000..c6786fb34 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/KotlinlangServerTest.kt @@ -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() + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/algolia/AlgoliaClientTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/algolia/AlgoliaClientTest.kt new file mode 100644 index 000000000..4f9dff6a0 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/algolia/AlgoliaClientTest.kt @@ -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(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 { + 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 { + client.search("coroutines") + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/algolia/AlgoliaModelsTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/algolia/AlgoliaModelsTest.kt new file mode 100644 index 000000000..10dbe5b88 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/algolia/AlgoliaModelsTest.kt @@ -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(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 coroutines support", + "matchLevel": "full" + } + } + } + ] + } + """.trimIndent() + + val response = json.decodeFromString(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 coroutines 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(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(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(responseJson) + + assertEquals(emptyList(), response.hits) + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/cache/TtlCacheTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/cache/TtlCacheTest.kt new file mode 100644 index 000000000..a191fe715 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/cache/TtlCacheTest.kt @@ -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(ttl = 10.minutes) + cache.put("key", "value") + assertEquals("value", cache.get("key")) + } + + @Test + fun `get returns null for missing key`() { + val cache = TtlCache(ttl = 10.minutes) + assertNull(cache.get("missing")) + } + + @Test + fun `get returns null after TTL expires`() { + val timeSource = TestTimeSource() + val cache = TtlCache(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(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(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(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(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(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(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(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(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() + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/config/ServerConfigTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/config/ServerConfigTest.kt new file mode 100644 index 000000000..571460267 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/config/ServerConfigTest.kt @@ -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 { + config.toServerConfig() + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/content/PageFetcherTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/content/PageFetcherTest.kt new file mode 100644 index 000000000..1cd3e6b24 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/content/PageFetcherTest.kt @@ -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 { + 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 { + fetcher.fetch("https://kotlinlang.org/docs/_llms/coroutines-overview.txt") + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/content/UrlMapperTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/content/UrlMapperTest.kt new file mode 100644 index 000000000..c2076a303 --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/content/UrlMapperTest.kt @@ -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 { + mapPathToUrl("") + } + } + + @Test + fun `only slashes throws IllegalArgumentException`() { + assertFailsWith { + mapPathToUrl("///") + } + } + + @Test + fun `blank path throws IllegalArgumentException`() { + assertFailsWith { + mapPathToUrl(" ") + } + } + + @Test + fun `path traversal throws IllegalArgumentException`() { + assertFailsWith { + mapPathToUrl("../../secret") + } + } + + @Test + fun `path traversal in the middle throws IllegalArgumentException`() { + assertFailsWith { + mapPathToUrl("multiplatform/../secret") + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/tools/GetKotlinlangPageTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/tools/GetKotlinlangPageTest.kt new file mode 100644 index 000000000..18f0cfe1e --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/tools/GetKotlinlangPageTest.kt @@ -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(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 { + tool.handle("coroutines-overview") + } + } +} diff --git a/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/tools/SearchKotlinlangTest.kt b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/tools/SearchKotlinlangTest.kt new file mode 100644 index 000000000..7e10420ae --- /dev/null +++ b/samples/kotlinlang-mcp-server/src/test/kotlin/org/kotlinlang/mcp/tools/SearchKotlinlangTest.kt @@ -0,0 +1,231 @@ +package org.kotlinlang.mcp.tools + +import io.ktor.client.* +import io.ktor.client.engine.mock.* +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.algolia.AlgoliaClient +import org.kotlinlang.mcp.cache.TtlCache +import org.kotlinlang.mcp.config.ServerConfig +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.minutes +import kotlin.time.TestTimeSource + +class SearchKotlinlangTest { + + private val testConfig = ServerConfig( + algoliaAppId = "test-app-id", + algoliaApiKey = "test-api-key", + algoliaIndexName = "test-index", + ) + + private val json = Json { ignoreUnknownKeys = true } + + private fun createSearchTool( + mockEngine: MockEngine, + timeSource: TestTimeSource = TestTimeSource(), + ): SearchKotlinlang { + val httpClient = HttpClient(mockEngine) { + install(ContentNegotiation) { json(json) } + } + val algoliaClient = AlgoliaClient(testConfig, httpClient) + val cache = TtlCache(ttl = 10.minutes, timeSource = timeSource) + return SearchKotlinlang(algoliaClient, cache) + } + + private fun mockEngineWithHits(hitsJson: String): MockEngine = MockEngine { + respond( + content = """{"hits": [$hitsJson]}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + private fun hit( + objectID: String = "id1", + mainTitle: String? = "Title", + url: String = "/docs/test.html", + snippet: String? = "some text", + matchLevel: String = "full", + ): String = buildString { + append("""{"objectID":"$objectID","url":"$url"""") + if (mainTitle != null) append(""","mainTitle":"$mainTitle"""") + if (snippet != null) { + append(""","_snippetResult":{"content":{"value":"$snippet","matchLevel":"$matchLevel"}}""") + } + append("}") + } + + @Test + fun `filters out non-docs hits`() = runTest { + val engine = mockEngineWithHits( + listOf( + hit(objectID = "1", url = "/docs/coroutines.html", mainTitle = "Coroutines"), + hit(objectID = "2", url = "https://kotlinlang.org/api/core/kotlin/", mainTitle = "API"), + hit(objectID = "3", url = "/docs/flow.html", mainTitle = "Flow"), + ).joinToString(",") + ) + val tool = createSearchTool(engine) + + val result = tool.handle("coroutines") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + assertTrue(text.contains("Coroutines")) + assertTrue(text.contains("Flow")) + assertTrue(!text.contains("API")) + } + + @Test + fun `takes only top 5 after filtering`() = runTest { + val hits = (1..10).map { i -> + hit(objectID = "id$i", url = "/docs/page$i.html", mainTitle = "Page $i", snippet = "text $i") + } + val engine = mockEngineWithHits(hits.joinToString(",")) + val tool = createSearchTool(engine) + + val result = tool.handle("kotlin") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + for (i in 1..5) assertTrue(text.contains("Page $i"), "Should contain Page $i") + for (i in 6..10) assertTrue(!text.contains("Page $i"), "Should not contain Page $i") + } + + @Test + fun `formats results with title path and snippet`() = runTest { + val engine = mockEngineWithHits( + hit( + objectID = "1", + url = "/docs/coroutines-overview.html", + mainTitle = "Coroutines overview", + snippet = "Kotlin provides coroutines support", + ) + ) + val tool = createSearchTool(engine) + + val result = tool.handle("coroutines") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + assertEquals( + """ + 1. Coroutines overview [coroutines-overview] + Kotlin provides coroutines support + """.trimIndent(), + text, + ) + } + + @Test + fun `strips HTML tags from snippet`() = runTest { + val engine = mockEngineWithHits( + hit(snippet = "coroutines are great") + ) + val tool = createSearchTool(engine) + + val result = tool.handle("coroutines") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + assertTrue(text.contains("coroutines are great")) + assertTrue(!text.contains("")) + assertTrue(!text.contains("")) + } + + @Test + fun `uses Untitled when mainTitle is null`() = runTest { + val engine = mockEngineWithHits( + hit(mainTitle = null, url = "/docs/basics.html", snippet = "some text") + ) + val tool = createSearchTool(engine) + + val result = tool.handle("basics") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + assertTrue(text.contains("Untitled [basics]")) + } + + @Test + fun `omits snippet line when snippetResult is null`() = runTest { + val engine = mockEngineWithHits( + hit(url = "/docs/basics.html", mainTitle = "Basics", snippet = null) + ) + val tool = createSearchTool(engine) + + val result = tool.handle("basics") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + assertEquals("1. Basics [basics]", text) + } + + @Test + fun `returns no results message when all hits are filtered out`() = runTest { + val engine = mockEngineWithHits( + hit(url = "https://kotlinlang.org/api/core/kotlin/", mainTitle = "API") + ) + val tool = createSearchTool(engine) + + val result = tool.handle("something") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + assertNull(result.isError) + assertEquals("No results found for: something", text) + } + + @Test + fun `returns isError true when Algolia fails`() = runTest { + val engine = MockEngine { + respond( + content = """{"message": "Internal server error"}""", + status = HttpStatusCode.InternalServerError, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + val tool = createSearchTool(engine) + + val result = tool.handle("coroutines") + + assertEquals(true, result.isError) + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + assertTrue(text.startsWith("Search failed:")) + } + + @Test + fun `caches result and does not call Algolia on second request`() = runTest { + var callCount = 0 + val engine = MockEngine { + callCount++ + respond( + content = """{"hits": [${hit(url = "/docs/test.html")}]}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + val tool = createSearchTool(engine) + + tool.handle("coroutines") + tool.handle("coroutines") + + assertEquals(1, callCount) + } + + @Test + fun `extracts path from nested URL`() = runTest { + val engine = mockEngineWithHits( + hit( + url = "/docs/multiplatform/compose.html", + mainTitle = "Compose", + snippet = "text", + ) + ) + val tool = createSearchTool(engine) + + val result = tool.handle("compose") + val text = result.content.first().let { (it as io.modelcontextprotocol.kotlin.sdk.types.TextContent).text } + + assertTrue(text.contains("[multiplatform/compose]")) + } +}