MCP(Model Context Protocol) Deep Dive

MCP(Model Context Protocol) Deep Dive

요즘 AI 개발에 대해 얘기하자면 MCP를 빼놓고 얘기할 수 없다. LLM이 나오면서 AI가 모든 것을 바꿔 버릴 것이라는 우려가 있었지만 막상 Chatbot수준의 정보를 얻는 것 말고는 활용 가치가 그리 크지 않았다. 하지만 Antropic에서 MCP규격(2024.11)을 내 놓으면서 기류는 크게 바뀌었다. LLM이 인공지능 즉, 두뇌 역할을 담당했다면 MCP는 이 두뇌에 팔과 다리를 달아주었기 때문이다. 결국 사람이 AI에게 말을 건네면 AI는 그 말를 이해하고 실제 도움이 되는 행동을 할 수 있게 된 셈이다.

이 글에서는 MCP가 무엇인지 규격(Specification)위주로 간략하게 설명하고 Langchain MCP Adapter와 MCP SDK를 활용하여 간단한 시나리오가 실제 구현되어 동작하는 모습까지 따라가 보고자 한다. 확언컨데, 이 글을 끝까지 따라 간다면 MCP가 어떤 매커니즘으로 동작하는지 개념정리 뿐만 아니라 MCP Tool을 활용하는 AI 어플리케이션 개발에도 자신감을 불어 넣어 줄 것으로 생각한다.

Model Context Protocol

Model Context Protocol 스펙의 정의를 빌리자면 개방형(Open) 프로토콜로서 LLM 어플리케이션과 그 외부의 데이터 그리고 툴(tool) 사이를 자연스럽게 통합시켜주는 규격이라 할 수 있다. 이 글을 작성하는 시점의 최신 버전은 2025-06-18이며, 아직은 성숙 단계라 안정화 될 때까지는 잦은 변경 및 개선사항 반영이 예상된다.

프로토콜은 내부적으로 JSON-RPC 2.0 규격을 따르며 아래 구성요소들 간의 데이터 통신에 적용된다.

  • Hosts: LLM 어플리케이션(or Agent)으로 Connection을 맺는 주체이다.
  • Clients: Host어플리케이션 내부의 Connector에 해당한다.
  • Servers: Context와 능력치(tool 을 포함한 capability)를 제공한다.

MCP는 Client-Host-Server 아키텍처를 따르며 Host 하나가 다수의 Client를 실행할 수 있다. 이들 구성요소를 그림으로 표현하면 아래와 같다.

주요 구성요소(Components)

Host

서블릿(Servlet) 컨테이너 처럼 컨테이너 역할과 조정자(Coordinator) 역할을 담당한다.

  • 다수의 Client 인스턴스 생성 및 관리
  • Client의 Connection permission과 Lifecycle 제어
  • 보안정책(Security Policy)에 부합하는 요구사항 강제화
  • 사용자 Authorization 처리
  • AI/LLM 통합과 Sampling을 조정
  • Client 간의 Context aggregation 관리

Clients

Host에 의해 생성되며 각자 독립된(isolated) 형태로 서버와 연결(Connection)을 유지한다.

  • 각 서버별 Stateful 세션 형성
  • Protocol Negotiation과 Capability 교환(exchange) 처리(handshaking)
  • 양방향 프로토콜 메시지 라우팅
  • Subscription과 Notification 관리
  • 서버들 간의 Security Boundary 유지

Host 어플리케이션은 다수의 Client를 생성 관리하며 이때 각 Client와 특정 서버간의 관계는 1:1 이다.

Servers

특화된 Context와 능력치(Capabilities)을 제공한다.

  • 리소스(Resources), 툴(Tools), 프롬프트(Prompts) 공개
  • 특정 업무 또는 기능에 특화되어 독립적으로 동작
  • 클라이언트의 interface를 통해 Sampling 요청
  • Security 제약사항 준수 필수
  • 로컬 process로 동작 또는 원격 서비스 형태로 가능

Capability Negotiation

MCP는 능력치에 기반(Capability-based)한 협의(Negotiation) 시스템을 활용하며 협의 과정에서 Client와 Server는 각자 활용 가능한 기능을 명시적으로 선언한다. Capability는 세션이 유지되는 동안 프로토콜 상의 어떤 기능과 primitive를 사용 가능한지를 결정한다.

  • Server는 구독 가능한 리소스(Resource Subscription), 지원하는 (Tool Support), 프롬프트 템플릿(Prompt Template)과 같은 것을 선언한다.
  • Client는 Sampling 지원 여부와 Notification 처리 여부 같은 기능을 선언한다.
  • Client와 Server는 세션이 유지되는 동안 각자 선언한 기능을 서로 신뢰해야 한다.
  • Protocol 확장(extension)을 통해 추가적인 기능을 negotiation할 수 있다.

