docs: update client and server samples (#598)
Updated the client and server examples for further use in the following guides: https://modelcontextprotocol.io/docs/develop/build-server and https://modelcontextprotocol.io/docs/develop/build-client ## How Has This Been Tested? claude desktop, cli ## Breaking Changes NaN ## Types of changes - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Example, Documentation update ## 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 - [ ] I have added appropriate error handling - [ ] I have added or updated documentation as needed
This commit is contained in:
parent
4ecd95218a
commit
921b1fd30e
9 changed files with 311 additions and 228 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
||||
// Start the server process
|
||||
val process = ProcessBuilder(command).start()
|
||||
|
||||
// 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
|
||||
add(serverScriptPath)
|
||||
}
|
||||
|
||||
// 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(),
|
||||
)
|
||||
|
||||
// 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
|
||||
|
|
@ -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(
|
||||
|
|
@ -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 = "")
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package io.modelcontextprotocol.sample.server
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.http.ContentType
|
||||
|
|
@ -15,6 +16,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult
|
|||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.TextContent
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
|
@ -32,47 +34,59 @@ import kotlinx.serialization.json.putJsonObject
|
|||
* weather alerts by state and weather forecasts by latitude/longitude.
|
||||
*/
|
||||
fun runMcpServer() {
|
||||
// Base URL for the Weather API
|
||||
val baseUrl = "https://api.weather.gov"
|
||||
createHttpClient().use { httpClient ->
|
||||
val server = Server(
|
||||
Implementation(
|
||||
name = "weather",
|
||||
version = "1.0.0",
|
||||
),
|
||||
ServerOptions(
|
||||
capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = true)),
|
||||
),
|
||||
)
|
||||
|
||||
// Create an HTTP client with a default request configuration and JSON content negotiation
|
||||
val httpClient = HttpClient {
|
||||
defaultRequest {
|
||||
url(baseUrl)
|
||||
headers {
|
||||
append("Accept", "application/geo+json")
|
||||
append("User-Agent", "WeatherApiClient/1.0")
|
||||
server.registerTools(httpClient)
|
||||
|
||||
val transport = StdioServerTransport(
|
||||
System.`in`.asInput(),
|
||||
System.out.asSink().buffered(),
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
val session = server.createSession(transport)
|
||||
val done = Job()
|
||||
session.onClose {
|
||||
done.complete()
|
||||
}
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
// Install content negotiation plugin for JSON serialization/deserialization
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
},
|
||||
)
|
||||
done.join()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the MCP Server instance with a basic implementation
|
||||
val server = Server(
|
||||
Implementation(
|
||||
name = "weather", // Tool name is "weather"
|
||||
version = "1.0.0", // Version of the implementation
|
||||
),
|
||||
ServerOptions(
|
||||
capabilities = ServerCapabilities(tools = ServerCapabilities.Tools(listChanged = true)),
|
||||
),
|
||||
)
|
||||
private fun createHttpClient(): HttpClient = HttpClient(CIO) {
|
||||
defaultRequest {
|
||||
url("https://api.weather.gov")
|
||||
headers {
|
||||
append("Accept", "application/geo+json")
|
||||
append("User-Agent", "WeatherApiClient/1.0")
|
||||
}
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Server.registerTools(httpClient: HttpClient) {
|
||||
// Register a tool to fetch weather alerts by state
|
||||
server.addTool(
|
||||
addTool(
|
||||
name = "get_alerts",
|
||||
description = """
|
||||
Get weather alerts for a US state. Input is Two-letter US state code (e.g. CA, NY)
|
||||
""".trimIndent(),
|
||||
description = "Get weather alerts for a US state. Input is a two-letter US state code (e.g. CA, NY)",
|
||||
inputSchema = ToolSchema(
|
||||
properties = buildJsonObject {
|
||||
putJsonObject("state") {
|
||||
|
|
@ -82,33 +96,62 @@ fun runMcpServer() {
|
|||
},
|
||||
required = listOf("state"),
|
||||
),
|
||||
toolAnnotations = ToolAnnotations(readOnlyHint = true, openWorldHint = true),
|
||||
) { request ->
|
||||
val state = request.arguments?.get("state")?.jsonPrimitive?.content ?: return@addTool CallToolResult(
|
||||
content = listOf(TextContent("The 'state' parameter is required.")),
|
||||
val state = request.arguments?.get("state")?.jsonPrimitive?.content
|
||||
?: return@addTool CallToolResult(
|
||||
content = listOf(TextContent("The 'state' parameter is required.")),
|
||||
)
|
||||
|
||||
if (state.length != 2) {
|
||||
return@addTool CallToolResult(
|
||||
content = listOf(TextContent("Invalid state code: '$state'. Must be a two-letter US state code.")),
|
||||
isError = true,
|
||||
)
|
||||
}
|
||||
|
||||
val stateCode = state.uppercase()
|
||||
|
||||
httpClient.getAlerts(stateCode).fold(
|
||||
onSuccess = { alerts ->
|
||||
if (alerts.isEmpty()) {
|
||||
CallToolResult(content = listOf(TextContent("No active alerts for $stateCode")))
|
||||
} else {
|
||||
val alertsText = "Active alerts for $stateCode:\n\n${alerts.joinToString("\n---\n")}"
|
||||
CallToolResult(content = listOf(TextContent(alertsText)))
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
CallToolResult(
|
||||
content = listOf(TextContent("Failed to retrieve alerts data: ${e.message}")),
|
||||
isError = true,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
val alerts = httpClient.getAlerts(state)
|
||||
|
||||
CallToolResult(content = alerts.map { TextContent(it) })
|
||||
}
|
||||
|
||||
// Register a tool to fetch weather forecast by latitude and longitude
|
||||
server.addTool(
|
||||
addTool(
|
||||
name = "get_forecast",
|
||||
description = """
|
||||
Get weather forecast for a specific latitude/longitude
|
||||
""".trimIndent(),
|
||||
description = "Get weather forecast for a location. Note: only US locations are supported by the NWS API.",
|
||||
inputSchema = ToolSchema(
|
||||
properties = buildJsonObject {
|
||||
putJsonObject("latitude") {
|
||||
put("type", "number")
|
||||
put("description", "Latitude of the location")
|
||||
put("minimum", -90)
|
||||
put("maximum", 90)
|
||||
}
|
||||
putJsonObject("longitude") {
|
||||
put("type", "number")
|
||||
put("description", "Longitude of the location")
|
||||
put("minimum", -180)
|
||||
put("maximum", 180)
|
||||
}
|
||||
},
|
||||
required = listOf("latitude", "longitude"),
|
||||
),
|
||||
toolAnnotations = ToolAnnotations(readOnlyHint = true, openWorldHint = true),
|
||||
) { request ->
|
||||
val latitude = request.arguments?.get("latitude")?.jsonPrimitive?.doubleOrNull
|
||||
val longitude = request.arguments?.get("longitude")?.jsonPrimitive?.doubleOrNull
|
||||
|
|
@ -118,23 +161,26 @@ fun runMcpServer() {
|
|||
)
|
||||
}
|
||||
|
||||
val forecast = httpClient.getForecast(latitude, longitude)
|
||||
|
||||
CallToolResult(content = forecast.map { TextContent(it) })
|
||||
}
|
||||
|
||||
// Create a transport using standard IO for server communication
|
||||
val transport = StdioServerTransport(
|
||||
System.`in`.asInput(),
|
||||
System.out.asSink().buffered(),
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
val session = server.createSession(transport)
|
||||
val done = Job()
|
||||
session.onClose {
|
||||
done.complete()
|
||||
}
|
||||
done.join()
|
||||
httpClient.getForecast(latitude, longitude).fold(
|
||||
onSuccess = { periods ->
|
||||
if (periods.isEmpty()) {
|
||||
CallToolResult(content = listOf(TextContent("No forecast periods available")))
|
||||
} else {
|
||||
val forecastText = periods.joinToString("\n---\n")
|
||||
CallToolResult(content = listOf(TextContent(forecastText)))
|
||||
}
|
||||
},
|
||||
onFailure = { _ ->
|
||||
CallToolResult(
|
||||
content = listOf(
|
||||
TextContent(
|
||||
"Failed to retrieve grid point data for coordinates: $latitude, $longitude. " +
|
||||
"This location may not be supported by the NWS API (only US locations are supported).",
|
||||
),
|
||||
),
|
||||
isError = true,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,88 +4,90 @@ import io.ktor.client.HttpClient
|
|||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
// Extension function to fetch forecast information for given latitude and longitude
|
||||
suspend fun HttpClient.getForecast(latitude: Double, longitude: Double): List<String> {
|
||||
// Build the URI using provided latitude and longitude
|
||||
val uri = "/points/$latitude,$longitude"
|
||||
// Request the points data from the API
|
||||
val points = this.get(uri).body<Points>()
|
||||
|
||||
// Request the forecast using the URL provided in the points response
|
||||
val forecast = this.get(points.properties.forecast).body<Forecast>()
|
||||
|
||||
// Map each forecast period to a formatted string
|
||||
return forecast.properties.periods.map { period ->
|
||||
"""
|
||||
${period.name}:
|
||||
Temperature: ${period.temperature} ${period.temperatureUnit}
|
||||
Wind: ${period.windSpeed} ${period.windDirection}
|
||||
Forecast: ${period.detailedForecast}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
import java.util.Locale
|
||||
|
||||
// Extension function to fetch weather alerts for a given state
|
||||
suspend fun HttpClient.getAlerts(state: String): List<String> {
|
||||
// Build the URI using the given state code
|
||||
suspend fun HttpClient.getAlerts(state: String): Result<List<String>> = runCatching {
|
||||
val uri = "/alerts/active/area/$state"
|
||||
// Request the alerts data from the API
|
||||
val alerts = this.get(uri).body<Alert>()
|
||||
val alerts = this.get(uri).body<AlertsResponse>()
|
||||
|
||||
// Map each alert feature to a formatted string
|
||||
return alerts.features.map { feature ->
|
||||
alerts.features.map { feature ->
|
||||
"""
|
||||
Event: ${feature.properties.event}
|
||||
Area: ${feature.properties.areaDesc}
|
||||
Severity: ${feature.properties.severity}
|
||||
Description: ${feature.properties.description}
|
||||
Instruction: ${feature.properties.instruction}
|
||||
Status: ${feature.properties.status}
|
||||
Headline: ${feature.properties.headline}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
// Data class representing the points response from the API
|
||||
@Serializable
|
||||
data class Points(
|
||||
val properties: Properties
|
||||
) {
|
||||
@Serializable
|
||||
data class Properties(val forecast: String)
|
||||
// Extension function to fetch forecast information for given latitude and longitude
|
||||
suspend fun HttpClient.getForecast(latitude: Double, longitude: Double): Result<List<String>> = runCatching {
|
||||
val lat = String.format(Locale.US, "%.4f", latitude)
|
||||
val lon = String.format(Locale.US, "%.4f", longitude)
|
||||
val uri = "/points/$lat,$lon"
|
||||
val points = this.get(uri).body<PointsResponse>()
|
||||
|
||||
val forecastUrl = points.properties.forecast
|
||||
?: error("No forecast URL available for coordinates: $latitude, $longitude")
|
||||
val forecast = this.get(forecastUrl).body<ForecastResponse>()
|
||||
|
||||
forecast.properties.periods.map { period ->
|
||||
"""
|
||||
${period.name}:
|
||||
Temperature: ${period.temperature}°${period.temperatureUnit}
|
||||
Wind: ${period.windSpeed} ${period.windDirection}
|
||||
${period.shortForecast}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
// Data class representing the forecast response from the API
|
||||
@Serializable
|
||||
data class Forecast(
|
||||
val properties: Properties
|
||||
) {
|
||||
@Serializable
|
||||
data class Properties(val periods: List<Period>)
|
||||
data class PointsResponse(
|
||||
val properties: PointsProperties,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Period(
|
||||
val number: Int, val name: String, val startTime: String, val endTime: String,
|
||||
val isDaytime: Boolean, val temperature: Int, val temperatureUnit: String,
|
||||
val temperatureTrend: String, val probabilityOfPrecipitation: JsonObject,
|
||||
val windSpeed: String, val windDirection: String,
|
||||
val shortForecast: String, val detailedForecast: String,
|
||||
)
|
||||
}
|
||||
|
||||
// Data class representing the alerts response from the API
|
||||
@Serializable
|
||||
data class Alert(
|
||||
val features: List<Feature>
|
||||
) {
|
||||
@Serializable
|
||||
data class Feature(
|
||||
val properties: Properties
|
||||
)
|
||||
data class PointsProperties(
|
||||
val forecast: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Properties(
|
||||
val event: String, val areaDesc: String, val severity: String,
|
||||
val description: String, val instruction: String?,
|
||||
)
|
||||
}
|
||||
@Serializable
|
||||
data class ForecastResponse(
|
||||
val properties: ForecastProperties,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForecastProperties(
|
||||
val periods: List<ForecastPeriod> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ForecastPeriod(
|
||||
val name: String? = null,
|
||||
val temperature: Int? = null,
|
||||
val temperatureUnit: String? = null,
|
||||
val windSpeed: String? = null,
|
||||
val windDirection: String? = null,
|
||||
val shortForecast: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AlertsResponse(
|
||||
val features: List<AlertFeature> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AlertFeature(
|
||||
val properties: AlertProperties,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AlertProperties(
|
||||
val event: String? = null,
|
||||
val areaDesc: String? = null,
|
||||
val severity: String? = null,
|
||||
val status: String? = null,
|
||||
val headline: String? = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ fun main(): Unit = runBlocking {
|
|||
val process = ProcessBuilder(
|
||||
"java",
|
||||
"-jar",
|
||||
"${System.getProperty("user.dir")}/build/libs/weather-stdio-server-0.1.0-all.jar",
|
||||
).redirectErrorStream(true)
|
||||
"${System.getProperty("user.dir")}/samples/weather-stdio-server/build/libs/weather-stdio-server-0.1.0-all.jar",
|
||||
).redirectError(ProcessBuilder.Redirect.INHERIT)
|
||||
.start()
|
||||
|
||||
val transport = StdioClientTransport(
|
||||
|
|
@ -44,7 +44,7 @@ fun main(): Unit = runBlocking {
|
|||
|
||||
val alertResult =
|
||||
client.callTool(
|
||||
name = "get_alert",
|
||||
name = "get_alerts",
|
||||
arguments = mapOf("state" to "TX"),
|
||||
).content.map { if (it is TextContent) it.text else it.toString() }
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue