MCP를 써봅시다.

얼마전에 친구가 MCP에 대한 얘기를 했는데 그냥 LLM에 일시키는 프로토콜인갑다 하고 넘겼는데 이걸 구글에 검색했더니 유튜브에 MCP 관련 영상을 겁나게 추천하기 시작했다.

그래서 뭔가 해봐야겠다 싶어서 만들어봄. 검색해보니 MCP SDK로 코틀린도 있길래 코틀린 성애자인 나는 코틀린을 써보기로 했다. 사실 본 영상중에 90%는 파이썬을 썼고, 10%는 타입스크립트를 쓰는데 나는 파이썬을 싫어하고 타입스크립트는 내가 컴을 새로 사고 노드를 설치했는지 기억이 안나서 코틀린 씀

기본적인 흐름

기본적인 흐름은 For Server Developers – Model Context Protocol 를 참고하면 된다.

그래서 뭐함?

ChatGPT는 MCP를 지원하지 않고, Claude는 웹검색을 지원하지 않으므로 간단히 Playwright를 이용해서 웹페이지를 탐색하고 그 컨텐츠를 가져와서 정리해달라는 간단한 요청을 해보려고 한다.

참고로 ChatGPT는 MCP를 지원하지 않아서 재미없어서 구독해지하고 Claude 결제함… 이거가지고는 재밌는거 할 수 있을 듯.

사실 Ollama 이용해서도 할 수 있다는데 MCP를 주도적으로 제창한 것이 Claude의 개발사인 Anthropic 이라서 그런 것도 있다.

필요한 것

위에서 제시한 예시 페이지는 API를 호출해서 날씨를 가져와 정리해주는 예제인데, 우리가 할 것은 Playwright를 이용해서 탐색하는 것이므로 디펜던시가 약간 다르다. 내가 쓴 것은 다음 3개이다.

implementation("io.modelcontextprotocol:kotlin-sdk:0.4.0")
implementation("com.microsoft.playwright:playwright:1.51.0")
implementation("com.github.ajalt.clikt:clikt:5.0.1")

clikt는 args 파싱해주는 유틸이다. 추가로 예시 페이지에 있듯이 shadowJar가 필요하다. 그런데 shadowJar만 설정해 놓으면 실제로 jar파일을 만들어서 실행할 때 어떤 클래스가 엔트리포인트인지 모르므로 build.gradle.kts에 다음과 같이 매니페스트에 Main-Class를 설정해준다.

tasks.shadowJar {
    manifest {
        attributes["Main-Class"] = "dev.skystar1.MainKt"
    }
}

Playwright 워커

Playwright를 이용하여 헤드리스(가 아닐수도 있음)모드로 크롬을 실행하고 작업을 하는 객체를 만든다. 테스트용이므로 크게 4가지 작업을 해야한다.

  1. 새로운 탭을 만들고 적절한 아이디를 반환 (open-new-tab)
  2. 탭의 아이디를 만들어서 그 탭을 닫음 (close-tab)
  3. 특정 URL을 받아서 그 URL로 이동 (navigate)
  4. 탭의 아이디를 받아서 그 탭의 content를 읽어옴 (get-contents)

헤드리스크롬은 하나만 있으면 되니 다음과 같이 만든다.

package dev.skystar1

import com.microsoft.playwright.Browser
import com.microsoft.playwright.BrowserType.LaunchOptions
import com.microsoft.playwright.Page
import com.microsoft.playwright.Playwright
import com.microsoft.playwright.Response
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid

@OptIn(ExperimentalUuidApi::class)
object PlaywrightHelper : AutoCloseable {
    private var ChromePath: Path? = null
    private lateinit var Browser: Browser
    private val PageMap: MutableMap<String, Page> = mutableMapOf()

    init {
        Runtime.getRuntime().addShutdownHook(Thread { close() })
    }

    internal fun init(chromePath: String, headless: Boolean = true) {
        if (chromePath.isNotBlank()) {
            ChromePath = Path(chromePath)
        }
        Browser = Playwright.create().chromium().launch(LaunchOptions().apply {
            if (ChromePath != null) {
                setExecutablePath(ChromePath)
            }
            setHeadless(headless)
        })
    }

    fun createNewTab(): String {
        val uuid = Uuid.random();
        PageMap[uuid.toString()] = Browser.newPage()
        return uuid.toString()
    }

    fun closeTab(uuid: String) {
        PageMap[uuid]?.close()
        PageMap.remove(uuid)
    }

    fun getContents(uuid: String): String {
        return requireNotNull(PageMap[uuid]) {
            "Tab named '$uuid' does not exist"
        }.content()
    }

    fun navigate(uuid: String, url: String): Response {
        return requireNotNull(PageMap[uuid]) {
            "Tab named '$uuid' does not exist"
        }.navigate(url)
    }