이와 같은 Capability negotiation은 프로토콜의 확장성을 유지하면서 Client와 Server가 각자 지원하는 기능을 명확하게 이해 하도록 돕는다. 위 시퀀스다이어그램서 나오는 Tool, Resource, Sampling, Prompt(그림에 없음) 등은 핵심 기능들이긴 하지만 툴을 제외한 기능들은 향후 기회가 될 때 다루기로 하고 이 글에서는 생략한다. 지금까지 Client와 Server가 MCP를 통해 제공하는 주요 능력치(Capability)와 이들 간의 동작을 살펴 봤다면 이제 동작 과정에서 어떠한 Lifecycle 단계를 거치는지 살펴 보자.

Lifecycle

MCP는 Client-Server 연결을 위해 엄격한 Lifecycle을 정의하고 있으며 이를 통해 적절한 Capability Negotiation과 상태 관리가 되도록 한다.

Initialization

초기화(Intialization)단계는 Client와 Server간에 수행되는 최초의 interaction으로 Client와 Server는 아래의 동작을 수행한다.

  • 프로토콜 버전 호환성 확립
  • 기능(Capability) 교환과 협상(negotiation)
  • 세부 구현사항 공유

Operation

동작(Operation) 단계에서 Client와 Server는 상호 협의된 Capability에 따라 메시지를 교환한다. 이때

  • 상호 협의된 프로토콜 버전을 준수해야 하며
  • 협의된 Capability들 만을 사용해야 한다.

Shutdown

종료(Shutdown) 단계는 어느 한 쪽(보통 Client 쪽)에서 프로토콜의 Connection을 종료한다. 명시적인 shutdown 메시지는 정의되지 않았으나 내부적으로 Connection 종료 신호를 보내기 위한 Transport 매커니즘이 적용된다.

Transport

Client와 Server간의 통신에서 프로토콜은 두 가지의 전송 메커니즘을 표준으로 규정하고 있으며 MCP에서 중요한 부분을 차지한다. 네트워크 프로그램을 개발하려면 Socket에 대한 지식이 있어야 하듯이 MCP 프로그램을 개발하려면 Trasport에 대한 이해가 필수적이다. MCP는 메시지 송수신을 인코딩하기 위해 JSON-RPC를 이용하며 반드시 UTF-8로 인코딩 되어야 한다.

현재 MCP 프로토콜(ver 2025-06-18)은 Client와 Server 통신에 두 가지의 표준 Transport 매커니즘을 정의하고 있다.

  • stdio (표준 input/output 을 이용한 통신)
  • Streamable HTTP

참고로, stdio 방식은 Client가 필수 구현사항으로 규정하고 있으며, 커스텀(custom) trasport를 만들어 적용하는 것도 가능하다. 일반적으로 대부분의 AI 프로그램은 웹 환경에서 동작하므로 여기서는 Streamable HTTP 기반으로 설명하고 sdio는 추후 필요시 설명하도록 한다.

Streamable HTTP

Streamable HTTP Transport는 2024-11-05 버전의 프로토콜에서 소개된 HTTP + SSE transport를 대체하는 것으로 Server는 독립 프로세스로 동작하며 다수의 Client Connection 처리가 가능하다. 이 Transport는 HTTP Method중 POST와 GET 요청을 처리하며 다수의 서버 메시지를 스트리밍하기 위해 선택적으로 SSE(Server-Sent Event)를 적용한다.

Server는 POST와 GET을 지원하는 MCP Endpoint(/mcp)를 반드시 제공해야 하며 일반적으로 아래와 같은 형태를 가진다.

https://example.com/mcp

메시지 송신(to server)

Client에서 JSON-RPC 메시지 송신의 시작은 반드시 HTTP POST 를 통해 MCP Endpoint로 요청되어야 한다.

메시지 수신(from server)

Client가 SSE 스트림을 오픈하려면 HTTP GET 방식으로 MCP Endpoint로 요청을 보내면 된다. 이 경우 Client는 HTTP POST로 요청을 먼저 보내지 않더라도 서버와 통신을 할 수 있다. 이때 Client Request 의 헤더의 Accept 키 값으로 text/event-stream을 기술한다. 또한 역으로 서버가 먼저 SSE stream을 시작할 수도 있다(자세한 내용은 규격 참조).

Multiple Connections

Client는 동시에 다수의 SSE 스트림을 연결하고 유지할 수 있다.

Session Management

MCP에서 Session은 초기화(initialization) 단계에서 시작하며 Client와 Server사이에서 논리적으로 서로 연결된 interaction들을 말한다. 초기화 단계에서 서버는 session id를 생성하고 HTTP Response의 Header에 Mcp-Session-Id 키로 그 값을 설정한 후 전송한다.

