Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions samples/kotlin-mcp-client/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
[versions]
anthropic = "2.9.0"
anthropic = "2.15.0"
kotlin = "2.2.21"
ktor = "3.2.3"
mcp-kotlin = "0.8.1"
mcp-kotlin = "0.9.0"
shadow = "9.2.2"
slf4j = "2.0.17"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ package io.modelcontextprotocol.sample.client

import com.anthropic.client.okhttp.AnthropicOkHttpClient
import com.anthropic.core.JsonValue
import com.anthropic.models.messages.ContentBlockParam
import com.anthropic.models.messages.MessageCreateParams
import com.anthropic.models.messages.MessageParam
import com.anthropic.models.messages.Model
import com.anthropic.models.messages.Tool
import com.anthropic.models.messages.ToolResultBlockParam
import com.anthropic.models.messages.ToolUnion
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
Expand All @@ -14,24 +15,28 @@ import io.modelcontextprotocol.kotlin.sdk.client.StdioClientTransport
import io.modelcontextprotocol.kotlin.sdk.types.EmptyJsonObject
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import kotlinx.coroutines.withContext
import kotlinx.io.asSink
import kotlinx.io.asSource
import kotlinx.io.buffered
import kotlinx.serialization.json.JsonObject
import kotlin.jvm.optionals.getOrNull
import java.util.concurrent.TimeUnit