    override fun close() {
        try {
            Browser.close()
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
}

서버 만들기

이건 예시 페이지에 있는 것에서 조금 수정한다. 우리는 4개의 작업을 만들었으므로 addTool이 4번 호출된다.

package dev.skystar1

import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import io.ktor.utils.io.streams.*
import io.modelcontextprotocol.kotlin.sdk.*
import io.modelcontextprotocol.kotlin.sdk.server.Server
import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions
import io.modelcontextprotocol.kotlin.sdk.server.StdioServerTransport
import kotlinx.coroutines.Job
import kotlinx.coroutines.runBlocking
import kotlinx.io.asSink
import kotlinx.io.buffered
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive

class MCPServer : CliktCommand() {
    private val executablePath by option(
        "-e",
        "--executable-path",
        help = "Path to Chrome executable"
    ).default("")
    private val noHeadless by option(
        "--no-headless",
        help = "Run Chrome in headless mode",
    ).flag(default = false)

    override fun run() {
        runServer(executablePath.trim(), !noHeadless)
    }

    private fun runServer(executablePath: String, headless: Boolean = true) {
        PlaywrightHelper.init(executablePath, headless)

        val server = Server(
            Implementation(
                name = "mcp-playwright-test",
                version = "1.0.0"
            ),
            ServerOptions(
                capabilities = ServerCapabilities(
                    tools = ServerCapabilities.Tools(listChanged = true)
                )
            )
        )

        val transport = StdioServerTransport(
            System.`in`.asInput(),
            System.out.asSink().buffered()
        )

        server.addTool(
            name = "open-new-tab",
            description = """
                    Open new tab and return its UUID.
                """.trimIndent(),
        ) {
            CallToolResult(
                content = listOf(TextContent(PlaywrightHelper.createNewTab()))
            )
        }

        server.addTool(
            name = "close-tab",
            description = """
                    Close tab by UUID.
                """.trimIndent(),
            inputSchema = Tool.Input(
                properties = JsonObject(mapOf("uuid" to JsonPrimitive("string"))),
                required = listOf("uuid")
            )
        ) { request ->
            val uuid = request.arguments["uuid"]?.jsonPrimitive?.contentOrNull
                ?: return@addTool CallToolResult(
                    content = listOf(
                        TextContent("The 'uuid' parameter is required.")
                    )
                )

            PlaywrightHelper.closeTab(uuid)

            CallToolResult(
                content = listOf(TextContent("Closed tab '$uuid'"))
            )
        }

        server.addTool(
            name = "navigate",
            description = """
                    Navigate to a specific URL in the tab identified by UUID.
                """.trimIndent(),
            inputSchema = Tool.Input(
                properties = JsonObject(
                    mapOf(
                        "uuid" to JsonPrimitive("string"),
                        "url" to JsonPrimitive("string")
                    )
                ),
                required = listOf("uuid", "url")
            )
        ) { request ->
            val uuid = request.arguments["uuid"]?.jsonPrimitive?.contentOrNull
                ?: return@addTool CallToolResult(
                    content = listOf(
                        TextContent("The 'uuid' parameter is required.")
                    )
                )
            val url = request.arguments["url"]?.jsonPrimitive?.contentOrNull
                ?: return@addTool CallToolResult(
                    content = listOf(
                        TextContent("The 'url' parameter is required.")
                    )
                )

            val response = PlaywrightHelper.navigate(uuid, url)

            CallToolResult(
                content = listOf(
                    TextContent(
                        "navigation result : ${response.statusText()}"
                    )
                )
            )
        }

        server.addTool(
            name = "get-contents",
            description = """
                    Get contents of the tab identified by UUID.
                """.trimIndent(),
            inputSchema = Tool.Input(
                properties = JsonObject(mapOf("uuid" to JsonPrimitive("string"))),
                required = listOf("uuid")
            )
        ) { request ->
            val uuid = request.arguments["uuid"]?.jsonPrimitive?.contentOrNull
                ?: return@addTool CallToolResult(
                    content = listOf(
                        TextContent("The 'uuid' parameter is required.")
                    )
                )
            CallToolResult(
                content = listOf(TextContent(PlaywrightHelper.getContents(uuid)))
            )
        }

        runBlocking {
            server.connect(transport)
            val done = Job()
            server.onClose {
                done.complete()
            }
            done.join()
        }
    }
}

inputSchema나 CallToolResult를 적절하게만 반환해주면 LLM이 알아서 알아듣는다.

메인은 Clikt를 사용했으므로 간단하다.

package dev.skystar1

import com.github.ajalt.clikt.core.main

fun main(args: Array<String>) = MCPServer().main(args)

이제 gradle의 shadowJar 태스크를 실행해서 jar를 만든다.

그리고 claude_desktop_config.json에 다음과 같이 추가했다.

{
    "mcpServers": {
        "mcp-playwright-test": {
            "command": "C:\\Users\\USER\\.jdks\\temurin-21.0.6\\bin\\java",
            "args": [
                "-jar",
                "\\PATH_TO_BUILD_PATH\\mcp-test-1.0-SNAPSHOT-all.jar",
                "-e C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
                "--no-headless"
            ]
        }
    }
}

playwright 가 일단 처음에 브라우저를 받으므로 이건 검색해서 설정을 꺼도 되고 걍 처음에 임의로 실행해서 받아두면 되긴 한다.

이제 Claude Desktop을 켜서 다음과 같이 잘 등록이 되었는지 확인을 한다.

이게 잘 뜨면 대충 물어보면 알아서 답해준다. 참고로 MCP 도구는 뭐 trusted 설정같은 것도 안되어 있으므로 각 세션마다 사용할지 물어본다.

사용 예제

  • 네이버 웹사이트에 접속해서 컨텐츠 요약
  • 네이버와 구글 첫페이지의 웹접근성 비교

굿. 잘 된다.

오늘의 코드 : Aosamesan/kotlin-mcp-playwright-test

참고

Claude는 기본적으로 웹검색을 지원하지 않는다고 했다. MCP 설정을 없애고 새 세션에서 물어보니 다음과 같이 난 그런거 못ㅋ함ㅋ 해버린다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다