사용자가 어플리케이션을 종료하는 상황에서 더이상 Client가 session을 유지할 필요가 없다면 명시적으로 session을 종료할 수 있도록 헤더에 Mcp-Session-Id 를 포함하여 MCP Endpoint로 HTTP DELETE Request를 전송해야 한다.

Sequence Diagram

Server Features

MCP는 LLM에 Context를 추가할 수 있도록 Server는 아래아 같은 기본적인 구성 요소를 제공한다. 이를 기반으로 Client와 Server 그리고 LLM사이에 활발한 상호작용이 가능해 진다.

  • Prompt: 사전 정의된 템플릿 또는 명령어로 LLM과의 상호작용을 가이드 한다.
  • Resource: 구조적인 데이터 또는 컨텐츠로 LLM에 추가적인 context를 제공한다.
  • Tools: 실행가능한 function으로 LLM이 동작을 수행하거나 정보를 추출할 수 있도록 한다.

요약하면, Prompt는 앱에서 제공하는 메뉴 형태로 사용자가 직접 제어하며, Resource는 파일 시스템상의 파일 목록과 같이 Client에서 관리되며 어플리케이션이 제어한다. 마지막으로 Tool은 LLM이 직접 서버 상에 있는 fuction(RESTful API)을 호출하는 형태로 LLM이 Context에 적합한 도구를 선택하고 직접 수행하는 역할을 한다. Tool이 사람의 팔과 다리 역할을 한다면 Resource는 눈과 귀, 그리고 Prompt는 입의 역할을 한다고 생각하면 어려움이 없을 것이다. 이 문서에서는 MCP에서 핵심 기능으로 인식되는 Tool 활용에 촛점을 맞춰 설명을 이어가도록 한다.

Tools

MCP를 이용하면 LLM이 이용할 수 있는 툴을 Server를 통해 노출할 수 있다. 이런 Tool을 활용하면 데이터베이스 쿼리 실행이나 API 호출과 같이 LLM이 외부에 있는 시스템과 연동이 가능해 진다.

툴(tool)은 LLM이 컨트롤 할 수 있도록 설계 되어 있다. 즉, 사용자의 Prompt와 Context에 기반하여 LLM이 자동으로 툴을 식별하고 실행까지 할 수 있음을 의미한다. Tool을 식별하고 실행하는 과정은 아래의 그림과 같다.

Message Flow

지금까지 MCP에 관한 대략적인 부분을 살펴 봤다면 이제 이런 내용을 바탕으로 구체적인 Agent를 제작해 보자. 소스코딩으로 직접 AI Agent를 개발하려면 심도있게는 아니더라도 MCP가 내부적으로 어떻게 동작하는 지를 이해하는 것이 매우 중요하다. 인터넷에 돌아다니는 MCP관련 단편적인 소스코드를 다운받아 편하게 실행해 볼 수는 있겠지만 이렇게 해서는 느낌 정도만 파악할 뿐 막상 개발하려면 막막해 지기 때문이다.

Applying MCP in Real Scenario

MCP를 활용하여 Agent를 개발하려면 우선 어떤 프로그래밍 언어로 시작 할지 결정해야 한다. 활용 가능한 여러가지 언어들이 있겠지만 여기서는 JavaScript를 이용하여 Agent를 구현해 보도록 한다. 필요하다면 Python과 같이 자신에게 익숙한 언어를 사용해도 무방하다. 이 경우 전반적인 개념은 동일 하더라도 구현 방법에는 차이가 많으니 참고하기 바란다.

우선 AI Agent 어플리케이션을 구현하기 위해서는 아래와 같은 개발 툴킷이 필요하다.

  • LLM 기반의 AI 어플리케이션 개발 프레임워크인 LangChain.js
  • MCP 프로토콜 스펙 구현체인 MCP SDK
  • LangChain 상에서 MCP를 기능을 확장할 수 있도록 지원하는 LangChain.js MCP Adapter
  • MCP Server를 구동하기 위한 Express.js

이들 툴킷을 설치하는 방법은 링크로 연결된 각 페이지를 참조하기 바란다.

여기서는 사칙연산 툴과 날씨를 알려주는 툴을 각각 서로 다른 MCP서버로 구현하고 AI Agent는 사용자가 입력하는 프롬프트를 이해한 후 적절하게 이들 툴을 활용하는 지 살펴보도록 한다. 우선, 시칙연산을 위한 MCP 툴을 만들고 Express.js 를 이용하여 MCP Client로 해당 툴을 노출하는 과정을 제작해 보자.

개발 단계별로 핵심이 되는 내용을 먼저 설명하고 전체 소스코드는 맨 나중에 확인할 수 있도록 구성했다. 전체 코드를 먼저 확인하고 싶다면 뒷부분으로 바로 이동해도 무방하다.

MCP Server 생성과 툴(Tool) 등록

비교적 간단하게 MCP Server를 생성하고 툴도 쉽게 제작 등록할 수 있다.