class MCPClient : AutoCloseable {
// Configures using the `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` environment variables
private val anthropic = AnthropicOkHttpClient.fromEnv()
private const val MODEL = "claude-sonnet-4-20250514"
private const val MAX_TOKENS = 1024L

class MCPClient(apiKey: String) : AutoCloseable {
private val anthropic = AnthropicOkHttpClient.builder()
.apiKey(apiKey)
.build()

// Initialize MCP client
private val mcp: Client = Client(clientInfo = Implementation(name = "mcp-client-cli", version = "1.0.0"))

private val messageParamsBuilder: MessageCreateParams.Builder = MessageCreateParams.builder()
.model(Model.CLAUDE_4_SONNET_20250514)
.maxTokens(1024)
// Server process reference for cleanup
private var serverProcess: Process? = null

// List of tools offered by the server
private lateinit var tools: List<ToolUnion>
Expand All @@ -44,52 +49,52 @@ class MCPClient : AutoCloseable {

// Connect to the server using the path to the server
suspend fun connectToServer(serverScriptPath: String) {
try {
// Build the command based on the file extension of the server script
val command = buildList {
when (serverScriptPath.substringAfterLast(".")) {
"js" -> add("node")
"py" -> add(if (System.getProperty("os.name").lowercase().contains("win")) "python" else "python3")
"jar" -> addAll(listOf("java", "-jar"))
else -> throw IllegalArgumentException("Server script must be a .js, .py or .jar file")
}
add(serverScriptPath)
// Build the command based on the file extension of the server script
val command = buildList {
when (serverScriptPath.substringAfterLast(".")) {
"js" -> add("node")
"py" -> add(if (System.getProperty("os.name").lowercase().contains("win")) "python" else "python3")
"jar" -> addAll(listOf("java", "-jar"))
else -> throw IllegalArgumentException("Server script must be a .js, .py or .jar file")
}
add(serverScriptPath)
}

// Start the server process
val process = ProcessBuilder(command).start()
// Start the server process
val process = withContext(Dispatchers.IO) {
ProcessBuilder(command)
.redirectError(ProcessBuilder.Redirect.INHERIT)
.start()
}
serverProcess = process

// Setup I/O transport using the process streams
val transport = StdioClientTransport(
input = process.inputStream.asSource().buffered(),
output = process.outputStream.asSink().buffered(),
)
// Setup I/O transport using the process streams
val transport = StdioClientTransport(
input = process.inputStream.asSource().buffered(),
output = process.outputStream.asSink().buffered(),
)

// Connect the MCP client to the server using the transport
mcp.connect(transport)

// Request the list of available tools from the server
val toolsResult = mcp.listTools()
tools = toolsResult.tools.map { tool ->
ToolUnion.ofTool(
Tool.builder()
.name(tool.name)
.description(tool.description ?: "")
.inputSchema(
Tool.InputSchema.builder()
.type(JsonValue.from(tool.inputSchema.type))
.properties(tool.inputSchema.properties?.toJsonValue() ?: EmptyJsonObject.toJsonValue())
.putAdditionalProperty("required", JsonValue.from(tool.inputSchema.required))
.build(),
)
.build(),
)
}
println("Connected to server with tools: ${tools.joinToString(", ") { it.tool().get().name() }}")
} catch (e: Exception) {
println("Failed to connect to MCP server: $e")
throw e
// Connect the MCP client to the server using the transport
mcp.connect(transport)

// Request the list of available tools from the server
val toolsResult = mcp.listTools()
tools = toolsResult.tools.map { tool ->
ToolUnion.ofTool(
Tool.builder()
.name(tool.name)
.description(tool.description ?: "")
.inputSchema(
Tool.InputSchema.builder()
.type(JsonValue.from(tool.inputSchema.type))
.properties(tool.inputSchema.properties?.toJsonValue() ?: EmptyJsonObject.toJsonValue())
.putAdditionalProperty("required", JsonValue.from(tool.inputSchema.required))
.build(),
)
.build(),
)
}
println("Connected to server with tools: ${tools.joinToString(", ") { it.tool().get().name() }}")
}

// Process a user query and return a string response
Expand All @@ -104,23 +109,29 @@ class MCPClient : AutoCloseable {

// Send the query to the Anthropic model and get the response
val response = anthropic.messages().create(
messageParamsBuilder
MessageCreateParams.builder()
.model(MODEL)
.maxTokens(MAX_TOKENS)
.messages(messages)
.tools(tools)
.build(),
)

val finalText = mutableListOf<String>()
val toolResults = mutableListOf<ContentBlockParam>()

response.content().forEach { content ->
when {
// Append text outputs from the response
content.isText() -> finalText.add(content.text().getOrNull()?.text() ?: "")
content.isText() -> finalText.add(content.text().get().text())

// If the response indicates a tool use, process it further
content.isToolUse() -> {
val toolName = content.toolUse().get().name()
val toolUse = content.toolUse().get()
val toolName = toolUse.name()
val toolUseId = toolUse.id()
val toolArgs =
content.toolUse().get()._input().convert(object : TypeReference<Map<String, JsonValue>>() {})
toolUse._input().convert(object : TypeReference<Map<String, JsonValue>>() {})

// Call the tool with provided arguments
val result = mcp.callTool(
Expand All @@ -129,37 +140,55 @@ class MCPClient : AutoCloseable {
)
finalText.add("[Calling tool $toolName with args $toolArgs]")

// Add the tool result message to the conversation
messages.add(
MessageParam.builder()
.role(MessageParam.Role.USER)
.content(
"""
"type": "tool_result",
"tool_name": $toolName,
"result": ${
result.content.joinToString("\n") {
(it as TextContent).text
}
}
""".trimIndent(),
)
.build(),
)
// Build tool_result content block with tool_use_id
val toolResultContent = result.content
.filterIsInstance<TextContent>()
.joinToString("\n") { it.text }

// Retrieve an updated response after tool execution
val aiResponse = anthropic.messages().create(
messageParamsBuilder
.messages(messages)
.build(),
)
val toolResultBlock = ToolResultBlockParam.builder()
.toolUseId(toolUseId)
.content(toolResultContent)
.apply { if (result.isError == true) isError(true) }
.build()

// Append the updated response to final text
finalText.add(aiResponse.content().first().text().getOrNull()?.text() ?: "")
toolResults.add(ContentBlockParam.ofToolResult(toolResultBlock))
}
}
}

// If there were tool calls, send tool results back and get final response
if (toolResults.isNotEmpty()) {
// Add the full assistant response (includes tool_use blocks)
messages.add(
MessageParam.builder()
.role(MessageParam.Role.ASSISTANT)
.contentOfBlockParams(response.content().map { it.toParam() })
.build(),
)

// Add user message with tool results
messages.add(
MessageParam.builder()
.role(MessageParam.Role.USER)
.contentOfBlockParams(toolResults)
.build(),
)

// Retrieve an updated response after tool execution (without tools)
val aiResponse = anthropic.messages().create(
MessageCreateParams.builder()
.model(MODEL)
.maxTokens(MAX_TOKENS)
.messages(messages)
.build(),
)

// Append the updated response to final text
aiResponse.content()
.filter { it.isText() }
.forEach { finalText.add(it.text().get().text()) }
}

return finalText.joinToString("\n", prefix = "", postfix = "")
}

Expand All @@ -171,17 +200,27 @@ class MCPClient : AutoCloseable {
while (true) {
print("\nQuery: ")
val message = readlnOrNull() ?: break
if (message.lowercase() == "quit") break
val response = processQuery(message)
println("\n$response")
yield()
if (message.trim().lowercase() == "quit") break

try {
val response = processQuery(message)
println("\n$response")
} catch (e: Exception) {
println("\nError: ${e.message}")
}
}
}

override fun close() {
runBlocking {
mcp.close()
anthropic.close()
}
serverProcess?.let { process ->
process.destroy()
if (!process.waitFor(5, TimeUnit.SECONDS)) {
process.destroyForcibly()
}
}
anthropic.close()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ package io.modelcontextprotocol.sample.client
import kotlinx.coroutines.runBlocking

fun main(args: Array<String>) = runBlocking {
require(args.isNotEmpty()) {
"Usage: java -jar <your_path>/build/libs/kotlin-mcp-client-0.1.0-all.jar <path_to_server_script>"
}
val serverPath = args.first()
val client = MCPClient()
require(args.isNotEmpty()) { "Usage: java -jar <path> <path_to_server_script>" }

val apiKey = System.getenv("ANTHROPIC_API_KEY")
require(!apiKey.isNullOrBlank()) { "ANTHROPIC_API_KEY environment variable is not set" }

val client = MCPClient(apiKey)
client.use {
client.connectToServer(serverPath)
client.connectToServer(args.first())
client.chatLoop()
}
}
2 changes: 1 addition & 1 deletion samples/weather-stdio-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ Add the following to your Claude Desktop configuration:

| Name | Description |
|----------------|------------------------------------------------------------------------------------------|
| `get_forecast` | Returns weather forecast for a given `latitude` / `longitude` using the weather.gov API. |
| `get_alerts` | Returns active weather alerts for a two-letter US `state` code (e.g. `CA`, `NY`). |
| `get_forecast` | Returns weather forecast for a given `latitude` / `longitude` using the weather.gov API. |

## Additional Resources

Expand Down
4 changes: 1 addition & 3 deletions samples/weather-stdio-server/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,12 @@ version = "0.1.0"

dependencies {
implementation(dependencies.platform(libs.ktor.bom))
implementation(libs.mcp.kotlin.server)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.mcp.kotlin.server)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.client.cio)
implementation(libs.slf4j.simple)
runtimeOnly(libs.kotlin.logging)
runtimeOnly(libs.kotlinx.collections.immutable)

testImplementation(kotlin("test"))

Expand Down
5 changes: 1 addition & 4 deletions samples/weather-stdio-server/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
[versions]
collections-immutable = "0.4.0"
coroutines = "1.10.2"
kotlin = "2.2.21"
ktor = "3.2.3"
logging = "7.0.13"
mcp-kotlin = "0.8.1"
mcp-kotlin = "0.9.0"
shadow = "9.2.2"
slf4j = "2.0.17"

[libraries]
kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging-jvm", version.ref = "logging" }
kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable-jvm", version.ref = "collections-immutable" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" }
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-jvm" }
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
mcp-kotlin-client = { group = "io.modelcontextprotocol", name = "kotlin-sdk-client", version.ref = "mcp-kotlin" }
mcp-kotlin-server = { group = "io.modelcontextprotocol", name = "kotlin-sdk-server", version.ref = "mcp-kotlin" }
slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" }
Expand Down
Loading
Loading