Skip to content

Commit 51ca461

Browse files
authored
Merge pull request #140 from joreilly/mcp
initial mcp server
2 parents 54d65d8 + 91a9ca6 commit 51ca461

File tree

10 files changed

+377
-1132
lines changed

10 files changed

+377
-1132
lines changed

composeApp/ClimateTrace.ipynb

Lines changed: 153 additions & 1115 deletions
Large diffs are not rendered by default.

composeApp/build.gradle.kts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ kotlin {
3434
}
3535

3636

37-
jvm("desktop")
37+
jvm()
3838

3939
listOf(
4040
iosX64(),
@@ -56,8 +56,6 @@ kotlin {
5656
languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi")
5757
}
5858

59-
val desktopMain by getting
60-
6159
commonMain.dependencies {
6260
implementation(compose.runtime)
6361
implementation(compose.foundation)
@@ -100,11 +98,9 @@ kotlin {
10098
implementation(libs.koin.android)
10199
implementation(libs.kstore.file)
102100
implementation(libs.ktor.client.android)
103-
// workaround for https://youtrack.jetbrains.com/issue/CMP-5959/Invalid-redirect-in-window-core#focus=Comments-27-10365630.0-0
104-
implementation("androidx.window:window-core:1.3.0")
105101
}
106102

107-
desktopMain.dependencies {
103+
jvmMain.dependencies {
108104
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:${libs.versions.kotlinx.coroutines}")
109105
implementation(compose.desktop.currentOs)
110106
implementation(libs.harawata.appdirs)
@@ -118,10 +114,7 @@ kotlin {
118114
}
119115

120116

121-
val desktopTest by getting
122-
123-
// Adds the desktop test dependency
124-
desktopTest.dependencies {
117+
jvmTest.dependencies {
125118
implementation(compose.desktop.currentOs)
126119
}
127120

gradle/libs.versions.toml

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
[versions]
2-
kotlin = "2.1.20"
3-
ksp = "2.1.20-2.0.0"
4-
kotlinx-coroutines = "1.10.1"
2+
kotlin = "2.1.21"
3+
ksp = "2.1.21-2.0.1"
4+
kotlinx-coroutines = "1.10.2"
55

66

7-
agp = "8.9.2"
7+
agp = "8.9.3"
88
android-compileSdk = "35"
99
android-minSdk = "24"
1010
android-targetSdk = "35"
1111
androidx-activityCompose = "1.10.1"
12-
compose = "1.8.0"
12+
compose = "1.8.2"
1313
compose-plugin = "1.7.3"
1414
composeAdaptiveLayout = "1.0.0"
1515
harawata-appdirs = "1.2.2"
@@ -23,7 +23,8 @@ ktor = "3.1.1"
2323
treemapChart = "0.1.1"
2424
voyager= "1.1.0-beta03"
2525
molecule = "2.0.0"
26-
26+
mcp = "0.5.0"
27+
shadowPlugin = "8.1.1"
2728

2829

2930
[libraries]
@@ -63,6 +64,7 @@ treemap-chart = { module = "io.github.overpas:treemap-chart", version.ref = "tre
6364
treemap-chart-compose = { module = "io.github.overpas:treemap-chart-compose", version.ref = "treemapChart" }
6465

6566
molecule = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
67+
mcp-kotlin = { group = "io.modelcontextprotocol", name = "kotlin-sdk", version.ref = "mcp" }
6668

6769
[bundles]
6870
ktor-common = ["ktor-client-core", "ktor-client-json", "ktor-client-logging", "ktor-client-serialization", "ktor-client-content-negotiation", "ktor-serialization-kotlinx-json"]
@@ -77,4 +79,5 @@ kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref =
7779
kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
7880
kmpNativeCoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "kmpNativeCoroutines" }
7981
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
80-
82+
kotlinJvm = { id = "org.jetbrains.kotlin.jvm" }
83+
shadowPlugin = { id = "com.github.johnrengelman.shadow", version.ref = "shadowPlugin" }

mcp-server/build.gradle.kts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
plugins {
2+
alias(libs.plugins.kotlinJvm)
3+
alias(libs.plugins.kotlinx.serialization)
4+
alias(libs.plugins.shadowPlugin)
5+
application
6+
}
7+
8+
dependencies {
9+
implementation(libs.mcp.kotlin)
10+
implementation(libs.koin.core)
11+
implementation(projects.composeApp)
12+
}
13+
14+
java {
15+
toolchain {
16+
languageVersion = JavaLanguageVersion.of(17)
17+
}
18+
}
19+
20+
application {
21+
mainClass = "MainKt"
22+
}
23+
24+
tasks.shadowJar {
25+
archiveFileName.set("serverAll.jar")
26+
archiveClassifier.set("")
27+
manifest {
28+
attributes["Main-Class"] = "MainKt"
29+
}
30+
}
31+

mcp-server/src/main/kotlin/main.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Entry point.
3+
* It initializes and runs the appropriate server mode based on the input arguments.
4+
*
5+
* Command-line arguments passed to the application:
6+
* - args[0]: Specifies the server mode. Supported values are:
7+
* - "--sse-server": Runs the SSE MCP server.
8+
* - "--stdio": Runs the MCP server using standard input/output.
9+
* Defaults to "--sse-server" if not provided.
10+
* - args[1]: Specifies the port number for the server. Defaults to 3001 if not provided or invalid.
11+
*/
12+
13+
14+
fun main(args: Array<String>) {
15+
val command = args.firstOrNull() ?: "--sse-server"
16+
val port = args.getOrNull(1)?.toIntOrNull() ?: 3001
17+
when (command) {
18+
"--sse-server" -> `run sse mcp server`(port)
19+
"--stdio" -> `run mcp server using stdio`()
20+
else -> {
21+
System.err.println("Unknown command: $command")
22+
}
23+
}
24+
}

mcp-server/src/main/kotlin/server.kt

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import dev.johnoreilly.climatetrace.data.ClimateTraceRepository
2+
import dev.johnoreilly.climatetrace.di.initKoin
3+
import io.ktor.server.application.*
4+
import io.ktor.server.cio.*
5+
import io.ktor.server.engine.*
6+
import io.ktor.server.routing.*
7+
import io.ktor.server.sse.*
8+
import io.ktor.util.collections.*
9+
import io.ktor.utils.io.streams.*
10+
import io.modelcontextprotocol.kotlin.sdk.*
11+
import io.modelcontextprotocol.kotlin.sdk.server.Server
12+
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
13+
import io.modelcontextprotocol.kotlin.sdk.server.SseServerTransport
14+
import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport
15+
import kotlinx.coroutines.Job
16+
import kotlinx.coroutines.runBlocking
17+
import kotlinx.io.asSink
18+
import kotlinx.io.buffered
19+
import kotlinx.serialization.json.JsonObject
20+
import kotlinx.serialization.json.JsonPrimitive
21+
import kotlinx.serialization.json.jsonPrimitive
22+
23+
24+
private val koin = initKoin(enableNetworkLogs = true).koin
25+
26+
fun configureServer(): Server {
27+
val climateTraceRepository = koin.get<ClimateTraceRepository>()
28+
29+
val server = Server(
30+
Implementation(
31+
name = "mcp-kotlin PeopleInSpace server",
32+
version = "1.0.0"
33+
),
34+
ServerOptions(
35+
capabilities = ServerCapabilities(
36+
prompts = ServerCapabilities.Prompts(listChanged = true),
37+
resources = ServerCapabilities.Resources(subscribe = true, listChanged = true),
38+
tools = ServerCapabilities.Tools(listChanged = true)
39+
)
40+
)
41+
)
42+
43+
44+
server.addTool(
45+
name = "get-countries",
46+
description = "List of countries"
47+
) {
48+
val countries = climateTraceRepository.fetchCountries()
49+
CallToolResult(
50+
content =
51+
countries.map { TextContent("${it.name}, ${it.alpha3}") }
52+
)
53+
}
54+
55+
server.addTool(
56+
name = "get-emissions",
57+
description = "List emission info for a particular country",
58+
inputSchema = Tool.Input(
59+
properties = JsonObject(
60+
mapOf(
61+
"countryCode" to JsonPrimitive("string"),
62+
"year" to JsonPrimitive("date"),
63+
64+
)
65+
),
66+
required = listOf("countryCode")
67+
)
68+
69+
) { request ->
70+
val countryCode = request.arguments["countryCode"]
71+
val year = request.arguments["year"]
72+
if (countryCode == null || year == null) {
73+
return@addTool CallToolResult(
74+
content = listOf(TextContent("The 'countryCode' and `year` parameters are required."))
75+
)
76+
}
77+
78+
val countryEmissionInfo = climateTraceRepository.fetchCountryEmissionsInfo(
79+
countryCode.jsonPrimitive.content,
80+
year.jsonPrimitive.content
81+
)
82+
CallToolResult(
83+
content =
84+
countryEmissionInfo.map { TextContent(it.emissions.co2.toString()) }
85+
)
86+
}
87+
88+
return server
89+
}
90+
91+
/**
92+
* Runs an MCP (Model Context Protocol) server using standard I/O for communication.
93+
*
94+
* This function initializes a server instance configured with predefined tools and capabilities.
95+
* It sets up a transport mechanism using standard input and output for communication.
96+
* Once the server starts, it listens for incoming connections, processes requests,
97+
* and executes the appropriate tools. The server shuts down gracefully upon receiving
98+
* a close event.
99+
*/
100+
fun `run mcp server using stdio`() {
101+
val server = configureServer()
102+
val transport = StdioServerTransport(
103+
System.`in`.asInput(),
104+
System.out.asSink().buffered()
105+
)
106+
107+
runBlocking {
108+
server.connect(transport)
109+
val done = Job()
110+
server.onClose {
111+
done.complete()
112+
}
113+
done.join()
114+
}
115+
}
116+
117+
/**
118+
* Launches an SSE (Server-Sent Events) MCP (Model Context Protocol) server on the specified port.
119+
* This server enables clients to connect via SSE for real-time communication and provides endpoints
120+
* for handling specific messages.
121+
*
122+
* @param port The port number on which the SSE server should be started.
123+
*/
124+
fun `run sse mcp server`(port: Int): Unit = runBlocking {
125+
val servers = ConcurrentMap<String, Server>()
126+
127+
val server = configureServer()
128+
embeddedServer(CIO, host = "0.0.0.0", port = port) {
129+
install(SSE)
130+
routing {
131+
sse("/sse") {
132+
val transport = SseServerTransport("/message", this)
133+
134+
servers[transport.sessionId] = server
135+
136+
server.onClose {
137+
servers.remove(transport.sessionId)
138+
}
139+
140+
server.connect(transport)
141+
}
142+
post("/message") {
143+
val sessionId: String = call.request.queryParameters["sessionId"]!!
144+
val transport = servers[sessionId]?.transport as? SseServerTransport
145+
if (transport == null) {
146+
call.respond("Session not found", null)
147+
return@post
148+
}
149+
150+
transport.handlePostMessage(call)
151+
}
152+
}
153+
}.start(wait = true)
154+
}

settings.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,7 @@ dependencyResolutionManagement {
1919
}
2020
}
2121

22+
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
2223
rootProject.name = "ClimateTraceKMP"
2324
include(":composeApp")
25+
include(":mcp-server")

0 commit comments

Comments
 (0)