const server = () => {
  const mcp = new McpServer({ name: 'calc-server', version: '1.0.0' });

  // tool registeration
  mcp.registerTool('add', {
    title: 'Addition Tool',
    description: 'Add two numbers',
    inputSchema: { a: z.number(), b: z.number() }
  }, async ({ a, b }) => {
    console.log(`executes operation ${a} + ${b}`);
    return { content: [{ type: 'text', text: String(a + b) }] };
  });

  mcp.registerTool('multiply', {
    title: 'Multiply Tool',
    description: 'Multiply two numbers',
    inputSchema: { a: z.number(), b: z.number() }
  }, async ({ a, b }) => {
    console.log(`executes operation ${a} * ${b}`);
    return { content: [{ type: 'text', text: String(a * b) }] };
  });

  console.log('tool and resource are registered!');
  return mcp;
};

McpServer 객체를 생성한 후 registerTool() 메소드를 활용하여 tool nametitle, description, 그리고 입력 파라미터의 Schema(파라미터 시그너처)를 정의한다. 여서기 기술하는 titledescription은 MCP Client와의 초기화 과정(hand-shaking)에서 Agent(LLM)로 전달된 후 Context를 형성하므로 AI가 이해하기 쉬운 내용으로 작성하는 것이 좋다. 그리고 마지막 파라미터는 Agent가 tool 실행을 위해 Request를 보내면 실행되는 callback function으로 이 부분에서 tool의 상세 기능을 구현하면 된다. callback function의 파라미터는 inputSchema에서 정의한 명세를 따른다. 위 예제에서는 더하기와 곱하기 툴을 만들고 등록했다.

Transport 생성 후 서버와 연결

MCP 프로토콜 처리를 위해 StreamableHTTPServerTransport 객체를 생성하고 앞에서 생성한 McpServer객체와 연결한다.

// holds transport instances for caching
const transports = { };

let transport = new StreamableHTTPServerTransport({
  sessionIdGenerator: () => randomUUID(),
  onsessioninitialized: (sessionId) => {
    console.log(`Session initialized with ID: ${sessionId}`);
    transports[sessionId] = transport;
  },
  enableDnsRebindingProtection: true,
  allowHosts: ['127.0.0.1']
});

transport.onclose = () => {
  const { sessionId } = transport;
  if (sessionId && transport[sessionId]) {
    console.log(`Transport closed for session ${sessionId}, removing from cahce`);
    delete transports[sessionId];
  }
};

await server().connect(transport);
await transport.handleRequest(req, res, req.body);
console.log('Server is connected with StreamableHTTPServerTransport!');

StreamableHTTPServerTransport 객체 생성시 Mcp-Session-Id를 같이 생성하며 이렇게 생성된 Session Id를 이용하여 동일 세션에는 동일한 StreamableHTTPServerTransport가 사용되도록 onsessioninitialized 호출시 Session Id를 키로하여 Caching 처리한다. 그리고 Security(DNS rebinding attacks) 제약사항으로 allowHosts는 로컬머신 상에서 Client와 연동되는 Host를 명시하도록 하며 enableDnsRebindingProtection 옵션을 설정(default: true)하도록 하고 있다.

마지막으로 Transport가 Close될 때 callback 받는 onclose() 메소드에서는 Caching 했던 StreamableHTTPServerTransport를 삭제처리 한다.

Express.js를 이용한 RESTful API 구현

앞에서 HTTP POST, GET 은 반드시 구현해야 하며 session을 종료하기 위해 DELETE 도 같이 구현해 주어야 한다고 했다. 그럼 가장 기본이 되는 POST를 먼저 구현하도록 하자.

