간단하게 만들면서 이해해보는 MCP

Eunmin Kim·2025년 3월 24일
5

MCP에 대한 좋은 글이 많이 있는데요. 개발자로서 MCP를 이해하기 쉬운 글이 없어 주말 동안 MCP에 대해 살펴본 것을 정리해 봅니다. MCP는 Model Context Protocol의 약자입니다. 작년 말 쯤 클로드로 유명한 앤트로픽에서 발표한 프로토콜입니다. 공식 문서는 웹사이트에 있습니다. MCP를 설명하는 영상과 문서는 많이 있으니 그것을 찾아보면 더 좋을 것 같고 여기서는 간단하게 MCP 서버와 MCP를 활용하는 클라이언트를 대충 만들면서 이해해 봅시다.

Function calling

MCP에 대해 알아보기 전에 LLM 기능 중에 하나인 함수 호출에 대해 알아봅시다. 대부분의 LLM 모델은 함수 호출 기능이 있습니다. 함수 호출이지만 실제로 함수를 부르는 것은 아니고 LLM에게 질문을 할 때 이러한 함수들이 있고 그 함수에 인자는 이러한 것이 있다고 알려주면 LLM이 질문에 따라 적절한 함수를 부르면 좋겠다고 골라주고 파라미터까지 잘 파싱해 줍니다. 다음은 OpenAI API 문서에 있는 함수 호출입니다.

curl https://api.openai.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-d '{
    "model": "gpt-4o",
    "messages": [
        {
            "role": "user",
            "content": "What is the weather like in Paris today?"
        }
    ],
    "tools": [
        {
            "type": "function",
            "function": {
                "name": "get_weather",
                "description": "Get current temperature for a given location.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "location": {
                            "type": "string",
                            "description": "City and country e.g. Bogotá, Colombia"
                        }
                    },
                    "required": [
                        "location"
                    ],
                    "additionalProperties": false
                },
                "strict": true
            }
        }
    ]
}'

예제에서 보면 질문이 파리의 오늘 날씨가 어떠냐고 물어봅니다. 그리고 tools 목록에 get_weather라는 함수는 뭐 할 때 쓰는 것이고 인자로 문자열 타입으로 도시 이름을 받는다고 알려줬습니다. 이렇게 물어보면 LLM은 질문에 답하려면 get_weather 함수를 써야 하고 질문 내용 중에 Paris가 도시 이름이니까 인자로 Paris를 넘겨야 할 것이라고 다음과 같이 응답해줍니다.

[{
    "id": "call_12345xyz",
    "type": "function",
    "function": {
        "name": "get_weather",
        "arguments": "{\"location\":\"Paris, France\"}"
    }
}]

위 API를 부른 코드에서 이 응답을 받았다면 코드에 다음과 같이 get_weather 함수를 부르고 사용자에게 결과를 알려주거나 조금 더 말하는 것처럼 알려주르면 결과를 가지고 다시 LLM에게 문장을 만들어서 사용자에게 응답해주면 됩니다.

