본문 바로가기

Claude 에 Stido 기반 Mcp 서버 연동하기 With Kotlin

@whateverU2025. 12. 16. 17:57
반응형

참고 

https://www.anthropic.com/news/model-context-protocol

 

Introducing the Model Context Protocol

The Model Context Protocol (MCP) is an open standard for connecting AI assistants to the systems where data lives, including content repositories, business tools, and development environments. Its aim is to help frontier models produce better, more relevan

www.anthropic.com

https://techblog.woowahan.com/22342

 

우리 서비스와 연결된 MCP Server 빠르게 구현해보기: MCP 해커톤 후기 | 우아한형제들 기술블로그

들어가며 LLM, 그리고 AI Agent에 관한 관심이 뜨거운 요즈음입니다. 우아한형제들에서도 사내 지원으로 LLM ChatBot이나 AI Code Generate 도구를 지원하고, 구성원들이 자발적으로 사내 메신저에 AI TIL (To

techblog.woowahan.com

https://github.com/modelcontextprotocol/servers

 

GitHub - modelcontextprotocol/servers: Model Context Protocol Servers

Model Context Protocol Servers. Contribute to modelcontextprotocol/servers development by creating an account on GitHub.

github.com

 

목적

때로는 직접 해보는게 이해가 빠를데가… 해보면서 이해하자.
간단한 Json-Rpc 를 이용한 Mcp Server 만들어 보고 Claude Desktop 에서 Local Mcp Server 를 연동해보기

https://github.com/modelcontextprotocol/kotlin-sdk/tree/main/samples/weather-stdio-server 해당 예제를 따라서 만들어 보았습니다.

 

만들어 보기

Claude = MCP Client
Client 에서는 Mcp 서버를 내부 프로세스로 실행 시킨 후 내부통신(Standrad Input, Output) JSON-RPC 방식으로 통신함

작업한 저장소 

https://github.com/ChoiSangIl/whateveru/tree/main/ai-mcp-study

 

whateveru/ai-mcp-study at main · ChoiSangIl/whateveru

테스트, 공부 해보고 싶은 것들. Contribute to ChoiSangIl/whateveru development by creating an account on GitHub.

github.com

의존성

참고로 Kotlin 버전이 높아서 Intellij Version 이 낮으면 뭔가 요상하게 동작함

plugins {
    kotlin("jvm") version "2.2.21"
    kotlin("plugin.serialization") version "1.9.0"
    application
}

application {
    mainClass.set("study.stdio.jsonrpc.Main")
}

group = "org.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

val mcpVersion = "0.8.0"
val ktorVersion = "3.3.2"
val slf4jVersion = "2.0.9"

dependencies {
    testImplementation(kotlin("test"))
    implementation("io.modelcontextprotocol:kotlin-sdk:$mcpVersion")
    implementation("io.ktor:ktor-client-cio:$ktorVersion")
    implementation("io.ktor:ktor-server-netty:$ktorVersion")
    implementation("io.ktor:ktor-client-content-negotiation:${ktorVersion}")
    implementation("io.ktor:ktor-serialization-kotlinx-json:${ktorVersion}")
    implementation("org.slf4j:slf4j-nop:${slf4jVersion}")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
}

tasks.jar {
    manifest {
        attributes["Main-Class"] = "study.stdio.jsonrpc.MainKt"
    }
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
    from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) })
}


tasks.test {
    useJUnitPlatform()
}
kotlin {
    jvmToolchain(17)
}

코드

메인

fun main() = runMcpServer()

McpWeatherServer

package study.stdio.jsonrpc

import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.headers
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.streams.asInput
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport
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.ToolSchema
import kotlinx.coroutines.Job
import kotlinx.coroutines.runBlocking
import kotlinx.io.asSink
import kotlinx.io.buffered
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject

/**
 * Starts an MCP server that provides weather-related tools for fetching active
 * weather alerts by state and weather forecasts by latitude/longitude.
 */
fun runMcpServer() {
    // Base URL for the Weather API
    val baseUrl = "https://api.weather.gov"

    // 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")
            }
            contentType(ContentType.Application.Json)
        }
        // Install content negotiation plugin for JSON serialization/deserialization
        install(ContentNegotiation) {
            json(
                Json {
                    ignoreUnknownKeys = true
                    prettyPrint = true
                },
            )
        }
    }

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

    // Register a tool to fetch weather alerts by state
    server.addTool(
        name = "get_alerts",
        description = """
            Get weather alerts for a US state. Input is Two-letter US state code (e.g. CA, NY)
        """.trimIndent(),
        inputSchema = ToolSchema(
            properties = buildJsonObject {
                putJsonObject("state") {
                    put("type", "string")
                    put("description", "Two-letter US state code (e.g. CA, NY)")
                }
            },
            required = listOf("state"),
        ),
    ) { request ->
        val state = request.arguments?.get("state")?.jsonPrimitive?.content ?: return@addTool CallToolResult(
            content = listOf(TextContent("The 'state' parameter is required.")),
        )

        val alerts = httpClient.getAlerts(state)

        CallToolResult(content = alerts.map { TextContent(it) })
    }

    // Register a tool to fetch weather forecast by latitude and longitude
    server.addTool(
        name = "get_forecast",
        description = """
            Get weather forecast for a specific latitude/longitude
        """.trimIndent(),
        inputSchema = ToolSchema(
            properties = buildJsonObject {
                putJsonObject("latitude") {
                    put("type", "number")
                }
                putJsonObject("longitude") {
                    put("type", "number")
                }
            },
            required = listOf("latitude", "longitude"),
        ),
    ) { request ->
        val latitude = request.arguments?.get("latitude")?.jsonPrimitive?.doubleOrNull
        val longitude = request.arguments?.get("longitude")?.jsonPrimitive?.doubleOrNull
        if (latitude == null || longitude == null) {
            return@addTool CallToolResult(
                content = listOf(TextContent("The 'latitude' and 'longitude' parameters are required.")),
            )
        }

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

 

WeatherApi

package study.stdio.jsonrpc

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

// 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
    val uri = "/alerts/active/area/$state"
    // Request the alerts data from the API
    val alerts = this.get(uri).body<Alert>()

    // Map each alert feature to a formatted string
    return alerts.features.map { feature ->
        """
            Event: ${feature.properties.event}
            Area: ${feature.properties.areaDesc}
            Severity: ${feature.properties.severity}
            Description: ${feature.properties.description}
            Instruction: ${feature.properties.instruction}
        """.trimIndent()
    }
}

// Data class representing the points response from the API
@Serializable
data class Points(
    val properties: Properties
) {
    @Serializable
    data class Properties(val forecast: String)
}

// Data class representing the forecast response from the API
@Serializable
data class Forecast(
    val properties: Properties
) {
    @Serializable
    data class Properties(val periods: List<Period>)

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

    @Serializable
    data class Properties(
        val event: String, val areaDesc: String, val severity: String,
        val description: String, val instruction: String?,
    )
}

Json-Rpc 호출

main 함수를 실행하여 프로세스를 실행한 다음 아래와 같이 명령어를 실행해봄. 결과적으로 MCP 를 연결하면 내부적으로 서버를 올려 아래와 같이 통신을 해서 정보를 얻어와서 llm 에서 해당 정보를 참고하여 사용자에게 정보를 전달함.