HTTP POST- app.post(END_POINT, async (req, res) => { … }

HTTP POST는 Client에서 최초로 호출하는 Endpoint로서 Transport 객체를 생성한 후 앞에서 설명했던 server()메소드를 이용하여 MCPServer와 연결(connect)하는 것이 주요 기능이다. 추가적으로 한 번 생성된 Transport 객체를 재사용하기 위해 sessionId를 이용하여 Caching하는 기능도 같이 구현 했다.

HTTP GET- app.get(END_POINT, async (req, res) => { … }

HTTP GET은 SSE(Server Sent Event) 스트림을 처리하는 기능을 담당한다.

HTTP DELETE- app.delete(END_POINT, async (req, res) => { … }

HTTP DELETE는 Client와 Server간에 맺어진 Session을 종료할 때 호출되며 관련된 기능을 처리한다.

지금까지 설명한 내용을 완전한 소스코드로 기술하면 아래와 같다.

import express from 'express';
import { randomUUID } from "node:crypto";
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod';
import cors from 'cors';

//MCP endpoint and server listening port 
const [END_POINT, PORT] = ['/mcp', 3000];

// Create an MCP server
const server = () => {
  const mcp = new McpServer({ name: 'calc-server', version: '1.0.0' });

  // tool registeration
  mcp.registerTool('add', {
    title: 'Addition Tool',
    description: 'Add two numbers',
    inputSchema: { a: z.number(), b: z.number() }
  }, async ({ a, b }) => {
    console.log(`executes operation ${a} + ${b}`);
    return { content: [{ type: 'text', text: String(a + b) }] };
  });

  mcp.registerTool('multiply', {
    title: 'Multiply Tool',
    description: 'Multiply two numbers',
    inputSchema: { a: z.number(), b: z.number() }
  }, async ({ a, b }) => {
    console.log(`executes operation ${a} * ${b}`);
    return { content: [{ type: 'text', text: String(a * b) }] };
  });

  // resource registration
  mcp.registerResource("greeting", new ResourceTemplate("greeting://{name}", {
    list: undefined
  }), { 
    title: "Greeting Resource",      // Display name for UI
    description: "Dynamic greeting generator"
  }, async (uri, { name }) => ({
    contents: [{
      uri: uri.href,
      text: `Hello, ${name}!`
    }]
  }));
  console.log('Tools and Resources are registered!');
  return mcp;
};

// new instance of Express
const app = express();

// holds transport instances for caching
const transports = { };
app.use(express.json());
app.use(cors({
  origin: '*',
  exposedHeaders: ['Mcp-Session-Id'],
  allowHeaders: ['Content-Type', 'mcp-session-id']
}));

/** handle POST request to MCP endpoint */
app.post(END_POINT, async (req, res) => {
  const sessionId = req.headers['mcp-session-id'];
  const msg = (sessionId) ? `Received MCP request for session: ${sessionId}`: 'Request body:';
  console.log(msg, req.body);

  let transport;
  if (sessionId && transports[sessionId]) {
    transport = transports[sessionId];
  } else if (!sessionId && isInitializeRequest(req.body)) {
    transport = new StreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (sessionId) => {
        console.log(`Session initialized with ID: ${sessionId}`);
        transports[sessionId] = transport;
      },
      enableDnsRebindingProtection: true,
      allowHosts: ['127.0.0.1']
    });

    transport.onclose = () => {
      const { sessionId } = transport;
      if (sessionId && transport[sessionId]) {
        console.log(`Transport closed for session ${sessionId}, removing from cahce`);
        delete transports[sessionId];
      }
    };

    await server().connect(transport);
    await transport.handleRequest(req, res, req.body);
    console.log('Server is connected with StreamableHTTPServerTransport!\n');
    return;
  } else {
    res.status(400).json({
      jsonrpc: '2.0',
      error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
      id: null
    });
    return;
  }
  await transport.handleRequest(req, res, req.body);
});

/** handle GET request to MCP endpoint */
app.get(END_POINT, async (req, res) => {
  const sessionId = req.headers['mcp-session-id'];
  if (!sessionId || !transports[sessionId]) {
    res.status(400).send('Invalid or missing sesionID');
    return;
  }
  const lastEventId = req.headers['last-event-id'];
  const msg = (lastEventId) ? `Client reconnecting with Last-Event-Id: ${lastEventId}`
      : `Establishing new SSE stream for session ${sessionId}`;
  console.log(msg);

  await transports[sessionId].handleRequest(req, res);
});

/** handle DELETE request to MCP endpoint */
app.delete(END_POINT, async (req, res) => {
  const sessionId = req.headers['mcp-session-id'];
  if (!sessionId || !transports[sessionId]) {
    res.status(400).send('Invalid or missing sesionID');
    return;
  }
  console.log(`Received session termination request for session ${sessionId}`);
  try {
    await transports[sessionId].handleRequest(req, res);
  } catch (error) {
    console.error('Error handling session termination:', error);
    if (!res.headerSent) {
      res.status(500).send('Error processing session termination');
    }
  }
});

// start server
app.listen(PORT, () => {
  console.log(`MCP Server is listening on port ${PORT}`);
});

MCP 서버 연동을 위해 간단한 기능을 구현해 보았다. 이제, 이렇게 구현된 서버 기능을 테스트 하기 위해 Agent 프로그램을 간단하게 만들어 보도록 하자.

AI Agent 구현

LLM으로 전달하기 위한 사용자 프롬프트를 콘솔로부터 입력 받으며, MCP 서버가 노출한 툴과 연계된 질문을 받았을 때 정상적으로 해당 툴이 실행되는지 확인할 수 있도록 비교적 간단한 AI Agent 프로그램을 만들어 보도록 하겠다.

MCP SDK에 Client 클래스가 포함되어 있긴 하지만 이 클래스는 하나의 Transport와 연결(connect)하도록 되어 있어 여러 개의 Server와 연결 하기에는 제약사항이 많다. 특히 하나의 AI Agent는 여러 MCP Server에서 제공하는 툴을 활용할 수 있어야 하므로 Langchain Adapter에서 제공하는 MultiServerMCPClient 클래스를 이용하면 편리하게 이 제약사항에서 벗어날 수 있다.

예제에서는 내부적으로 OpenAI의 LLM을 활용하므로 OpenAI Platform을 방문한 후 API Key를 먼저 발급 받아야 한다. 발급받은 API Key는 콘솔을 통해 OPENAI_API_KEY 환경변수로 설정하면 바로 사용할 수 있다.

// terminal console
export OPENAI_API_KEY=sk-bQn.....

참고로, 아래와 같이 AI Agent 소스파일 상단에 기술해도 되긴 하지만 보안에 취약하므로 이 방법은 되도록이면 피하는 것이 좋다.

// inside source file
process.env.OPENAI_API_KEY = 'sk-bQn..... ';

MCP를 활용하여 AI Agent 를 개발할 때 가장 핵심이 되는 부분은 MCP Server를 지정하는 것이다. MCP Server는 MultiServerMCPClient 객체 생성 시점에 파라미터로 지정하며 아래와 같이 설정할 수 있다.

const servers = {
  throwOnLoadError: true,
  prefixToolNameWithServerName: false,
  additionalToolNamePrefix: '',
  useStandardContentBlocks: true,

  mcpServers: {
    calculator: {
      url: 'http://localhost:3000/mcp', // endpoint is '/mcp', check out server's exposed endpoint
      automaticSSEFallback: true
    },
    weather: {
      url: 'http://localhost:3100/sse', // endpoint is '/sse', check out server's exposed endpoint
      automaticSSEFallback: true
    }
  }
}

const client = new MultiServerMCPClient(servers);

mcpServers 키로 설정하며 위 코드에서와 같이 다수의 MCP Server를 등록할 수 있다. 앞서 만든 MCP Server가 localhost의 포트 3000에서 실행중이라면 임의의 이름(calculator)으로 RESTful API target url을 지정하면 된다.

여러 개의 MCP Server를 등록할 수 있으므로 동작 확인을 위해 샘플로 날씨를 알려주는 간단한 Mock MCP Server를 하나 더 만들고 이 서버의 RESTful API target url을 지정하였다. 참고로 이 MCP Server는 프로토콜 하위호환 동작 확인을 위해 MCP 프로토콜 초기 버전으로 구현하였으며 실행해 보면 현재 버전(ver 2025-06-18)과 호환이 됨을 확인할 수 있다. 날씨를 알려주는 Mock MCP Server 소소코드는 글 마지막에 추가하였으니 참고하기 바란다.

다음으로 OpenAI API를 이용하여 LLM을 만들고, 앞에서 생성한 client 객체를 이용하여 서버로부터 Tool를 얻어온 다음 Langchain의 createReactAgent()를 이용하여 AI Agent를 생성한다. 여기서 활용한 LLM 모델은 ‘gpt-4o‘이며 간결하고 명확한 답을 얻어야 하므로 temperature를 0으로 설정하였다.

const tools = await client.getTools();
const llm = new ChatOpenAI({ modelName: 'gpt-4o', temperature: 0 });
const agent = createReactAgent({ llm, tools });

완전한 소스코드는 아래와 같다. 소스코드 중 MCP Server에 등록된 Resource를 가져오는 부분을 추가는 했지만 구체적 활용 시나리오가 없으므로 참고 용도로만 보기 바란다.

import { ChatOpenAI } from '@langchain/openai';
import { createReactAgent } from '@langchain/langgraph/prebuilt';
import { HumanMessage, AIMessage } from '@langchain/core/messages';
import { MultiServerMCPClient } from '@langchain/mcp-adapters';
import { stdin as input, stdout as output } from 'node:process';
import * as readline from 'node:readline/promises';

const servers = {
  throwOnLoadError: true,
  prefixToolNameWithServerName: false,
  additionalToolNamePrefix: '',
  useStandardContentBlocks: true,

  mcpServers: {
    calculator: {
      url: 'http://localhost:3000/mcp', // endpoint is '/mcp', check out server's exposed endpoint
      automaticSSEFallback: true
    },
    weather: {
      url: 'http://localhost:3100/sse', // endpoint is '/sse', check out server's exposed endpoint
      automaticSSEFallback: true
    }
  }
}

const rl = readline.createInterface({ input, output });
const conversation = [];

const client = new MultiServerMCPClient(servers);
const tools = await client.getTools();
console.log('Tools available from servers are: ');
for (const { name, description } of tools) {
  console.log(`\t- ${name}: ${description}`);
}

// examples for fetching a resource from calculator server
const calc = await client.getClient('calculator');
const resource = await calc.readResource({uri: 'greeting://skanto'});
console.log('resource: \n', resource);

const llm = new ChatOpenAI({ modelName: 'gpt-4o', temperature: 0 });
const agent = createReactAgent({ llm, tools });
console.log(`\nAgent is ready. Type your message or 'exit' to quit.\n`);

// handle prompts user inputs
while (true) {
  const prompt = await rl.question('Your question: ');
  if (!prompt.trim()) continue;
  if (prompt.toLowerCase() === 'exit') break;

  conversation.push(new HumanMessage(prompt));
  console.log('Agent thinking...\n');

  try {
    const { messages } = await agent.invoke({ messages: conversation });
    let { content } = messages.pop(); // last item of messages array
    if (typeof content === 'object')  content = JSON.stringify(content);

    console.log('Agent: ', content, '\n');
    conversation.push(new AIMessage(content));
  } catch (err) {
    console.error('Agent error: ', err.message);
  }
}

rl.close();
await client.close();
console.log('Disconnected from MCP server');
console.log('Chatting with agent is terminated.');

Executing Scenarios

이제 앞에서 작성했던 MCP Server와 AI Agent를 실행한 후 예상했던 대로 사용자가 입력한 프롬프트의 컨텍스트에 따라 해당 툴이 실행되는지 확인해 보자

Run MCP Server

먼저 아래와 같이 사칙연산 MCP Server와 날씨 예보 MCP Server를 각각 다른 PORT(3000, 3100)로 실행한다.

root@4d1382e3b115:/home/node/mcp# node MCP_Calc_Server.mjs 
MCP Server is listening on port 3000
root@4d1382e3b115:/home/node/mcp# node MCP_Weather_Server.mjs 
Tool registeration completed!
MCP Server is listening on port 3100

위와 같은 결과가 출력되었다면 두 MCP Server는 정상적으로 동작하고 있다.

Run AI Agent

이제 앞에서 작성한 AI Agent 프로그램을 실행하면 아래와 같은 결과가 출력될 것이다. MCP Server에 등록된 Tool(3가지)을 확인할 수 있도록 로그 메시지를 추가 하였다.

root@4d1382e3b115:/home/node/mcp# node client.mjs 
Tools available from servers are: 
	- add: Add two numbers
	- multiply: Multiply two numbers
	- forecast: Forecast weather of a city on a specific date
resource: 
 { contents: [ { uri: 'greeting://skanto', text: 'Hello, skanto!' } ] }

Agent is ready. Type your message or 'exit' to quit.

Your question: 

이때 MCP Server 들은 Hand shaking 과정에서 각각 아래와 같은 결과를 출력할 것이다.

root@4d1382e3b115:/home/node/mcp# node MCP_Calc_Server.mjs 
MCP Server is listening on port 3000
Request body: {
  method: 'initialize',
  params: {
    protocolVersion: '2025-06-18',
    capabilities: {},
    clientInfo: { name: 'langchain-mcp-adapter', version: '0.1.0' }
  },
  jsonrpc: '2.0',
  id: 0
}
Tools and Resources are registered!
Session initialized with ID: bb2d3083-77fe-4c7e-b7da-8e9d1ca0e280
Server is connected with StreamableHTTPServerTransport!

Received MCP request for session: bb2d3083-77fe-4c7e-b7da-8e9d1ca0e280 { method: 'notifications/initialized', jsonrpc: '2.0' }
Establishing new SSE stream for session bb2d3083-77fe-4c7e-b7da-8e9d1ca0e280
Received MCP request for session: bb2d3083-77fe-4c7e-b7da-8e9d1ca0e280 { method: 'tools/list', params: {}, jsonrpc: '2.0', id: 1 }
Received MCP request for session: bb2d3083-77fe-4c7e-b7da-8e9d1ca0e280 {
  method: 'resources/read',
  params: { uri: 'greeting://skanto' },
  jsonrpc: '2.0',
  id: 2
}
root@4d1382e3b115:/home/node/mcp# node MCP_Weather_Server.mjs 
Tool registeration completed!
MCP Server is listening on port 3100
Server is connected with SSEServerTransport!
Received MCP request for session: 355b6ca1-ec94-4a4e-97c0-a9c5ac8fe984 {
  method: 'initialize',
  params: {
    protocolVersion: '2025-06-18',
    capabilities: {},
    clientInfo: { name: 'langchain-mcp-adapter', version: '0.1.0' }
  },
  jsonrpc: '2.0',
  id: 0
}
Received MCP request for session: 355b6ca1-ec94-4a4e-97c0-a9c5ac8fe984 { method: 'notifications/initialized', jsonrpc: '2.0' }
Received MCP request for session: 355b6ca1-ec94-4a4e-97c0-a9c5ac8fe984 { method: 'tools/list', params: {}, jsonrpc: '2.0', id: 1 }

프롬프트를 이용한 동작 확인

AI Agent 프롬프트에서 아래와 같은 내용을 입력해 보면 AI가 관련된 툴을 실행하고 그 결과를 알려 준다. 여기서 눈여겨 볼 만한 부분은 프롬프트에 두 가지 답을 요구하는 프롬프트를 입력하더라도 정상적인 답을 출력한다. 즉, 하나의 프롬프트로 각각 서로 다른 MCP Server의 툴을 실행하고 그 결과를 AI Agent가 취합해서 결과로 보여 준다.

Your question: What is the result if you add two numbers 22 and 33
Agent thinking...

Agent:  The result of adding 22 and 33 is 55. 

Your question: 오늘은 7월 29일이야 내일 서울의 날씨는 어떄?
Agent thinking...

Agent:  내일 서울의 날씨는 맑고 덥습니다. 

Your question: 34와 77을 곱하고 모레의 날씨도 알려줘
Agent thinking...

Agent:  34와 77을 곱하면 2618입니다. 모레 서울의 날씨는 맑고 덥습니다. 

Your question: 

입력한 언어에 따라 적절하게 결과를 보여주는 것도 확인할 수 있다. 이때 각각의 MCP Server에서는 어떤 동작했는지 로그 메시지를 확인해 보면 아래와 같다.

Received MCP request for session: 96032a11-533a-4938-9319-5648a804f687 {
  method: 'resources/read',
  params: { uri: 'greeting://skanto' },
  jsonrpc: '2.0',
  id: 2
}
Received MCP request for session: 96032a11-533a-4938-9319-5648a804f687 {
  method: 'tools/call',
  params: { name: 'add', arguments: { a: 22, b: 33 } },
  jsonrpc: '2.0',
  id: 3
}
Received MCP request for session: 96032a11-533a-4938-9319-5648a804f687 {
  method: 'tools/call',
  params: { name: 'multiply', arguments: { a: 34, b: 77 } },
  jsonrpc: '2.0',
  id: 5
}
executes operation 34 * 77
Received MCP request for session: 50ba8f5e-1660-4275-a8f4-9cc0608ee6af { method: 'tools/list', params: {}, jsonrpc: '2.0', id: 1 }
Received MCP request for session: 50ba8f5e-1660-4275-a8f4-9cc0608ee6af {
  method: 'tools/call',
  params: { name: 'forecast', arguments: { city: '서울', date: '2023-07-30' } },
  jsonrpc: '2.0',
  id: 2
}
forecast weather of 서울 on 2023-07-30
Received MCP request for session: 50ba8f5e-1660-4275-a8f4-9cc0608ee6af {
  method: 'tools/call',
  params: { name: 'forecast', arguments: { city: '서울', date: '2023-07-31' } },
  jsonrpc: '2.0',
  id: 3
}
forecast weather of 서울 on 2023-07-31

MCP Server – 날씨 예보 Mock 툴

이 예제는 MCP 프로토콜 초기버전의 하위 호환을 보여주기 위해 작성하였으니 참고용으로만 활용하고 실제 AI Agent개발에는 되도록이면 최신 버전의 프로토콜과 SDK를 활용하기 바란다.

import express from 'express';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { z } from 'zod';
import cors from 'cors';

//EP: EndPoint for each target functionality
const [SSE_EP, MSG_EP] = ['/sse', '/messages'];
const PORT = 3100; // server listening port

const server = new McpServer({ name: 'weather-server', version: '1.0.0' });
server.registerTool('forecast', {
  title: 'Weather Forecast Tool',
  description: 'Forecast weather of a city on a specific date',
  inputSchema: { city: z.string(), date: z.string() }
}, async ({ city, date }) => {
  console.log(`forecast weather of ${city} on ${date}`);
  return { content: [{ type: 'text', text: 'the weather is sunny and hot temperature' }] };
});
console.log('Tool registeration completed!');

const transports = { };
const app = express();
app.use(express.json());

/** handle GET request for SSE endpoint */
app.get(SSE_EP, async (req, res) => {
  const transport = new SSEServerTransport(MSG_EP, res);
  transports[transport.sessionId] = transport;
  res.on('close', () => {
    delete transports[transport.sessionId];
  });
  await server.connect(transport);
  console.log('Server is connected with SSEServerTransport!');
});

/** handle POST request for message endpoint */
app.post(MSG_EP, async (req, res) => {
  const sessionId = req.query.sessionId;
  const transport = transports[sessionId];
  if (transport) {
    const msg = (sessionId) ? `Received MCP request for session: ${sessionId}`: 'Request body:';
    console.log(msg, req.body);
    await transport.handlePostMessage(req, res, req.body);
  } else {
    console.error('No transport found for sessionId: ', sessionId);
    res.status(400).send('No transport found for sessionId');
  }
});

// start server
app.listen(PORT, () => {
  console.log(`MCP Server is listening on port ${PORT}`);
});

끝.

답글 남기기

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