// 무슨 언어인지는 모르겠습니다. ㅋㅋㅋ 대충 pseudo 코드
response = api.call(...어쩌고 저쩌고
tool = get_tool(response)
if(tool.function = 'get_weather') { 
  weather = get_weather(tool.arguments.first); // 진짜 함수 부르기
... weather를 가지고 어쩌고 저쩌고

이런 LLM의 함수 호출을 이용하면 자연어로 어떤 동작을 실행할 수 있는 가능성이 생깁니다.

MCP Server

위에서 알아본 LLM 함수 호출을 사용하려면 함수 목록이 필요하고 실제 함수를 부를 수 있어야 합니다. 랭체인을 사용하면 다른 사람이 만든 여러 도구를 사용해 함수 호출을 쓸 수 있습니다. 하지만 라이브러리 형식이라 내 코드에 포함해야 합니다. MCP는 라이브러리와 비슷하게 함수 목록과, 함수 부르는 기능을 다른 프로세스에서 제공하는 서버입니다. 따라서 내 코드에 포함하지 않고 유연하게 연동할 수 있습니다.

그럼 간단하게 ulid 값을 생성해주는 MCP 서버를 만들어 봅시다. MCP는 서버라고 하지만 보통 로컬 클라이언트와 많이 쓰기 때문에 그냥 MCP 서버 프로세스에 stdin로 요청을 쓰고 stdout으로 응답을 받는 방법을 많이 씁니다. 물론 Server-Sent Events 방식으로 만든 MCP 서버도 있습니다. 여기서는 stdio 방법으로 만들어 보겠습니다.

JSON-RPC 2.0

MCP는 요청과 응답 형식을 JSON-RPC 방법을 쓰고 있습니다. JSON-RPC는 대략 아래와 같이 생겼습니다.

  • 요청

    {"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23},
  • 응답

    {"jsonrpc": "2.0", "result": 19, "id": 3}

초기화

MCP 서버가 떠 있다고 바로 요청을 주고 받을 수 없습니다. 초기화를 해줘야 그 때부터 MCP 서버와 통신할 수 있습니다. 초기화 method는 두 개 입니다. 하나는 initialize method이고 다른 하나는 notifications/initialized 입니다. notifications/initialized는 클라이언트가 요청을 하지 않아도 서버에서 클라이언트에게 응답을 줄 수 있는 MCP 기능을 사용하기 위해 있습니다.

그림에서 보는 것처럼 서버가 initialize 요청을 받으면 다음과 같이 응답해주면 됩니다.

{
   "jsonrpc":"2.0",
   "id":0,
   "result":{
      "protocolVersion":"2024-11-05",
      "capabilities":{
         "experimental":{
            
         },
         "tools":{
            "listChanged":false
         }
      },
      "serverInfo":{
         "name":"mcp-time",
         "version":"1.0.0"
      }
   }
}

notifications/initialized 요청은 서버가 응답을 주지 않아도 됩니다.

함수 목록과 부르기

MCP에는 리소스나 프롬프트 같은 것을 가져올 수 있는 프로토콜도 있지만 가장 많이 사용하는 MCP에서 제공하는 함수 목록을 가져오고 함수를 부르는 방법만 만들어 보겠습니다.

함수 목록

MCP 클라이언트는 MCP 서버에 어떤 기능이 있는지 알아야 LLM function calling을 할 수 있기 때문에 LLM function calling에 보낼 기능 목록을 가져와야 합니다. 다음과 같이 tools/list method로 함수 목록을 가져올 수 있습니다.

{"method":"tools/list","params":{},"jsonrpc":"2.0","id":1}

id는 클라이언트에서 고유한 값을 만들어주고 서버는 응답에 같은 id 값을 내려주면 됩니다. 서버가 위와 같은 JSON-RPC 요청을 받으면 stdout으로 서버에 부를 수 있는 함수 목록을 다음과 같이 응답으로 내려주면 됩니다. 아래는 time이라는 MCP 서버의 응답입니다.

{
   "jsonrpc":"2.0",
   "id":1,
   "result":{
      "tools":[
         {
            "name":"get_current_time",
            "description":"Get current time in a specific timezones",
            "inputSchema":{
               "type":"object",
               "properties":{
                  "timezone":{
                     "type":"string",
                     "description":"IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use 'UTC' as local timezone if no timezone provided by the user."
                  }
               },
               "required":[
                  "timezone"
               ]
            }
         },
         {
            "name":"convert_time",
            "description":"Convert time between timezones",
            "inputSchema":{
               "type":"object",
               "properties":{
                  "source_timezone":{
                     "type":"string",
                     "description":"Source IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use 'UTC' as local timezone if no source timezone provided by the user."
                  },
                  "time":{
                     "type":"string",
                     "description":"Time to convert in 24-hour format (HH:MM)"
                  },
                  "target_timezone":{
                     "type":"string",
                     "description":"Target IANA timezone name (e.g., 'Asia/Tokyo', 'America/San_Francisco'). Use 'UTC' as local timezone if no target timezone provided by the user."
                  }
               },
               "required":[
                  "source_timezone",
                  "time",
                  "target_timezone"
               ]
            }
         }
      ]
   }
}

함수 부르기

함수를 부를 때는 tools/call method를 사용합니다.

{
   "method":"tools/call",
   "params":{
      "name":"get_current_time",
      "arguments":{
         "timezone":"UTC"
      }
   },
   "jsonrpc":"2.0",
   "id":12
}

응답은 다음과 같이 내려줍니다.

{
   "jsonrpc":"2.0",
   "id":12,
   "result":{
      "content":[
         {
            "type":"text",
            "text":"{\n  \"timezone\": \"UTC\",\n  \"datetime\": \"2025-03-22T05:52:37+00:00\",\n  \"is_dst\": false\n}"
         }
      ],
      "isError":false
   }
}

간단한 서버 만들어 보기

stdio로 요청과 응답을 처리하는 간단한 서버를 만들어봅시다. 간단하게 Clojure로 만들어 보겠습니다. 파이썬으로 만든 코드는 여기를 참고하세요. 먼저 stdin으로 요청을 받으면 stdout로 응답을 주는 무한 반복 코드를 -main 함수로 만듭니다.

(defn -main []
  (loop []
    (when-let [result (-> (read-line)
                          (json/read-str :key-fn keyword)
                          request-handler)]
      (println (json/write-str result)))
    (recur)))

JSON-RPC는 요청과 응답이 JSON이기 때문에 (require '[clojure.data.json :as json])를 해줘서 stdin을 json으로 바꿔주고 request-handler 함수에 응답이 있으면 json으로 바꿔 stdout으로 응답해줍니다.

request-handler는 Clojure 멀티메서드로 만들면 처리하기 쉽니다. 처리할 JSON-RPC method는 처음 클라이언트가 부르는 initialize와 다음에 부르는 notifications/initialized 함수 목록을 달라고 하는 tools/list와 마지막으로 함수 호출을 하는 tools/call 입니다. method 키에 이런 값이 들어 있기 때문에 멀티 메서드는 다음과 같이 만듭니다.

(defmulti request-handler :method)

(defmethod request-handler :default [_])

이제 요청에 해당하는 함수를 하나씩 만들어주면 됩니다. 먼처 초기화 코드에서는 정해진 응답을 그냥 내려줍니다.

(defmethod request-handler "initialize" [{:keys [id]}]
  (make-jsonrpc-response
   id
   {:protocolVersion "2024-11-05"
    :capabilities {:experimental {}
                   :tools {:listChanged false}}
    :serverInfo {:name "mcpulid"
                 :version "1.0.0"}}))

내려줄 때 주의할 것은 요청에 들어온 id를 그대로 돌려주는 것입니다. make-jsonrcp-response는 JSON-RPC 형식을 맞춰주는 함수입니다.

(defn make-jsonrpc-response
  [id result]
  {:jsonrpc "2.0"
   :id id
   :result result})

notifications/initialized 요청은 그냥 아무 응답을 하지 않아도 됩니다.

(defmethod request-handler "notifications/initialized" [_])

함수 목록은 ulid 값을 생성해주는 get_ulid라는 함수입니다. 인자는 없습니다. description을 잘 써야 LLM에게 선택 받을 수 있습니다. :)

(defmethod request-handler "tools/list" [{:keys [id]}]
  (make-jsonrpc-response
   id
   {:tools [{:name "get_ulid"
             :description "고유한 식별자를 생성합니다"
             :inputSchema {:type "object"
                           :properties {}}}]}))

마지막으로 함수 호출 부분입니다. 어떤 함수를 부르는지는 paramsname에 들어있습니다. (ulid) 함수는 clj-ulid 라이브러리를 사용합니다.

(defmethod request-handler "tools/call" [{:keys [id params]}]
  (case (:name params)
    "get_ulid" (make-jsonrpc-response
                id
                {:content [{:type "text"
                            :text (ulid)
                            :isError false}]})))

이제 다 만들었습니다. 코드를 빌드하고 실행하면 stdin으로 요청을 기다립니다.

$ java -jar mcpulid.jar

여기에 initializenotifications/initialized를 부르면 요청을 주고 받을 수 있습니다. 물론 지금 만든 서버는 간단히 만들었기 때문에 처음부터 함수를 부를 수 있습니다. :)

$ java -jar mcpulid.jar
{"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"claude-ai","version":"0.1.0"}},"jsonrpc":"2.0","id":0}
{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2024-11-05","capabilities":{"experimental":{},"tools":{"listChanged":false}},"serverInfo":{"name":"mcpulid","version":"1.0.0"}}}
{"method":"notifications/initialized","jsonrpc":"2.0"}

함수 목록을 서버에 요청해 봅니다.

$ java -jar mcpulid.jar
...
{"method":"tools/list","params":{},"jsonrpc":"2.0","id":1}
{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"get_ulid","description":"고유한 식별자를 생성합니다","inputSchema":{"type":"object","properties":{}}}]}}

다음은 함수를 불러봅니다.

$ java -jar mcpulid.jar
...
{"method":"tools/call","params":{"name":"get_ulid","arguments":{}},"jsonrpc":"2.0","id":12}
{"jsonrpc":"2.0","id":12,"result":{"content":[{"type":"text","text":"01jq3ckxj54ew76rkddd9kscs1","isError":false}]}}

ULID 값을 잘 만들어 줍니다.

inspector

서버가 stdio로 되어 있기 때문에 그냥 터미널에서 실행해 볼 수 있지만 MCP inspector 툴을 쓰면 조금 더 쉽게 서버를 테스트해볼 수 있습니다.

인스펙터에서 Server Notification도 볼 수 있는데 지원하는 Notification 형태는 다음 코드를 참고하세요.

클라이언트

처음에 봤던 Function calling API를 이용해서 MCP 클라이언트 코드를 만들 수도 있지만 MCP를 지원하는 클라이언트를 사용하면 코드를 작성하지 않고 바로 MCP 서버를 연결해 볼 수 있습니다. 가장 많이 쓰는 클라이언트는 Claude Desktop 클라이언트와 Cursor입니다. 위에서 만든 서버를 Claude Desktop이나 Cursor에 연결해서 쓸 수 있습니다.

공개 MCP 서버와 클라이언트

많은 분들이 만든 MCP 서버와 클라이언트가 Github에 잘 정리되어 있습니다.

마무리

MCP는 위에서 만든 함수 호출 말고도 적절한 Resource나 Prompt를 가져올 수 있는 기능과 다른 프로토콜이 많이 있습니다. 간단히 이해하기 위해 단순하게 서버를 만들어 봤습니다. 자세한 내용은 공식 문서를 참고하세요. MCP 표준을 만들고 커뮤니티가 MCP 맞춰 서버와 클라이언트를 많이 만들고 있는 것 같아 에이전트 커뮤니티가 더 활성화 되는 것 같아 좋습니다. :)

profile
Functional Programmer @Constacts, Inc.

0개의 댓글

관련 채용 정보