더보기

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}
{"id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"weather","version":"1.0.0"}},"jsonrpc":"2.0"}

{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"id":2,"result":{"tools":[{"name":"get_alerts","inputSchema":{"properties":{"state":{"type":"string","description":"Two-letter US state code (e.g. CA, NY)"}},"required":["state"],"type":"object"},"description":"Get weather alerts for a US state. Input is Two-letter US state code (e.g. CA, NY)"},{"name":"get_forecast","inputSchema":{"properties":{"latitude":{"type":"number"},"longitude":{"type":"number"}},"required":["latitude","longitude"],"type":"object"},"description":"Get weather forecast for a specific latitude/longitude"}]},"jsonrpc":"2.0"}

{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_alerts","arguments":{"state":"CA"}}}
{"id":3,"result":{"content":[{"text":"            Event: Air Quality Alert\n            Area: Calabasas and Agoura Hills; Catalina and Santa Barbara Islands; Eastern San Fernando Valley; Eastern Santa Monica Mountains Recreational Area; Los Angeles County Beaches; Los Angeles County Inland Coast including Downtown Los Angeles; Los Angeles County San Gabriel Valley; Malibu Coast; Palos Verdes Hills; Santa Clarita Valley; Santa Susana Mountains; Western San Fernando Valley; Western Santa Monica Mountains Recreational Area\n            Severity: Unknown\n            Description: * WHAT...The South Coast AQMD has issued a mandatory wood-burning\nban for residents in the South Coast Air Basin. The No-Burn rule\nbans burning wood, pellets, and manufactured fire logs in any indoor\nor outdoor wood-burning device.\n\n* WHERE...The South Coast Air Basin, which includes Orange County\nand non-desert portions of Los Angeles, Riverside, and San\nBernardino counties. No-Burn Day alerts do not apply to areas above\n3,000 feet in elevation, the Coachella Valley, or the High Desert.\nHomes that rely on wood as a sole source of heat, low-income\nhouseholds and those without natural gas service also are exempt\nfrom the requirement.\n\n* WHEN...midnight to midnight on Friday.\n\n* IMPACTS...Particles in smoke can get deep into the lungs and cause\nhealth problems (including asthma attacks), increases in emergency\nroom visits, and hospitalizations. No-Burn Days are needed to\nprotect public health when fine particle pollution levels are\nexpected to be high anywhere in the South Coast Air Basin. No-Burn\nDays are based on fine particle pollution levels that are forecasted\nfor an entire 24-hour period, which may not be reflected in\nreal-time air quality maps.\n            Instruction: To protect your health, check current and forecasted air quality\nlevels at http://www.aqmd.gov or download the mobile app at\nhttp://www.aqmd.gov/mobileapp. When air quality is poor in your area,\nlimited extended or intense outdoor activity and remain inside with\nwindows and doors closed. Run your air conditioner and/or an air\npurifier and avoid using devices that bring in outside air like a\nswamp cooler or whole house fan if you have other ways to stay cool.\n\nAdditional details for this air quality alert are available at\nhttp://www.aqmd.gov/advisory. Information on the South Coast AQMD Check\nBefore You Burn program is available at http://www.aqmd.gov/cbyb.","type":"text"},{"text":"            Event: Beach Hazards Statement\n            Area: San Francisco; Coastal North Bay Including Point Reyes National Seashore; San Francisco Peninsula Coast; Northern Monterey Bay; Southern Monterey Bay and Big Sur Coast\n            Severity: Moderate\n            Description: * WHAT...Moderate to long period northwesterly swell will result\nin an increased risk for sneaker waves and rip currents.\nBreaking waves of 13 to 18 feet are expected.\n\n* WHERE...Pacific Coast beaches.\n\n* WHEN...Through 10 PM PST Sunday evening.\n\n* IMPACTS...Sneaker waves can unexpectedly run significantly\nfarther up the beach than normal, including over rocks and\njetties. Rip currents are typically more frequent and stronger\nin the vicinity of jetties, inlets, and piers.\n            Instruction: Dangerous conditions are forecast along the shoreline. Hazards\ninclude sneaker waves, strong rip currents, and large breaking\nwaves. Stay off the rocks and NEVER turn your back on the ocean!","type":"text"},{"text":"            Event: Dense Fog Advisory\n            Area: Los Banos - Dos Palos; Merced - Madera - Mendota; Planada - Le Grand - Snelling; West Side of Fresno and Kings Counties; Caruthers - San Joaquin - Selma; Fresno-Clovis; Buttonwillow - Lost Hills - I5; Delano-Wasco-Shafter; Hanford - Corcoran - Lemoore; Visalia - Porterville - Reedley; Bakersfield; Southeast San Joaquin Valley\n            Severity: Moderate\n            Description: * WHAT...Visibility one quarter mile or less in dense fog.\n\n* WHERE...A portion of central California.\n\n* WHEN...Until 10 AM PST this morning.\n\n* IMPACTS...Low visibility could make driving conditions hazardous.\n\n* ADDITIONAL DETAILS...Highways impacted include, but are not\nlimited to, Interstate 5, as well as California State Routes 99,\n33, 41, 43, 46, 59, 152, 165, 168, 180, and 198.\n            Instruction: If driving, slow down, use your headlights, and leave plenty of\ndistance ahead of you.","type":"text"},{"text":"            Event: Air Quality Alert\n            Area: Los Angeles County San Gabriel Valley\n            Severity: Unknown\n            Description: * WHAT...The South Coast AQMD has issued a mandatory wood-burning\nban for residents in the South Coast Air Basin. The No-Burn rule\nbans burning wood, pellets, and manufactured fire logs in any\nindoor or outdoor wood-burning device.\n\n* WHERE...The South Coast Air Basin, which includes Orange County\nand non-desert portions of Los Angeles, Riverside, and San\nBernardino counties. No-Burn Day alerts do not apply to areas above\n3,000 feet in elevation, the Coachella Valley, or the High Desert.\nHomes that rely on wood as a sole source of heat, low-income\nhouseholds and those without natural gas service also are exempt\nfrom the requirement.\n\n* WHEN...Midnight tonight to midnight Friday.\n\n* IMPACTS...Particles in smoke can get deep into the lungs and cause\nhealth problems (including asthma attacks), increases in emergency\nroom visits, and hospitalizations. No-Burn Days are needed to\nprotect public health when fine particle pollution levels are\nexpected to be high anywhere in the South Coast Air Basin. No- Burn\nDays are based on fine particle pollution levels that are\nforecasted for an entire 24-hour period, which may not be reflected\nin real- time air quality maps.\n            Instruction: To protect your health, check current and forecasted air quality\nlevels at http://www.aqmd.gov or download the mobile app at\nhttp://www.aqmd.gov/mobileapp. When air quality is poor in your area,\nlimited extended or intense outdoor activity and remain inside with\nwindows and doors closed. Run your air conditioner and/or an air\npurifier and avoid using devices that bring in outside air like a\nswamp cooler or whole house fan if you have other ways to stay cool.\n\nAdditional details for this air quality alert are available at\nhttp://www.aqmd.gov/advisory. Information on the South Coast AQMD Check\nBefore You Burn program is available at http://www.aqmd.gov/cbyb.","type":"text"},{"text":"            Event: Air Quality Alert\n            Area: San Bernardino and Riverside County Valleys-The Inland Empire; Riverside County Mountains; San Gorgonio Pass Near Banning; Orange County Coastal; Orange County Inland\n            Severity: Unknown\n            Description: * WHAT...The South Coast AQMD has issued a mandatory wood-burning\nban for residents in the South Coast Air Basin. The No-Burn rule\nbans burning wood, pellets, and manufactured fire logs in any indoor\nor outdoor wood-burning device.\n\n* WHERE...The South Coast Air Basin, which includes Orange County\nand non-desert portions of Los Angeles, Riverside, and San\nBernardino counties. No-Burn Day alerts do not apply to areas above\n3,000 feet in elevation, the Coachella Valley, or the High Desert.\nHomes that rely on wood as a sole source of heat, low-income\nhouseholds and those without natural gas service also are exempt\nfrom the requirement.\n\n* WHEN...midnight to midnight on Friday.\n\n* IMPACTS...Particles in smoke can get deep into the lungs and cause\nhealth problems (including asthma attacks), increases in emergency\nroom visits, and hospitalizations. No-Burn Days are needed to\nprotect public health when fine particle pollution levels are\nexpected to be high anywhere in the South Coast Air Basin. No-Burn\nDays are based on fine particle pollution levels that are forecasted\nfor an entire 24-hour period, which may not be reflected in\nreal-time air quality maps.\n            Instruction: To protect your health, check current and forecasted air quality\nlevels at http://www.aqmd.gov or download the mobile app at\nhttp://www.aqmd.gov/mobileapp. When air quality is poor in your area,\nlimited extended or intense outdoor activity and remain inside with\nwindows and doors closed. Run your air conditioner and/or an air\npurifier and avoid using devices that bring in outside air like a\nswamp cooler or whole house fan if you have other ways to stay cool.\n\nAdditional details for this air quality alert are available at\nhttp://www.aqmd.gov/advisory. Information on the South Coast AQMD Check\nBefore You Burn program is available at http://www.aqmd.gov/cbyb.\n\nFor the latest air quality forecasts and information, visit the\nwebsite at aqmd.gov.","type":"text"},{"text":"            Event: Air Quality Alert\n            Area: Los Angeles County San Gabriel Valley\n            Severity: Unknown\n            Description: * WHAT...The South Coast AQMD has issued an air quality alert due to\nharmful levels of particle pollution from windblown dust. Visit\nhttps://www.aqmd.gov/alertinfo to check current and forecasted air\nquality levels.\n\n* WHERE...in the The South Coast Air Basin, which includes large\nareas of Los Angeles, Orange, Riverside and San Bernardino Counties\nwith the exception of the High Desert and the Coachella Valley.\nLevels of particle pollution can vary by time and location depending\non emissions and local weather conditions.\n\n* WHEN...Friday.\n\n* IMPACTS... Particle pollution can get deep into the lungs and\ncause serious health problems such as asthma attacks, heart and lung\ndisease symptoms, and increased risk of lung infections. Everyone\ncan be affected, but sensitive groups such as people with lung or\nheart disease, older adults, people who are pregnant, children, and\nthose who spend a lot of time outdoors are at greater risk.\n            Instruction: To protect your health, check air quality levels and act as needed:\n\n* When Air Quality Index (AQI) levels are \"Unhealthy for\nSensitive Groups\" (orange), sensitive groups as specified\nabove should limit extended or intense outdoor activity.\n* When AQI is \"Unhealthy\" (red), everyone may experience health\nimpacts. Sensitive groups should avoid extended time outdoors.\n* When AQI is \"Very Unhealthy\" (purple), sensitive groups should\navoid all outdoor physical activity. Everyone else should\navoid extended or intense outdoor activity.\n* When AQI is \"Hazardous\" (maroon), everyone should avoid all\noutdoor physical activity.\n\n* ADDITIONAL DETAILS...\n\nTo help keep indoor air clean when air quality is poor:\n\n* Keep windows and doors closed\n* Run your air conditioner and/or an air purifier\n* Do not use whole house fans or swamp coolers that bring in\noutside air if you have other methods to stay cool\n* Avoid other sources of pollution such as fireplaces, candles,\nincense, grilling, and gasoline-powered lawn and garden\nequipment\n\nTo help minimize outdoor particle pollution levels:\n\n* Carpool, telecommute, reduce trips, or take public\ntransportation\n* Slow down if driving on dirt roads\n* Stabilize loose soils","type":"text"}]},"jsonrpc":"2.0"}

 

Claude Desktop 연동

Claude Destkop 에서는 로컬환경의 MCP 를 연동하는 방법을 제공한다.

code $env:AppData\Claude\claude_desktop_config.json 파일을 수정해주면 됨

아래 구성편집을 누르면 바로 json 파일 경로가 나옴(윈도우의 경우) 

 

 

윈도우 환경 셋팅 

{
  "mcpServers": {
    "weather": {
      "command": "java",
      "args": [
        "-jar",
        "C:\\PATH\\TO\\PARENT\\FOLDER\\weather\\build\\libs\\weather-0.1.0-all.jar"
      ]
    }
  }
}

설정을 해주고 Claude Desktop 을 재시작 해주면 아래처럼 연결된 MCP 서버를 확인할 수 있음

 

 

테스트 & 결과

What are the active weather alerts in Texas?

라고 질문하면 아래처럼 Local Mcp Server 와 연동되고 해당 Mcp 를 사용할지 Claude 가 판단하여 사용자에게 허용을 요청하는 것을 볼 수 있다.

결론

Claude Desktop이 로컬 MCP 서버를 내부 프로세스로 실행하고, STDIO 기반 JSON-RPC로 통신한다는 구조는 생각보다 단순했고, REST API를 새로 노출하지 않아도 된다는 점도 내부 도구 연동 관점에서는 꽤 매력적이다.

또한 SSE(Server-Sent Events) 방식 역시 공식적으로 지원되기 때문에 연동이 가능하다. 결과적으로 MCP는 “LLM이 우리 서비스의 데이터를 자연스럽게 참고하도록 만드는 표준 인터페이스” 에 가깝고, AI Agent나 사내 도구, 내부 운영 시스템과 LLM을 연결하려는 팀이라면 한 번쯤 직접 만들어보면서 감을 잡아볼 가치가 충분한 프로토콜이라고 느꼈다.

반응형
whateverU
@whateverU :: whateverU

sang12.co.kr https://github.com/ChoiSangIl

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차